feat(capture): v1 transport + partial-retry send loop (capture v1, 3/6)#703
Conversation
|
Reviews (1): Last reviewed commit: "feat(capture): add v1 transport and part..." | Re-trigger Greptile |
posthog-python Compliance ReportDate: 2026-07-06 21:21:34 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
a901fdc to
7fd7dcd
Compare
419d8ac to
41c6948
Compare
7fd7dcd to
32d7b02
Compare
41c6948 to
d6d4aa2
Compare
32d7b02 to
3677400
Compare
3677400 to
c698bd5
Compare
d6d4aa2 to
4ee420a
Compare
|
Reviews (2): Last reviewed commit: "fix(capture): stabilize v1 created_at, i..." | Re-trigger Greptile |
|
2 things here
posthog/capture_v1.py:510-526 Python collects drop results, but if there are no retry events it returns successfully and never surfaces the dropped Go does this differently: posthog-go/capture_v1_send.go:173-174 calls failure callback immediately for drop. Rust also surfaces 2xx drop/final retry outcomes through on_error: posthog-rs/src/client/transport.rs:835-850. This means Python users won’t be told that the backend explicitly rejected an event.
posthog/capture_v1.py:404-413 Python sleeps exactly Retry-After when present. Go/Rust treat Retry-After as a minimum and wait for max(configured_backoff, retry_after):
With a small Retry-After, Python retries earlier than the normal exponential backoff, which drifts from the canonical |
|
👋 looked into it, PR parity issues are addressed, thanks for the callout! Also surfaced a couple of other small parity Issues across the 3 SDKs (some extending into prior defaults that effect v1, not v1-specific stuff) so opening a couple other tiny PRs to try to normalize so these things can be gated/smoke tested in a backend SDK-agnostic way as the porting continues 👍 |
|
👋 update on this:
Spot changes to those SDKs: |
| ) | ||
|
|
||
|
|
||
| def resolve_capture_compression( |
There was a problem hiding this comment.
can we make this internal? eg _resolve_capture_compression
| CAPTURE_COMPRESSION_ENV_VAR = "POSTHOG_CAPTURE_COMPRESSION" | ||
|
|
||
|
|
||
| class CaptureCompression(str, Enum): |
There was a problem hiding this comment.
May want to add zstd to this list, it's quite a bit faster/better than gzip or zlib/deflate, and is available for free on some platforms (it is on node, not sure about python I'm afraid!)
There was a problem hiding this comment.
I'm rolling that in now, good call. Will shim slightly b/c this will be an external lib until Python 3.14 when it lands in stdlib 👍 (this is why I skipped initially)
Adds the HTTP transport for POST /i/v1/analytics/events alongside the pure transforms: a single Bearer-authed attempt (post_v1) with the required v1 headers, response classification (parse_v1_response), and the send loop (send_v1_batch) that resends only the events the server tags "retry", logs drops, honors Retry-After, and raises CaptureV1Error on terminal/transport failure or retry exhaustion so the consumer's existing on_error path fires unchanged. 429 is terminal in v1 (unlike v0). A 2xx with an unparseable body is terminal to avoid an infinite resend loop. A stable PostHog-Request-Id and created_at span attempts; PostHog-Attempt increments. Factors a shared gzip_compress helper out of request.post (no v0 behavior change). Still inert: nothing calls send_v1_batch until the consumer wiring PR. 74 capture_v1 tests (47 transform + 27 transport); ruff/mypy clean.
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.
Address review of the v1 transport (#703): - Accumulate server `drop` verdicts across all attempts and raise CaptureV1Error even on a 2xx with no retry events (a success status is not full delivery) and when a later attempt clears the retries, so on_error sees every dropped uuid. Drops also ride along on the retry-exhaustion, malformed-2xx, and terminal non-2xx errors. - _backoff treats Retry-After as a minimum (max of configured backoff and Retry-After), hard-capped at 1 day, matching posthog-go/posthog-rs so a small header can't retry earlier than the schedule and a hostile one can't park the consumer thread. Adds drop-surfacing and backoff tests; regenerates public_api_snapshot.
Replace the 1-day RETRY_BACKOFF_CAP_SECONDS with a single MAX_BACKOFF_SECONDS (30s) that both caps the exponential backoff and clamps the server Retry-After, so the max retry wait is bounded and the default matches posthog-go/posthog-rs. Retry-After remains a minimum; there is no separate large cap.
Underscore-prefix the transport layer (post_v1, parse_v1_response, send_v1_batch, response dataclasses, backoff ceiling) and the compression resolver; declare __all__ so the public surface is CaptureCompression, its env var name, and CaptureV1Error (which reaches user code via on_error callbacks). Re-export CaptureCompression at the package top level for symmetry with CaptureMode.
Add CaptureCompression.ZSTD backed by the optional zstandard package (pip install posthog[zstd]); Python has no stdlib zstd until 3.14. Explicitly requesting zstd without the package raises ValueError (fail loud at construction); requesting it via POSTHOG_CAPTURE_COMPRESSION warns and falls back so operator config never silently breaks capture. The server already decodes Content-Encoding: zstd standard frames.
4ee420a to
e7bb960
Compare
58cb3e5 to
46bbd7d
Compare
Rename the env-var lookup binding so it is not confused with the explicit-kwarg resolved value (CaptureCompression | None vs CaptureCompression).
Annotate the module binding explicitly so mypy stays clean whether or not the zstandard package is installed in the dev environment.
…#704) * feat(capture): route analytics through v1 submitter when enabled Wires send_v1_batch into both send paths so capture_mode actually takes effect end to end. The consumer's async path (Consumer.request) and the client's sync path (_enqueue) now pick the analytics submitter by capture_mode: v1 -> the partial-retry send loop, v0 -> the legacy batch_post. The dedicated AI endpoint has no v1 form, so $ai_* events on it always use the legacy submitter regardless of capture_mode. Refactors Consumer.request to route via _send_analytics/_send_ai helpers, stores Client.max_retries so the sync path can pass it through, and forwards gzip/timeout/retries/historical_migration to the v1 submitter. Default is still v0, so existing callers are unaffected. Adds consumer routing-matrix tests (v0/v1, dedicated-AI split, config forwarding) and client sync-mode tests (v0 vs v1, dedicated-AI event stays legacy, analytics event uses v1). ruff/mypy clean. * feat(capture): wire capture_compression through client and consumer Resolve capture_compression once on the client (kwarg > env > legacy gzip flag > none) and thread it to the v1 submitter via the consumer and the sync path. Parameterize the capture_mode routing tests and use consistent submitter labels. * chore(capture): test $ai_* rides v1 when dedicated AI endpoint disabled * chore(capture): v1 compliance adapter + CI job (capture v1, 5/6) (#705) * chore(capture): add v1 compliance adapter mode and CI job Teaches the SDK compliance adapter to speak capture-v1 and adds a second CI job that runs the harness capture_v1 suite against it. Splits the workflow into v0 + v1 jobs, both on the 0.10.0 harness (which carries the capture_v1 suites), and adds adapter timing/debug settings for reliable test execution. * chore(capture): document capture_mode + release changeset (capture v1, 6/6) (#706) * docs(capture): document capture_mode, capture_compression, and changeset Adds the Sampo changeset for the opt-in capture_mode (v1 ingestion protocol) and capture_compression, plus an AGENTS.md section mapping capture_mode/capture_compression and their env vars to the modules and routing that implement them, the v1 invariants to preserve, and the sync_mode blocking-retry behavior. User-facing usage stays in the official docs per the README convention. * docs(capture): note v1 drop surfacing and Retry-After minimum Document the two parity behaviors added to the v1 transport: server `drop` verdicts are accumulated across attempts and surfaced via CaptureV1Error/ on_error even on a 2xx, and Retry-After acts as a minimum (max of configured backoff and Retry-After, hard-capped) rather than a replacement. * docs(capture): note the unified 30s retry backoff ceiling MAX_BACKOFF_SECONDS (30s) caps both the exponential backoff and the Retry-After clamp; update the v1 invariants note accordingly. * docs(capture): document zstd compression and private helper names
* feat(capture): add v1 wire serialization transforms Add posthog/capture_v1.py with the pure (no-I/O) transform layer for /i/v1/analytics/events: - to_v1_event(): lifts sentinel properties into the typed options object (with the $ignore_sent_at -> disable_skew_correction rename), promotes $session_id/$window_id to top-level fields, relocates top-level $set/$set_once into properties (v1 has no top-level form), and strips $lib/$lib_version (server injects them from PostHog-Sdk-Info). Options are coerced to native JSON types or omitted, since a wrong type would 400 the whole batch. Pure: the input message is not mutated. - build_v1_batch_body(): the api_key/sent_at-free envelope with a tz-aware RFC3339 created_at. - Shared constants: path, required header names, result codes, retryable/ terminal status sets. Stacked on the capture_mode scaffolding; still inert (nothing calls these yet). * refactor(capture): store v1 option coercers as callables Hold the coercer function directly in _OPTION_SENTINELS instead of a stringly-typed name keyed through a side _COERCERS dict, removing a KeyError foot-gun and tightening the types. * refactor(capture): privatize v1 transform helpers Underscore-prefix the wire constants and transform functions and declare an empty __all__: the transforms are submitter plumbing, not public API. The user-facing surface (CaptureMode etc.) is exported elsewhere. * feat(capture): v1 transport + partial-retry send loop (capture v1, 3/6) (#703) * feat(capture): add v1 transport and partial-retry send loop Adds the HTTP transport for POST /i/v1/analytics/events alongside the pure transforms: a single Bearer-authed attempt (post_v1) with the required v1 headers, response classification (parse_v1_response), and the send loop (send_v1_batch) that resends only the events the server tags "retry", logs drops, honors Retry-After, and raises CaptureV1Error on terminal/transport failure or retry exhaustion so the consumer's existing on_error path fires unchanged. 429 is terminal in v1 (unlike v0). A 2xx with an unparseable body is terminal to avoid an infinite resend loop. A stable PostHog-Request-Id and created_at span attempts; PostHog-Attempt increments. Factors a shared gzip_compress helper out of request.post (no v0 behavior change). Still inert: nothing calls send_v1_batch until the consumer wiring PR. 74 capture_v1 tests (47 transform + 27 transport); ruff/mypy clean. * 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. * fix(capture): surface v1 drops and treat Retry-After as a minimum Address review of the v1 transport (#703): - Accumulate server `drop` verdicts across all attempts and raise CaptureV1Error even on a 2xx with no retry events (a success status is not full delivery) and when a later attempt clears the retries, so on_error sees every dropped uuid. Drops also ride along on the retry-exhaustion, malformed-2xx, and terminal non-2xx errors. - _backoff treats Retry-After as a minimum (max of configured backoff and Retry-After), hard-capped at 1 day, matching posthog-go/posthog-rs so a small header can't retry earlier than the schedule and a hostile one can't park the consumer thread. Adds drop-surfacing and backoff tests; regenerates public_api_snapshot. * fix(capture): unify v1 retry backoff ceiling at 30s Replace the 1-day RETRY_BACKOFF_CAP_SECONDS with a single MAX_BACKOFF_SECONDS (30s) that both caps the exponential backoff and clamps the server Retry-After, so the max retry wait is bounded and the default matches posthog-go/posthog-rs. Retry-After remains a minimum; there is no separate large cap. * refactor(capture): privatize v1 transport and compression resolver Underscore-prefix the transport layer (post_v1, parse_v1_response, send_v1_batch, response dataclasses, backoff ceiling) and the compression resolver; declare __all__ so the public surface is CaptureCompression, its env var name, and CaptureV1Error (which reaches user code via on_error callbacks). Re-export CaptureCompression at the package top level for symmetry with CaptureMode. * feat(capture): add optional zstd compression for capture v1 Add CaptureCompression.ZSTD backed by the optional zstandard package (pip install posthog[zstd]); Python has no stdlib zstd until 3.14. Explicitly requesting zstd without the package raises ValueError (fail loud at construction); requesting it via POSTHOG_CAPTURE_COMPRESSION warns and falls back so operator config never silently breaks capture. The server already decodes Content-Encoding: zstd standard frames. * fix(capture): satisfy mypy on env compression resolution Rename the env-var lookup binding so it is not confused with the explicit-kwarg resolved value (CaptureCompression | None vs CaptureCompression). * fix(capture): avoid unused-ignore on optional zstandard import Annotate the module binding explicitly so mypy stays clean whether or not the zstandard package is installed in the dev environment. * feat(capture): route analytics through v1 submitter (capture v1, 4/6) (#704) * feat(capture): route analytics through v1 submitter when enabled Wires send_v1_batch into both send paths so capture_mode actually takes effect end to end. The consumer's async path (Consumer.request) and the client's sync path (_enqueue) now pick the analytics submitter by capture_mode: v1 -> the partial-retry send loop, v0 -> the legacy batch_post. The dedicated AI endpoint has no v1 form, so $ai_* events on it always use the legacy submitter regardless of capture_mode. Refactors Consumer.request to route via _send_analytics/_send_ai helpers, stores Client.max_retries so the sync path can pass it through, and forwards gzip/timeout/retries/historical_migration to the v1 submitter. Default is still v0, so existing callers are unaffected. Adds consumer routing-matrix tests (v0/v1, dedicated-AI split, config forwarding) and client sync-mode tests (v0 vs v1, dedicated-AI event stays legacy, analytics event uses v1). ruff/mypy clean. * feat(capture): wire capture_compression through client and consumer Resolve capture_compression once on the client (kwarg > env > legacy gzip flag > none) and thread it to the v1 submitter via the consumer and the sync path. Parameterize the capture_mode routing tests and use consistent submitter labels. * chore(capture): test $ai_* rides v1 when dedicated AI endpoint disabled * chore(capture): v1 compliance adapter + CI job (capture v1, 5/6) (#705) * chore(capture): add v1 compliance adapter mode and CI job Teaches the SDK compliance adapter to speak capture-v1 and adds a second CI job that runs the harness capture_v1 suite against it. Splits the workflow into v0 + v1 jobs, both on the 0.10.0 harness (which carries the capture_v1 suites), and adds adapter timing/debug settings for reliable test execution. * chore(capture): document capture_mode + release changeset (capture v1, 6/6) (#706) * docs(capture): document capture_mode, capture_compression, and changeset Adds the Sampo changeset for the opt-in capture_mode (v1 ingestion protocol) and capture_compression, plus an AGENTS.md section mapping capture_mode/capture_compression and their env vars to the modules and routing that implement them, the v1 invariants to preserve, and the sync_mode blocking-retry behavior. User-facing usage stays in the official docs per the README convention. * docs(capture): note v1 drop surfacing and Retry-After minimum Document the two parity behaviors added to the v1 transport: server `drop` verdicts are accumulated across attempts and surfaced via CaptureV1Error/ on_error even on a 2xx, and Retry-After acts as a minimum (max of configured backoff and Retry-After, hard-capped) rather than a replacement. * docs(capture): note the unified 30s retry backoff ceiling MAX_BACKOFF_SECONDS (30s) caps both the exponential backoff and the Retry-After clamp; update the v1 invariants note accordingly. * docs(capture): document zstd compression and private helper names
…701) * feat(capture): add capture_mode config scaffolding Introduce a CaptureMode enum (V0 legacy /batch/, V1 /i/v1/analytics/events) and resolve_capture_mode() with precedence kwarg > POSTHOG_CAPTURE_MODE env > V0. Plumb capture_mode through Client and Consumer (including fork-reinit and the module-level default client). The mode is resolved and stored but inert in this change: V0 still runs everywhere, so behavior is unchanged. First of a stacked series adding Capture V1 support. * test(capture): prove capture_mode kwarg precedence over env Set the env to the opposite mode in each precedence row so every case actually exercises kwarg-over-env, and assert an invalid kwarg raises even when a valid env value is present. * refactor(capture): make capture-mode resolver internal Rename resolve_capture_mode to _resolve_capture_mode and declare __all__ so only CaptureMode and the env var name are public API. The resolver is plumbing for Client.__init__, not a user entry point. * feat(capture): v1 wire serialization transforms (capture v1, 2/6) (#702) * feat(capture): add v1 wire serialization transforms Add posthog/capture_v1.py with the pure (no-I/O) transform layer for /i/v1/analytics/events: - to_v1_event(): lifts sentinel properties into the typed options object (with the $ignore_sent_at -> disable_skew_correction rename), promotes $session_id/$window_id to top-level fields, relocates top-level $set/$set_once into properties (v1 has no top-level form), and strips $lib/$lib_version (server injects them from PostHog-Sdk-Info). Options are coerced to native JSON types or omitted, since a wrong type would 400 the whole batch. Pure: the input message is not mutated. - build_v1_batch_body(): the api_key/sent_at-free envelope with a tz-aware RFC3339 created_at. - Shared constants: path, required header names, result codes, retryable/ terminal status sets. Stacked on the capture_mode scaffolding; still inert (nothing calls these yet). * refactor(capture): store v1 option coercers as callables Hold the coercer function directly in _OPTION_SENTINELS instead of a stringly-typed name keyed through a side _COERCERS dict, removing a KeyError foot-gun and tightening the types. * refactor(capture): privatize v1 transform helpers Underscore-prefix the wire constants and transform functions and declare an empty __all__: the transforms are submitter plumbing, not public API. The user-facing surface (CaptureMode etc.) is exported elsewhere. * feat(capture): v1 transport + partial-retry send loop (capture v1, 3/6) (#703) * feat(capture): add v1 transport and partial-retry send loop Adds the HTTP transport for POST /i/v1/analytics/events alongside the pure transforms: a single Bearer-authed attempt (post_v1) with the required v1 headers, response classification (parse_v1_response), and the send loop (send_v1_batch) that resends only the events the server tags "retry", logs drops, honors Retry-After, and raises CaptureV1Error on terminal/transport failure or retry exhaustion so the consumer's existing on_error path fires unchanged. 429 is terminal in v1 (unlike v0). A 2xx with an unparseable body is terminal to avoid an infinite resend loop. A stable PostHog-Request-Id and created_at span attempts; PostHog-Attempt increments. Factors a shared gzip_compress helper out of request.post (no v0 behavior change). Still inert: nothing calls send_v1_batch until the consumer wiring PR. 74 capture_v1 tests (47 transform + 27 transport); ruff/mypy clean. * 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. * fix(capture): surface v1 drops and treat Retry-After as a minimum Address review of the v1 transport (#703): - Accumulate server `drop` verdicts across all attempts and raise CaptureV1Error even on a 2xx with no retry events (a success status is not full delivery) and when a later attempt clears the retries, so on_error sees every dropped uuid. Drops also ride along on the retry-exhaustion, malformed-2xx, and terminal non-2xx errors. - _backoff treats Retry-After as a minimum (max of configured backoff and Retry-After), hard-capped at 1 day, matching posthog-go/posthog-rs so a small header can't retry earlier than the schedule and a hostile one can't park the consumer thread. Adds drop-surfacing and backoff tests; regenerates public_api_snapshot. * fix(capture): unify v1 retry backoff ceiling at 30s Replace the 1-day RETRY_BACKOFF_CAP_SECONDS with a single MAX_BACKOFF_SECONDS (30s) that both caps the exponential backoff and clamps the server Retry-After, so the max retry wait is bounded and the default matches posthog-go/posthog-rs. Retry-After remains a minimum; there is no separate large cap. * refactor(capture): privatize v1 transport and compression resolver Underscore-prefix the transport layer (post_v1, parse_v1_response, send_v1_batch, response dataclasses, backoff ceiling) and the compression resolver; declare __all__ so the public surface is CaptureCompression, its env var name, and CaptureV1Error (which reaches user code via on_error callbacks). Re-export CaptureCompression at the package top level for symmetry with CaptureMode. * feat(capture): add optional zstd compression for capture v1 Add CaptureCompression.ZSTD backed by the optional zstandard package (pip install posthog[zstd]); Python has no stdlib zstd until 3.14. Explicitly requesting zstd without the package raises ValueError (fail loud at construction); requesting it via POSTHOG_CAPTURE_COMPRESSION warns and falls back so operator config never silently breaks capture. The server already decodes Content-Encoding: zstd standard frames. * fix(capture): satisfy mypy on env compression resolution Rename the env-var lookup binding so it is not confused with the explicit-kwarg resolved value (CaptureCompression | None vs CaptureCompression). * fix(capture): avoid unused-ignore on optional zstandard import Annotate the module binding explicitly so mypy stays clean whether or not the zstandard package is installed in the dev environment. * feat(capture): route analytics through v1 submitter (capture v1, 4/6) (#704) * feat(capture): route analytics through v1 submitter when enabled Wires send_v1_batch into both send paths so capture_mode actually takes effect end to end. The consumer's async path (Consumer.request) and the client's sync path (_enqueue) now pick the analytics submitter by capture_mode: v1 -> the partial-retry send loop, v0 -> the legacy batch_post. The dedicated AI endpoint has no v1 form, so $ai_* events on it always use the legacy submitter regardless of capture_mode. Refactors Consumer.request to route via _send_analytics/_send_ai helpers, stores Client.max_retries so the sync path can pass it through, and forwards gzip/timeout/retries/historical_migration to the v1 submitter. Default is still v0, so existing callers are unaffected. Adds consumer routing-matrix tests (v0/v1, dedicated-AI split, config forwarding) and client sync-mode tests (v0 vs v1, dedicated-AI event stays legacy, analytics event uses v1). ruff/mypy clean. * feat(capture): wire capture_compression through client and consumer Resolve capture_compression once on the client (kwarg > env > legacy gzip flag > none) and thread it to the v1 submitter via the consumer and the sync path. Parameterize the capture_mode routing tests and use consistent submitter labels. * chore(capture): test $ai_* rides v1 when dedicated AI endpoint disabled * chore(capture): v1 compliance adapter + CI job (capture v1, 5/6) (#705) * chore(capture): add v1 compliance adapter mode and CI job Teaches the SDK compliance adapter to speak capture-v1 and adds a second CI job that runs the harness capture_v1 suite against it. Splits the workflow into v0 + v1 jobs, both on the 0.10.0 harness (which carries the capture_v1 suites), and adds adapter timing/debug settings for reliable test execution. * chore(capture): document capture_mode + release changeset (capture v1, 6/6) (#706) * docs(capture): document capture_mode, capture_compression, and changeset Adds the Sampo changeset for the opt-in capture_mode (v1 ingestion protocol) and capture_compression, plus an AGENTS.md section mapping capture_mode/capture_compression and their env vars to the modules and routing that implement them, the v1 invariants to preserve, and the sync_mode blocking-retry behavior. User-facing usage stays in the official docs per the README convention. * docs(capture): note v1 drop surfacing and Retry-After minimum Document the two parity behaviors added to the v1 transport: server `drop` verdicts are accumulated across attempts and surfaced via CaptureV1Error/ on_error even on a 2xx, and Retry-After acts as a minimum (max of configured backoff and Retry-After, hard-capped) rather than a replacement. * docs(capture): note the unified 30s retry backoff ceiling MAX_BACKOFF_SECONDS (30s) caps both the exponential backoff and the Retry-After clamp; update the v1 invariants note accordingly. * docs(capture): document zstd compression and private helper names
💡 Motivation and Context
Third PR in the stacked Capture V1 series (stacked on #702). Adds the HTTP transport and partial-retry send loop for
POST /i/v1/analytics/events, on top of the pure transforms from #702. Still inert —_send_v1_batchhas no caller until the consumer-wiring PR.New in
posthog/capture_v1.py(transport helpers are private; onlyCaptureV1Erroris public):_post_v1(...)— a single attempt. Bearer auth (noapi_keyin the body), the required v1 headers (PostHog-Sdk-Info,PostHog-Attempt,PostHog-Request-Id,PostHog-Request-Timestamp), and optional body compression. Returns the raw response; this is also the monkeypatch seam the test harness adapter will drive._parse_v1_response(...)— classifies one response without raising: 2xx parses the per-uuidresultsmap (an unparseable 2xx body is flaggedmalformed), non-2xx best-effort extracts an error message, andRetry-After(delta-seconds or HTTP-date) is parsed in both cases._send_v1_batch(...)— the v1 sibling ofConsumer._send. Loops up tomax_retries + 1attempts, but shrinks the batch to only the events the server taggedretryafter each 2xx.ok/warning/absent events succeed silently;dropevents are logged (a request the server accepted-but-dropped is not a delivery failure, so it is not raised). RaisesCaptureV1Error(anAPIErrorsubclass, so the consumer's existingon_error(exc, batch)keeps working) on a batch-level terminal/transport failure or once retries are exhausted.CaptureV1Error(public).Also adds
posthog/capture_compression.py:CaptureCompressionenum (none/gzip/deflate/zstd) with internal_resolve_capture_compression. ZSTD needs the optionalposthog[zstd]extra (zstandardpackage); explicit kwarg without it raises, env var without it warns and falls back.Behavior choices, verified against the Rust contract and posthog-go's
capture_v1_send.go:Retry-After.PostHog-Request-Idandcreated_atspan all attempts;PostHog-Attemptincrements — so the backend can correlate/dedupe a retried batch.Retry-Afterwins, else capped exponential) so both wire protocols back off identically.Also factors a shared
gzip_compresshelper out ofrequest.post— pure refactor, no v0 behavior change (covered by the existingtest_request.py).💚 How did you test it?
posthog/test/test_capture_v1.pynow has 74 cases (47 transform + 27 transport). New transport coverage:post_v1header/url/no-key-in-body/gzip-magic;parse_v1_responsesuccess/malformed/missing-results/error-body-variants/text-fallback/Retry-After; andsend_v1_batchdriven by a stubbedpost_v1with mocked sleeps — all-ok, absent-uuid-accepted, partial-retry-shrinks-to-retry-uuids, stable-request-id + incrementing-attempt, drop-logged-not-raised, retry-exhausted-raises, malformed-2xx-terminal, 400/429-terminal-not-retried, 503-then-success (honorsRetry-After), 503-exhausted-raises, transport-error-then-success, transport-error-exhausted-reraises.ruff format/checkclean;mypyclean oncapture_v1.py+request.py;test_request.py(61) still green after the gzip refactor; regeneratedreferences/public_api_snapshot.txt.📝 Checklist
send_v1_batchhas no caller yet).🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Authored with Cursor (Claude Opus 4.8) per the agreed plan. posthog-go's
sendV1is woven into its client lifecycle (channels,notifyFailure/notifySuccess,maxAttempts); this port instead fits posthog-python'sConsumer._sendshape — a synchronous loop that raises on failure so the existingupload()->on_error(exc, batch)path fires unchanged — while preserving go's partial-retry algorithm, status matrix, stable-request-id semantics, and per-event drop/retry handling.