From 5f4aee4bf16e6c1ee0c560a9e0a37b4c4d448aac Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Sat, 27 Jun 2026 13:20:20 -0700 Subject: [PATCH 1/2] 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. --- .github/workflows/sdk-compliance.yml | 12 +- sdk_compliance_adapter/Dockerfile.v1 | 25 ++++ sdk_compliance_adapter/adapter.py | 140 +++++++++++++++++++++- sdk_compliance_adapter/docker-compose.yml | 12 +- 4 files changed, 181 insertions(+), 8 deletions(-) create mode 100644 sdk_compliance_adapter/Dockerfile.v1 diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index 1dfcb908..be989753 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -13,9 +13,19 @@ on: jobs: compliance: - name: PostHog SDK compliance tests + name: PostHog SDK compliance tests (capture v0) uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@02c049e529001d02f37a534745678e057d371fb0 with: adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" adapter-context: "." test-harness-version: "0.10.0" + report-name: "sdk-compliance-report-v0" + + compliance-v1: + name: PostHog SDK compliance tests (capture v1) + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@02c049e529001d02f37a534745678e057d371fb0 + with: + adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1" + adapter-context: "." + test-harness-version: "0.10.0" + report-name: "sdk-compliance-report-v1" diff --git a/sdk_compliance_adapter/Dockerfile.v1 b/sdk_compliance_adapter/Dockerfile.v1 new file mode 100644 index 00000000..6891837a --- /dev/null +++ b/sdk_compliance_adapter/Dockerfile.v1 @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Copy the SDK source code +COPY posthog/ /app/sdk/posthog/ +COPY setup.py pyproject.toml README.md LICENSE /app/sdk/ + +# Install the SDK from source +RUN cd /app/sdk && pip install --no-cache-dir -e . + +# Install adapter dependencies +RUN pip install --no-cache-dir flask python-dateutil + +# Copy adapter code +COPY sdk_compliance_adapter/adapter.py /app/adapter.py + +# Select the capture-v1 protocol; same adapter code, different runtime mode. +ENV CAPTURE_MODE=v1 + +# Expose port 8080 +EXPOSE 8080 + +# Run the adapter +CMD ["python", "/app/adapter.py"] diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 88dbb691..d9453e00 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -14,6 +14,8 @@ from flask import Flask, jsonify, request from posthog import Client +from posthog.capture_compression import CaptureCompression +from posthog.capture_v1 import _post_v1 as original_post_v1 from posthog.request import EVENTS_ENDPOINT from posthog.request import batch_post as original_batch_post from posthog.version import VERSION @@ -26,6 +28,16 @@ app = Flask(__name__) +# Selects which capture protocol this adapter process speaks. Baked at build +# time via the CAPTURE_MODE env var ("v1" => capture-v1, anything else => legacy +# v0), mirroring the v0/v1 Dockerfile split. One process speaks one mode and +# advertises it via /health capabilities. +CAPTURE_MODE = os.environ.get("CAPTURE_MODE", "") + + +def is_v1() -> bool: + return CAPTURE_MODE == "v1" + class RequestInfo: """Information about an HTTP request made by the SDK""" @@ -132,6 +144,33 @@ def record_request(self, status_code: int, batch: List[Dict], batch_id: str): if retry_attempt > 0: self.total_retries += 1 + def record_request_v1( + self, status_code: int, batch: List[Dict], attempt: int, terminal_count: int + ): + """Record a capture-v1 HTTP attempt. + + Unlike v0, the retry attempt is carried on the request (PostHog-Attempt, + 1-based) rather than inferred from a batch id, and a 2xx no longer means + the whole batch was accepted — only events with a terminal (non-"retry") + per-event result count as sent. + """ + with self.lock: + uuid_list = [event.get("uuid", "") for event in batch] + self.requests_made.append( + RequestInfo( + timestamp_ms=int(time.time() * 1000), + status_code=status_code, + retry_attempt=attempt - 1, + event_count=len(batch), + uuid_list=uuid_list, + ) + ) + if attempt > 1: + self.total_retries += 1 + if 200 <= status_code < 300: + self.total_events_sent += terminal_count + self.pending_events = max(0, self.pending_events - terminal_count) + def record_error(self, error: str): """Record an error""" with self.lock: @@ -188,6 +227,60 @@ def patched_batch_post( raise +def patched_post_v1( + api_key: str, + host: Optional[str], + batch_body: Dict, + *, + attempt: int, + request_id: str, + compression: CaptureCompression = CaptureCompression.NONE, + timeout: int = 15, + session: Any = None, +): + """Patched version of _post_v1 that records requests for /state assertions. + + Mirrors the legacy `patched_batch_post`, but reads the retry attempt from the + call (1-based) and counts only terminal per-event results as sent. + """ + batch = batch_body.get("batch", []) + try: + response = original_post_v1( + api_key, + host, + batch_body, + attempt=attempt, + request_id=request_id, + compression=compression, + timeout=timeout, + session=session, + ) + except Exception as e: + status_code = getattr(e, "status", 0) + state.record_request_v1( + status_code if isinstance(status_code, int) else 0, batch, attempt, 0 + ) + state.record_error(str(e)) + raise + + terminal = 0 + status = response.status_code + if 200 <= status < 300: + try: + results = response.json().get("results", {}) + # Mirror _send_v1_batch: only a non-retry directive is terminal. A + # missing/null `result` is not counted as sent. + terminal = sum( + 1 + for r in results.values() + if (r or {}).get("result") not in (None, "retry") + ) + except Exception: + terminal = 0 + state.record_request_v1(status, batch, attempt, terminal) + return response + + # Monkey-patch the batch_post function import posthog.request # noqa: E402 @@ -198,16 +291,26 @@ def patched_batch_post( posthog.consumer.batch_post = patched_batch_post +# Patch the capture-v1 submitter. `_send_v1_batch` resolves `_post_v1` as a module +# global at call time, so patching it here covers both the async consumer and the +# sync client paths. +import posthog.capture_v1 # noqa: E402 + +posthog.capture_v1._post_v1 = patched_post_v1 + @app.route("/health", methods=["GET"]) def health(): """Health check endpoint""" + capabilities = ( + ["capture_v1", "encoding_gzip"] if is_v1() else ["capture_v0", "encoding_gzip"] + ) return jsonify( { "sdk_name": "posthog-python", "sdk_version": VERSION, "adapter_version": "1.0.0", - "capabilities": ["capture_v0", "encoding_gzip"], + "capabilities": capabilities, } ) @@ -228,6 +331,11 @@ def init(): flush_interval_ms = data.get("flush_interval_ms", 500) max_retries = data.get("max_retries", 3) enable_compression = data.get("enable_compression", False) + # Compliance tests assert the request-level default when callers omit + # disable_geoip, so the adapter default keeps geoip-enabled /flags + # requests while still allowing per-call overrides. + disable_geoip = data.get("disable_geoip", False) + historical_migration = data.get("historical_migration", False) if not api_key: return jsonify({"error": "api_key is required"}), 400 @@ -237,6 +345,9 @@ def init(): # Convert flush_interval from ms to seconds flush_interval = flush_interval_ms / 1000.0 + # One adapter process speaks one capture protocol, selected by CAPTURE_MODE. + capture_mode = "v1" if is_v1() else "v0" + # Create client client = Client( project_api_key=api_key, @@ -246,10 +357,9 @@ def init(): gzip=enable_compression, max_retries=max_retries, debug=False, - # Compliance tests assert the request-level default when callers omit - # disable_geoip. Configure the adapter to exercise geoip-enabled - # /flags requests by default while still allowing per-call overrides. - disable_geoip=False, + disable_geoip=disable_geoip, + historical_migration=historical_migration, + capture_mode=capture_mode, ) state.client = client @@ -257,7 +367,9 @@ def init(): logger.info( f"Initialized SDK with api_key={api_key[:10]}..., host={host}, " f"flush_at={flush_at}, flush_interval={flush_interval}, " - f"max_retries={max_retries}, gzip={enable_compression}" + f"max_retries={max_retries}, gzip={enable_compression}, " + f"capture_mode={capture_mode}, disable_geoip={disable_geoip}, " + f"historical_migration={historical_migration}" ) return jsonify({"success": True}) @@ -279,12 +391,28 @@ def capture(): event = data.get("event") properties = data.get("properties") timestamp = data.get("timestamp") + options = data.get("options") if not distinct_id: return jsonify({"error": "distinct_id is required"}), 400 if not event: return jsonify({"error": "event is required"}), 400 + # Fold capture-v1 options back into the magic `$`-prefixed properties the + # SDK lifts onto the wire `options` object. Renamed keys mirror the SDK's + # sentinel table; unknown keys get a bare `$` prefix. v0 has no wire + # options object, so this only applies in v1 mode. + if options and is_v1(): + properties = dict(properties or {}) + option_to_property = { + "cookieless_mode": "$cookieless_mode", + "disable_skew_correction": "$ignore_sent_at", + "process_person_profile": "$process_person_profile", + "product_tour_id": "$product_tour_id", + } + for key, value in options.items(): + properties[option_to_property.get(key, "$" + key)] = value + # Capture event kwargs = {"distinct_id": distinct_id, "properties": properties} if timestamp: diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 471db84d..e6519de5 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.8" services: - # PostHog Python SDK adapter + # PostHog Python SDK adapter (capture v0) sdk-adapter: build: context: .. @@ -11,6 +11,16 @@ services: networks: - test-network + # PostHog Python SDK adapter (capture v1) + sdk-adapter-v1: + build: + context: .. + dockerfile: sdk_compliance_adapter/Dockerfile.v1 + ports: + - "8082:8080" + networks: + - test-network + # Test harness test-harness: image: ghcr.io/posthog/sdk-test-harness:0.10.0 From 91b8c0a09a57902ed84efded8611f8069840f779 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Mon, 6 Jul 2026 13:59:38 -0700 Subject: [PATCH 2/2] 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 --- .sampo/changesets/capture-v1-mode.md | 9 +++++++++ AGENTS.md | 21 +++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 .sampo/changesets/capture-v1-mode.md diff --git a/.sampo/changesets/capture-v1-mode.md b/.sampo/changesets/capture-v1-mode.md new file mode 100644 index 00000000..cffd1471 --- /dev/null +++ b/.sampo/changesets/capture-v1-mode.md @@ -0,0 +1,9 @@ +--- +pypi/posthog: minor +--- + +Add an opt-in `capture_mode` for the Capture V1 ingestion protocol (`POST /i/v1/analytics/events`). Set `capture_mode="v1"` on the client (or the `POSTHOG_CAPTURE_MODE=v1` environment variable) to use Bearer auth, per-event results, and partial retry. Defaults to `"v0"` (the legacy `/batch/` endpoint), so existing setups are unaffected. + +When using `capture_mode="v1"`, request bodies can be compressed via `capture_compression` (or `POSTHOG_CAPTURE_COMPRESSION`): `"gzip"`, `"deflate"`, `"zstd"` (requires the optional `posthog[zstd]` extra), or `"none"` (default). The legacy `gzip=True` flag is honored as a fallback. + +Per-event server verdicts are surfaced through the existing `on_error` handler: events the backend explicitly drops, or fails to accept after retries, raise a `CaptureV1Error` carrying the affected event UUIDs — so a rejection is never silently lost, even when the HTTP request itself succeeded. diff --git a/AGENTS.md b/AGENTS.md index 8a53f11d..7e0af378 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,27 @@ Guidance for coding agents working in `posthog-python`. - The project uses `uv` for local development. See `CONTRIBUTING.md` for setup. - Keep edits targeted and follow existing patterns. Prefer adding or updating tests near the behavior you change. +## Capture protocol (`capture_mode`) + +The client supports two ingestion wire protocols, selected by `capture_mode` (precedence: explicit `Client(capture_mode=...)` kwarg > `POSTHOG_CAPTURE_MODE` env var > default). + +- `"v0"` (default) — legacy `POST /batch/`. Upgrades stay transparent; existing callers are unaffected. +- `"v1"` — `POST /i/v1/analytics/events`: Bearer auth, a typed event `options` object, per-event results, and partial retry. + +v1 request bodies can additionally be compressed via `capture_compression` (precedence: explicit `Client(capture_compression=...)` kwarg > `POSTHOG_CAPTURE_COMPRESSION` env var > the legacy `gzip` flag > none). Supported values are `"none"`, `"gzip"`, `"deflate"` (zlib-wrapped, RFC 1950, to match the server's decoder and the Go/Rust SDKs), and `"zstd"` (requires the optional `posthog[zstd]` extra; explicit zstd without the package raises, env-var zstd warns and falls back). v0 keeps using its own `gzip` flag; `capture_compression` is v1-only. + +Where the pieces live: + +- `posthog/capture_mode.py` — the `CaptureMode` enum and `_resolve_capture_mode()` precedence logic. +- `posthog/capture_compression.py` — the `CaptureCompression` enum and `_resolve_capture_compression()` precedence logic (with `gzip` fallback). +- `posthog/capture_v1.py` — pure transforms (`_to_v1_event`, `_build_v1_batch_body`) and transport (`_post_v1`, `_compress_v1`, `_parse_v1_response`, `_send_v1_batch`, `CaptureV1Error`). +- Public API surface (enforced by `references/public_api_snapshot.txt`): `CaptureMode`, `CaptureCompression` (both re-exported from `posthog`), `CaptureV1Error`, and the two env var names. Everything else in these modules is underscore-private plumbing. +- Routing: `Consumer._send_analytics` (async) and `Client._enqueue` (sync) pick the analytics submitter by `capture_mode`. The dedicated `$ai_*` endpoint has no v1 form and always uses the legacy submitter. + +v1-specific behavior to preserve when editing: sentinel `$`-properties are lifted into `options` (coerced to native JSON types or omitted — a wrong type 400s the whole batch); top-level `$set`/`$set_once` are relocated into `properties`; only events the server tags `retry` are resent (stable `PostHog-Request-Id`/`created_at`, incrementing `PostHog-Attempt`); a server `drop` is a terminal per-event rejection — drops are accumulated across attempts and surfaced via `CaptureV1Error`/`on_error` even on a 2xx with no retries (a success status is not full delivery); `Retry-After` is a *minimum*, not a replacement (the client waits `max(configured_backoff, min(Retry-After, _MAX_BACKOFF_SECONDS))`); `_MAX_BACKOFF_SECONDS` (30s) is the single ceiling for both the exponential backoff and the `Retry-After` clamp; `429` is terminal. + +Retry blocking matches v0: in the default async mode retries happen on the background consumer thread, but with `sync_mode=True` the partial-retry loop (including its backoff sleeps) runs inline on the calling thread, so a slow/erroring endpoint blocks the caller until retries are exhausted. + ## Validation Useful checks: