diff --git a/.changeset/three-pants-ask.md b/.changeset/three-pants-ask.md new file mode 100644 index 0000000..d7026a7 --- /dev/null +++ b/.changeset/three-pants-ask.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +The new `getRemoteConfigPayload()` reads the payload of a remote config feature flag by its key, with no distinct ID. It needs the `secretKey` the client was created with. It returns `null` and logs a warning on a failure. diff --git a/api/public-api.json b/api/public-api.json index 689550f..6709093 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -824,6 +824,24 @@ "returnType": "?string", "parameters": [] }, + "getRemoteConfigPayload": { + "static": false, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "groupIdentify": { "static": false, "abstract": false, @@ -4836,6 +4854,24 @@ } ] }, + "getRemoteConfigPayload": { + "static": true, + "abstract": false, + "final": false, + "returnType": "mixed", + "parameters": [ + { + "name": "key", + "type": "string", + "byReference": false, + "variadic": false, + "optional": false, + "default": null, + "defaultConstant": null, + "hasDefault": false + } + ] + }, "groupIdentify": { "static": true, "abstract": false, diff --git a/lib/Client.php b/lib/Client.php index 01e9631..46b1a6d 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -1225,6 +1225,87 @@ public function getFeatureFlagPayload( return $result?->getPayload(); } + /** + * Fetch the remote config payload of a feature flag from PostHog. + * + * Needs a secret key. A Personal API Key returns encrypted payloads decrypted. A Project Secret + * API Key returns them redacted. The call sends one request, with no retry, and captures no + * `$feature_flag_called` event. It logs a warning and returns null when the client is disabled, + * when no secret key is set, when the key is empty, `.` or `..`, when the request fails, or when + * PostHog answers with a non-2xx status. An empty response means the flag has no payload: the + * call returns null without a warning. + * + * @param string $key Feature flag key. + * @return mixed The decoded payload, or null when no payload could be read. + */ + public function getRemoteConfigPayload(string $key): mixed + { + if (!$this->enabled) { + $this->logWarning('The client is disabled'); + return null; + } + + if ($this->secretKey === null) { + $this->logWarning('A secret key is required to fetch a remote config payload from PostHog'); + return null; + } + + if (in_array($key, ['', '.', '..'], true)) { + $this->logWarning('A feature flag key is required to fetch a remote config payload from PostHog'); + return null; + } + + $response = $this->httpClient->sendRequest( + '/api/projects/@current/feature_flags/' . rawurlencode($key) + . '/remote_config?token=' . rawurlencode($this->apiKey), + null, + [ + "User-Agent: " . $this->options['library_name'] . "/" . $this->options['library_version'], + "Authorization: Bearer " . $this->secretKey + ], + [ + 'shouldRetry' => false, + 'timeout' => $this->featureFlagsRequestTimeout + ] + ); + + $code = $response->getResponseCode(); + // cURL keeps the 2xx status when the transfer fails after the headers arrive. + $curlErrno = $response->getCurlErrno(); + if ($curlErrno !== 0 || $code < 200 || $code >= 300) { + error_log( + '[PostHog][Client] Unable to fetch remote config payload: ' + . ($curlErrno !== 0 || $code === 0 + ? 'connection error (curl errno ' . $curlErrno . ')' + : 'HTTP ' . $code) + ); + return null; + } + + // PostHog answers a flag that has no payload with an empty 2xx body. + $body = (string) $response->getResponse(); + if (trim($body) === '') { + return null; + } + + try { + $result = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + error_log('[PostHog][Client] Unable to read remote config payload: invalid JSON response'); + return null; + } + + if (is_string($result)) { + try { + return json_decode($result, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $result; + } + } + + return $result; + } + /** * get the feature flag value for this distinct id. * diff --git a/lib/PostHog.php b/lib/PostHog.php index 135699f..4f10c09 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -390,6 +390,20 @@ public static function getFeatureFlagPayload( ); } + /** + * Fetch the remote config payload of a feature flag from PostHog. + * See Client::getRemoteConfigPayload() + * + * @param string $key Feature flag key. + * @return mixed The decoded payload, or null when no payload could be read. + */ + public static function getRemoteConfigPayload(string $key): mixed + { + self::checkClient(); + + return self::$client->getRemoteConfigPayload($key); + } + /** * Evaluate every feature flag for a distinct id in a single round trip and return a snapshot. * When distinctId is omitted, the current request context distinctId is used if available. diff --git a/test/MockedHttpClient.php b/test/MockedHttpClient.php index 5fd1747..7f4323f 100644 --- a/test/MockedHttpClient.php +++ b/test/MockedHttpClient.php @@ -30,6 +30,9 @@ class MockedHttpClient extends \PostHog\HttpClient private $batchEndpointResponse; private $batchEndpointResponseCode; private $batchEndpointCurlErrno; + private $remoteConfigEndpointResponse; + private $remoteConfigEndpointResponseCode; + private $remoteConfigEndpointCurlErrno; public function __construct( string $host, @@ -47,7 +50,10 @@ public function __construct( int $flagsEndpointCurlErrno = 0, $batchEndpointResponse = '{"status":1}', int $batchEndpointResponseCode = 200, - int $batchEndpointCurlErrno = 0 + int $batchEndpointCurlErrno = 0, + $remoteConfigEndpointResponse = '{}', + int $remoteConfigEndpointResponseCode = 200, + int $remoteConfigEndpointCurlErrno = 0 ) { parent::__construct( $host, @@ -71,6 +77,9 @@ public function __construct( $this->batchEndpointResponse = $batchEndpointResponse; $this->batchEndpointResponseCode = $batchEndpointResponseCode; $this->batchEndpointCurlErrno = $batchEndpointCurlErrno; + $this->remoteConfigEndpointResponse = $remoteConfigEndpointResponse; + $this->remoteConfigEndpointResponseCode = $remoteConfigEndpointResponseCode; + $this->remoteConfigEndpointCurlErrno = $remoteConfigEndpointCurlErrno; } /** @@ -165,6 +174,15 @@ public function sendRequest(string $path, ?string $payload, array $extraHeaders ); } + if (str_starts_with($path, "/api/projects/@current/feature_flags/")) { + return new HttpResponse( + $this->remoteConfigEndpointResponse, + $this->remoteConfigEndpointResponseCode, + null, + $this->remoteConfigEndpointCurlErrno + ); + } + throw new \LogicException('Unexpected HTTP request in test: ' . $path); } } diff --git a/test/RemoteConfigPayloadTest.php b/test/RemoteConfigPayloadTest.php new file mode 100644 index 0000000..81965a0 --- /dev/null +++ b/test/RemoteConfigPayloadTest.php @@ -0,0 +1,317 @@ +client($this->httpClient('{"foo":["bar","baz"]}')); + + $this->assertSame(['foo' => ['bar', 'baz']], $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsEmptyArrayForEmptyObject() + { + $client = $this->client($this->httpClient('{}')); + + $this->assertSame([], $client->getRemoteConfigPayload('test-flag')); + } + + public function testDecodesDoubleEncodedStringBody() + { + $client = $this->client($this->httpClient(json_encode('{ "foo":["bar","baz"]}'))); + + $this->assertSame(['foo' => ['bar', 'baz']], $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsStringWhenSecondDecodeFails() + { + $client = $this->client($this->httpClient('"hello"')); + + $this->assertSame('hello', $client->getRemoteConfigPayload('test-flag')); + } + + public function testReturnsNullForJsonNullBodyWithoutLogging() + { + global $errorMessages; + $client = $this->client($this->httpClient('null')); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $errorMessages); + } + + public function testReturnsNullAndLogsWhenBodyIsNotJson() + { + global $errorMessages; + $client = $this->client($this->httpClient('not json')); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('invalid JSON response', $errorMessages[0]); + } + + #[DataProvider('emptyBodies')] + public function testReturnsNullWithoutLoggingForEmpty2xxBody(string $body) + { + global $errorMessages; + $http = $this->httpClient($body); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertSame([], $errorMessages); + } + + public static function emptyBodies(): array + { + return [ + 'empty' => [''], + 'whitespace' => [" \r\n\t"], + ]; + } + + public function testReturnsNullAndLogsOn404WithEmptyBody() + { + global $errorMessages; + $client = $this->client($this->httpClient('', 404)); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('HTTP 404', $errorMessages[0]); + } + + public function testReturnsNullAndLogsWhenNoSecretKey() + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http, secretKey: null); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $http->calls ?? []); + $this->assertSame( + ['[PostHog][Client] A secret key is required to fetch a remote config payload from PostHog'], + $errorMessages + ); + } + + public function testReturnsNullAndLogsWhenClientDisabled() + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http, projectKey: ''); + $errorMessages = []; + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertSame([], $http->calls ?? []); + $this->assertSame(['[PostHog][Client] The client is disabled'], $errorMessages); + } + + #[DataProvider('refusedKeys')] + public function testReturnsNullAndLogsForRefusedKey(string $key) + { + global $errorMessages; + $http = $this->httpClient(); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload($key)); + $this->assertSame([], $http->calls ?? []); + $this->assertSame( + ['[PostHog][Client] A feature flag key is required to fetch a remote config payload from PostHog'], + $errorMessages + ); + } + + public static function refusedKeys(): array + { + return [ + 'empty' => [''], + 'dot' => ['.'], + 'double dot' => ['..'], + ]; + } + + #[DataProvider('failureStatuses')] + public function testReturnsNullAndLogsOnNon2xxWithoutRetry(int $code) + { + global $errorMessages; + $http = $this->httpClient('{"detail":"nope"}', $code); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('HTTP ' . $code, $errorMessages[0]); + } + + public static function failureStatuses(): array + { + return [[401], [403], [404], [429], [500], [503]]; + } + + #[DataProvider('curlErrnos')] + public function testReturnsNullAndLogsOnNetworkFailure(int $errno) + { + global $errorMessages; + $http = $this->httpClient(false, 0, $errno); + $client = $this->client($http); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $http->calls); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('curl errno ' . $errno, $errorMessages[0]); + } + + public function testReturnsNullAndLogsWhenTransferFailsAfter2xxHeaders() + { + global $errorMessages; + $client = $this->client($this->httpClient(false, 200, 28)); + + $this->assertNull($client->getRemoteConfigPayload('test-flag')); + $this->assertCount(1, $errorMessages); + $this->assertStringContainsString('curl errno 28', $errorMessages[0]); + } + + public static function curlErrnos(): array + { + return [[28], [6]]; + } + + public function testEncodesReservedCharactersInFlagKey() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('a/b?c#d e'); + + $this->assertSame( + '/api/projects/@current/feature_flags/a%2Fb%3Fc%23d%20e/remote_config?token=random_key', + $http->calls[0]['path'] + ); + } + + public function testEncodesProjectTokenInQuery() + { + $http = $this->httpClient(); + $client = $this->client($http, projectKey: 'a b&c'); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertSame( + '/api/projects/@current/feature_flags/test-flag/remote_config?token=a%20b%26c', + $http->calls[0]['path'] + ); + } + + public function testSendsGetRequestWithBearerHeaders() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertCount(1, $http->calls); + $this->assertSame( + [ + 'path' => self::PATH, + 'payload' => null, + 'extraHeaders' => [ + 'User-Agent: posthog-php/' . PostHog::VERSION, + 'Authorization: Bearer phs_test', + ], + 'requestOptions' => ['shouldRetry' => false, 'timeout' => 3000], + ], + $http->calls[0] + ); + } + + public function testUsesPersonalApiKeyAliasForAuthorization() + { + $http = $this->httpClient(); + $client = $this->client($http, secretKey: null, personalApiKey: 'test'); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertContains('Authorization: Bearer test', $http->calls[0]['extraHeaders']); + } + + public function testUsesConfiguredFeatureFlagTimeout() + { + $http = $this->httpClient(); + $client = $this->client($http, options: ['feature_flag_request_timeout_ms' => 1500]); + + $client->getRemoteConfigPayload('test-flag'); + + $this->assertSame(1500, $http->calls[0]['requestOptions']['timeout']); + } + + public function testSendsNoFeatureFlagCalledEvent() + { + $http = $this->httpClient(); + $client = $this->client($http); + + $client->getRemoteConfigPayload('test-flag'); + $client->flush(); + + $this->assertCount(1, $http->calls); + foreach ($http->calls as $call) { + $this->assertStringStartsWith('/api/projects/@current/feature_flags/', $call['path']); + } + } + + public function testStaticFacadeDelegatesToClient() + { + $client = $this->client($this->httpClient('{"foo":"bar"}')); + PostHog::init(null, null, $client); + + $this->assertSame(['foo' => 'bar'], PostHog::getRemoteConfigPayload('test-flag')); + } +}