Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flag-called-has-experiment.md
Original file line number Diff line number Diff line change
@@ -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).
17 changes: 17 additions & 0 deletions api/public-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,13 @@
"default": null,
"hasDefault": false
},
"hasExperiment": {
"static": false,
"readonly": true,
"type": "?bool",
"default": null,
"hasDefault": false
},
"id": {
"static": false,
"readonly": true,
Expand Down Expand Up @@ -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
}
]
},
Expand Down
22 changes: 22 additions & 0 deletions lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -1034,6 +1050,9 @@ public function evaluateFlags(
version: null,
reason: 'Evaluated locally',
locallyEvaluated: true,
hasExperiment: isset($flag['has_experiment'])
? (bool) $flag['has_experiment']
: null,
);
}

Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions lib/EvaluatedFlagRecord.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,6 +32,7 @@ public function __construct(
public readonly ?int $version,
public readonly ?string $reason,
public readonly bool $locallyEvaluated,
public readonly ?bool $hasExperiment = null,
) {
}

Expand Down
7 changes: 7 additions & 0 deletions lib/FeatureFlagEvaluations.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
54 changes: 54 additions & 0 deletions test/FeatureFlagEvaluationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions test/FeatureFlagLocalEvaluationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
35 changes: 35 additions & 0 deletions test/FeatureFlagTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
Loading