2121The response is per-event: a 200 carries a ``results`` map keyed by event uuid,
2222each tagged ``ok``/``warning`` (terminal-success), ``drop`` (terminal-failure),
2323or ``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
3035import json
3136import logging
3237import time
38+ import zlib
3339from collections .abc import Callable
3440from dataclasses import dataclass
3541from datetime import datetime , timezone
3642from 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
3846from uuid import uuid4
3947
48+ from posthog .capture_compression import CaptureCompression
4049from posthog .request import (
4150 DatetimeSerializer ,
4251 USER_AGENT ,
4352 APIError ,
4453 _get_session ,
45- gzip_compress ,
4654 normalize_host ,
4755)
4856from 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+
288323def 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 :
0 commit comments