From 4581f92c302f27a8c24c316412d1c3571c2a94e9 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 27 Jun 2026 14:37:30 +0200 Subject: [PATCH 1/7] chore: add SDK compliance harness 0.8.0 --- .github/workflows/sdk-compliance.yml | 21 + lib/Client.php | 26 +- lib/HttpClient.php | 55 +- lib/QueueConsumer.php | 4 +- sdk-harness-audit/posthog-php.md | 58 +++ sdk_compliance_adapter/Dockerfile | 21 + sdk_compliance_adapter/README.md | 12 + sdk_compliance_adapter/adapter.php | 601 ++++++++++++++++++++++ sdk_compliance_adapter/docker-compose.yml | 21 + 9 files changed, 792 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/sdk-compliance.yml create mode 100644 sdk-harness-audit/posthog-php.md create mode 100644 sdk_compliance_adapter/Dockerfile create mode 100644 sdk_compliance_adapter/README.md create mode 100644 sdk_compliance_adapter/adapter.php create mode 100644 sdk_compliance_adapter/docker-compose.yml diff --git a/.github/workflows/sdk-compliance.yml b/.github/workflows/sdk-compliance.yml new file mode 100644 index 0000000..2285355 --- /dev/null +++ b/.github/workflows/sdk-compliance.yml @@ -0,0 +1,21 @@ +name: SDK Compliance Tests + +permissions: + contents: read + packages: read + pull-requests: write + +on: + pull_request: + push: + branches: + - main + +jobs: + compliance: + name: PostHog SDK compliance tests + uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@be8b8d5a3f94a249659844e94832e874f049c1e4 + with: + adapter-dockerfile: "sdk_compliance_adapter/Dockerfile" + adapter-context: "." + test-harness-version: "0.8.0" diff --git a/lib/Client.php b/lib/Client.php index 47b8bcf..9185070 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -635,7 +635,7 @@ private function doGetFeatureFlagResult( if (!$flagWasEvaluatedLocally && !$onlyEvaluateLocally) { try { - $response = $this->requestFlags($distinctId, $groups, $personProperties, $groupProperties); + $response = $this->requestFlags($distinctId, $groups, $personProperties, $groupProperties, false, [$key]); $errors = []; if (isset($response['errorsWhileComputingFlags']) && $response['errorsWhileComputingFlags']) { @@ -1634,26 +1634,14 @@ private function requestFlags( } $payload = array( - 'api_key' => $this->apiKey, + 'token' => $this->apiKey, 'distinct_id' => $distinctId, + 'groups' => empty($groups) ? (object) [] : $groups, + 'person_properties' => empty($personProperties) ? (object) [] : $personProperties, + 'group_properties' => empty($groupProperties) ? (object) [] : $groupProperties, + 'geoip_disable' => $disableGeoip, ); - if (!empty($groups)) { - $payload["groups"] = $groups; - } - - if (!empty($personProperties)) { - $payload["person_properties"] = $personProperties; - } - - if (!empty($groupProperties)) { - $payload["group_properties"] = $groupProperties; - } - - if ($disableGeoip) { - $payload["geoip_disable"] = true; - } - if ($flagKeys !== null) { $payload["flag_keys_to_evaluate"] = array_values($flagKeys); } @@ -1953,7 +1941,7 @@ private function hasExplicitCaptureDistinctId(array &$msg): bool private function normalizeMessageUuid(array $msg): array { - if (array_key_exists('uuid', $msg) && !$this->isValidUuid($msg['uuid'])) { + if (!array_key_exists('uuid', $msg) || !$this->isValidUuid($msg['uuid'])) { $msg['uuid'] = Uuid::v4(); } diff --git a/lib/HttpClient.php b/lib/HttpClient.php index bbf78fd..13706d2 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -102,6 +102,7 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders do { // open connection $ch = curl_init(); + $responseHeaders = []; if (null !== $payload) { curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); @@ -129,9 +130,14 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders curl_setopt($ch, CURLOPT_FRESH_CONNECT, true); } - // Capture response headers if we need to extract ETag + // Capture response headers if we need to extract ETag or honor Retry-After. if ($includeEtag) { curl_setopt($ch, CURLOPT_HEADER, true); + } else { + curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $header) use (&$responseHeaders): int { + $responseHeaders[] = trim($header); + return strlen($header); + }); } // retry failed requests just once to diminish impact on performance @@ -153,14 +159,14 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders if ($shouldRetry === false) { break; - } elseif (($responseCode >= 500 && $responseCode <= 600) || 429 == $responseCode) { - // If status code is greater than 500 and less than 600, it indicates server error - // Error code 429 indicates rate limited. - // Retry uploading in these cases. - usleep($backoff * 1000); + } elseif ($this->isRetryableStatus($responseCode)) { + // Retry transient failures. Honor Retry-After when provided, otherwise use + // exponential backoff starting at 100ms. + $retryAfterMs = $this->retryAfterMilliseconds($responseHeaders); + usleep(($retryAfterMs ?? $backoff) * 1000); $backoff *= 2; } else { - // Do not retry non-5xx/non-429 responses (e.g. 4xx, 413 Payload Too Large, + // Do not retry non-transient responses (e.g. 400, 401, 403, 413, // or responseCode 0 for network errors). PHP sends synchronously in the hosting // app's request path, so broad retries would slow down the host application. break; @@ -197,6 +203,41 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse return new HttpResponse($response, $responseCode, null, $curlErrno); } + private function isRetryableStatus(int $responseCode): bool + { + return $responseCode === 408 + || $responseCode === 429 + || ($responseCode >= 500 && $responseCode <= 600); + } + + /** + * @param array $headers + */ + private function retryAfterMilliseconds(array $headers): ?int + { + foreach ($headers as $header) { + if (stripos($header, 'Retry-After:') !== 0) { + continue; + } + + $value = trim(substr($header, strlen('Retry-After:'))); + if ($value === '') { + return null; + } + + if (ctype_digit($value)) { + return max(0, (int) $value * 1000); + } + + $timestamp = strtotime($value); + if ($timestamp !== false) { + return max(0, (int) (($timestamp - time()) * 1000)); + } + } + + return null; + } + private function handleError($code, $message) { if (null !== $this->errorHandler) { diff --git a/lib/QueueConsumer.php b/lib/QueueConsumer.php index 9f522f8..0db1629 100644 --- a/lib/QueueConsumer.php +++ b/lib/QueueConsumer.php @@ -76,7 +76,9 @@ public function __construct($apiKey, $options = array()) } if (isset($options["compress_request"])) { - $this->compress_request = json_decode($options["compress_request"]); + $this->compress_request = is_bool($options["compress_request"]) + ? $options["compress_request"] + : (bool) json_decode($options["compress_request"]); } $this->queue = array(); diff --git a/sdk-harness-audit/posthog-php.md b/sdk-harness-audit/posthog-php.md new file mode 100644 index 0000000..51724f2 --- /dev/null +++ b/sdk-harness-audit/posthog-php.md @@ -0,0 +1,58 @@ +# posthog-php SDK compliance harness audit + +## Summary + +Implemented the SDK compliance harness for posthog-php and fixed the SDK/adapter issues needed for the local Docker Compose compliance run to pass. + +## Changed files + +- `.github/workflows/sdk-compliance.yml` — added SDK compliance workflow using the shared harness action. +- `sdk_compliance_adapter/adapter.php` — added long-running PHP HTTP adapter with `/health`, `/init`, `/capture`, `/flush`, `/state`, `/reset`, and `/get_feature_flag` endpoints. +- `sdk_compliance_adapter/Dockerfile` — added adapter image build. +- `sdk_compliance_adapter/docker-compose.yml` — added local adapter + harness compose setup with a unique compose project name. +- `sdk_compliance_adapter/README.md` — added local run instructions. +- `lib/Client.php` — generate UUIDs for captured events, send modern `/flags/?v=2` payload fields (`token`, `groups`, `group_properties`, `geoip_disable`, `flag_keys_to_evaluate`) for single flag remote evaluation. +- `lib/HttpClient.php` — retry 408 responses and honor `Retry-After` while retaining exponential backoff for retryable failures. +- `lib/QueueConsumer.php` — accept boolean `compress_request` values in addition to JSON string values. + +## Failing tests fixed + +Compliance failures fixed locally: + +- Missing harness/workflow. +- Missing event UUID generation. +- Retry behavior for 408. +- `Retry-After` delay behavior for 429. +- Gzip compression option handling. +- Feature flag adapter endpoint and `/flags/?v=2` request payload contract. +- Feature flag side-effect `$feature_flag_called` event in the harness path. + +## Commands run and exit codes + +- `php -l sdk_compliance_adapter/adapter.php && docker compose -f sdk_compliance_adapter/docker-compose.yml build sdk-adapter` — exit 0. +- `docker compose -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test-harness` — exit 143 before compose project isolation; harness run was interrupted/ambiguous due sibling compose project/name collisions. +- `docker run --rm ghcr.io/posthog/sdk-test-harness:0.8.0 --help` — exit 0. +- `docker run --rm ghcr.io/posthog/sdk-test-harness:0.8.0 run --help` — exit 0. +- `php -l sdk_compliance_adapter/adapter.php && php -l lib/HttpClient.php && php -l lib/Client.php && php -l lib/QueueConsumer.php` — exit 0. +- `docker compose -p posthog-php-sdk-compliance -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test-harness` — exit 0; final output: `Total: 45 | 45 passed | 0 failed | Duration: 95110ms` and `All tests passed! ✓`. +- `docker compose -p posthog-php-sdk-compliance -f sdk_compliance_adapter/docker-compose.yml build sdk-adapter` — exit 0. +- `composer install --no-interaction --prefer-dist --no-progress && vendor/bin/phpunit --colors=never test/FeatureFlagTest.php test/FeatureFlagEvaluationsTest.php test/QueueConsumerTest.php test/HttpClientTest.php` — exit 1; existing exact-payload unit tests need updates for the new `/flags` payload and UUID fields. +- `vendor/bin/phpunit --colors=never test/` — exit 1; 18 failures, all observed failures are exact expected payload assertions affected by UUID generation or the modern `/flags` payload shape. +- `git diff --cached --quiet; echo no_staged_exit:$?` — exit 0 (`no_staged_exit:0`). + +## Validation output + +Final SDK compliance harness run: + +```text +CAPTURE Tests: all 29 passed +FEATURE_FLAGS Tests: all 16 passed +Total: 45 | 45 passed | 0 failed | Duration: 95110ms +All tests passed! ✓ +``` + +## Remaining blockers / risks + +- The local SDK compliance harness passes. +- PHPUnit is not fully green after these SDK contract changes: existing exact JSON payload expectations still reflect the old feature flag request shape and the previous absence of event UUIDs in some expected batch payloads. These tests should be updated in a follow-up if the repository requires full unit-suite green in the same change. +- No files are staged. diff --git a/sdk_compliance_adapter/Dockerfile b/sdk_compliance_adapter/Dockerfile new file mode 100644 index 0000000..e37fa73 --- /dev/null +++ b/sdk_compliance_adapter/Dockerfile @@ -0,0 +1,21 @@ +FROM php:8.3-cli + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends git unzip \ + && 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/ + +EXPOSE 8080 + +CMD ["php", "/app/sdk_compliance_adapter/adapter.php"] diff --git a/sdk_compliance_adapter/README.md b/sdk_compliance_adapter/README.md new file mode 100644 index 0000000..09d83d6 --- /dev/null +++ b/sdk_compliance_adapter/README.md @@ -0,0 +1,12 @@ +# PostHog PHP SDK Compliance Adapter + +This adapter wraps the PostHog PHP SDK for the PostHog SDK compliance test harness. + +## Local run + +```sh +cd sdk_compliance_adapter +docker compose up --build --abort-on-container-exit --exit-code-from test-harness +``` + +The adapter exposes the standard harness endpoints: `/health`, `/init`, `/capture`, `/flush`, `/state`, and `/reset`. diff --git a/sdk_compliance_adapter/adapter.php b/sdk_compliance_adapter/adapter.php new file mode 100644 index 0000000..29b507d --- /dev/null +++ b/sdk_compliance_adapter/adapter.php @@ -0,0 +1,601 @@ + $this->timestampMs, + 'status_code' => $this->statusCode, + 'retry_attempt' => $this->retryAttempt, + 'event_count' => $this->eventCount, + 'uuid_list' => $this->uuidList, + ]; + } +} + +final class AdapterState +{ + public ?Client $client = null; + public int $totalEventsCaptured = 0; + public int $totalEventsSent = 0; + public int $totalRetries = 0; + public ?string $lastError = null; + /** @var list */ + public array $requestsMade = []; + public int $pendingEvents = 0; + + public function reset(): void + { + if ($this->client !== null) { + $this->discardQueuedEvents($this->client); + try { + $this->client->shutdown(); + } catch (Throwable $e) { + error_log('[adapter] error shutting down client: ' . $e->getMessage()); + } + } + + $this->client = null; + $this->totalEventsCaptured = 0; + $this->totalEventsSent = 0; + $this->totalRetries = 0; + $this->lastError = null; + $this->requestsMade = []; + $this->pendingEvents = 0; + } + + public function recordCaptured(): void + { + $this->totalEventsCaptured++; + $this->pendingEvents++; + } + + public function recordRequest(int $statusCode, int $retryAttempt, int $eventCount, array $uuidList): void + { + $this->requestsMade[] = new RequestInfo( + (int) floor(microtime(true) * 1000), + $statusCode, + $retryAttempt, + $eventCount, + $uuidList, + ); + + if ($retryAttempt > 0) { + $this->totalRetries++; + } + + if ($statusCode === 200) { + $this->totalEventsSent += $eventCount; + $this->pendingEvents = max(0, $this->pendingEvents - $eventCount); + } + } + + public function recordError(string $error): void + { + $this->lastError = $error; + } + + private function discardQueuedEvents(Client $client): void + { + try { + $clientReflection = new ReflectionObject($client); + $consumerProperty = $clientReflection->getProperty('consumer'); + $consumerProperty->setAccessible(true); + $consumer = $consumerProperty->getValue($client); + if (!is_object($consumer)) { + return; + } + + $consumerReflection = new ReflectionObject($consumer); + while (!$consumerReflection->hasProperty('queue')) { + $parent = $consumerReflection->getParentClass(); + if ($parent === false) { + return; + } + $consumerReflection = $parent; + } + + $queueProperty = $consumerReflection->getProperty('queue'); + $queueProperty->setAccessible(true); + $queueProperty->setValue($consumer, []); + } catch (Throwable $e) { + error_log('[adapter] error discarding queued events: ' . $e->getMessage()); + } + } + + public function toArray(): array + { + return [ + 'pending_events' => $this->pendingEvents, + 'total_events_captured' => $this->totalEventsCaptured, + 'total_events_sent' => $this->totalEventsSent, + 'total_retries' => $this->totalRetries, + 'last_error' => $this->lastError, + 'requests_made' => array_map(static fn (RequestInfo $r): array => $r->toArray(), $this->requestsMade), + ]; + } +} + +final class TrackedHttpClient extends HttpClient +{ + public function __construct( + private AdapterState $state, + private string $trackedHost, + private bool $trackedUseSsl = true, + private int $trackedMaximumBackoffDuration = 10000, + private bool $trackedCompressRequests = false, + private bool $trackedDebug = false, + private int $trackedCurlTimeoutMilliseconds = 10000, + ) { + parent::__construct( + $trackedHost, + $trackedUseSsl, + $trackedMaximumBackoffDuration, + $trackedCompressRequests, + $trackedDebug, + null, + $trackedCurlTimeoutMilliseconds, + ); + } + + public function sendRequest(string $path, ?string $payload, array $extraHeaders = [], array $requestOptions = []): HttpResponse + { + $protocol = $this->trackedUseSsl ? 'https://' : 'http://'; + $backoff = 100; + $shouldRetry = $requestOptions['shouldRetry'] ?? true; + $shouldVerify = $requestOptions['shouldVerify'] ?? true; + $includeEtag = $requestOptions['includeEtag'] ?? false; + $timeout = isset($requestOptions['timeout']) + ? (int) $requestOptions['timeout'] + : $this->trackedCurlTimeoutMilliseconds; + $retryAttempt = 0; + $httpResponse = new HttpResponse(false, 0, null, 0); + + do { + $ch = curl_init(); + $responseHeaders = []; + + if ($payload !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $payload); + } + + $headers = ['Content-Type: application/json']; + if ($this->trackedCompressRequests) { + $headers[] = 'Content-Encoding: gzip'; + } + + curl_setopt($ch, CURLOPT_HTTPHEADER, array_merge($headers, $extraHeaders)); + curl_setopt($ch, CURLOPT_URL, $protocol . $this->trackedHost . $path); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, $shouldVerify); + curl_setopt($ch, CURLOPT_TIMEOUT_MS, $shouldVerify ? $timeout : 1); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, $timeout); + if (!$shouldVerify) { + curl_setopt($ch, CURLOPT_NOSIGNAL, true); + curl_setopt($ch, CURLOPT_FRESH_CONNECT, true); + } + if ($includeEtag) { + curl_setopt($ch, CURLOPT_HEADER, true); + } else { + curl_setopt($ch, CURLOPT_HEADERFUNCTION, static function ($ch, string $header) use (&$responseHeaders): int { + $responseHeaders[] = trim($header); + return strlen($header); + }); + } + + $response = curl_exec($ch); + $responseCode = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $curlErrno = (int) curl_errno($ch); + $etag = null; + + if ($includeEtag && $response !== false) { + $headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $rawHeaders = substr((string) $response, 0, $headerSize); + $body = substr((string) $response, $headerSize); + if (preg_match('/^etag:\s*(.+)$/mi', $rawHeaders, $matches)) { + $etag = trim($matches[1]); + } + $response = $body; + } + + curl_close($ch); + $httpResponse = new HttpResponse($response, $responseCode, $etag, $curlErrno); + + if ($path === '/batch/') { + [$eventCount, $uuidList] = $this->extractBatchInfo($payload); + $this->state->recordRequest($responseCode, $retryAttempt, $eventCount, $uuidList); + } + + if ($responseCode === 304) { + break; + } + + if ($shouldVerify && $responseCode !== 200) { + if ($shouldRetry === false) { + break; + } + + if ($this->isRetryableStatus($responseCode)) { + $retryAfterMs = $this->retryAfterMilliseconds($responseHeaders); + usleep(($retryAfterMs ?? $backoff) * 1000); + $backoff *= 2; + $retryAttempt++; + } else { + break; + } + } else { + break; + } + } while ($shouldRetry && $backoff < $this->trackedMaximumBackoffDuration); + + return $httpResponse; + } + + private function isRetryableStatus(int $responseCode): bool + { + return $responseCode === 408 + || $responseCode === 429 + || ($responseCode >= 500 && $responseCode <= 600); + } + + /** + * @param array $headers + */ + private function retryAfterMilliseconds(array $headers): ?int + { + foreach ($headers as $header) { + if (stripos($header, 'Retry-After:') !== 0) { + continue; + } + + $value = trim(substr($header, strlen('Retry-After:'))); + if ($value === '') { + return null; + } + + if (ctype_digit($value)) { + return max(0, (int) $value * 1000); + } + + $timestamp = strtotime($value); + if ($timestamp !== false) { + return max(0, (int) (($timestamp - time()) * 1000)); + } + } + + return null; + } + + /** @return array{0:int,1:list} */ + private function extractBatchInfo(?string $payload): array + { + if ($payload === null || $payload === '') { + return [0, []]; + } + + $json = $payload; + if ($this->trackedCompressRequests) { + $decoded = gzdecode($payload); + if ($decoded !== false) { + $json = $decoded; + } + } + + $decoded = json_decode($json, true); + if (!is_array($decoded) || !isset($decoded['batch']) || !is_array($decoded['batch'])) { + return [0, []]; + } + + $uuidList = []; + foreach ($decoded['batch'] as $event) { + if (is_array($event) && isset($event['uuid']) && is_string($event['uuid'])) { + $uuidList[] = $event['uuid']; + } + } + + return [count($decoded['batch']), $uuidList]; + } +} + +function jsonResponse($client, int $status, array $payload): void +{ + $body = json_encode($payload, JSON_UNESCAPED_SLASHES); + if ($body === false) { + $status = 500; + $body = '{"error":"failed to encode response"}'; + } + + $reason = [ + 200 => 'OK', + 400 => 'Bad Request', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 500 => 'Internal Server Error', + ][$status] ?? 'OK'; + + fwrite($client, "HTTP/1.1 {$status} {$reason}\r\n"); + fwrite($client, "Content-Type: application/json\r\n"); + fwrite($client, 'Content-Length: ' . strlen($body) . "\r\n"); + fwrite($client, "Connection: close\r\n\r\n"); + fwrite($client, $body); +} + +function readRequest($client): ?array +{ + $requestLine = fgets($client); + if ($requestLine === false || trim($requestLine) === '') { + return null; + } + + $parts = explode(' ', trim($requestLine), 3); + if (count($parts) < 2) { + return null; + } + + $headers = []; + while (($line = fgets($client)) !== false) { + $line = rtrim($line, "\r\n"); + if ($line === '') { + break; + } + $headerParts = explode(':', $line, 2); + if (count($headerParts) === 2) { + $headers[strtolower(trim($headerParts[0]))] = trim($headerParts[1]); + } + } + + $length = isset($headers['content-length']) ? (int) $headers['content-length'] : 0; + $body = ''; + while (strlen($body) < $length && !feof($client)) { + $body .= fread($client, $length - strlen($body)); + } + + return [ + 'method' => strtoupper($parts[0]), + 'path' => parse_url($parts[1], PHP_URL_PATH) ?: '/', + 'body' => $body, + ]; +} + +function requestJson(array $request): array +{ + if ($request['body'] === '') { + return []; + } + + $decoded = json_decode($request['body'], true); + return is_array($decoded) ? $decoded : []; +} + +function normalizeHost(string $host): array +{ + $useSsl = true; + $normalized = trim($host); + if (preg_match('/^http:\/\//i', $normalized) === 1) { + $useSsl = false; + $normalized = preg_replace('/^http:\/\//i', '', $normalized) ?? $normalized; + } elseif (preg_match('/^https:\/\//i', $normalized) === 1) { + $useSsl = true; + $normalized = preg_replace('/^https:\/\//i', '', $normalized) ?? $normalized; + } + + return [$normalized, $useSsl]; +} + +function maxBackoffDurationForRetries(int $maxRetries): int +{ + if ($maxRetries <= 0) { + return 100; + } + + return (100 * (2 ** $maxRetries)) + 1; +} + +function handleRequest(array $request, AdapterState $state): array +{ + try { + if ($request['method'] === 'GET' && $request['path'] === '/health') { + return [200, [ + 'sdk_name' => 'posthog-php', + 'sdk_version' => PostHog::VERSION, + 'adapter_version' => '1.0.0', + 'capabilities' => ['capture_v0', 'encoding_gzip'], + ]]; + } + + if ($request['method'] === 'POST' && $request['path'] === '/init') { + $data = requestJson($request); + $apiKey = isset($data['api_key']) ? trim((string) $data['api_key']) : ''; + $host = isset($data['host']) ? trim((string) $data['host']) : ''; + if ($apiKey === '') { + return [400, ['error' => 'api_key is required']]; + } + if ($host === '') { + return [400, ['error' => 'host is required']]; + } + + $state->reset(); + [$normalizedHost, $useSsl] = normalizeHost($host); + $flushAt = max(1, (int) ($data['flush_at'] ?? 100)); + $flushIntervalMs = max(0, (int) ($data['flush_interval_ms'] ?? 5000)); + $maxRetries = max(0, (int) ($data['max_retries'] ?? 3)); + $enableCompression = (bool) ($data['enable_compression'] ?? false); + $maximumBackoffDuration = maxBackoffDurationForRetries($maxRetries); + $timeoutMs = max(1000, (int) ($data['timeout_ms'] ?? 10000)); + + $httpClient = new TrackedHttpClient( + $state, + $normalizedHost, + $useSsl, + $maximumBackoffDuration, + $enableCompression, + true, + $timeoutMs, + ); + + $state->client = new Client($apiKey, [ + 'host' => $normalizedHost, + 'ssl' => $useSsl, + 'consumer' => 'lib_curl', + 'batch_size' => $flushAt, + 'flush_interval_seconds' => $flushIntervalMs / 1000, + 'maximum_backoff_duration' => $maximumBackoffDuration, + 'compress_request' => $enableCompression ? 'true' : 'false', + 'debug' => true, + 'timeout' => $timeoutMs, + ], $httpClient, null, false); + + return [200, ['success' => true]]; + } + + if ($request['method'] === 'POST' && $request['path'] === '/capture') { + if ($state->client === null) { + return [400, ['error' => 'SDK not initialized']]; + } + + $data = requestJson($request); + $distinctId = isset($data['distinct_id']) ? (string) $data['distinct_id'] : ''; + $event = isset($data['event']) ? (string) $data['event'] : ''; + if ($distinctId === '') { + return [400, ['error' => 'distinct_id is required']]; + } + if ($event === '') { + return [400, ['error' => 'event is required']]; + } + + $message = [ + 'distinctId' => $distinctId, + 'event' => $event, + 'properties' => (isset($data['properties']) && is_array($data['properties'])) ? $data['properties'] : [], + ]; + if (isset($data['timestamp'])) { + $message['timestamp'] = $data['timestamp']; + } + + $state->client->capture($message); + + $state->recordCaptured(); + return [200, ['success' => true, 'uuid' => $message['uuid'] ?? null]]; + } + + if ($request['method'] === 'POST' && $request['path'] === '/get_feature_flag') { + if ($state->client === null) { + return [400, ['error' => 'SDK not initialized']]; + } + + $data = requestJson($request); + $key = isset($data['key']) ? (string) $data['key'] : ''; + $distinctId = isset($data['distinct_id']) ? (string) $data['distinct_id'] : ''; + if ($key === '') { + return [400, ['error' => 'key is required']]; + } + if ($distinctId === '') { + return [400, ['error' => 'distinct_id is required']]; + } + + $groups = (isset($data['groups']) && is_array($data['groups'])) ? $data['groups'] : []; + $personProperties = (isset($data['person_properties']) && is_array($data['person_properties'])) + ? $data['person_properties'] + : []; + $groupProperties = (isset($data['group_properties']) && is_array($data['group_properties'])) + ? $data['group_properties'] + : []; + $forceRemote = (bool) ($data['force_remote'] ?? true); + $disableGeoip = (bool) ($data['disable_geoip'] ?? false); + + if ($disableGeoip) { + $snapshot = $state->client->evaluateFlags( + $distinctId, + $groups, + $personProperties, + $groupProperties, + !$forceRemote, + true, + [$key], + ); + $value = $snapshot->getFlag($key); + } else { + $value = @$state->client->getFeatureFlag( + $key, + $distinctId, + $groups, + $personProperties, + $groupProperties, + !$forceRemote, + true, + ); + } + + return [200, ['success' => true, 'value' => $value]]; + } + + if ($request['method'] === 'POST' && $request['path'] === '/flush') { + if ($state->client === null) { + return [400, ['error' => 'SDK not initialized']]; + } + + $state->client->flush(); + return [200, ['success' => true, 'events_flushed' => $state->totalEventsSent]]; + } + + if ($request['method'] === 'GET' && $request['path'] === '/state') { + return [200, $state->toArray()]; + } + + if ($request['method'] === 'POST' && $request['path'] === '/reset') { + $state->reset(); + return [200, ['success' => true]]; + } + + return [404, ['error' => 'not found']]; + } catch (Throwable $e) { + $state->recordError($e->getMessage()); + error_log('[adapter] ' . $e); + return [500, ['error' => $e->getMessage()]]; + } +} + +$server = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr); +if ($server === false) { + fwrite(STDERR, "Failed to start server: {$errstr} ({$errno})\n"); + exit(1); +} + +$state = new AdapterState(); +fwrite(STDERR, "PostHog PHP SDK compliance adapter listening on :8080\n"); + +while (true) { + $client = @stream_socket_accept($server, -1); + if ($client === false) { + usleep(10000); + continue; + } + + $request = readRequest($client); + if ($request === null) { + fclose($client); + continue; + } + + [$status, $payload] = handleRequest($request, $state); + jsonResponse($client, $status, $payload); + fclose($client); +} diff --git a/sdk_compliance_adapter/docker-compose.yml b/sdk_compliance_adapter/docker-compose.yml new file mode 100644 index 0000000..878432c --- /dev/null +++ b/sdk_compliance_adapter/docker-compose.yml @@ -0,0 +1,21 @@ +name: posthog-php-sdk-compliance + +services: + sdk-adapter: + build: + context: .. + dockerfile: sdk_compliance_adapter/Dockerfile + networks: + - test-network + + test-harness: + image: ghcr.io/posthog/sdk-test-harness:0.8.0 + command: ["run", "--adapter-url", "http://sdk-adapter:8080", "--mock-url", "http://test-harness:8081"] + networks: + - test-network + depends_on: + - sdk-adapter + +networks: + test-network: + driver: bridge From dfa3bd24b45652a8495ed3747b5028c9f597c3ba Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 29 Jun 2026 13:02:18 +0200 Subject: [PATCH 2/7] fix: exclude compliance adapter from phpcs --- sdk_compliance_adapter/adapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk_compliance_adapter/adapter.php b/sdk_compliance_adapter/adapter.php index 29b507d..55fafee 100644 --- a/sdk_compliance_adapter/adapter.php +++ b/sdk_compliance_adapter/adapter.php @@ -1,5 +1,6 @@ Date: Mon, 29 Jun 2026 13:13:22 +0200 Subject: [PATCH 3/7] fix: update phpunit expectations for harness changes --- lib/HttpClient.php | 4 +- sdk_compliance_adapter/adapter.php | 51 ++++++++----------------- test/FeatureFlagLocalEvaluationTest.php | 21 +++++++++- test/FeatureFlagTest.php | 34 +++++++++++++---- test/PostHogTest.php | 27 +++++++++++-- 5 files changed, 87 insertions(+), 50 deletions(-) diff --git a/lib/HttpClient.php b/lib/HttpClient.php index 89a4c71..45583f3 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -205,7 +205,7 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse return new HttpResponse($response, $responseCode, null, $curlErrno); } - private function isRetryableStatus(int $responseCode): bool + protected function isRetryableStatus(int $responseCode): bool { return $responseCode === 408 || $responseCode === 429 @@ -215,7 +215,7 @@ private function isRetryableStatus(int $responseCode): bool /** * @param array $headers */ - private function retryAfterMilliseconds(array $headers): ?int + protected function retryAfterMilliseconds(array $headers): ?int { foreach ($headers as $header) { if (stripos($header, 'Retry-After:') !== 0) { diff --git a/sdk_compliance_adapter/adapter.php b/sdk_compliance_adapter/adapter.php index 55fafee..8d47f04 100644 --- a/sdk_compliance_adapter/adapter.php +++ b/sdk_compliance_adapter/adapter.php @@ -9,6 +9,7 @@ use PostHog\HttpClient; use PostHog\HttpResponse; use PostHog\PostHog; +use PostHog\Uuid; final class RequestInfo { @@ -250,41 +251,6 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders return $httpResponse; } - private function isRetryableStatus(int $responseCode): bool - { - return $responseCode === 408 - || $responseCode === 429 - || ($responseCode >= 500 && $responseCode <= 600); - } - - /** - * @param array $headers - */ - private function retryAfterMilliseconds(array $headers): ?int - { - foreach ($headers as $header) { - if (stripos($header, 'Retry-After:') !== 0) { - continue; - } - - $value = trim(substr($header, strlen('Retry-After:'))); - if ($value === '') { - return null; - } - - if (ctype_digit($value)) { - return max(0, (int) $value * 1000); - } - - $timestamp = strtotime($value); - if ($timestamp !== false) { - return max(0, (int) (($timestamp - time()) * 1000)); - } - } - - return null; - } - /** @return array{0:int,1:list} */ private function extractBatchInfo(?string $payload): array { @@ -316,6 +282,15 @@ private function extractBatchInfo(?string $payload): array } } +function isValidUuid(mixed $uuid): bool +{ + return is_string($uuid) + && preg_match( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $uuid + ) === 1; +} + function jsonResponse($client, int $status, array $payload): void { $body = json_encode($payload, JSON_UNESCAPED_SLASHES); @@ -491,10 +466,14 @@ function handleRequest(array $request, AdapterState $state): array $message['timestamp'] = $data['timestamp']; } + if (!isset($message['uuid']) || !isValidUuid($message['uuid'])) { + $message['uuid'] = Uuid::v4(); + } + $state->client->capture($message); $state->recordCaptured(); - return [200, ['success' => true, 'uuid' => $message['uuid'] ?? null]]; + return [200, ['success' => true, 'uuid' => $message['uuid']]]; } if ($request['method'] === 'POST' && $request['path'] === '/get_feature_flag') { diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 8d39666..a4cc0be 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -38,6 +38,24 @@ public function checkEmptyErrorLogs(): void $this->assertTrue(empty($errorMessages), "Error logs are not empty: " . implode("\n", $errorMessages)); } + private function assertAndStripBatchUuid(int $callIndex): void + { + $payload = json_decode($this->http_client->calls[$callIndex]['payload'], true); + $this->assertIsArray($payload); + $this->assertArrayHasKey('batch', $payload); + + foreach ($payload['batch'] as $index => $event) { + $this->assertArrayHasKey('uuid', $event); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $event['uuid'] + ); + unset($payload['batch'][$index]['uuid']); + } + + $this->http_client->calls[$callIndex]['payload'] = json_encode($payload); + } + public function testMatchPropertyEquals(): void { $prop = [ @@ -1442,6 +1460,7 @@ public function testSimpleFlag() PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array( @@ -1694,7 +1713,7 @@ public function testFeatureFlagsLocalEvaluationForNegatedCohorts() array( 0 => array( "path" => "/flags/?v=2", - 'payload' => '{"api_key":"random_key","distinct_id":"some-distinct-id","person_properties":{"distinct_id":"some-distinct-id","region":"USA","other":"thing"}}', + 'payload' => '{"token":"random_key","distinct_id":"some-distinct-id","groups":{},"person_properties":{"distinct_id":"some-distinct-id","region":"USA","other":"thing"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["beta-feature"]}', "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index b51d067..3e99f4c 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -46,6 +46,24 @@ public function checkEmptyErrorLogs(): void $this->assertTrue(empty($errorMessages), "Error logs are not empty: " . implode("\n", $errorMessages)); } + private function assertAndStripBatchUuid(int $callIndex): void + { + $payload = json_decode($this->http_client->calls[$callIndex]['payload'], true); + $this->assertIsArray($payload); + $this->assertArrayHasKey('batch', $payload); + + foreach ($payload['batch'] as $index => $event) { + $this->assertArrayHasKey('uuid', $event); + $this->assertMatchesRegularExpression( + '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', + $event['uuid'] + ); + unset($payload['batch'][$index]['uuid']); + } + + $this->http_client->calls[$callIndex]['payload'] = json_encode($payload); + } + public static function decideResponseCases(): array { return [ @@ -233,7 +251,7 @@ public function testIsFeatureEnabled($response) ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","person_properties":{"distinct_id":"user-id"}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -247,12 +265,13 @@ public function testIsFeatureEnabledCapturesFeatureFlagCalledEventWithAdditional $this->setUp(MockedResponses::FLAGS_V2_RESPONSE, personalApiKey: null); $this->assertTrue(PostHog::isFeatureEnabled('simple-test', 'user-id')); PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","person_properties":{"distinct_id":"user-id"}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -275,7 +294,7 @@ public function testWhitespacePersonalApiKeyFallsBackToFlagsEndpoint() [ [ "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","person_properties":{"distinct_id":"user-id"}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), "extraHeaders" => [0 => 'User-Agent: posthog-php/' . PostHog::VERSION], "requestOptions" => ["timeout" => 3000, "shouldRetry" => false], ], @@ -304,7 +323,7 @@ public function testIsFeatureEnabledGroups($response) 1 => array( "path" => "/flags/?v=2", "payload" => sprintf( - '{"api_key":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}}}', + '{"token":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', self::FAKE_API_KEY ), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), @@ -332,7 +351,7 @@ public function testGetFeatureFlag($response) ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","person_properties":{"distinct_id":"user-id"}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -346,12 +365,13 @@ public function testGetFeatureFlagCapturesFeatureFlagCalledEventWithAdditionalMe $this->setUp(MockedResponses::FLAGS_V2_RESPONSE, personalApiKey: null); $this->assertEquals("variant-value", PostHog::getFeatureFlag('multivariate-test', 'user-id')); PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","person_properties":{"distinct_id":"user-id"}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -400,7 +420,7 @@ public function testGetFeatureFlagGroups($response) 1 => array( "path" => "/flags/?v=2", "payload" => sprintf( - '{"api_key":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}}}', + '{"token":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY ), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), diff --git a/test/PostHogTest.php b/test/PostHogTest.php index da81bc1..8b91915 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -78,6 +78,21 @@ private function assertValidUuidV4(string $uuid): void ); } + private function assertAndStripBatchUuid(int $callIndex): void + { + $payload = json_decode($this->http_client->calls[$callIndex]['payload'], true); + $this->assertIsArray($payload); + $this->assertArrayHasKey('batch', $payload); + + foreach ($payload['batch'] as $index => $event) { + $this->assertArrayHasKey('uuid', $event); + $this->assertValidUuidV4($event['uuid']); + unset($payload['batch'][$index]['uuid']); + } + + $this->http_client->calls[$callIndex]['payload'] = json_encode($payload); + } + private function withEnvApiKey(?string $apiKey, callable $callback): void { $previousApiKey = getenv(PostHog::ENV_API_KEY); @@ -961,6 +976,7 @@ public function testCaptureWithSendFeatureFlagsOption(): void ); PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array ( @@ -1017,6 +1033,7 @@ public function testCaptureWithLocalSendFlags(): void PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array ( @@ -1066,6 +1083,7 @@ public function testCaptureWithLocalSendFlagsNoOverrides(): void PostHog::flush(); + $this->assertAndStripBatchUuid(1); $this->assertEquals( $this->http_client->calls, array ( @@ -1280,7 +1298,7 @@ public function testDefaultPropertiesGetAddedProperly(): void ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1302,7 +1320,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"override"},"group_properties":{"company":{"$group_key":"group_override"},"instance":{"$group_key":"app.posthog.com"}}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"override"},"group_properties":{"company":{"$group_key":"group_override"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1318,7 +1336,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"some_id"},"group_properties":{"company":{"$group_key":"id:5"}}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"some_id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1334,7 +1352,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}}}', self::FAKE_API_KEY), + "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1368,6 +1386,7 @@ public function testCaptureWithSendFeatureFlagsFalse(): void PostHog::flush(); + $this->assertAndStripBatchUuid(1); // When send_feature_flags is explicitly false, NO feature flags should be added $this->assertEquals( $this->http_client->calls, From 9769c85f33b67badb43dda0e6ccf32fc9dc115a5 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 29 Jun 2026 16:40:53 +0200 Subject: [PATCH 4/7] chore: remove harness audit notes --- sdk-harness-audit/posthog-php.md | 58 -------------------------------- 1 file changed, 58 deletions(-) delete mode 100644 sdk-harness-audit/posthog-php.md diff --git a/sdk-harness-audit/posthog-php.md b/sdk-harness-audit/posthog-php.md deleted file mode 100644 index 51724f2..0000000 --- a/sdk-harness-audit/posthog-php.md +++ /dev/null @@ -1,58 +0,0 @@ -# posthog-php SDK compliance harness audit - -## Summary - -Implemented the SDK compliance harness for posthog-php and fixed the SDK/adapter issues needed for the local Docker Compose compliance run to pass. - -## Changed files - -- `.github/workflows/sdk-compliance.yml` — added SDK compliance workflow using the shared harness action. -- `sdk_compliance_adapter/adapter.php` — added long-running PHP HTTP adapter with `/health`, `/init`, `/capture`, `/flush`, `/state`, `/reset`, and `/get_feature_flag` endpoints. -- `sdk_compliance_adapter/Dockerfile` — added adapter image build. -- `sdk_compliance_adapter/docker-compose.yml` — added local adapter + harness compose setup with a unique compose project name. -- `sdk_compliance_adapter/README.md` — added local run instructions. -- `lib/Client.php` — generate UUIDs for captured events, send modern `/flags/?v=2` payload fields (`token`, `groups`, `group_properties`, `geoip_disable`, `flag_keys_to_evaluate`) for single flag remote evaluation. -- `lib/HttpClient.php` — retry 408 responses and honor `Retry-After` while retaining exponential backoff for retryable failures. -- `lib/QueueConsumer.php` — accept boolean `compress_request` values in addition to JSON string values. - -## Failing tests fixed - -Compliance failures fixed locally: - -- Missing harness/workflow. -- Missing event UUID generation. -- Retry behavior for 408. -- `Retry-After` delay behavior for 429. -- Gzip compression option handling. -- Feature flag adapter endpoint and `/flags/?v=2` request payload contract. -- Feature flag side-effect `$feature_flag_called` event in the harness path. - -## Commands run and exit codes - -- `php -l sdk_compliance_adapter/adapter.php && docker compose -f sdk_compliance_adapter/docker-compose.yml build sdk-adapter` — exit 0. -- `docker compose -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test-harness` — exit 143 before compose project isolation; harness run was interrupted/ambiguous due sibling compose project/name collisions. -- `docker run --rm ghcr.io/posthog/sdk-test-harness:0.8.0 --help` — exit 0. -- `docker run --rm ghcr.io/posthog/sdk-test-harness:0.8.0 run --help` — exit 0. -- `php -l sdk_compliance_adapter/adapter.php && php -l lib/HttpClient.php && php -l lib/Client.php && php -l lib/QueueConsumer.php` — exit 0. -- `docker compose -p posthog-php-sdk-compliance -f sdk_compliance_adapter/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test-harness` — exit 0; final output: `Total: 45 | 45 passed | 0 failed | Duration: 95110ms` and `All tests passed! ✓`. -- `docker compose -p posthog-php-sdk-compliance -f sdk_compliance_adapter/docker-compose.yml build sdk-adapter` — exit 0. -- `composer install --no-interaction --prefer-dist --no-progress && vendor/bin/phpunit --colors=never test/FeatureFlagTest.php test/FeatureFlagEvaluationsTest.php test/QueueConsumerTest.php test/HttpClientTest.php` — exit 1; existing exact-payload unit tests need updates for the new `/flags` payload and UUID fields. -- `vendor/bin/phpunit --colors=never test/` — exit 1; 18 failures, all observed failures are exact expected payload assertions affected by UUID generation or the modern `/flags` payload shape. -- `git diff --cached --quiet; echo no_staged_exit:$?` — exit 0 (`no_staged_exit:0`). - -## Validation output - -Final SDK compliance harness run: - -```text -CAPTURE Tests: all 29 passed -FEATURE_FLAGS Tests: all 16 passed -Total: 45 | 45 passed | 0 failed | Duration: 95110ms -All tests passed! ✓ -``` - -## Remaining blockers / risks - -- The local SDK compliance harness passes. -- PHPUnit is not fully green after these SDK contract changes: existing exact JSON payload expectations still reflect the old feature flag request shape and the previous absence of event UUIDs in some expected batch payloads. These tests should be updated in a follow-up if the repository requires full unit-suite green in the same change. -- No files are staged. From 473e612375d06fe53981fe66cd66dfcd84a93c06 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 29 Jun 2026 16:44:13 +0200 Subject: [PATCH 5/7] fix: keep flags api key field --- lib/Client.php | 2 +- test/FeatureFlagLocalEvaluationTest.php | 2 +- test/FeatureFlagTest.php | 14 +++++++------- test/PostHogTest.php | 8 ++++---- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index fa9306a..4e4b40c 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1660,7 +1660,7 @@ private function requestFlags( } $payload = array( - 'token' => $this->apiKey, + 'api_key' => $this->apiKey, 'distinct_id' => $distinctId, 'groups' => empty($groups) ? (object) [] : $groups, 'person_properties' => empty($personProperties) ? (object) [] : $personProperties, diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index a4cc0be..479bc81 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -1713,7 +1713,7 @@ public function testFeatureFlagsLocalEvaluationForNegatedCohorts() array( 0 => array( "path" => "/flags/?v=2", - 'payload' => '{"token":"random_key","distinct_id":"some-distinct-id","groups":{},"person_properties":{"distinct_id":"some-distinct-id","region":"USA","other":"thing"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["beta-feature"]}', + 'payload' => '{"api_key":"random_key","distinct_id":"some-distinct-id","groups":{},"person_properties":{"distinct_id":"some-distinct-id","region":"USA","other":"thing"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["beta-feature"]}', "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 3e99f4c..9c699f3 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -251,7 +251,7 @@ public function testIsFeatureEnabled($response) ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -271,7 +271,7 @@ public function testIsFeatureEnabledCapturesFeatureFlagCalledEventWithAdditional array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -294,7 +294,7 @@ public function testWhitespacePersonalApiKeyFallsBackToFlagsEndpoint() [ [ "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["simple-test"]}', self::FAKE_API_KEY), "extraHeaders" => [0 => 'User-Agent: posthog-php/' . PostHog::VERSION], "requestOptions" => ["timeout" => 3000, "shouldRetry" => false], ], @@ -323,7 +323,7 @@ public function testIsFeatureEnabledGroups($response) 1 => array( "path" => "/flags/?v=2", "payload" => sprintf( - '{"token":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', + '{"api_key":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["having_fun"]}', self::FAKE_API_KEY ), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), @@ -351,7 +351,7 @@ public function testGetFeatureFlag($response) ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -371,7 +371,7 @@ public function testGetFeatureFlagCapturesFeatureFlagCalledEventWithAdditionalMe array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"user-id","groups":{},"person_properties":{"distinct_id":"user-id"},"group_properties":{},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -420,7 +420,7 @@ public function testGetFeatureFlagGroups($response) 1 => array( "path" => "/flags/?v=2", "payload" => sprintf( - '{"token":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', + '{"api_key":"%s","distinct_id":"user-id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"user-id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["multivariate-test"]}', self::FAKE_API_KEY ), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), diff --git a/test/PostHogTest.php b/test/PostHogTest.php index 8b91915..07fcc08 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -1298,7 +1298,7 @@ public function testDefaultPropertiesGetAddedProperly(): void ), 1 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1320,7 +1320,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"override"},"group_properties":{"company":{"$group_key":"group_override"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"override"},"group_properties":{"company":{"$group_key":"group_override"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1336,7 +1336,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"some_id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5"},"person_properties":{"distinct_id":"some_id"},"group_properties":{"company":{"$group_key":"id:5"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), @@ -1352,7 +1352,7 @@ public function testDefaultPropertiesGetAddedProperly(): void array( 0 => array( "path" => "/flags/?v=2", - "payload" => sprintf('{"token":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), + "payload" => sprintf('{"api_key":"%s","distinct_id":"some_id","groups":{"company":"id:5","instance":"app.posthog.com"},"person_properties":{"distinct_id":"some_id","x1":"y1"},"group_properties":{"company":{"$group_key":"id:5","x":"y"},"instance":{"$group_key":"app.posthog.com"}},"geoip_disable":false,"flag_keys_to_evaluate":["random_key"]}', self::FAKE_API_KEY), "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION), "requestOptions" => array("timeout" => 3000, "shouldRetry" => false), ), From e20c60ed98657a287b59b3c1f45215800958c0a8 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 29 Jun 2026 16:49:17 +0200 Subject: [PATCH 6/7] test: cover Retry-After date parsing --- test/HttpClientTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/test/HttpClientTest.php b/test/HttpClientTest.php index 29e1cc0..a6361f2 100644 --- a/test/HttpClientTest.php +++ b/test/HttpClientTest.php @@ -5,6 +5,15 @@ use PHPUnit\Framework\TestCase; use PostHog\HttpClient; +class RetryAfterHttpClient extends HttpClient +{ + /** @param array $headers */ + public function parseRetryAfter(array $headers): ?int + { + return $this->retryAfterMilliseconds($headers); + } +} + class HttpClientTest extends TestCase { public function testMaskTokensInUrl(): void @@ -36,4 +45,24 @@ public function testMaskTokensInUrl(): void $result = $httpClient->maskTokensInUrl($url); $this->assertEquals('https://example.com/api/flags?token=&other=value', $result); } + + public function testRetryAfterMillisecondsParsesSeconds(): void + { + $httpClient = new RetryAfterHttpClient("app.posthog.com"); + + $this->assertSame(3000, $httpClient->parseRetryAfter(['Retry-After: 3'])); + $this->assertSame(0, $httpClient->parseRetryAfter(['Retry-After: 0'])); + } + + public function testRetryAfterMillisecondsParsesHttpDate(): void + { + $httpClient = new RetryAfterHttpClient("app.posthog.com"); + $retryAt = gmdate('D, d M Y H:i:s \G\M\T', time() + 2); + + $retryAfterMs = $httpClient->parseRetryAfter(['Retry-After: ' . $retryAt]); + + $this->assertNotNull($retryAfterMs); + $this->assertGreaterThanOrEqual(0, $retryAfterMs); + $this->assertLessThanOrEqual(2000, $retryAfterMs); + } } From 1314bca559647bc7d3b0f25628d65ecfbed893a7 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Mon, 29 Jun 2026 16:49:57 +0200 Subject: [PATCH 7/7] chore: add PHP retry changeset --- .changeset/fresh-pears-retry.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fresh-pears-retry.md diff --git a/.changeset/fresh-pears-retry.md b/.changeset/fresh-pears-retry.md new file mode 100644 index 0000000..3cf5029 --- /dev/null +++ b/.changeset/fresh-pears-retry.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Retry capture delivery on transient HTTP errors and respect Retry-After responses.