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
44 changes: 41 additions & 3 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,47 @@ on:

jobs:
compliance:
name: PostHog SDK compliance tests
name: PostHog PHP compliance (${{ matrix.consumer }})
strategy:
fail-fast: false
matrix:
include:
- consumer: lib_curl
dockerfile: sdk_compliance_adapter/Dockerfile
- consumer: socket
dockerfile: sdk_compliance_adapter/Dockerfile.socket
- consumer: fork_curl
dockerfile: sdk_compliance_adapter/Dockerfile.fork_curl
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@6d19abb9c81e2262dacbe340e7dddda9c871c178
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-dockerfile: ${{ matrix.dockerfile }}
adapter-context: "."
test-harness-version: "0.10.0"
test-harness-version: "1.0.0"
report-name: sdk-compliance-${{ matrix.consumer }}
sdk-type: server
concurrency: 1
continue-on-error: true

report-completeness:
name: PHP report completeness (${{ matrix.consumer }})
needs: compliance
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
strategy:
fail-fast: false
matrix:
consumer: [lib_curl, socket, fork_curl]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Test report checker
run: python3 -m unittest discover -s sdk_compliance_adapter -p 'test_check_report.py' -v
- name: Download profile report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: sdk-compliance-${{ matrix.consumer }}
path: report
- name: Require the complete test inventory
run: python3 sdk_compliance_adapter/check_report.py "${{ matrix.consumer }}" report/sdk-compliance-report.md
10 changes: 8 additions & 2 deletions sdk_compliance_adapter/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ FROM php:8.3-cli
WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip \
&& apt-get install -y --no-install-recommends git unzip curl gzip python3 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
Expand All @@ -16,6 +16,12 @@ RUN composer install --no-interaction --prefer-dist --no-dev --no-progress

COPY sdk_compliance_adapter/ /app/sdk_compliance_adapter/

RUN php -r 'exit(extension_loaded("curl") ? 0 : 1);'

ENV POSTHOG_CONSUMER=lib_curl

RUN PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s sdk_compliance_adapter -p 'test_*.py' -v

EXPOSE 8080

CMD ["php", "/app/sdk_compliance_adapter/adapter.php"]
CMD ["python3", "/app/sdk_compliance_adapter/server.py"]
27 changes: 27 additions & 0 deletions sdk_compliance_adapter/Dockerfile.fork_curl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM php:8.3-cli

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip curl gzip python3 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock /app/
COPY lib/ /app/lib/
COPY bin/ /app/bin/

RUN composer install --no-interaction --prefer-dist --no-dev --no-progress

COPY sdk_compliance_adapter/ /app/sdk_compliance_adapter/

RUN php -r 'exit(extension_loaded("curl") ? 0 : 1);'

ENV POSTHOG_CONSUMER=fork_curl

RUN PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s sdk_compliance_adapter -p 'test_*.py' -v

EXPOSE 8080

