diff --git a/.changeset/local-evaluation-payloads.md b/.changeset/local-evaluation-payloads.md new file mode 100644 index 0000000..a18f85f --- /dev/null +++ b/.changeset/local-evaluation-payloads.md @@ -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. diff --git a/lib/Client.php b/lib/Client.php index 8f496b4..eef10fd 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -806,6 +806,9 @@ private function doGetFeatureFlagResult( } $flagWasEvaluatedLocally = !is_null($result); + if ($flagWasEvaluatedLocally) { + $payload = $this->localFlagPayload($localFlagDefinition, $result); + } $requestId = null; $evaluatedAt = null; $flagDetail = null; @@ -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', @@ -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 $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. * diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 0c8fa12..a83f07f 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -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;