Skip to content

Commit 7fd7dcd

Browse files
committed
fix(capture): stabilize v1 created_at, isolate compression, quiet drops
Address review of the v1 transport: - Hoist the batch created_at out of the retry loop so the envelope stays stable across attempts (only the events list and PostHog-Attempt change). - Isolate v1 request compression behind a CaptureCompression selector supporting gzip and zlib-wrapped deflate (RFC 1950), reverting the gzip_compress extraction from request.py so the v1 path owns its codecs. - Stop logging per-event drops at WARNING; a server-chosen drop on a 2xx is not a delivery failure and is already carried on CaptureV1Error for batch-level surfacing via on_error.
1 parent fa4c20b commit 7fd7dcd

6 files changed

Lines changed: 317 additions & 53 deletions

File tree

posthog/capture_compression.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import logging
2+
import os
3+
from enum import Enum
4+
from typing import Optional, Union
5+
6+
log = logging.getLogger("posthog")
7+
8+
CAPTURE_COMPRESSION_ENV_VAR = "POSTHOG_CAPTURE_COMPRESSION"
9+
10+
11+
class CaptureCompression(str, Enum):
12+
"""Selects the request-body compression for capture-v1 uploads.
13+
14+
Only honored when ``capture_mode`` is ``V1``; the legacy ``/batch/`` path
15+
keeps using its own ``gzip`` flag. ``NONE`` sends the body uncompressed.
16+
``GZIP`` and ``DEFLATE`` (zlib, RFC 1950) are both stdlib / zero-dependency
17+
and map to the matching ``Content-Encoding`` token the v1 server decodes
18+
(``br``/``zstd`` are accepted by the server too but need extra dependencies,
19+
so they are intentionally left out for now). Inheriting from ``str`` keeps
20+
the members comparable to and serializable as their token values.
21+
"""
22+
23+
NONE = "none"
24+
GZIP = "gzip"
25+
DEFLATE = "deflate"
26+
27+
28+
# Accepted spellings for both the kwarg and the env var. ``identity`` mirrors
29+
# the HTTP token for "no encoding".
30+
_ALIASES: dict[str, CaptureCompression] = {
31+
"none": CaptureCompression.NONE,
32+
"identity": CaptureCompression.NONE,
33+
"gzip": CaptureCompression.GZIP,
34+
"deflate": CaptureCompression.DEFLATE,
35+
}
36+
37+
38+
def _coerce_explicit(
39+
value: Union[CaptureCompression, str],
40+
) -> CaptureCompression:
41+
"""Normalize an explicitly-supplied compression to a ``CaptureCompression``.
42+
43+
An explicit but unrecognized value is a programming error, so it raises
44+
``ValueError`` rather than silently defaulting (unlike the env var, which is
45+
operator-supplied and defaults defensively).
46+
"""
47+
if isinstance(value, CaptureCompression):
48+
return value
49+
if isinstance(value, str):
50+
resolved = _ALIASES.get(value.strip().lower())
51+
if resolved is not None:
52+
return resolved
53+
raise ValueError(
54+
f"invalid capture_compression {value!r}; expected a CaptureCompression "
55+
f"or one of {sorted(_ALIASES)}"
56+
)
57+
58+
59+
def resolve_capture_compression(
60+
capture_compression: Optional[Union[CaptureCompression, str]] = None,
61+
*,
62+
gzip_fallback: bool = False,
63+
) -> CaptureCompression:
64+
"""Resolve the effective v1 compression.
65+
66+
Precedence: explicit ``capture_compression`` argument >
67+
``POSTHOG_CAPTURE_COMPRESSION`` env var > the legacy ``gzip`` flag
68+
(``GZIP`` when set) > ``NONE``. An unrecognized env value logs a warning and
69+
falls back to the ``gzip`` flag, so a typo never silently changes encoding.
70+
"""
71+
if capture_compression is not None:
72+
return _coerce_explicit(capture_compression)
73+
74+
fallback = CaptureCompression.GZIP if gzip_fallback else CaptureCompression.NONE
75+
76+
raw = os.environ.get(CAPTURE_COMPRESSION_ENV_VAR)
77+
if raw is None or raw.strip() == "":
78+
return fallback
79+
80+
resolved = _ALIASES.get(raw.strip().lower())
81+
if resolved is None:
82+
log.warning(
83+
"Unrecognized %s=%r; falling back to %s. Expected one of %s.",
84+
CAPTURE_COMPRESSION_ENV_VAR,
85+
raw,
86+
fallback.value,
87+
sorted(_ALIASES),
88+
)
89+
return fallback
90+
return resolved