CMD ["python3", "/app/sdk_compliance_adapter/server.py"]
27 changes: 27 additions & 0 deletions sdk_compliance_adapter/Dockerfile.socket
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM php:8.3-cli

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends git unzip curl gzip python3 \
&& rm -rf /var/lib/apt/lists/*

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

COPY composer.json composer.lock /app/
COPY lib/ /app/lib/
COPY bin/ /app/bin/

RUN composer install --no-interaction --prefer-dist --no-dev --no-progress

COPY sdk_compliance_adapter/ /app/sdk_compliance_adapter/

RUN php -r 'exit(extension_loaded("curl") ? 0 : 1);'

ENV POSTHOG_CONSUMER=socket

RUN PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s sdk_compliance_adapter -p 'test_*.py' -v

EXPOSE 8080

CMD ["python3", "/app/sdk_compliance_adapter/server.py"]
129 changes: 124 additions & 5 deletions sdk_compliance_adapter/README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,131 @@
# PostHog PHP SDK Compliance Adapter
# PostHog PHP SDK compliance adapter

This adapter wraps the PostHog PHP SDK for the PostHog SDK compliance test harness.
The adapter runs the repository's `PostHog\Client` with production capture consumers
and production `HttpClient` feature-flag requests. Harness 1.0.0 selects **30 server
capture V0 tests and 17 flag tests per profile**, including UTC timestamp overrides
and gzip. No tests are filtered; compliance assertions remain advisory in CI.

## Local run
## Profiles

| `POSTHOG_CONSUMER` | Capture transport | Completion |
| --- | --- | --- |
| `lib_curl` (default) | SDK LibCurl + HttpClient | Verified synchronous HTTP |
| `socket` | SDK Socket | Synchronous socket response handling |
| `fork_curl` | SDK ForkCurl + system curl/gzip | Foreground, using existing `debug=true` |

CI runs all three profiles with distinct report artifacts. An independent
`report-completeness` job checks each artifact against `expected_inventory.json`
(the harness 1.0.0 server V0 and flags IDs). Missing, empty, incomplete, duplicate,
or unexpected results and inconsistent summary counts fail this gate. Complete
reports with failing assertions still pass the inventory gate; SDK compliance
assertions remain advisory. The checker reads the pinned harness's Markdown result
tables, not diagnostic text or the reusable job's conclusion.

All profiles use
`debug=true`, no local flag definitions/secret key, and SDK flag-called events.
Compression is enabled only when requested at `/init`. Flags use the SDK's own
uncompressed HTTP client even when capture compression is enabled.

The adapter maps `flush_at` to `batch_size` and `flush_interval_ms` to
`flush_interval_seconds` (SDK default: 5 seconds, enforced on enqueue, not an idle
timer). `max_retries` maps to the existing backoff-duration option: for three
retries, 801 ms for HttpClient and 800 ms for Socket, reflecting their different
limit checks. ForkCurl does not implement that retry option. Socket's connection
timeout is expressed in seconds; the PHP worker's `default_socket_timeout=1`
bounds reads, including idle retry connections. This is a configured runtime
profile, not certification of default PHP socket timing.

## Observation and isolation

`server.py` exposes `/health`, `/init`, `/capture`, `/get_feature_flag`, `/flush`,
`/state`, and `/reset`. It starts one PHP SDK worker per initialization. SDK traffic
passes through a loopback TCP relay to the supplied **HTTP mock host**. The relay
forwards request and response bytes unchanged, once per connection, without
retries or response substitution. It parses copies only for request telemetry;
flag values and side-effect events come from SDK APIs. HTTPS/TLS is not covered.

The public `before_send` callback returns the SDK-enriched event unchanged and
observes its UUID and capture count, including flag-called events. Capture input
omits UUID so generation happens inside `Client::capture`. Capture and flush
responses preserve the SDK's boolean result, including failures.

State has explicit observation limits:

- `pending_events` is `null` while initialized: there is no public queue-length
accessor. Captured counts are events observed by `before_send`, not proof of
enqueue or delivery.
- `requests_made` records HTTP responses observed at the relay, including flags.
Connections without an HTTP response are not represented. Retry indices group
identical request bytes within a single adapter action; separate flag calls
start independent sequences.
- `total_events_sent` counts events in observed HTTP 200 batches.
`events_flushed` counts those events only during that flush action, not over the
client lifetime. Neither counter overrides the SDK's success/failure result.

Reset kills and waits for only the owned PHP worker, without running its
queue-flushing destructors. It then closes the relay. This provides test isolation,
not SDK shutdown certification; `/flush` always calls the real public SDK method.

## Known failures and applicability

LibCurl is expected to pass all 47 selected tests. Socket and foreground ForkCurl
each expose these 11 existing capture failures:

- `capture.retry_behavior.retries_on_503`
- `capture.retry_behavior.retries_on_500`
- `capture.retry_behavior.retries_on_502`
- `capture.retry_behavior.retries_on_504`
- `capture.retry_behavior.respects_retry_after_header`
- `capture.retry_behavior.implements_backoff`
- `capture.retry_behavior.max_retries_respected`
- `capture.error_handling.retries_on_408`
- `capture.deduplication.preserves_uuid_on_retry`
- `capture.deduplication.preserves_uuid_and_timestamp_on_retry`
- `capture.deduplication.preserves_uuid_and_timestamp_on_batch_retry`

Socket does not reset its write state on retry, treats 408 as terminal, and ignores
Retry-After. ForkCurl does not retry HTTP rejection and reports a successful curl
process exit as successful delivery even for HTTP errors. The adapter exposes
these behaviors rather than retrying on the SDK's behalf.

Background ForkCurl and LibCurl's explicit fire-and-forget option do not promise
blocking verified delivery and are not selected. File/noop consumers are not HTTP
sinks; file replay is not covered. Capture V1, dedicated AI capture, and non-gzip
encodings are not supported capabilities of this adapter's SDK entry.

## Running

```sh
cd sdk_compliance_adapter
docker compose up --build --abort-on-container-exit --exit-code-from test-harness
POSTHOG_CONSUMER=socket docker compose up --build --abort-on-container-exit --exit-code-from test-harness
```

For native PHP 8.3, Python 3, curl and gzip, from the repository root:

```sh
composer install --prefer-dist --no-progress
PORT=18270 PROXY_PORT=19271 BIND_HOST=127.0.0.1 POSTHOG_CONSUMER=lib_curl \
python3 sdk_compliance_adapter/server.py
```

Run the harness against that adapter with a separate mock port. The adapter does
not support parallel test execution. `PORT` defaults to 8080 and `PROXY_PORT` to
8082 inside containers; choose unused loopback ports for concurrent local work.

Adapter regression checks run during each Docker build and can also run locally:

```sh
TEST_MOCK_PORT=19276 TEST_PROXY_PORT=19277 PYTHONDONTWRITEBYTECODE=1 \
python3 -m unittest discover -s sdk_compliance_adapter -p 'test_*.py' -v
```

The report checker can also run independently without PHP or network access:

```sh
python3 -m unittest discover -s sdk_compliance_adapter -p 'test_check_report.py' -v
python3 sdk_compliance_adapter/check_report.py socket report/sdk-compliance-report.md
```

The adapter exposes the standard harness endpoints: `/health`, `/init`, `/capture`, `/flush`, `/state`, and `/reset`.
The adapter checks cover byte-preserving relay behavior, SDK-generated UUIDs, immediate
capture, production retries, compression plus flags, real SDK result booleans,
worker failure, and reset isolation. They use local mock traffic only.
Loading
Loading