Skip to content
Open
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
85 changes: 74 additions & 11 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: SDK Compliance Tests

permissions:
contents: read
packages: read
pull-requests: write

on:
pull_request:
Expand All @@ -23,16 +21,81 @@ 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

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
6 changes: 3 additions & 3 deletions sdk_compliance_adapter/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions sdk_compliance_adapter/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions sdk_compliance_adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
166 changes: 72 additions & 94 deletions sdk_compliance_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_<codec> 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"""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -267,18 +280,20 @@ 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 = {
"project_api_key": api_key,
"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,
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -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)


Expand Down
5 changes: 4 additions & 1 deletion sdk_compliance_adapter/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ services:
build:
context: ..
dockerfile: sdk_compliance_adapter/Dockerfile
environment:
# One of gzip, deflate or zstd; selects the encoding_<codec> test.
COMPRESSION: ${COMPRESSION:-gzip}
ports:
- "8080:8080"
networks:
- test-network

# 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
Expand Down
Loading
Loading