From 52f0e04fa91812a840313e295a45e923479a01ac Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 27 Oct 2026 15:12:07 -0700 Subject: [PATCH 1/6] chore: run the capture v1 compliance suites from harness 1.23.0 Declare the adapter capabilities harness 1.23.0 uses to gate tests: event_uuid_override, bounded_retries, disable_geoip and historical_migration. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sdk-compliance.yml | 2 +- sdk_compliance_adapter/adapter.py | 4 ++++ sdk_compliance_adapter/test_adapter.py | 10 +++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index b77681cad..b6fe6ee01 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -41,7 +41,7 @@ jobs: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.13.1 + HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.23.0 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 7a9ec437c..718fffbd3 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -237,6 +237,10 @@ def health(): "capture_v1", "capture_ai_v1", "event_options", + "event_uuid_override", + "bounded_retries", + "disable_geoip", + "historical_migration", "encoding_" + compression_name(), "feature_flags_local_evaluation_v1", ] diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 02eeac261..ad2e550bc 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -88,7 +88,15 @@ def test_health_and_init_use_one_codec(adapter, monkeypatch, env_value, codec): monkeypatch.setenv("COMPRESSION", env_value) capabilities = adapter.app.test_client().get("/health").json["capabilities"] assert "feature_flags_local_evaluation_v1" in capabilities - assert {"capture_v1", "capture_ai_v1", "event_options"} <= set(capabilities) + assert { + "capture_v1", + "capture_ai_v1", + "event_options", + "event_uuid_override", + "bounded_retries", + "disable_geoip", + "historical_migration", + } <= set(capabilities) assert "capture_v0" not in capabilities assert "capture_ai_v0" not in capabilities assert [c for c in capabilities if c.startswith("encoding_")] == [ From 01593b92b5b819aab2e03e48cc2677148e0be2fc Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 27 Oct 2026 17:26:16 -0700 Subject: [PATCH 2/6] chore: support the harness reported_drops, log_capture, generated_payloads, property_layers and fail_open features Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/adapter.py | 69 +++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 718fffbd3..c2dc027f0 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -5,6 +5,7 @@ It wraps the posthog-python SDK and exposes a REST API for the test harness to exercise. """ +import json import logging import os import threading @@ -83,10 +84,14 @@ def __init__(self): self.client: Optional[Client] = None self.remote_client: Client | None = None self.reload_thread: threading.Thread | None = None + self.reported_drops: List[Dict[str, Any]] = [] + self.log_lines: List[str] = [] def reset(self): """Reset all state""" with self.lock: + self.reported_drops = [] + self.log_lines = [] clients_to_shutdown = (self.client, self.remote_client) self.client = None self.remote_client = None @@ -157,13 +162,57 @@ def get_state(self) -> Dict[str, Any]: "total_retries": self.total_retries, "last_error": self.last_error, "requests_made": [r.to_dict() for r in self.requests_made], + "reported_drops": list(self.reported_drops), + "log_lines": list(self.log_lines), } + def record_drops(self, error: Exception, batch: List[Dict[str, Any]]) -> None: + """on_error hook: record each event capture dropped, by uuid.""" + names = {event.get("uuid"): event.get("event") for event in batch or []} + with self.lock: + for uuid, details in getattr(error, "drops", []): + self.reported_drops.append({"uuid": uuid, "event": names.get(uuid), "details": details}) + + def record_log(self, line: str) -> None: + with self.lock: + self.log_lines.append(line) + # Global state state = SDKState() +class _StateLogHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + state.record_log(self.format(record)) + + +# The SDK logs under "posthog"; /state returns those lines (log_capture). +_sdk_logger = logging.getLogger("posthog") +_sdk_logger.addHandler(_StateLogHandler()) +_sdk_logger.setLevel(logging.DEBUG) + +_PADDING_KEY = "$test_padding" + + +def _padded_properties(properties: Optional[Dict[str, Any]], size: int, marker: str) -> Dict[str, Any]: + """Add one ASCII padding property so the properties' compact JSON is exactly ``size`` bytes.""" + padded = {**(properties or {}), _PADDING_KEY: ""} + fill = size - len(json.dumps(padded, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) + if fill < len(marker): + raise ValueError(f"pad_properties_to_bytes {size} is smaller than the properties") + padded[_PADDING_KEY] = marker + "x" * (fill - len(marker)) + return padded + + +def _before_send(set_properties: Dict[str, Any]): + def hook(event: Dict[str, Any]) -> Dict[str, Any]: + event.setdefault("properties", {}).update(set_properties) + return event + + return hook + + def patched_post_v1( api_key: str, host: Optional[str], @@ -241,6 +290,11 @@ def health(): "bounded_retries", "disable_geoip", "historical_migration", + "reported_drops", + "log_capture", + "generated_payloads", + "property_layers", + "fail_open", "encoding_" + compression_name(), "feature_flags_local_evaluation_v1", ] @@ -276,7 +330,8 @@ def init(): disable_geoip = data.get("disable_geoip", False) historical_migration = data.get("historical_migration", False) - if not api_key: + # An empty or personal key must reach the SDK, which disables itself (fail_open). + if api_key is None: return jsonify({"error": "api_key is required"}), 400 if not host: return jsonify({"error": "host is required"}), 400 @@ -303,7 +358,12 @@ def init(): "disable_geoip": disable_geoip, "historical_migration": historical_migration, "enable_local_evaluation": False, + "on_error": state.record_drops, + "super_properties": data.get("super_properties"), } + before_send = data.get("before_send") + if before_send: + client_options["before_send"] = _before_send(before_send.get("set_properties", {})) personal_api_key = data.get("personal_api_key") client = Client(**client_options, secret_key=personal_api_key) state.client = client @@ -314,7 +374,7 @@ def init(): state.remote_client = Client(**client_options) logger.info( - f"Initialized SDK with api_key={api_key[:10]}..., host={host}, " + f"Initialized SDK with api_key={api_key[:4]}..., host={host}, " f"flush_at={flush_at}, flush_interval={flush_interval}, " f"max_retries={max_retries}, compression={enable_compression}, " f"disable_geoip={disable_geoip}, " @@ -348,9 +408,12 @@ def _capture_with(capture_method_name: str): if not event: return jsonify({"error": "event is required"}), 400 + properties = data.get("properties") + if data.get("pad_properties_to_bytes") is not None: + properties = _padded_properties(properties, data["pad_properties_to_bytes"], data.get("marker") or "") kwargs = { "distinct_id": distinct_id, - "properties": data.get("properties"), + "properties": properties, "options": data.get("options"), } if timestamp: From 9041b17e201da84e020f195dd5f9b3d6d2a8bd85 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 28 Oct 2026 09:35:03 -0700 Subject: [PATCH 3/6] chore: clear adapter diagnostics after the old client shuts down, size padding markers in bytes Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/adapter.py | 35 ++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index c2dc027f0..bae4d89df 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -90,8 +90,6 @@ def __init__(self): def reset(self): """Reset all state""" with self.lock: - self.reported_drops = [] - self.log_lines = [] clients_to_shutdown = (self.client, self.remote_client) self.client = None self.remote_client = None @@ -106,7 +104,10 @@ def reset(self): except Exception as e: logger.warning(f"Error shutting down client: {e}") + # Cleared after shutdown, which can still report drops and log lines for the old client. with self.lock: + self.reported_drops = [] + self.log_lines = [] self.pending_events = 0 self.total_events_captured = 0 self.total_events_sent = 0 @@ -171,7 +172,9 @@ def record_drops(self, error: Exception, batch: List[Dict[str, Any]]) -> None: names = {event.get("uuid"): event.get("event") for event in batch or []} with self.lock: for uuid, details in getattr(error, "drops", []): - self.reported_drops.append({"uuid": uuid, "event": names.get(uuid), "details": details}) + self.reported_drops.append( + {"uuid": uuid, "event": names.get(uuid), "details": details} + ) def record_log(self, line: str) -> None: with self.lock: @@ -195,13 +198,21 @@ def emit(self, record: logging.LogRecord) -> None: _PADDING_KEY = "$test_padding" -def _padded_properties(properties: Optional[Dict[str, Any]], size: int, marker: str) -> Dict[str, Any]: +def _padded_properties( + properties: Optional[Dict[str, Any]], size: int, marker: str +) -> Dict[str, Any]: """Add one ASCII padding property so the properties' compact JSON is exactly ``size`` bytes.""" padded = {**(properties or {}), _PADDING_KEY: ""} - fill = size - len(json.dumps(padded, separators=(",", ":"), ensure_ascii=False).encode("utf-8")) - if fill < len(marker): - raise ValueError(f"pad_properties_to_bytes {size} is smaller than the properties") - padded[_PADDING_KEY] = marker + "x" * (fill - len(marker)) + fill = size - len( + json.dumps(padded, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + ) + # The marker's bytes as written in JSON, escapes included, without its quotes. + marker_bytes = len(json.dumps(marker, ensure_ascii=False).encode("utf-8")) - 2 + if fill < marker_bytes: + raise ValueError( + f"pad_properties_to_bytes {size} is smaller than the properties" + ) + padded[_PADDING_KEY] = marker + "x" * (fill - marker_bytes) return padded @@ -363,7 +374,9 @@ def init(): } before_send = data.get("before_send") if before_send: - client_options["before_send"] = _before_send(before_send.get("set_properties", {})) + client_options["before_send"] = _before_send( + before_send.get("set_properties", {}) + ) personal_api_key = data.get("personal_api_key") client = Client(**client_options, secret_key=personal_api_key) state.client = client @@ -410,7 +423,9 @@ def _capture_with(capture_method_name: str): properties = data.get("properties") if data.get("pad_properties_to_bytes") is not None: - properties = _padded_properties(properties, data["pad_properties_to_bytes"], data.get("marker") or "") + properties = _padded_properties( + properties, data["pad_properties_to_bytes"], data.get("marker") or "" + ) kwargs = { "distinct_id": distinct_id, "properties": properties, From 9c7070233d4115ee47195f680dd9809f7c0589df Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 28 Oct 2026 10:08:51 -0700 Subject: [PATCH 4/6] chore: run the capture v1 compliance suites from harness 1.25.0 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sdk-compliance.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index b6fe6ee01..bb2a85933 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -41,7 +41,7 @@ jobs: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} env: - HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.23.0 + HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.25.0 steps: - name: Check out source code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 From 50add7c342fc8f7edaf7e9aa93294a434037e3fc Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 28 Oct 2026 12:54:27 -0700 Subject: [PATCH 5/6] fix(adapter): name each duplicate uuid drop after its own copy Also point adapter docs at harness 1.25.0. Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/CONTRIBUTING.md | 4 ++-- sdk_compliance_adapter/README.md | 2 +- sdk_compliance_adapter/adapter.py | 18 ++++++++++++++++-- sdk_compliance_adapter/docker-compose.yml | 2 +- 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/sdk_compliance_adapter/CONTRIBUTING.md b/sdk_compliance_adapter/CONTRIBUTING.md index ffddda4b3..e9fcc841d 100644 --- a/sdk_compliance_adapter/CONTRIBUTING.md +++ b/sdk_compliance_adapter/CONTRIBUTING.md @@ -4,7 +4,7 @@ This package contains the PostHog Python SDK compliance adapter used with the Po ## Running tests -Tests run automatically in CI via GitHub Actions against harness **1.13.1**. The compliance job runs once for each capture compression (gzip, deflate and zstd). Each run opts into the local-evaluation suite and fails on compliance regressions. +Tests run automatically in CI via GitHub Actions against harness **1.25.0**. The compliance job runs once for each capture compression (gzip, deflate and zstd). Each run opts into the local-evaluation suite and fails on compliance regressions. Run adapter protocol tests from the repository root in an activated virtual environment: @@ -44,7 +44,7 @@ docker run -d --name sdk-adapter --network test-network -p 8080:8080 posthog-pyt docker run --rm \ --name test-harness \ --network test-network \ - ghcr.io/posthog/sdk-test-harness:1.13.1 \ + ghcr.io/posthog/sdk-test-harness:1.25.0 \ run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081 # Cleanup diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md index 9efbee7ce..795ed9e52 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -39,7 +39,7 @@ The adapter implements the standard SDK adapter interface defined in the [test h ### Local feature flag evaluation The adapter advertises `feature_flags_local_evaluation_v1` for harness -**1.13.1**. The capability versions the adapter protocol and tests both legacy and +**1.25.0**. The capability versions the adapter protocol and tests both legacy and explicit property matching; it does not change the SDK's default matching mode. - `/init` maps optional `personal_api_key` to the SDK's `secret_key`. Ordinary diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index bae4d89df..617bf4bff 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -169,11 +169,25 @@ def get_state(self) -> Dict[str, Any]: def record_drops(self, error: Exception, batch: List[Dict[str, Any]]) -> None: """on_error hook: record each event capture dropped, by uuid.""" - names = {event.get("uuid"): event.get("event") for event in batch or []} + # A duplicate uuid has several names in batch order; capture keeps the first copy. + names: Dict[str, List[str]] = {} + for event in batch or []: + names.setdefault(event.get("uuid"), []).append(event.get("event")) + duplicates_seen: Dict[str, int] = {} with self.lock: for uuid, details in getattr(error, "drops", []): + copies = names.get(uuid, []) + index = 0 + if details == "duplicate_event_uuid": + index = duplicates_seen.get(uuid, 0) + 1 + duplicates_seen[uuid] = index + event = ( + copies[index] + if index < len(copies) + else (copies[0] if copies else None) + ) self.reported_drops.append( - {"uuid": uuid, "event": names.get(uuid), "details": details} + {"uuid": uuid, "event": event, "details": details} ) def record_log(self, line: str) -> None: diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 9281fa8e3..0f0c9ac25 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -16,7 +16,7 @@ services: # Test harness test-harness: - image: ghcr.io/posthog/sdk-test-harness:1.13.1 + image: ghcr.io/posthog/sdk-test-harness:1.25.0 command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] networks: - test-network From df7b9419421ed3915a53e22d72c0a816293b1fcd Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Wed, 28 Oct 2026 12:57:54 -0700 Subject: [PATCH 6/6] fix(adapter): type the duplicate drop names for mypy Co-Authored-By: Claude Opus 5.5 --- sdk_compliance_adapter/adapter.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index 617bf4bff..31f5a6a37 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -170,7 +170,7 @@ def get_state(self) -> Dict[str, Any]: def record_drops(self, error: Exception, batch: List[Dict[str, Any]]) -> None: """on_error hook: record each event capture dropped, by uuid.""" # A duplicate uuid has several names in batch order; capture keeps the first copy. - names: Dict[str, List[str]] = {} + names: Dict[Optional[str], List[Optional[str]]] = {} for event in batch or []: names.setdefault(event.get("uuid"), []).append(event.get("event")) duplicates_seen: Dict[str, int] = {} @@ -181,13 +181,9 @@ def record_drops(self, error: Exception, batch: List[Dict[str, Any]]) -> None: if details == "duplicate_event_uuid": index = duplicates_seen.get(uuid, 0) + 1 duplicates_seen[uuid] = index - event = ( - copies[index] - if index < len(copies) - else (copies[0] if copies else None) - ) + name = copies[min(index, len(copies) - 1)] if copies else None self.reported_drops.append( - {"uuid": uuid, "event": event, "details": details} + {"uuid": uuid, "event": name, "details": details} ) def record_log(self, line: str) -> None: