diff --git a/History.md b/History.md index fc42c2e..051ffd6 100644 --- a/History.md +++ b/History.md @@ -1,6 +1,7 @@ -3.7.3 / 2025-11-24 +3.7.3 / 2025-12-04 ================== +* feat(flags): Add ETag support for local evaluation caching * feat(flags): include `evaluated_at` properties in `$feature_flag_called` events diff --git a/example.php b/example.php index 31fad79..d78c208 100644 --- a/example.php +++ b/example.php @@ -88,9 +88,10 @@ function loadEnvFile() echo "2. Feature flag local evaluation examples\n"; echo "3. Feature flag dependencies examples\n"; echo "4. Context management and tagging examples\n"; -echo "5. Run all examples\n"; -echo "6. Exit\n"; -$choice = trim(readline("\nEnter your choice (1-6): ")); +echo "5. ETag polling examples (for local evaluation)\n"; +echo "6. Run all examples\n"; +echo "7. Exit\n"; +$choice = trim(readline("\nEnter your choice (1-7): ")); function identifyAndCaptureExamples() { @@ -420,6 +421,83 @@ function contextManagementExamples() echo "āœ… Context management examples completed!\n"; } +function etagPollingExamples() +{ + echo "\n" . str_repeat("=", 60) . "\n"; + echo "ETAG POLLING EXAMPLES\n"; + echo str_repeat("=", 60) . "\n"; + echo "This example demonstrates ETag-based caching for feature flags.\n"; + echo "ETag support reduces bandwidth by skipping full payload transfers\n"; + echo "when flags haven't changed (304 Not Modified response).\n\n"; + + // Re-initialize with debug enabled + PostHog::init( + $_ENV['POSTHOG_PROJECT_API_KEY'], + [ + 'host' => $_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', + 'debug' => true, + 'ssl' => !str_starts_with($_ENV['POSTHOG_HOST'] ?? 'https://app.posthog.com', 'http://') + ], + null, + $_ENV['POSTHOG_PERSONAL_API_KEY'] + ); + + $client = PostHog::getClient(); + + // Initial load - should get full response with ETag + echo "šŸ“„ Initial flag load (expecting full response with ETag)...\n"; + $client->loadFlags(); + $initialEtag = $client->getFlagsEtag(); + $flagCount = count($client->featureFlags); + + if ($initialEtag) { + echo " āœ… Received ETag: " . substr($initialEtag, 0, 30) . "...\n"; + } else { + echo " āš ļø No ETag received (server may not support ETag caching)\n"; + } + echo " šŸ“Š Loaded $flagCount feature flag(s)\n\n"; + + // Second load - should get 304 Not Modified if flags haven't changed + echo "šŸ“„ Second flag load (expecting 304 Not Modified if unchanged)...\n"; + $client->loadFlags(); + $secondEtag = $client->getFlagsEtag(); + $secondFlagCount = count($client->featureFlags); + + echo " šŸ“Š Flag count: $secondFlagCount (should match initial: $flagCount)\n"; + if ($secondEtag === $initialEtag && $initialEtag !== null) { + echo " āœ… ETag unchanged - server likely returned 304 Not Modified\n"; + } elseif ($secondEtag !== null) { + echo " šŸ“ ETag changed: " . substr($secondEtag, 0, 30) . "...\n"; + echo " (flags may have been updated on the server)\n"; + } + echo "\n"; + + // Continuous polling - runs until Ctrl+C + echo "šŸ”„ Starting continuous polling (every 5 seconds)...\n"; + echo " Press Ctrl+C to stop.\n"; + echo " Try changing feature flags in PostHog to see ETag changes!\n\n"; + + $iteration = 1; + while (true) { + $timestamp = date('H:i:s'); + echo " [$timestamp] Poll #$iteration: "; + + $beforeEtag = $client->getFlagsEtag(); + $client->loadFlags(); + $afterEtag = $client->getFlagsEtag(); + $currentFlagCount = count($client->featureFlags); + + if ($beforeEtag === $afterEtag && $beforeEtag !== null) { + echo "No change (304 Not Modified) - $currentFlagCount flag(s)\n"; + } else { + echo "šŸ”„ Flags updated! New ETag: " . ($afterEtag ? substr($afterEtag, 0, 20) . "..." : "none") . " - $currentFlagCount flag(s)\n"; + } + + $iteration++; + sleep(5); + } +} + function runAllExamples() { identifyAndCaptureExamples(); @@ -434,6 +512,7 @@ function runAllExamples() contextManagementExamples(); echo "\nšŸŽ‰ All examples completed!\n"; + echo " (ETag polling skipped - run separately with option 5)\n"; } // Handle user choice @@ -451,13 +530,16 @@ function runAllExamples() contextManagementExamples(); break; case '5': - runAllExamples(); + etagPollingExamples(); break; case '6': + runAllExamples(); + break; + case '7': echo "šŸ‘‹ Goodbye!\n"; exit(0); default: - echo "āŒ Invalid choice. Please run the script again and choose 1-6.\n"; + echo "āŒ Invalid choice. Please run the script again and choose 1-7.\n"; exit(1); } diff --git a/lib/Client.php b/lib/Client.php index fae24a0..ec56ff5 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -72,6 +72,16 @@ class Client */ public $distinctIdsFeatureFlagsReported; + /** + * @var string|null Cached ETag for feature flag definitions + */ + private $flagsEtag; + + /** + * @var bool + */ + private $debug; + /** * Create a new posthog object with your app's API key * key @@ -89,6 +99,7 @@ public function __construct( ) { $this->apiKey = $apiKey; $this->personalAPIKey = $personalAPIKey; + $this->debug = $options["debug"] ?? false; $Consumer = self::CONSUMERS[$options["consumer"] ?? "lib_curl"]; $this->consumer = new $Consumer($apiKey, $options, $httpClient); $this->httpClient = $httpClient !== null ? $httpClient : new HttpClient( @@ -106,6 +117,7 @@ public function __construct( $this->cohorts = []; $this->featureFlagsByKey = []; $this->distinctIdsFeatureFlagsReported = new SizeLimitedHash(SIZE_LIMIT); + $this->flagsEtag = null; // Populate featureflags and grouptypemapping if possible if ( @@ -514,12 +526,32 @@ private function fetchFlagsResponse( public function loadFlags() { - $payload = json_decode($this->localFlags(), true); + $response = $this->localFlags(); + + // Handle 304 Not Modified - flags haven't changed, skip processing. + // On 304, we preserve the existing ETag unless the server sends a new one. + // This handles edge cases like server restarts where the server may send + // a refreshed ETag even though the content hasn't changed. + if ($response->isNotModified()) { + if ($response->getEtag()) { + $this->flagsEtag = $response->getEtag(); + } + if ($this->debug) { + error_log("[PostHog][Client] Flags not modified (304), using cached data"); + } + return; + } + + $payload = json_decode($response->getResponse(), true); if ($payload && array_key_exists("detail", $payload)) { throw new Exception($payload["detail"]); } + // On 200 responses, always update ETag (even if null) since we're replacing + // the cached flag data. A null ETag means the server doesn't support caching. + $this->flagsEtag = $response->getEtag(); + $this->featureFlags = $payload['flags'] ?? []; $this->groupTypeMapping = $payload['group_type_mapping'] ?? []; $this->cohorts = $payload['cohorts'] ?? []; @@ -532,17 +564,37 @@ public function loadFlags() } - public function localFlags() + public function localFlags(): HttpResponse { + $headers = [ + // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. + "User-Agent: posthog-php/" . PostHog::VERSION, + "Authorization: Bearer " . $this->personalAPIKey + ]; + + // Add If-None-Match header if we have a cached ETag + if ($this->flagsEtag !== null) { + $headers[] = "If-None-Match: " . $this->flagsEtag; + } + return $this->httpClient->sendRequest( '/api/feature_flag/local_evaluation?send_cohorts&token=' . $this->apiKey, null, + $headers, [ - // Send user agent in the form of {library_name}/{library_version} as per RFC 7231. - "User-Agent: posthog-php/" . PostHog::VERSION, - "Authorization: Bearer " . $this->personalAPIKey + 'includeEtag' => true ] - )->getResponse(); + ); + } + + /** + * Get the current cached ETag for feature flag definitions + * + * @return string|null + */ + public function getFlagsEtag(): ?string + { + return $this->flagsEtag; } private function normalizeFeatureFlags(string $response): string diff --git a/lib/HttpClient.php b/lib/HttpClient.php index 7ca81f7..e149dc6 100644 --- a/lib/HttpClient.php +++ b/lib/HttpClient.php @@ -73,6 +73,7 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders $shouldRetry = $requestOptions['shouldRetry'] ?? true; $shouldVerify = $requestOptions['shouldVerify'] ?? true; + $includeEtag = $requestOptions['includeEtag'] ?? false; do { // open connection @@ -104,13 +105,27 @@ 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 + if ($includeEtag) { + curl_setopt($ch, CURLOPT_HEADER, true); + } + // retry failed requests just once to diminish impact on performance - $httpResponse = $this->executePost($ch); + $httpResponse = $this->executePost($ch, $includeEtag); $responseCode = $httpResponse->getResponseCode(); //close connection curl_close($ch); + // Handle 304 Not Modified - this is a success, not an error + if ($responseCode === 304) { + if ($this->debug) { + $maskedUrl = $this->maskTokensInUrl($protocol . $this->host . $path); + error_log("[PostHog][HttpClient] GET " . $maskedUrl . " returned 304 Not Modified"); + } + break; + } + if ($shouldVerify && 200 != $responseCode) { // log error $this->handleError($ch, $responseCode); @@ -136,12 +151,27 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders return $httpResponse; } - private function executePost($ch): HttpResponse + private function executePost($ch, bool $includeEtag = false): HttpResponse { - return new HttpResponse( - curl_exec($ch), - curl_getinfo($ch, CURLINFO_RESPONSE_CODE) - ); + $response = curl_exec($ch); + $responseCode = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $etag = null; + + if ($includeEtag && $response !== false) { + // Parse headers to extract ETag + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $headers = substr($response, 0, $headerSize); + $body = substr($response, $headerSize); + + // Extract ETag from headers (case-insensitive) + if (preg_match('/^etag:\s*(.+)$/mi', $headers, $matches)) { + $etag = trim($matches[1]); + } + + return new HttpResponse($body, $responseCode, $etag); + } + + return new HttpResponse($response, $responseCode); } private function handleError($code, $message) @@ -155,4 +185,15 @@ private function handleError($code, $message) error_log("[PostHog][HttpClient] " . $message); } } + + /** + * Mask tokens in URLs to avoid exposing them in logs + * + * @param string $url + * @return string + */ + public function maskTokensInUrl(string $url): string + { + return preg_replace('/token=[^&]+/', 'token=[REDACTED]', $url); + } } diff --git a/lib/HttpResponse.php b/lib/HttpResponse.php index 9bf611c..26c2eed 100644 --- a/lib/HttpResponse.php +++ b/lib/HttpResponse.php @@ -6,11 +6,13 @@ class HttpResponse { private $response; private $responseCode; + private $etag; - public function __construct($response, $responseCode) + public function __construct($response, $responseCode, ?string $etag = null) { $this->response = $response; $this->responseCode = $responseCode; + $this->etag = $etag; } /** @@ -28,4 +30,22 @@ public function getResponseCode() { return $this->responseCode; } + + /** + * @return string|null + */ + public function getEtag(): ?string + { + return $this->etag; + } + + /** + * Check if the response is a 304 Not Modified + * + * @return bool + */ + public function isNotModified(): bool + { + return $this->responseCode === 304; + } } diff --git a/lib/PostHog.php b/lib/PostHog.php index 7a13d17..442c93a 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -287,6 +287,20 @@ public static function flush() return self::$client->flush(); } + /** + * Get the underlying client instance. + * Useful for accessing client-level functionality like loadFlags() or getFlagsEtag(). + * + * @return Client + * @throws Exception + */ + public static function getClient(): Client + { + self::checkClient(); + + return self::$client; + } + private static function cleanHost(?string $host): string { if (!isset($host)) { diff --git a/test/EtagSupportTest.php b/test/EtagSupportTest.php new file mode 100644 index 0000000..eeb3aec --- /dev/null +++ b/test/EtagSupportTest.php @@ -0,0 +1,325 @@ +assertTrue(empty($errorMessages), "Error logs are not empty: " . implode("\n", $errorMessages)); + } + + public function testStoresEtagFromInitialResponse(): void + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"abc123"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"abc123"', $this->client->getFlagsEtag()); + $this->assertCount(1, $this->client->featureFlags); + $this->checkEmptyErrorLogs(); + } + + public function testSendsIfNoneMatchHeaderOnSubsequentRequests(): void + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"initial-etag"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + // First call sets the ETag + $this->assertEquals('"initial-etag"', $this->client->getFlagsEtag()); + + // Set up queue for second call to return 304 + $this->http_client->setFlagEndpointResponseQueue([ + ['response' => [], 'etag' => '"initial-etag"', 'responseCode' => 304] + ]); + + // Reload flags - should send If-None-Match + $this->client->loadFlags(); + + // Check that If-None-Match was sent in the second request + $calls = $this->http_client->calls; + $this->assertCount(2, $calls); + + // First call should not have If-None-Match + $firstCallHeaders = $calls[0]['extraHeaders']; + $hasIfNoneMatch = false; + foreach ($firstCallHeaders as $header) { + if (str_starts_with($header, 'If-None-Match:')) { + $hasIfNoneMatch = true; + break; + } + } + $this->assertFalse($hasIfNoneMatch, "First call should not have If-None-Match header"); + + // Second call should have If-None-Match + $secondCallHeaders = $calls[1]['extraHeaders']; + $foundIfNoneMatch = false; + foreach ($secondCallHeaders as $header) { + if (str_starts_with($header, 'If-None-Match:')) { + $foundIfNoneMatch = true; + $this->assertEquals('If-None-Match: "initial-etag"', $header); + break; + } + } + $this->assertTrue($foundIfNoneMatch, "Second call should have If-None-Match header"); + + $this->checkEmptyErrorLogs(); + } + + public function testHandles304NotModifiedAndPreservesCachedFlags(): void + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"test-etag"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + // Verify initial flags are loaded + $this->assertCount(1, $this->client->featureFlags); + $this->assertEquals('person-flag', $this->client->featureFlags[0]['key']); + + // Set up queue for second call to return 304 + $this->http_client->setFlagEndpointResponseQueue([ + ['response' => [], 'etag' => '"test-etag"', 'responseCode' => 304] + ]); + + // Reload flags - should get 304 + $this->client->loadFlags(); + + // Flags should still be the same (not cleared) + $this->assertCount(1, $this->client->featureFlags); + $this->assertEquals('person-flag', $this->client->featureFlags[0]['key']); + + $this->checkEmptyErrorLogs(); + } + + public function testUpdatesEtagWhenFlagsChange(): void + { + $this->http_client = new MockedHttpClient(host: "app.posthog.com"); + + // Use queue to provide different responses + $this->http_client->setFlagEndpointResponseQueue([ + [ + 'response' => MockedResponses::LOCAL_EVALUATION_REQUEST, + 'etag' => '"etag-v1"', + 'responseCode' => 200 + ], + [ + 'response' => [ + 'flags' => [['id' => 2, 'key' => 'newFlag', 'active' => true, 'filters' => []]], + 'group_type_mapping' => [] + ], + 'etag' => '"etag-v2"', + 'responseCode' => 200 + ] + ]); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"etag-v1"', $this->client->getFlagsEtag()); + $this->assertEquals('person-flag', $this->client->featureFlags[0]['key']); + + $this->client->loadFlags(); + + $this->assertEquals('"etag-v2"', $this->client->getFlagsEtag()); + $this->assertEquals('newFlag', $this->client->featureFlags[0]['key']); + + $this->checkEmptyErrorLogs(); + } + + public function testClearsEtagWhenServerStopsSendingIt(): void + { + $this->http_client = new MockedHttpClient(host: "app.posthog.com"); + + // Use queue to provide different responses + $this->http_client->setFlagEndpointResponseQueue([ + [ + 'response' => MockedResponses::LOCAL_EVALUATION_REQUEST, + 'etag' => '"etag-v1"', + 'responseCode' => 200 + ], + [ + 'response' => [ + 'flags' => [['id' => 2, 'key' => 'newFlag', 'active' => true, 'filters' => []]], + 'group_type_mapping' => [] + ], + 'etag' => null, // No ETag + 'responseCode' => 200 + ] + ]); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"etag-v1"', $this->client->getFlagsEtag()); + + $this->client->loadFlags(); + + $this->assertNull($this->client->getFlagsEtag()); + $this->assertEquals('newFlag', $this->client->featureFlags[0]['key']); + + $this->checkEmptyErrorLogs(); + } + + public function testHandles304WithoutEtagHeaderAndPreservesExistingEtag(): void + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"original-etag"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"original-etag"', $this->client->getFlagsEtag()); + + // Set up queue for second call to return 304 without ETag + $this->http_client->setFlagEndpointResponseQueue([ + ['response' => [], 'etag' => null, 'responseCode' => 304] + ]); + + $this->client->loadFlags(); + + // ETag should be preserved since server returned 304 (even without new ETag) + $this->assertEquals('"original-etag"', $this->client->getFlagsEtag()); + // And flags should be preserved + $this->assertCount(1, $this->client->featureFlags); + + $this->checkEmptyErrorLogs(); + } + + public function testUpdatesEtagWhen304ResponseIncludesNewEtag(): void + { + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"original-etag"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"original-etag"', $this->client->getFlagsEtag()); + + // Set up queue for second call to return 304 with new ETag + $this->http_client->setFlagEndpointResponseQueue([ + ['response' => [], 'etag' => '"updated-etag"', 'responseCode' => 304] + ]); + + $this->client->loadFlags(); + + // ETag should be updated to the new value from 304 response + $this->assertEquals('"updated-etag"', $this->client->getFlagsEtag()); + // And flags should be preserved + $this->assertCount(1, $this->client->featureFlags); + + $this->checkEmptyErrorLogs(); + } + + public function testProcessesErrorResponseWithoutFlagsKey(): void + { + // This test verifies current behavior: error responses without a 'flags' key + // will result in empty flags (due to $payload['flags'] ?? []) + // This is pre-existing behavior that's consistent with or without ETag support + + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + flagEndpointEtag: '"original-etag"' + ); + + $this->client = new Client( + self::FAKE_API_KEY, + [], + $this->http_client, + "test" + ); + + $this->assertEquals('"original-etag"', $this->client->getFlagsEtag()); + $this->assertCount(1, $this->client->featureFlags); + $this->assertEquals('person-flag', $this->client->featureFlags[0]['key']); + + // Set up queue for second call to return 500 error with no flags key + $this->http_client->setFlagEndpointResponseQueue([ + ['response' => ['error' => 'Internal Server Error'], 'etag' => null, 'responseCode' => 500] + ]); + + // loadFlags will parse the response and set featureFlags to [] + // This is pre-existing behavior: error responses clear flags + $this->client->loadFlags(); + + // Flags are cleared because response doesn't have 'flags' key + // ($payload['flags'] ?? [] evaluates to []) + $this->assertCount(0, $this->client->featureFlags); + + // ETag is set to null (from the response) + $this->assertNull($this->client->getFlagsEtag()); + } +} diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 9986167..fb5c294 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -1084,7 +1084,7 @@ public function testFlagPersonBooleanProperties() "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), // no decide or capture calls ) @@ -1363,7 +1363,7 @@ public function testSimpleFlag() "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/batch/", @@ -1461,7 +1461,7 @@ public function testComputingInactiveFlagLocally() "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), // no decide or capture calls ) @@ -1496,7 +1496,7 @@ public function testFeatureFlagsLocalEvaluationForCohorts() "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), ) ); @@ -1567,7 +1567,7 @@ public function testFeatureFlagsLocalEvaluationForNegatedCohorts() "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), ) ); diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index fd0cd7b..1d355dc 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -67,7 +67,7 @@ public function testIsFeatureEnabled($response) "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/flags/?v=2", @@ -120,7 +120,7 @@ public function testIsFeatureEnabledGroups($response) "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/flags/?v=2", @@ -149,7 +149,7 @@ public function testGetFeatureFlag($response) "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/flags/?v=2", @@ -216,7 +216,7 @@ public function testGetFeatureFlagGroups($response) "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/flags/?v=2", diff --git a/test/HttpClientTest.php b/test/HttpClientTest.php new file mode 100644 index 0000000..29e1cc0 --- /dev/null +++ b/test/HttpClientTest.php @@ -0,0 +1,39 @@ +maskTokensInUrl($url); + $this->assertEquals('https://example.com/api/flags?token=[REDACTED]&send_cohorts', $result); + + // Test masking token at end of URL + $url = 'https://example.com/api/flags?token=phc_abc123xyz789'; + $result = $httpClient->maskTokensInUrl($url); + $this->assertEquals('https://example.com/api/flags?token=[REDACTED]', $result); + + // Test URL without token + $url = 'https://example.com/api/flags?other=value'; + $result = $httpClient->maskTokensInUrl($url); + $this->assertEquals('https://example.com/api/flags?other=value', $result); + + // Test short token - should still be redacted + $url = 'https://example.com/api/flags?token=short'; + $result = $httpClient->maskTokensInUrl($url); + $this->assertEquals('https://example.com/api/flags?token=[REDACTED]', $result); + + // Test empty token value + $url = 'https://example.com/api/flags?token=&other=value'; + $result = $httpClient->maskTokensInUrl($url); + $this->assertEquals('https://example.com/api/flags?token=&other=value', $result); + } +} diff --git a/test/MockedHttpClient.php b/test/MockedHttpClient.php index b00b297..c49f138 100644 --- a/test/MockedHttpClient.php +++ b/test/MockedHttpClient.php @@ -12,6 +12,11 @@ class MockedHttpClient extends \PostHog\HttpClient private $flagEndpointResponse; private $flagsEndpointResponse; + private $flagEndpointEtag; + private $flagEndpointResponseCode; + + /** @var array|null Queue of responses for sequential calls */ + private $flagEndpointResponseQueue; public function __construct( string $host, @@ -22,7 +27,9 @@ public function __construct( ?Closure $errorHandler = null, int $curlTimeoutMilliseconds = 750, array $flagEndpointResponse = [], - array $flagsEndpointResponse = [] + array $flagsEndpointResponse = [], + ?string $flagEndpointEtag = null, + int $flagEndpointResponseCode = 200 ) { parent::__construct( $host, @@ -35,6 +42,20 @@ public function __construct( ); $this->flagEndpointResponse = $flagEndpointResponse; $this->flagsEndpointResponse = !empty($flagsEndpointResponse) ? $flagsEndpointResponse : MockedResponses::FLAGS_REQUEST; + $this->flagEndpointEtag = $flagEndpointEtag; + $this->flagEndpointResponseCode = $flagEndpointResponseCode; + $this->flagEndpointResponseQueue = null; + } + + /** + * Set a queue of responses for the local_evaluation endpoint + * Each call will consume the next response in the queue + * + * @param array $responses Array of ['response' => array, 'etag' => string|null, 'responseCode' => int] + */ + public function setFlagEndpointResponseQueue(array $responses): void + { + $this->flagEndpointResponseQueue = $responses; } public function sendRequest(string $path, ?string $payload, array $extraHeaders = [], array $requestOptions = []): HttpResponse @@ -49,7 +70,31 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders } if (str_starts_with($path, "/api/feature_flag/local_evaluation")) { - return new HttpResponse(json_encode($this->flagEndpointResponse), 200); + // Check if we have a response queue + if ($this->flagEndpointResponseQueue !== null && !empty($this->flagEndpointResponseQueue)) { + $nextResponse = array_shift($this->flagEndpointResponseQueue); + $response = $nextResponse['response'] ?? []; + $etag = $nextResponse['etag'] ?? null; + $responseCode = $nextResponse['responseCode'] ?? 200; + + // Handle 304 Not Modified - return empty body + if ($responseCode === 304) { + return new HttpResponse('', $responseCode, $etag); + } + + return new HttpResponse(json_encode($response), $responseCode, $etag); + } + + // Handle 304 Not Modified - return empty body + if ($this->flagEndpointResponseCode === 304) { + return new HttpResponse('', 304, $this->flagEndpointEtag); + } + + return new HttpResponse( + json_encode($this->flagEndpointResponse), + $this->flagEndpointResponseCode, + $this->flagEndpointEtag + ); } return parent::sendRequest($path, $payload, $extraHeaders, $requestOptions); diff --git a/test/PostHogTest.php b/test/PostHogTest.php index 84db8c2..3669b29 100644 --- a/test/PostHogTest.php +++ b/test/PostHogTest.php @@ -114,7 +114,7 @@ public function testCaptureWithSendFeatureFlagsOption(): void "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array ( "path" => "/flags/?v=2", @@ -175,7 +175,7 @@ public function testCaptureWithLocalSendFlags(): void "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array ( "path" => "/batch/", @@ -223,7 +223,7 @@ public function testCaptureWithLocalSendFlagsNoOverrides(): void "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array ( @@ -402,7 +402,7 @@ public function testDefaultPropertiesGetAddedProperly(): void "path" => "/api/feature_flag/local_evaluation?send_cohorts&token=random_key", "payload" => null, "extraHeaders" => array(0 => 'User-Agent: posthog-php/' . PostHog::VERSION, 1 => 'Authorization: Bearer test'), - "requestOptions" => array(), + "requestOptions" => array("includeEtag" => true), ), 1 => array( "path" => "/flags/?v=2",