posthog/capture_v1.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -21,28 +21,36 @@
2121
The response is per-event: a 200 carries a ``results`` map keyed by event uuid,
2222
each tagged ``ok``/``warning`` (terminal-success), ``drop`` (terminal-failure),
2323
or ``retry``. :func:`send_v1_batch` resends only the ``retry`` events on the next
24-
attempt (a stable ``PostHog-Request-Id`` and ``created_at`` across attempts; an
25-
incrementing ``PostHog-Attempt``), logs drops, and raises a
26-
:class:`CaptureV1Error` on batch-level/terminal failure or retry exhaustion so
27-
the consumer's existing ``on_error(exc, batch)`` path fires unchanged.
24+
attempt, holding the ``PostHog-Request-Id`` and batch ``created_at`` stable
25+
across attempts while incrementing ``PostHog-Attempt``. ``ok``/``warning``/absent
26+
events succeed; ``drop`` and retry-exhaustion are carried on the
27+
:class:`CaptureV1Error` raised on batch-level/terminal failure, so the consumer's
28+
existing ``on_error(exc, batch)`` path surfaces them unchanged (no per-event
29+
logging of its own).
30+
31+
Request bodies are optionally compressed per :class:`~posthog.capture_compression.CaptureCompression`
32+
(``gzip`` or zlib-wrapped ``deflate``), advertised via ``Content-Encoding``.
2833
"""
2934

3035
import json
3136
import logging
3237
import time
38+
import zlib
3339
from collections.abc import Callable
3440
from dataclasses import dataclass
3541
from datetime import datetime, timezone
3642
from email.utils import parsedate_to_datetime
37-
from typing import TYPE_CHECKING, Any, Optional, cast
43+
from gzip import GzipFile
44+
from io import BytesIO
45+
from typing import TYPE_CHECKING, Any, Optional
3846
from uuid import uuid4
3947

48+
from posthog.capture_compression import CaptureCompression
4049
from posthog.request import (
4150
DatetimeSerializer,
4251
USER_AGENT,
4352
APIError,
4453
_get_session,
45-
gzip_compress,
4654
normalize_host,
4755
)
4856
from posthog.utils import guess_timezone, remove_trailing_slash
@@ -197,14 +205,20 @@ def to_v1_event(msg: dict) -> dict:
197205
return event
198206

199207

200-
def build_v1_batch_body(events: list[dict], historical_migration: bool = False) -> dict:
208+
def build_v1_batch_body(
209+
events: list[dict],
210+
historical_migration: bool = False,
211+
created_at: Optional[str] = None,
212+
) -> dict:
201213
"""Assemble the v1 batch envelope.
202214
203215
Carries no ``api_key`` (Bearer auth) and no ``sent_at``.
204216
``historical_migration`` is omitted when False (the server defaults it).
217+
``created_at`` defaults to now in UTC; :func:`send_v1_batch` passes a value
218+
hoisted once so it stays stable across retry attempts.
205219
"""
206220
body: dict[str, Any] = {
207-
"created_at": datetime.now(timezone.utc).isoformat(),
221+
"created_at": created_at or datetime.now(timezone.utc).isoformat(),
208222
"batch": events,
209223
}
210224
if historical_migration:
@@ -285,14 +299,35 @@ def _parse_retry_after(header_value: Optional[str]) -> Optional[float]:
285299
return None
286300

287301

302+
def _compress_v1(
303+
compression: CaptureCompression, data: str
304+
) -> tuple[str | bytes, Optional[str]]:
305+
"""Compress a v1 request body, returning ``(body, Content-Encoding token)``.
306+
307+
``GZIP`` emits a gzip stream; ``DEFLATE`` emits a *zlib-wrapped* deflate
308+
stream (RFC 1950, leading ``0x78``) to match posthog-go / posthog-rs and the
309+
server's zlib decoder for ``Content-Encoding: deflate`` — raw, headerless
310+
deflate would be misrouted. ``NONE`` returns the string body and no token.
311+
"""
312+
if compression == CaptureCompression.GZIP:
313+
buf = BytesIO()
314+
with GzipFile(fileobj=buf, mode="w") as gz:
315+
# `data` is produced by json.dumps(), whose default encoding is utf-8.
316+
gz.write(data.encode("utf-8"))
317+
return buf.getvalue(), "gzip"
318+
if compression == CaptureCompression.DEFLATE:
319+
return zlib.compress(data.encode("utf-8")), "deflate"
320+
return data, None
321+
322+
288323
def post_v1(
289324
api_key: str,
290325
host: Optional[str],
291326
batch_body: dict,
292327
*,
293328
attempt: int,
294329
request_id: str,
295-
gzip: bool = False,
330+
compression: CaptureCompression = CaptureCompression.NONE,
296331
timeout: int = 15,
297332
session: Optional["requests.Session"] = None,
298333
) -> "requests.Response":
@@ -301,11 +336,13 @@ def post_v1(
301336
Bearer-authed (no ``api_key`` in the body) with the required v1 headers.
302337
``attempt`` (1-based) and the stable ``request_id`` are echoed via
303338
``PostHog-Attempt``/``PostHog-Request-Id`` so the backend can correlate
304-
retries. Returns the raw response; classification is left to the caller.
339+
retries. The body is compressed per ``compression`` (advertised via
340+
``Content-Encoding``). Returns the raw response; classification is left to
341+
the caller.
305342
"""
306343
trimmed_host = remove_trailing_slash(normalize_host(host))
307344
url = trimmed_host + CAPTURE_V1_PATH
308-
data: str | bytes = json.dumps(batch_body, cls=DatetimeSerializer)
345+
data = json.dumps(batch_body, cls=DatetimeSerializer)
309346
headers = {
310347
"Content-Type": "application/json",
311348
"User-Agent": USER_AGENT,
@@ -315,13 +352,13 @@ def post_v1(
315352
HEADER_REQUEST_ID: request_id,
316353
HEADER_REQUEST_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
317354
}
318-
if gzip:
319-
headers["Content-Encoding"] = "gzip"
320-
data = gzip_compress(cast(str, data))
355+
body, encoding = _compress_v1(compression, data)
356+
if encoding is not None:
357+
headers["Content-Encoding"] = encoding
321358

322359
log.debug("capture v1 POST %s attempt=%s request_id=%s", url, attempt, request_id)
323360
return (session or _get_session()).post(
324-
url, data=data, headers=headers, timeout=timeout
361+
url, data=body, headers=headers, timeout=timeout
325362
)
326363

327364

@@ -404,7 +441,7 @@ def send_v1_batch(
404441
host: Optional[str],
405442
batch: list[dict],
406443
*,
407-
gzip: bool = False,
444+
compression: CaptureCompression = CaptureCompression.NONE,
408445
timeout: int = 15,
409446
max_retries: int = 3,
410447
historical_migration: bool = False,
@@ -414,21 +451,29 @@ def send_v1_batch(
414451
415452
The v1 sibling of ``Consumer._send``: it loops up to ``max_retries + 1``
416453
attempts, but unlike v0 it shrinks the batch to only the events the server
417-
tagged ``retry`` after each 2xx. ``ok``/``warning``/absent events succeed
418-
silently; ``drop`` events are logged (a successful request that the server
419-
chose to drop is not a delivery failure). Raises :class:`CaptureV1Error`
420-
(or the underlying transport exception) on a batch-level terminal/transport
421-
failure or once retries are exhausted, so the caller's ``on_error`` fires.
454+
tagged ``retry`` after each 2xx. ``ok``/``warning``/absent events succeed; a
455+
server-chosen ``drop`` on an otherwise-successful request is not a delivery
456+
failure, so it is not raised or logged per-event (the DEBUG summary tallies
457+
it). Raises :class:`CaptureV1Error` (or the underlying transport exception)
458+
on a batch-level terminal/transport failure or once retries are exhausted —
459+
carrying any ``drops`` and exhausted uuids — so the caller's ``on_error``
460+
fires unchanged. ``request_id`` and the batch ``created_at`` are stable
461+
across attempts; ``PostHog-Attempt`` increments.
422462
"""
423463
request_id = str(uuid4())
464+
# Hoisted once so the batch envelope is byte-identical across retry attempts
465+
# (only the events list shrinks and the attempt header increments).
466+
created_at = datetime.now(timezone.utc).isoformat()
424467
pending_events = [to_v1_event(m) for m in batch]
425468
pending_uuids = [e["uuid"] for e in pending_events]
426469
last_exc: Optional[Exception] = None
427470

428471
for attempt_index in range(max_retries + 1):
429472
attempt = attempt_index + 1
430473
last_attempt = attempt_index == max_retries
431-
body = build_v1_batch_body(pending_events, historical_migration)
474+
body = build_v1_batch_body(
475+
pending_events, historical_migration, created_at=created_at
476+
)
432477

433478
try:
434479
res = post_v1(
@@ -437,7 +482,7 @@ def send_v1_batch(
437482
body,
438483
attempt=attempt,
439484
request_id=request_id,
440-
gzip=gzip,
485+
compression=compression,
441486
timeout=timeout,
442487
session=session,
443488
)
@@ -477,14 +522,6 @@ def send_v1_batch(
477522
drops.append((uid, directive.details))
478523
# ok / warning / unrecognized -> terminal success.
479524

480-
for uid, details in drops:
481-
log.warning(
482-
"capture v1 dropped event uuid=%s request_id=%s details=%s",
483-
uid,
484-
request_id,
485-
details or "",
486-
)
487-
488525
if not retry_uuids:
489526
return
490527
if last_attempt:

posthog/request.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,6 @@ def _mask_tokens_in_url(url: str) -> str:
5050
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
5151

5252

53-
def gzip_compress(data: str) -> bytes:
54-
"""Gzip-compress a UTF-8 string for an ``Content-Encoding: gzip`` body."""
55-
buf = BytesIO()
56-
with GzipFile(fileobj=buf, mode="w") as gz:
57-
# `data` is produced by json.dumps(), whose default encoding is utf-8.
58-
gz.write(data.encode("utf-8"))
59-
return buf.getvalue()
60-
61-
6253
@dataclass
6354
class GetResponse:
6455
"""Response from a GET request with ETag support."""
@@ -251,7 +242,12 @@ def post(
251242
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
252243
if gzip:
253244
headers["Content-Encoding"] = "gzip"
254-
data = gzip_compress(cast(str, data))
245+
buf = BytesIO()
246+
with GzipFile(fileobj=buf, mode="w") as gz:
247+
# 'data' was produced by json.dumps(),
248+
# whose default encoding is utf-8.
249+
gz.write(cast(str, data).encode("utf-8"))
250+
data = buf.getvalue()
255251

256252
res = (session or _get_session()).post(
257253
url, data=data, headers=headers, timeout=timeout

0 commit comments

Comments
 (0)