diff --git a/.changeset/flag-called-has-experiment.md b/.changeset/flag-called-has-experiment.md new file mode 100644 index 0000000..07ad8fb --- /dev/null +++ b/.changeset/flag-called-has-experiment.md @@ -0,0 +1,5 @@ +--- +"posthog-php": minor +--- + +Add a $feature_flag_has_experiment boolean property to $feature_flag_called events, mirroring the server's has_experiment field. The property is only sent when the server explicitly reports has_experiment and is omitted when unknown (older deployments, missing metadata, missing flags). diff --git a/api/public-api.json b/api/public-api.json index 979073c..d2c9f7d 100644 --- a/api/public-api.json +++ b/api/public-api.json @@ -1576,6 +1576,13 @@ "default": null, "hasDefault": false }, + "hasExperiment": { + "static": false, + "readonly": true, + "type": "?bool", + "default": null, + "hasDefault": false + }, "id": { "static": false, "readonly": true, @@ -1712,6 +1719,16 @@ "default": null, "defaultConstant": null, "hasDefault": false + }, + { + "name": "hasExperiment", + "type": "?bool", + "byReference": false, + "variadic": false, + "optional": true, + "default": null, + "defaultConstant": null, + "hasDefault": true } ] }, diff --git a/lib/Client.php b/lib/Client.php index 273aff9..9826050 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -699,9 +699,11 @@ private function doGetFeatureFlagResult( $result = null; $payload = null; $featureFlagError = null; + $localFlagDefinition = null; foreach ($this->featureFlags as $flag) { if ($flag["key"] == $key) { + $localFlagDefinition = $flag; try { $result = $this->computeFlagLocally( $flag, @@ -785,11 +787,25 @@ private function doGetFeatureFlagResult( } if ($sendFeatureFlagEvents) { + // Locally-evaluated flags carry has_experiment in the stored definition; + // remotely-evaluated flags carry it in the response metadata. Null when the + // server (older deployment) does not report it, in which case the property + // is omitted from the event. + if ($flagWasEvaluatedLocally) { + $hasExperiment = $localFlagDefinition['has_experiment'] ?? null; + } else { + $hasExperiment = $flagDetail['metadata']['has_experiment'] ?? null; + } + $properties = [ '$feature_flag' => $key, '$feature_flag_response' => $result, ]; + if (!is_null($hasExperiment)) { + $properties['$feature_flag_has_experiment'] = (bool) $hasExperiment; + } + if (!is_null($requestId)) { $properties['$feature_flag_request_id'] = $requestId; } @@ -1034,6 +1050,9 @@ public function evaluateFlags( version: null, reason: 'Evaluated locally', locallyEvaluated: true, + hasExperiment: isset($flag['has_experiment']) + ? (bool) $flag['has_experiment'] + : null, ); } @@ -1106,6 +1125,9 @@ public function evaluateFlags( : null, reason: $flagDetail['reason']['description'] ?? null, locallyEvaluated: false, + hasExperiment: isset($flagDetail['metadata']['has_experiment']) + ? (bool) $flagDetail['metadata']['has_experiment'] + : null, ); } } catch (HttpException $e) { diff --git a/lib/EvaluatedFlagRecord.php b/lib/EvaluatedFlagRecord.php index ee8e6b0..f4b381e 100644 --- a/lib/EvaluatedFlagRecord.php +++ b/lib/EvaluatedFlagRecord.php @@ -20,6 +20,8 @@ final class EvaluatedFlagRecord * @param int|null $version Feature flag version, when provided by the API. * @param string|null $reason Evaluation reason, when provided by the API. * @param bool $locallyEvaluated Whether the value was computed locally. + * @param bool|null $hasExperiment Server-reported signal for whether the flag is linked to an + * experiment. Null when the server does not report it (older deployments). */ public function __construct( public readonly string $key, @@ -30,6 +32,7 @@ public function __construct( public readonly ?int $version, public readonly ?string $reason, public readonly bool $locallyEvaluated, + public readonly ?bool $hasExperiment = null, ) { } diff --git a/lib/FeatureFlagEvaluations.php b/lib/FeatureFlagEvaluations.php index 7a6a293..137d6f6 100644 --- a/lib/FeatureFlagEvaluations.php +++ b/lib/FeatureFlagEvaluations.php @@ -182,6 +182,13 @@ private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void 'locally_evaluated' => $record?->locallyEvaluated ?? false, ]; + // Server-reported signal for whether the flag is linked to an experiment. Only sent + // when the server explicitly reported it; omitted when unknown (older deployments, + // missing metadata, missing flags). + if ($record?->hasExperiment !== null) { + $properties['$feature_flag_has_experiment'] = $record->hasExperiment; + } + if ($record !== null) { if ($record->id !== null) { $properties['$feature_flag_id'] = $record->id; diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 2dc882c..57c4eae 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -364,6 +364,58 @@ public function testLocallyEvaluatedFlagTagsLocallyEvaluatedAndReason(): void $this->assertArrayNotHasKey('$feature_flag_version', $properties); $this->assertArrayNotHasKey('$feature_flag_request_id', $properties); $this->assertArrayNotHasKey('$feature_flag_evaluated_at', $properties); + // Local definition doesn't report has_experiment, so the property is omitted. + $this->assertArrayNotHasKey('$feature_flag_has_experiment', $properties); + } + + public function testHasExperimentFromRemoteMetadataPropagatesToEvent(): void + { + $response = MockedResponses::FLAGS_V2_RESPONSE; + $response['flags']['simple-test']['metadata']['has_experiment'] = true; + $response['flags']['multivariate-test']['metadata']['has_experiment'] = false; + // having_fun's metadata omits has_experiment, covering older deployments. + $this->makeClient(flagsEndpointResponse: $response); + + $snapshot = PostHog::evaluateFlags('user-1'); + $snapshot->isEnabled('simple-test'); + $snapshot->getFlag('multivariate-test'); + $snapshot->isEnabled('having_fun'); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $propertiesByFlag = []; + foreach ($batches[0]['batch'] as $event) { + $propertiesByFlag[$event['properties']['$feature_flag']] = $event['properties']; + } + + $this->assertTrue($propertiesByFlag['simple-test']['$feature_flag_has_experiment']); + $this->assertFalse($propertiesByFlag['multivariate-test']['$feature_flag_has_experiment']); + // Unreported has_experiment omits the property rather than defaulting it. + $this->assertArrayNotHasKey('$feature_flag_has_experiment', $propertiesByFlag['having_fun']); + } + + public function testHasExperimentFromLocalDefinitionPropagatesToEvent(): void + { + $localResponse = MockedResponses::LOCAL_EVALUATION_REQUEST; + $localResponse['flags'][0]['has_experiment'] = true; + $this->makeClient( + personalApiKey: 'test-personal-key', + localEvaluationResponse: $localResponse, + ); + $snapshot = PostHog::evaluateFlags( + 'user-1', + personProperties: ['region' => 'USA'] + ); + + $this->assertTrue($snapshot->isEnabled('person-flag')); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $properties = $batches[0]['batch'][0]['properties']; + $this->assertTrue($properties['locally_evaluated']); + $this->assertTrue($properties['$feature_flag_has_experiment']); } public function testLocalEvaluationSkipsRemoteFlagsRequestWhenAllResolved(): void @@ -409,6 +461,8 @@ public function testMissingFlagResponseIsNullNotFalse(): void $properties = $batches[0]['batch'][0]['properties']; $this->assertNull($properties['$feature_flag_response']); $this->assertFalse($properties['locally_evaluated']); + // Missing flags have no server-reported has_experiment, so the property is omitted. + $this->assertArrayNotHasKey('$feature_flag_has_experiment', $properties); } public function testRemotePayloadHandlesPreDecodedValue(): void diff --git a/test/FeatureFlagLocalEvaluationTest.php b/test/FeatureFlagLocalEvaluationTest.php index 5f4d773..789bb02 100644 --- a/test/FeatureFlagLocalEvaluationTest.php +++ b/test/FeatureFlagLocalEvaluationTest.php @@ -1578,6 +1578,31 @@ public function testSimpleFlag() }); } + public function testFeatureFlagCalledEventIncludesHasExperimentFromLocalDefinition() + { + $localResponse = MockedResponses::LOCAL_EVALUATION_SIMPLE_REQUEST; + $localResponse['flags'][0]['has_experiment'] = true; + + $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: $localResponse); + $this->client = new Client( + self::FAKE_API_KEY, + [ + "debug" => true, + ], + $this->http_client, + "test" + ); + PostHog::init(null, null, $this->client); + + $this->assertTrue(PostHog::getFeatureFlag('simple-flag', 'some-distinct-id')); + PostHog::flush(); + + $payload = json_decode($this->http_client->calls[1]['payload'], true); + $properties = $payload['batch'][0]['properties']; + $this->assertSame('simple-flag', $properties['$feature_flag']); + $this->assertTrue($properties['$feature_flag_has_experiment']); + } + public function testFeatureFlagsDontFallbackToDecideWhenOnlyLocalEvaluationIsTrue() { $this->http_client = new MockedHttpClient(host: "app.posthog.com", flagEndpointResponse: MockedResponses::FALLBACK_TO_FLAGS_REQUEST); diff --git a/test/FeatureFlagTest.php b/test/FeatureFlagTest.php index 47fc236..a1ae12c 100644 --- a/test/FeatureFlagTest.php +++ b/test/FeatureFlagTest.php @@ -488,6 +488,41 @@ public function testGetFeatureFlagCapturesFeatureFlagCalledEventWithAdditionalMe }); } + public static function hasExperimentCases(): array + { + // The event property mirrors the server's has_experiment field exactly and is + // omitted when the server does not report it (older deployments). + return [ + 'reported true' => [true], + 'reported false' => [false], + 'absent' => [null], + ]; + } + + /** + * @dataProvider hasExperimentCases + */ + public function testFeatureFlagCalledEventIncludesHasExperimentFromRemoteMetadata(?bool $reported) + { + $response = MockedResponses::FLAGS_V2_RESPONSE; + if ($reported !== null) { + $response['flags']['simple-test']['metadata']['has_experiment'] = $reported; + } + $this->setUp($response, personalApiKey: null); + + $this->assertTrue(PostHog::isFeatureEnabled('simple-test', 'user-id')); + PostHog::flush(); + + $payload = json_decode($this->http_client->calls[1]['payload'], true); + $properties = $payload['batch'][0]['properties']; + $this->assertSame('simple-test', $properties['$feature_flag']); + if ($reported === null) { + $this->assertArrayNotHasKey('$feature_flag_has_experiment', $properties); + } else { + $this->assertSame($reported, $properties['$feature_flag_has_experiment']); + } + } + /** * @dataProvider decideResponseCases */