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/local-evaluation-payloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-php": patch
---

Resolve feature flag payloads during local evaluation from the definition's `filters.payloads`, so `evaluateFlags()->getFlagPayload()` and `getFeatureFlagPayload()` no longer return null for locally evaluated flags.
26 changes: 25 additions & 1 deletion lib/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,9 @@ private function doGetFeatureFlagResult(
}

$flagWasEvaluatedLocally = !is_null($result);
if ($flagWasEvaluatedLocally) {
$payload = $this->localFlagPayload($localFlagDefinition, $result);
}
$requestId = null;
$evaluatedAt = null;
$flagDetail = null;
Expand Down Expand Up @@ -1137,7 +1140,7 @@ public function evaluateFlags(
key: $key,
enabled: $enabled,
variant: $variant,
payload: null,
payload: $this->localFlagPayload($flag, $value),
id: $id,
version: null,
reason: 'Evaluated locally',
Expand Down Expand Up @@ -1309,6 +1312,27 @@ private static function canonicalGroupsRepr(array $groups): string
return '_' . json_encode($groups, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}

/**
* Payload for a locally computed flag value, read from the definition's `filters.payloads`
* map: keyed by the variant for a multivariate flag, or by "true" / "false" for a boolean
* flag. Null when the flag carries no payload.
*
* @param array<string, mixed> $flag Local flag definition.
* @param bool|string $value Result of computeFlagLocally() for this flag.
* @return mixed
*/
private function localFlagPayload(array $flag, bool|string $value): mixed
{
$payloadKey = is_string($value) ? $value : ($value ? 'true' : 'false');
$rawPayload = $flag['filters']['payloads'][$payloadKey] ?? null;
if ($rawPayload === null) {
return null;
}

// Payloads come down as JSON strings, but defensively handle pre-decoded values too.
return is_string($rawPayload) ? json_decode($rawPayload, true) : $rawPayload;
}

/**
* Emit a non-fatal SDK warning.
*
Expand Down
97 changes: 97 additions & 0 deletions test/FeatureFlagEvaluationsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,103 @@ public function testLocalEvaluationSkipsRemoteFlagsRequestWhenAllResolved(): voi
$this->assertSame(0, $this->flagsRequestCount());
}

public function testLocalEvaluationResolvesPayloads(): void
{
// Local definitions carry `filters.payloads`, keyed by "true" for boolean flags and by
// variant key for multivariate ones, so a locally evaluated flag exposes its payload
// without a /flags request.
$this->makeClient(
personalApiKey: 'test-personal-key',
localEvaluationResponse: [
'flags' => [
[
'id' => 1,
'key' => 'boolean-payload',
'active' => true,
'filters' => [
'groups' => [['properties' => [], 'rollout_percentage' => 100]],
'payloads' => ['true' => '{"tiers": [33, 66, 100]}'],
],
],
[
'id' => 2,
'key' => 'variant-payload',
'active' => true,
'filters' => [
'groups' => [['properties' => [], 'rollout_percentage' => 100]],
'multivariate' => ['variants' => [
['key' => 'control', 'rollout_percentage' => 0],
['key' => 'test', 'rollout_percentage' => 100],
]],
'payloads' => ['control' => '"a"', 'test' => ['already' => 'decoded']],
],
],
[
'id' => 3,
'key' => 'off-with-payload',
'active' => false,
'filters' => [
'groups' => [['properties' => [], 'rollout_percentage' => 100]],
'payloads' => ['true' => '1'],
],
],
],
'group_type_mapping' => [],
'cohorts' => [],
],
);

$snapshot = PostHog::evaluateFlags('user-1');

$this->assertSame(['tiers' => [33, 66, 100]], $snapshot->getFlagPayload('boolean-payload'));
$this->assertSame('test', $snapshot->getFlag('variant-payload'));
$this->assertSame(['already' => 'decoded'], $snapshot->getFlagPayload('variant-payload'));
$this->assertNull($snapshot->getFlagPayload('off-with-payload'));
$this->assertSame(0, $this->flagsRequestCount());
}

#[DataProvider('falseFlagPayloadProvider')]
public function testLocalFalseFlagResolvesPayload(mixed $rawPayload, mixed $expectedPayload): void
{
$this->makeClient(
personalApiKey: 'test-personal-key',
localEvaluationResponse: [
'flags' => [[
'id' => 1,
'key' => 'false-payload',
'active' => true,
'filters' => [
'groups' => [['properties' => [], 'rollout_percentage' => 0]],
'payloads' => ['true' => '123', 'false' => $rawPayload],
],
]],
'group_type_mapping' => [],
'cohorts' => [],
],
);

$snapshot = $this->client->evaluateFlags('user-1');

$this->assertFalse($snapshot->getFlag('false-payload'));
$this->assertSame($expectedPayload, $snapshot->getFlagPayload('false-payload'));
$this->assertSame(
$expectedPayload,
$this->client->getFeatureFlagPayload('false-payload', 'user-1')
);
$this->assertSame(0, $this->flagsRequestCount());
}

public static function falseFlagPayloadProvider(): array
{
return [
'JSON false' => ['false', false],
'pre-decoded false' => [false, false],
'number' => ['400', 400],
'object' => ['{"fallback":true}', ['fallback' => true]],
'missing payload' => [null, null],
];
}

public function testRemoteEvaluatedAtPropagatesToEvent(): void
{
$response = MockedResponses::FLAGS_V2_RESPONSE;
Expand Down
Loading