From a1db628351ae080062f3bc7403db554121a308c2 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 3 Dec 2025 15:06:31 -0800 Subject: [PATCH 1/2] Add ETag support for local evaluation caching Add HTTP ETag caching for feature flag definitions to reduce bandwidth when polling for flag updates. When flags haven't changed, the server returns 304 Not Modified and cached flags are preserved. --- example.php | 92 ++++++- lib/Client.php | 64 ++++- lib/HttpClient.php | 53 +++- lib/HttpResponse.php | 22 +- lib/PostHog.php | 14 + test/EtagSupportTest.php | 325 ++++++++++++++++++++++++ test/FeatureFlagLocalEvaluationTest.php | 10 +- test/FeatureFlagTest.php | 8 +- test/HttpClientTest.php | 39 +++ test/MockedHttpClient.php | 49 +++- test/PostHogTest.php | 8 +- 11 files changed, 651 insertions(+), 33 deletions(-) create mode 100644 test/EtagSupportTest.php create mode 100644 test/HttpClientTest.php 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", From a5d1b2c7610dae34e7e745f52057d9ae5bcab80b Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 4 Dec 2025 11:43:29 -0800 Subject: [PATCH 2/2] Update changelog for ETag support --- History.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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