diff --git a/lib/Client.php b/lib/Client.php index 589ee08..ef4c87b 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -254,6 +254,44 @@ public function getFeatureFlag( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true ): null | bool | string { + $result = $this->getFeatureFlagResult( + $key, + $distinctId, + $groups, + $personProperties, + $groupProperties, + $onlyEvaluateLocally, + $sendFeatureFlagEvents + ); + + return $result?->getValue(); + } + + /** + * Get the feature flag result including value and payload. + * + * This is the recommended method for getting feature flag data as it returns + * both the flag value and payload in a single call, while properly tracking analytics. + * + * @param string $key + * @param string $distinctId + * @param array $groups + * @param array $personProperties + * @param array $groupProperties + * @param bool $onlyEvaluateLocally + * @param bool $sendFeatureFlagEvents + * @return FeatureFlagResult|null + * @throws Exception + */ + public function getFeatureFlagResult( + string $key, + string $distinctId, + array $groups = array(), + array $personProperties = array(), + array $groupProperties = array(), + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true + ): ?FeatureFlagResult { [$personProperties, $groupProperties] = $this->addLocalPersonAndGroupProperties( $distinctId, $groups, @@ -261,6 +299,7 @@ public function getFeatureFlag( $groupProperties ); $result = null; + $payload = null; $featureFlagError = null; foreach ($this->featureFlags as $flag) { @@ -309,6 +348,12 @@ public function getFeatureFlag( $result = null; } + // Extract payload from response + $rawPayload = $response['featureFlagPayloads'][$key] ?? null; + if ($rawPayload !== null) { + $payload = json_decode($rawPayload, true); + } + if (!empty($errors)) { $featureFlagError = implode(',', $errors); } @@ -371,13 +416,22 @@ public function getFeatureFlag( $this->distinctIdsFeatureFlagsReported->add($key, $distinctId); } - if (!is_null($result)) { - return $result; + if (is_null($result)) { + return null; + } + + // Determine enabled and variant from result + if (is_bool($result)) { + return new FeatureFlagResult($key, $result, null, $payload); + } else { + return new FeatureFlagResult($key, true, $result, $payload); } - return null; } /** + * @deprecated Use `getFeatureFlagResult()` instead which properly tracks the feature flag call, + * and includes both the flag value and payload in a single method. + * * @param string $key * @param string $distinctId * @param array $groups @@ -392,20 +446,17 @@ public function getFeatureFlagPayload( array $personProperties = array(), array $groupProperties = array(), ): mixed { - $results = $this->flags($distinctId, $groups, $personProperties, $groupProperties); - - if (isset($results['featureFlags'][$key]) === false || $results['featureFlags'][$key] !== true) { - return null; - } - - $payload = $results['featureFlagPayloads'][$key] ?? null; - - if ($payload === null) { - return null; - } + $result = $this->getFeatureFlagResult( + $key, + $distinctId, + $groups, + $personProperties, + $groupProperties, + false, + false + ); - # feature flag payloads are always JSON encoded strings. - return json_decode($payload, true); + return $result?->getPayload(); } /** diff --git a/lib/FeatureFlagResult.php b/lib/FeatureFlagResult.php new file mode 100644 index 0000000..bcb3224 --- /dev/null +++ b/lib/FeatureFlagResult.php @@ -0,0 +1,71 @@ +key = $key; + $this->enabled = $enabled; + $this->variant = $variant; + $this->payload = $payload; + } + + /** + * Get the feature flag key. + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Whether the flag is enabled. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Get the variant value if this is a multivariate flag. + */ + public function getVariant(): ?string + { + return $this->variant; + } + + /** + * Get the decoded JSON payload associated with this flag. + */ + public function getPayload(): mixed + { + return $this->payload; + } + + /** + * Get the flag value in the same format as getFeatureFlag(). + * Returns the variant if set, otherwise the enabled boolean. + * This matches the $feature_flag_response format. + */ + public function getValue(): bool|string + { + if ($this->variant !== null) { + return $this->variant; + } + return $this->enabled; + } +} diff --git a/lib/PostHog.php b/lib/PostHog.php index 8bfab70..83003fb 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -171,6 +171,46 @@ public static function getFeatureFlag( } /** + * Get the feature flag result including value and payload. + * + * This is the recommended method for getting feature flag data as it returns + * both the flag value and payload in a single call, while properly tracking analytics. + * + * @param string $key + * @param string $distinctId + * @param array $groups + * @param array $personProperties + * @param array $groupProperties + * @param bool $onlyEvaluateLocally + * @param bool $sendFeatureFlagEvents + * @return FeatureFlagResult|null + * @throws Exception + */ + public static function getFeatureFlagResult( + string $key, + string $distinctId, + array $groups = array(), + array $personProperties = array(), + array $groupProperties = array(), + bool $onlyEvaluateLocally = false, + bool $sendFeatureFlagEvents = true + ): ?FeatureFlagResult { + self::checkClient(); + return self::$client->getFeatureFlagResult( + $key, + $distinctId, + $groups, + $personProperties, + $groupProperties, + $onlyEvaluateLocally, + $sendFeatureFlagEvents + ); + } + + /** + * @deprecated Use getFeatureFlagResult() instead. This method does not send + * the $feature_flag_called event, leading to missing analytics. + * * @param string $key * @param string $distinctId * @param array $groups diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 9a8622a..ee2d699 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -300,4 +300,252 @@ public function testGetFeatureFlagPayloadHandlesFlagNotInResults($response): voi $this->assertNull(PostHog::getFeatureFlagPayload('non-existent-flag', 'some-distinct')); } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResult($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('json-payload', 'user-id'); + + $this->assertNotNull($result); + $this->assertEquals('json-payload', $result->getKey()); + $this->assertTrue($result->isEnabled()); + $this->assertNull($result->getVariant()); + $this->assertEquals(['key' => 'value'], $result->getPayload()); + $this->assertTrue($result->getValue()); + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultWithMultivariateFlag($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('multivariate-test', 'user-id'); + + $this->assertNotNull($result); + $this->assertEquals('multivariate-test', $result->getKey()); + $this->assertTrue($result->isEnabled()); + $this->assertEquals('variant-value', $result->getVariant()); + $this->assertEquals('variant-value', $result->getValue()); + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultReturnsNullForMissingFlag($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('non-existent-flag', 'user-id'); + + $this->assertNull($result); + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultForDisabledFlag($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('disabled-flag', 'user-id'); + + $this->assertNotNull($result); + $this->assertEquals('disabled-flag', $result->getKey()); + $this->assertFalse($result->isEnabled()); + $this->assertNull($result->getVariant()); + $this->assertFalse($result->getValue()); + } + + public function testGetFeatureFlagResultSendsEvent(): void + { + $this->executeAtFrozenDateTime(new \DateTime('2022-05-01'), function () { + $this->setUp(MockedResponses::FLAGS_V2_RESPONSE, personalApiKey: null); + + $result = PostHog::getFeatureFlagResult('json-payload', 'user-id'); + PostHog::flush(); + + $this->assertNotNull($result); + $this->assertEquals(['key' => 'value'], $result->getPayload()); + + // Verify that the $feature_flag_called event was sent + $batchCall = null; + foreach ($this->http_client->calls as $call) { + if ($call['path'] === '/batch/') { + $batchCall = $call; + break; + } + } + $this->assertNotNull($batchCall, 'Expected a batch call to be made'); + + $payload = json_decode($batchCall['payload'], true); + $this->assertNotEmpty($payload['batch']); + + $event = $payload['batch'][0]; + $this->assertEquals('$feature_flag_called', $event['event']); + $this->assertEquals('json-payload', $event['properties']['$feature_flag']); + $this->assertTrue($event['properties']['$feature_flag_response']); + }); + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultWithMultivariateFlagAndPayload($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('multivariate-simple-test', 'user-id'); + + $this->assertNotNull($result); + $this->assertEquals('multivariate-simple-test', $result->getKey()); + $this->assertTrue($result->isEnabled()); + $this->assertEquals('variant-simple-value', $result->getVariant()); + $this->assertEquals('variant-simple-value', $result->getValue()); + $this->assertEquals('some string payload', $result->getPayload()); + } + + public function testGetFeatureFlagPayloadDoesNotSendEvent(): void + { + $this->setUp(MockedResponses::FLAGS_V2_RESPONSE, personalApiKey: null); + + $payload = PostHog::getFeatureFlagPayload('json-payload', 'user-id'); + PostHog::flush(); + + $this->assertEquals(['key' => 'value'], $payload); + + // Verify that NO batch call was made (no event sent) + $batchCall = null; + foreach ($this->http_client->calls as $call) { + if ($call['path'] === '/batch/') { + $batchCall = $call; + break; + } + } + $this->assertNull($batchCall, 'Expected no batch call to be made for getFeatureFlagPayload'); + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultForDisabledFlagWithPayload($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult('disabled-flag-with-payload', 'user-id'); + + $this->assertNotNull($result); + $this->assertEquals('disabled-flag-with-payload', $result->getKey()); + $this->assertFalse($result->isEnabled()); + $this->assertNull($result->getVariant()); + $this->assertFalse($result->getValue()); + $this->assertEquals(['disabled' => true], $result->getPayload()); + } + + public function testGetFeatureFlagResultWithLocalEvaluationOnly(): void + { + // For local evaluation, we need to set flagEndpointResponse (not flagsEndpointResponse) + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_SIMPLE_REQUEST + ); + $this->client = new Client( + self::FAKE_API_KEY, + ["debug" => true], + $this->http_client, + "test" + ); + PostHog::init(null, null, $this->client); + + // Flag can be evaluated locally - should return result + $result = PostHog::getFeatureFlagResult( + 'simple-flag', + 'user-id', + [], + [], + [], + true // onlyEvaluateLocally + ); + + $this->assertNotNull($result); + $this->assertEquals('simple-flag', $result->getKey()); + $this->assertTrue($result->isEnabled()); + + // Verify no /flags/ network call was made + foreach ($this->http_client->calls as $call) { + $this->assertStringNotContainsString('/flags/', $call['path'], 'Expected no /flags/ call for local evaluation'); + } + } + + public function testGetFeatureFlagResultReturnsNullForLocalEvaluationWhenFlagCannotBeEvaluatedLocally(): void + { + // Use a flag config that requires cohorts which can't be evaluated locally + $this->http_client = new MockedHttpClient( + host: "app.posthog.com", + flagEndpointResponse: MockedResponses::LOCAL_EVALUATION_WITH_COHORTS_REQUEST + ); + $this->client = new Client( + self::FAKE_API_KEY, + ["debug" => true], + $this->http_client, + "test" + ); + PostHog::init(null, null, $this->client); + + // beta-feature requires cohort evaluation which needs server + // When onlyEvaluateLocally is true, should return null + $result = PostHog::getFeatureFlagResult( + 'beta-feature', + 'user-id', + [], + [], + [], + true // onlyEvaluateLocally + ); + + $this->assertNull($result); + + // Verify no /flags/ network call was made + foreach ($this->http_client->calls as $call) { + $this->assertStringNotContainsString('/flags/', $call['path'], 'Expected no /flags/ call for local evaluation only'); + } + } + + /** + * @dataProvider decideResponseCases + */ + public function testGetFeatureFlagResultWithGroups($response): void + { + $this->setUp($response); + + $result = PostHog::getFeatureFlagResult( + 'group-flag', + 'user-id', + ['company' => 'id:5'] + ); + + $this->assertNotNull($result); + $this->assertEquals('group-flag', $result->getKey()); + $this->assertTrue($result->isEnabled()); + $this->assertEquals('decide-fallback-value', $result->getVariant()); + + // Verify that groups were passed in the /flags/ request + $flagsCall = null; + foreach ($this->http_client->calls as $call) { + if (str_contains($call['path'], '/flags/')) { + $flagsCall = $call; + break; + } + } + $this->assertNotNull($flagsCall, 'Expected a /flags/ call to be made'); + + $payload = json_decode($flagsCall['payload'], true); + $this->assertArrayHasKey('groups', $payload); + $this->assertEquals(['company' => 'id:5'], $payload['groups']); + } } diff --git a/test/assests/MockedResponses.php b/test/assests/MockedResponses.php index 8c6a220..06c642c 100644 --- a/test/assests/MockedResponses.php +++ b/test/assests/MockedResponses.php @@ -57,6 +57,7 @@ class MockedResponses 'integer-payload' => true, 'string-payload' => true, 'array-payload' => true, + 'disabled-flag-with-payload' => false, ], 'featureFlagPayloads' => [ 'simpleFlag' => '{"key":"simpleFlag"}', @@ -67,6 +68,7 @@ class MockedResponses 'integer-payload' => '2500', 'string-payload' => '"A String"', 'array-payload' => '[1, 2, 3]', + 'disabled-flag-with-payload' => '{"disabled":true}', ], ]; @@ -368,6 +370,21 @@ class MockedResponses 'version' => 1, ] ], + 'disabled-flag-with-payload' => [ + 'key' => 'disabled-flag-with-payload', + 'enabled' => false, + 'variant' => null, + 'reason' => [ + 'code' => 'no_condition_match', + 'description' => 'No matching condition set', + 'condition_index' => null + ], + 'metadata' => [ + 'id' => 20, + 'payload' => '{"disabled":true}', + 'version' => 1, + ] + ], ], 'sessionRecording' => false, 'requestId' => '98487c8a-287a-4451-a085-299cd76228dd'