From 077162abb91c2c37900ecb781c87958e0d6e0674 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 6 Oct 2026 20:16:10 -0700 Subject: [PATCH 1/3] chore(ci): run compliance harness 1.13.1 once per codec The adapter now advertises capture_ai_v1 and event_options, passes options and uuids through unchanged, and takes its codec from COMPRESSION. The workflow runs the harness containers directly in a gzip/deflate/zstd matrix, so it no longer needs pull-requests: write. --- .github/workflows/sdk-compliance.yml | 83 +++++++++-- sdk_compliance_adapter/Dockerfile | 4 +- sdk_compliance_adapter/adapter.py | 166 ++++++++++------------ sdk_compliance_adapter/docker-compose.yml | 5 +- sdk_compliance_adapter/test_adapter.py | 52 ++++++- 5 files changed, 201 insertions(+), 109 deletions(-) diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml index 55cfc8ebe..2b5835a9d 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -2,8 +2,6 @@ name: SDK Compliance Tests permissions: contents: read - packages: read - pull-requests: write on: pull_request: @@ -28,11 +26,76 @@ jobs: run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30 compliance: - name: PostHog SDK compliance tests - uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0 - with: - adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" - adapter-context: "." - test-harness-version: "1.1.1" - continue-on-error: false - report-name: "sdk-compliance-report" + # The harness has one enable_compression flag but a test per codec, so each + # job runs the adapter with one codec. The reusable harness workflow cannot + # pass the COMPRESSION env var, so this job runs the containers directly. + # The SDK has no brotli support, so there is no br job. + name: PostHog SDK compliance tests (${{ matrix.compression }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + compression: [gzip, deflate, zstd] + concurrency: + 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 + steps: + - name: Check out source code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Pull test harness + run: docker pull "$HARNESS_IMAGE" + + - name: Build SDK adapter + run: docker build -f sdk_compliance_adapter/Dockerfile -t sdk-adapter:test . + + - name: Start SDK adapter + run: | + docker network create test-network + docker run -d \ + --name sdk-adapter \ + --network test-network \ + -e COMPRESSION=${{ matrix.compression }} \ + sdk-adapter:test + + - name: Run compliance suite + run: | + mkdir -p report + docker run --rm \ + --name test-harness \ + --network test-network \ + -v "${GITHUB_WORKSPACE}/report:/report" \ + "$HARNESS_IMAGE" \ + run \ + --adapter-url http://sdk-adapter:8080 \ + --mock-url http://test-harness:8081 \ + --output text \ + --report /report/sdk-compliance-report.md \ + --sdk-type server + + - name: Publish report to the job summary + if: always() + run: | + if [ -f report/sdk-compliance-report.md ]; then + cat report/sdk-compliance-report.md >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload compliance report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sdk-compliance-report-${{ matrix.compression }} + path: report/sdk-compliance-report.md + + - name: Show adapter logs on failure + if: failure() + run: docker logs sdk-adapter || true + + - name: Stop containers + if: always() + run: | + docker rm -f sdk-adapter || true + docker network rm test-network || true diff --git a/sdk_compliance_adapter/Dockerfile b/sdk_compliance_adapter/Dockerfile index c347f3266..bfc691dd3 100644 --- a/sdk_compliance_adapter/Dockerfile +++ b/sdk_compliance_adapter/Dockerfile @@ -6,8 +6,8 @@ WORKDIR /app 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 the SDK from source, with zstd for the zstd compliance job +RUN cd /app/sdk && pip install --no-cache-dir -e ".[zstd]" # Install adapter dependencies RUN pip install --no-cache-dir flask python-dateutil diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index be2e1905b..19068152b 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -28,6 +28,19 @@ app = Flask(__name__) +# The harness has one enable_compression flag but a test per codec, so each +# adapter instance takes its codec from COMPRESSION and advertises only that +# codec's encoding_ capability. The SDK has no brotli support. +COMPRESSION_CODECS = { + "gzip": CaptureCompression.GZIP, + "deflate": CaptureCompression.DEFLATE, + "zstd": CaptureCompression.ZSTD, +} + + +def compression_name() -> str: + return os.environ.get("COMPRESSION") or "gzip" + class RequestInfo: """Information about an HTTP request made by the SDK""" @@ -220,11 +233,11 @@ def patched_post_v1( @app.route("/health", methods=["GET"]) def health(): """Health check endpoint""" - # No AI capture capability: `capture_ai` posts capture v1 to - # /i/v1/ai/events, which this harness version has no suite for. capabilities = [ "capture_v1", - "encoding_gzip", + "capture_ai_v1", + "event_options", + "encoding_" + compression_name(), "feature_flags_local_evaluation_v1", ] return jsonify( @@ -267,6 +280,11 @@ def init(): # Convert flush_interval from ms to seconds flush_interval = flush_interval_ms / 1000.0 + compression = ( + COMPRESSION_CODECS[compression_name()] + if enable_compression + else CaptureCompression.NONE + ) # Explicit reloads exercise the real loader without background polling # racing the harness's per-test definition snapshots. client_options = { @@ -274,11 +292,8 @@ def init(): "host": host, "flush_at": flush_at, "flush_interval": flush_interval, - "capture_compression": ( - CaptureCompression.GZIP - if enable_compression - else CaptureCompression.NONE - ), + "capture_compression": compression, + "capture_ai_compression": compression, "max_retries": max_retries, "debug": False, "disable_geoip": disable_geoip, @@ -308,56 +323,55 @@ def init(): return jsonify({"error": str(e)}), 500 -@app.route("/capture", methods=["POST"]) -def capture(): - """Capture a single event""" - try: - if not state.client: - return jsonify({"error": "SDK not initialized"}), 400 +def _capture_with(capture_method_name: str): + """Capture one event through the named SDK method. - data = request.json or {} + Options pass through unchanged, and a supplied uuid stays on the wire. The + response carries the uuid the SDK actually sent, which differs from the + supplied one when the SDK canonicalizes or replaces it. + """ + if not state.client: + return jsonify({"error": "SDK not initialized"}), 400 - distinct_id = data.get("distinct_id") - event = data.get("event") - properties = data.get("properties") - timestamp = data.get("timestamp") - options = data.get("options") + data = request.json or {} - 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. - if options: - 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 + distinct_id = data.get("distinct_id") + event = data.get("event") + timestamp = data.get("timestamp") + + if not distinct_id: + return jsonify({"error": "distinct_id is required"}), 400 + if not event: + return jsonify({"error": "event is required"}), 400 + + kwargs = { + "distinct_id": distinct_id, + "properties": data.get("properties"), + "options": data.get("options"), + } + if timestamp: + # Parse ISO8601 timestamp + from dateutil.parser import parse - # Capture event - kwargs = {"distinct_id": distinct_id, "properties": properties} - if timestamp: - # Parse ISO8601 timestamp - from dateutil.parser import parse + kwargs["timestamp"] = parse(timestamp) + if data.get("uuid"): + kwargs["uuid"] = data["uuid"] - kwargs["timestamp"] = parse(timestamp) + uuid = getattr(state.client, capture_method_name)(event, **kwargs) - uuid = state.client.capture(event, **kwargs) + # Track that we captured an event + state.increment_captured() - # Track that we captured an event - state.increment_captured() + logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}") - logger.info(f"Captured event: {event} for {distinct_id}, uuid={uuid}") + return jsonify({"success": True, "uuid": uuid}) - return jsonify({"success": True, "uuid": uuid}) + +@app.route("/capture", methods=["POST"]) +def capture(): + """Capture a single event""" + try: + return _capture_with("capture") except Exception as e: logger.exception("Error capturing event") state.record_error(str(e)) @@ -368,50 +382,7 @@ def capture(): def capture_ai(): """Capture a single AI event on the dedicated AI capture endpoint""" try: - if not state.client: - return jsonify({"error": "SDK not initialized"}), 400 - - data = request.json or {} - - distinct_id = data.get("distinct_id") - event = data.get("event") - properties = data.get("properties") - timestamp = data.get("timestamp") - options = data.get("options") - supplied_uuid = data.get("uuid") - - if not distinct_id: - return jsonify({"error": "distinct_id is required"}), 400 - if not event: - return jsonify({"error": "event is required"}), 400 - - if options: - 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 - - kwargs = {"distinct_id": distinct_id, "properties": properties} - if timestamp: - from dateutil.parser import parse - - kwargs["timestamp"] = parse(timestamp) - # Unlike /capture, forward a supplied uuid so it's echoed back to the caller. - if supplied_uuid: - kwargs["uuid"] = supplied_uuid - - uuid = state.client.capture_ai(event, **kwargs) - - state.increment_captured() - - logger.info(f"Captured AI event: {event} for {distinct_id}, uuid={uuid}") - - return jsonify({"success": True, "uuid": uuid}) + return _capture_with("capture_ai") except Exception as e: logger.exception("Error capturing AI event") state.record_error(str(e)) @@ -638,7 +609,14 @@ def reset(): def main(): """Main entry point""" port = int(os.environ.get("PORT", 8080)) - logger.info(f"Starting SDK Test Adapter on port {port}") + if compression_name() not in COMPRESSION_CODECS: + raise SystemExit( + f"unsupported COMPRESSION {compression_name()!r}: " + f"want one of {', '.join(COMPRESSION_CODECS)}" + ) + logger.info( + f"Starting SDK Test Adapter on port {port} (compression={compression_name()})" + ) app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml index 56ef72680..9281fa8e3 100644 --- a/sdk_compliance_adapter/docker-compose.yml +++ b/sdk_compliance_adapter/docker-compose.yml @@ -6,6 +6,9 @@ services: build: context: .. dockerfile: sdk_compliance_adapter/Dockerfile + environment: + # One of gzip, deflate or zstd; selects the encoding_ test. + COMPRESSION: ${COMPRESSION:-gzip} ports: - "8080:8080" networks: @@ -13,7 +16,7 @@ services: # Test harness test-harness: - image: ghcr.io/posthog/sdk-test-harness:1.1.1 + image: ghcr.io/posthog/sdk-test-harness:1.13.1 command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] networks: - test-network diff --git a/sdk_compliance_adapter/test_adapter.py b/sdk_compliance_adapter/test_adapter.py index 1e06c4628..02eeac261 100644 --- a/sdk_compliance_adapter/test_adapter.py +++ b/sdk_compliance_adapter/test_adapter.py @@ -77,12 +77,60 @@ def initialize(adapter, **overrides): return adapter.app.test_client() -def test_health_opts_into_local_evaluation_without_losing_capture(adapter): +@pytest.mark.parametrize( + "env_value,codec", + [(None, "gzip"), ("gzip", "gzip"), ("deflate", "deflate"), ("zstd", "zstd")], +) +def test_health_and_init_use_one_codec(adapter, monkeypatch, env_value, codec): + if env_value is None: + monkeypatch.delenv("COMPRESSION", raising=False) + else: + 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" in capabilities + assert {"capture_v1", "capture_ai_v1", "event_options"} <= 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_")] == [ + f"encoding_{codec}" + ] + + initialize(adapter, enable_compression=True) + assert adapter.state.client.capture_compression.value == codec + assert adapter.state.client.capture_ai_compression.value == codec + + +@pytest.mark.parametrize( + "route,method", [("/capture", "capture"), ("/capture_ai", "capture_ai")] +) +def test_capture_passes_options_and_uuid_unchanged(adapter, monkeypatch, route, method): + client = initialize(adapter, personal_api_key=None) + sent = Mock(return_value="0190a5a8-0000-7000-8000-000000000001") + monkeypatch.setattr(adapter.state.client, method, sent) + options = {"cookieless_mode": "no", "process_person_profile": 0.0, "custom": [1]} + + response = client.post( + route, + json={ + "distinct_id": "user", + "event": "$ai_generation", + "properties": {"$ignore_sent_at": "maybe"}, + "options": options, + "uuid": "0190A5A8-0000-7000-8000-000000000001", + }, + ) + + assert response.json == { + "success": True, + "uuid": "0190a5a8-0000-7000-8000-000000000001", + } + sent.assert_called_once_with( + "$ai_generation", + distinct_id="user", + properties={"$ignore_sent_at": "maybe"}, + options=options, + uuid="0190A5A8-0000-7000-8000-000000000001", + ) def test_init_enables_explicit_definitions_loading_without_polling(adapter): From 9e1d498a8d8a3a8e0f5fe1c2411aeff3305a3386 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Tue, 6 Oct 2026 20:54:09 -0700 Subject: [PATCH 2/3] chore(ci): install zstd extra for adapter protocol tests --- .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 2b5835a9d..b77681cad 100644 --- a/.github/workflows/sdk-compliance.yml +++ b/.github/workflows/sdk-compliance.yml @@ -21,7 +21,7 @@ jobs: with: python-version: "3.12" - name: Install adapter and test dependencies - run: python -m pip install -e . -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio + run: python -m pip install -e ".[zstd]" -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio - name: Test adapter protocol run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30 From 5c2b03e5b3ef81bfb0e2ae3ec04b2f02a5a56c06 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Thu, 8 Oct 2026 09:21:15 -0700 Subject: [PATCH 3/3] chore: describe the single capture compliance job and harness 1.13.1 The standalone adapter-test setup installs the zstd extra, matching CI. --- sdk_compliance_adapter/CONTRIBUTING.md | 6 +++--- sdk_compliance_adapter/README.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sdk_compliance_adapter/CONTRIBUTING.md b/sdk_compliance_adapter/CONTRIBUTING.md index 3033609b3..ffddda4b3 100644 --- a/sdk_compliance_adapter/CONTRIBUTING.md +++ b/sdk_compliance_adapter/CONTRIBUTING.md @@ -4,12 +4,12 @@ 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.1.1**, for both capture protocols. Both jobs opt into the local-evaluation suite and fail on compliance regressions. +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. Run adapter protocol tests from the repository root in an activated virtual environment: ```bash -python -m pip install -e . -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio +python -m pip install -e ".[zstd]" -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30 ``` @@ -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.1.1 \ + ghcr.io/posthog/sdk-test-harness:1.13.1 \ 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 6b17bf5dd..9efbee7ce 100644 --- a/sdk_compliance_adapter/README.md +++ b/sdk_compliance_adapter/README.md @@ -38,8 +38,8 @@ The adapter implements the standard SDK adapter interface defined in the [test h ### Local feature flag evaluation -Both capture adapters advertise `feature_flags_local_evaluation_v1` for harness -**1.1.1**. The capability versions the adapter protocol and tests both legacy and +The adapter advertises `feature_flags_local_evaluation_v1` for harness +**1.13.1**. 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