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. 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 0fe816b..4e4b40c 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -661,7 +661,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']) { @@ -1662,24 +1662,12 @@ private function requestFlags( $payload = array( 'api_key' => $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); } @@ -2012,7 +2000,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 7e3c810..45583f3 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -104,6 +104,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); @@ -131,9 +132,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 @@ -155,14 +161,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; @@ -199,6 +205,41 @@ private function executePost($ch, bool $includeEtag = false): HttpResponse return new HttpResponse($response, $responseCode, null, $curlErrno); } + protected function isRetryableStatus(int $responseCode): bool + { + return $responseCode === 408 + || $responseCode === 429 + || ($responseCode >= 500 && $responseCode <= 600); + } + + /** + * @param array $headers + */ + protected 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_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..8d47f04 --- /dev/null +++ b/sdk_compliance_adapter/adapter.php @@ -0,0 +1,581 @@ + $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; + } + + /** @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 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); + 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']; + } + + if (!isset($message['uuid']) || !isValidUuid($message['uuid'])) { + $message['uuid'] = Uuid::v4(); + } + + $state->client->capture($message); + + $state->recordCaptured(); + return [200, ['success' => true, 'uuid' => $message['uuid']]]; + } + + 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 diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 8d39666..479bc81 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' => '{"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 b51d067..9c699f3 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('{"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), ), @@ -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('{"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), ), @@ -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('{"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], ], @@ -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"}}}', + '{"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), @@ -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('{"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), ), @@ -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('{"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), ), @@ -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"}}}', + '{"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/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); + } } diff --git a/test/PostHogTest.php b/test/PostHogTest.php index da81bc1..07fcc08 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('{"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), ), @@ -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('{"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), ), @@ -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('{"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), ), @@ -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('{"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), ), @@ -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,