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
25 changes: 21 additions & 4 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,37 @@ on:
- main

jobs:
adapter-tests:
name: Compliance adapter protocol tests
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
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
- name: Test adapter protocol
run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30

compliance:
name: PostHog SDK compliance tests (capture v0)
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@03d972e49be84402c491324320b0a0f38c2ddc53
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@1d6b197ef46758f577f470535bb3d70e37a15fa2 # 1.1.1
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "0.10.0"
test-harness-version: "1.1.1"
continue-on-error: false
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@03d972e49be84402c491324320b0a0f38c2ddc53
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@1d6b197ef46758f577f470535bb3d70e37a15fa2 # 1.1.1
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1"
adapter-context: "."
test-harness-version: "0.10.0"
test-harness-version: "1.1.1"
continue-on-error: false
report-name: "sdk-compliance-report-v1"
13 changes: 11 additions & 2 deletions sdk_compliance_adapter/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ This package contains the PostHog Python SDK compliance adapter used with the Po

## Running tests

Tests run automatically in CI via GitHub Actions.
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.

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 pytest sdk_compliance_adapter/test_adapter.py --timeout=30
```

These tests exercise the real SDK loader/evaluator with controlled transports, including failed/late reloads, local false versus inconclusive, and forced remote evaluation.

### Locally with Docker Compose

Expand Down Expand Up @@ -35,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:0.10.0 \
ghcr.io/posthog/sdk-test-harness:1.1.1 \
run --adapter-url http://sdk-adapter:8080 --mock-url http://test-harness:8081

# Cleanup
Expand Down
27 changes: 27 additions & 0 deletions sdk_compliance_adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ The adapter implements the standard SDK adapter interface defined in the [test h
- `POST /capture` - Capture an event
- `POST /flush` - Flush pending events
- `GET /state` - Return internal state
- `POST /get_feature_flag` - Evaluate a flag locally or remotely
- `POST /reload_feature_flag_definitions` - Fresh, bounded definitions readiness barrier
- `POST /reset` - Reset SDK state

### Key Implementation Details
Expand All @@ -34,6 +36,31 @@ The adapter implements the standard SDK adapter interface defined in the [test h

**UUID Tracking**: Extracts and tracks UUIDs from batches to verify deduplication.

### 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
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
capture/remote tests do not need it. Background polling is disabled in the
adapter; explicit reloads still use the real SDK definitions loader.
- `/reload_feature_flag_definitions` takes `timeout_ms` (default 5000, range
1–30000). It waits for a fresh successful publication, not merely an existing
snapshot. Failed fetches and authorization/quota resets return `ready: false`.
A timeout returns HTTP 504; the SDK's in-flight request may finish later on its
original Client, and another reload on that Client is rejected while it runs.
- `/get_feature_flag` with `only_evaluate_locally: true` uses the SDK's local-only
result API without emitting flag-called events. A conclusive false has
`locally_evaluated: true`; an inconclusive result has `value: null`,
`success: false`, and `locally_evaluated: false`, never remote fallback.
- `force_remote: true` conflicts with local-only mode. When definitions are
enabled, forced remote calls use a separate definitions-free SDK Client so
they cannot accidentally resolve from local rules. Legacy remote responses
and flag-called events are preserved. Reset disposes both Clients.

The adapter is sequential (it does not advertise parallel-test support).

## Documentation

For complete documentation on the test harness and how to implement adapters, see:
Expand Down
160 changes: 130 additions & 30 deletions sdk_compliance_adapter/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,25 +78,26 @@ def __init__(self):
self.last_error: Optional[str] = None
self.requests_made: List[RequestInfo] = []
self.client: Optional[Client] = None
self.remote_client: Client | None = None
self.reload_thread: threading.Thread | None = None
self.retry_attempts: Dict[str, int] = {} # Track retry attempts by batch ID

def reset(self):
"""Reset all state"""
client_to_shutdown = None
with self.lock:
client_to_shutdown = self.client
clients_to_shutdown = (self.client, self.remote_client)
self.client = None

if client_to_shutdown:
# Flush and shutdown the existing client outside state.lock.
# The patched transport records successful flush requests through
# SDKState.record_request(), which also needs state.lock. Holding the
# lock while shutdown() waits for the queue to drain can deadlock when
# a pending background event is being flushed during test reset.
try:
client_to_shutdown.shutdown()
except Exception as e:
logger.warning(f"Error shutting down client: {e}")
self.remote_client = None
# A timed-out load only owns its old Client, never a replacement.
self.reload_thread = None

for client in clients_to_shutdown:
if client:
# Flush outside state.lock: transport instrumentation needs it.
try:
client.shutdown()
except Exception as e:
logger.warning(f"Error shutting down client: {e}")

with self.lock:
self.pending_events = 0
Expand Down Expand Up @@ -309,6 +310,7 @@ def health():
if is_v1()
else ["capture_v0", "capture_ai_v0", "encoding_gzip"]
)
capabilities.append("feature_flags_local_evaluation_v1")
return jsonify(
{
"sdk_name": "posthog-python",
Expand Down Expand Up @@ -352,21 +354,29 @@ def init():
# 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,
host=host,
flush_at=flush_at,
flush_interval=flush_interval,
gzip=enable_compression,
max_retries=max_retries,
debug=False,
disable_geoip=disable_geoip,
historical_migration=historical_migration,
capture_mode=capture_mode,
)

# 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,
"gzip": enable_compression,
"max_retries": max_retries,
"debug": False,
"disable_geoip": disable_geoip,
"historical_migration": historical_migration,
"capture_mode": capture_mode,
"enable_local_evaluation": False,
}
personal_api_key = data.get("personal_api_key")
client = Client(**client_options, secret_key=personal_api_key)
state.client = client
if personal_api_key:
# The SDK has no force-remote switch once definitions are loaded.
# A definitions-free Client preserves the real remote API and its
# event side effects without mutating the local Client's snapshot.
state.remote_client = Client(**client_options)

logger.info(
f"Initialized SDK with api_key={api_key[:10]}..., host={host}, "
Expand Down Expand Up @@ -562,6 +572,70 @@ def get_state():
return jsonify({"error": str(e)}), 500


@app.route("/reload_feature_flag_definitions", methods=["POST"])
def reload_feature_flag_definitions():
"""Bound a fresh SDK load and acknowledge only its successful publication."""
data = request.json or {}
timeout_ms = data.get("timeout_ms", 5000)
if type(timeout_ms) is not int or not 1 <= timeout_ms <= 30000:
return jsonify(success=False, ready=False, error="Invalid timeout_ms"), 400

errors = []
with state.lock:
client = state.client
if client is None or not client.personal_api_key:
return jsonify(
success=False, ready=False, error="A personal_api_key is required"
), 400
if state.reload_thread and state.reload_thread.is_alive():
return jsonify(
success=False,
ready=False,
error="A definitions reload is still running",
), 409
previous_generation = client._flag_definition_published_generation

def load():
try:
client.load_feature_flags()
except Exception as error:
logger.exception("Error reloading feature flag definitions")
errors.append(str(error))

worker = threading.Thread(target=load, daemon=True)
state.reload_thread = worker
worker.start()

# The SDK's definitions transport timeout is longer than the adapter's
# deadline. Do not block this endpoint on it or start overlapping reloads.
worker.join(timeout_ms / 1000)
if worker.is_alive():
return jsonify(
success=False, ready=False, error="Definitions reload timed out"
), 504
if errors:
return jsonify(success=False, ready=False, error=errors[0]), 502
with state.lock:
if state.client is not client:
return jsonify(
success=False, ready=False, error="SDK reset during reload"
), 409
# load_feature_flags returns None even on failure. Its publication generation
# advances on successful GET/304 and auth/quota resets; the latter clear the
# fingerprint. Checking both avoids acknowledging stale or reset definitions.
with client._flag_definition_publication_lock:
ready = (
client._flag_definition_published_generation > previous_generation
and bool(client._flag_definition_fingerprint)
and client.feature_flags is not None
)
if not ready:
return jsonify(
success=False, ready=False, error="Fresh definitions were not loaded"
), 502
return jsonify(success=True, ready=True)


@app.route("/get_feature_flag", methods=["POST"])
def get_feature_flag():
"""Evaluate a feature flag"""
Expand All @@ -577,14 +651,40 @@ def get_feature_flag():
groups = data.get("groups")
group_properties = data.get("group_properties")
disable_geoip = data.get("disable_geoip")
force_remote = data.get("force_remote", True)
only_evaluate_locally = data.get("only_evaluate_locally", False)
force_remote = data.get("force_remote", not only_evaluate_locally)

if only_evaluate_locally and force_remote:
return jsonify(
{"error": "only_evaluate_locally conflicts with force_remote"}
), 400
if not key:
return jsonify({"error": "key is required"}), 400
if not distinct_id:
return jsonify({"error": "distinct_id is required"}), 400

value = state.client.get_feature_flag(
if only_evaluate_locally:
result = state.client.get_feature_flag_result(
key,
distinct_id,
person_properties=person_properties,
groups=groups,
group_properties=group_properties,
disable_geoip=disable_geoip,
only_evaluate_locally=True,
send_feature_flag_events=False,
)
# The real local-only SDK API returns None for inconclusive results;
# a conclusive false is a FeatureFlagResult, not a cache miss.
conclusive = result is not None
return jsonify(
success=conclusive,
value=result.get_value() if result is not None else None,
locally_evaluated=conclusive,
)

client = (state.remote_client or state.client) if force_remote else state.client
value = client.get_feature_flag(
key,
distinct_id,
person_properties=person_properties,
Expand All @@ -598,7 +698,7 @@ def get_feature_flag():
# the adapter action returns. Otherwise the harness may reset mock-server
# state for the next test while the background consumer is still flushing,
# leaking the previous test's event into the next test.
state.client.flush()
client.flush()

logger.info(f"Feature flag {key} for {distinct_id}: {value}")

Expand Down
2 changes: 1 addition & 1 deletion sdk_compliance_adapter/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ services:

# Test harness
test-harness:
image: ghcr.io/posthog/sdk-test-harness:0.10.0
image: ghcr.io/posthog/sdk-test-harness:1.1.1
command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"]
networks:
- test-network
Expand Down
Loading
Loading