Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
9 changes: 9 additions & 0 deletions .sampo/changesets/capture-v1-mode.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 25 additions & 0 deletions sdk_compliance_adapter/Dockerfile.v1
Original file line number Diff line number Diff line change
@@ -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"]
140 changes: 134 additions & 6 deletions sdk_compliance_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
)
Comment thread
eli-r-ph marked this conversation as resolved.
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

Expand All @@ -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,
}
)

Expand All @@ -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
Expand All @@ -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,
Expand All @@ -246,18 +357,19 @@ 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

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})
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion sdk_compliance_adapter/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
version: "3.8"

services:
# PostHog Python SDK adapter
# PostHog Python SDK adapter (capture v0)
sdk-adapter:
build:
context: ..
Expand All @@ -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
Expand Down
Loading