From 2556d45b7834a83bac8de89134952ceedd5b79b4 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:54:37 -0700 Subject: [PATCH 1/2] feat: add evaluateFlags() API for single-call flag evaluation Phase 1 of the Server SDK Feature Flag Evaluations RFC. Mirrors the Node (posthog-js#3476) and Python (posthog-python#539) implementations. * `Client::evaluateFlags()` returns a `FeatureFlagEvaluations` snapshot. Reads on the snapshot do not trigger additional `/flags` requests; access via `isEnabled` / `getFlag` fires a deduped `$feature_flag_called` event the first time each key is touched. `getFlagPayload` is silent. * `capture()` accepts a `flags` snapshot to attach `$feature/` and `$active_feature_flags` properties without a fresh `/flags` round trip. * The single-flag dedup is extracted to `Client::captureFlagCalledIfNeeded()`, shared by the legacy path and the snapshot. * `flag_keys_to_evaluate` and `geoip_disable` are forwarded on the `/flags` request body when callers pass `flagKeys` or `disableGeoip`. * New `feature_flags_log_warnings` option silences filter warnings emitted from `only()` / `onlyAccessed()`. Also fixes a pre-existing bug in `SizeLimitedHash::contains/add` that caused the per-distinct_id `$feature_flag_called` dedup to never match after the first event. The new snapshot path requires real dedup, and existing tests only ever made a single call so the bug was invisible until now. Generated-By: PostHog Code Task-Id: 1f29305a-ee56-456e-a341-8faa4eb8716d --- CHANGELOG.md | 10 + lib/Client.php | 220 +++++++++++++- lib/EvaluatedFlagRecord.php | 29 ++ lib/FeatureFlagEvaluations.php | 218 ++++++++++++++ lib/FeatureFlagEvaluationsHost.php | 35 +++ lib/PostHog.php | 32 ++ lib/SizeLimitedHash.php | 10 +- test/FakeFlagEvaluationsHost.php | 43 +++ test/FeatureFlagEvaluationsTest.php | 450 ++++++++++++++++++++++++++++ 9 files changed, 1029 insertions(+), 18 deletions(-) create mode 100644 lib/EvaluatedFlagRecord.php create mode 100644 lib/FeatureFlagEvaluations.php create mode 100644 lib/FeatureFlagEvaluationsHost.php create mode 100644 test/FakeFlagEvaluationsHost.php create mode 100644 test/FeatureFlagEvaluationsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 056b528..2ac08ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +* feat(flags): Add `evaluateFlags()` API for single-call flag evaluation. Returns a + `FeatureFlagEvaluations` snapshot you can read repeatedly without further `/flags` requests; pass + it to `capture()` via the new `flags` key to attach `$feature/` and `$active_feature_flags` + on the captured event without an extra round trip. +* fix(flags): `SizeLimitedHash::contains()` and `add()` were storing entries on the outer map and + comparing values to keys, so the per-distinct_id `$feature_flag_called` dedup never matched after + the first event. Both helpers now operate on a per-key set as intended. + ## 4.2.2 - 2026-04-21 * [Full Changelog](https://github.com/PostHog/posthog-php/compare/4.2.1...4.2.2) diff --git a/lib/Client.php b/lib/Client.php index 0f332a5..adfb442 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -11,7 +11,7 @@ const SIZE_LIMIT = 50_000; -class Client +class Client implements FeatureFlagEvaluationsHost { private const CONSUMERS = [ "socket" => Socket::class, @@ -88,6 +88,11 @@ class Client */ private $options; + /** + * @var bool Whether to surface non-fatal warnings from feature flag helpers. + */ + private $featureFlagsLogWarnings; + /** * Create a new posthog object with your app's API key * key @@ -123,6 +128,7 @@ public function __construct( (int) ($options['timeout'] ?? 10000) ); $this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000); + $this->featureFlagsLogWarnings = (bool) ($options['feature_flags_log_warnings'] ?? true); $this->featureFlags = []; $this->groupTypeMapping = []; $this->cohorts = []; @@ -155,6 +161,9 @@ public function __destruct() */ public function capture(array $message) { + $flagsSnapshot = $message["flags"] ?? null; + unset($message["flags"]); + $message = $this->message($message); $message["type"] = "capture"; @@ -162,7 +171,14 @@ public function capture(array $message) $message["properties"]['$groups'] = $message['$groups']; } - if (array_key_exists("send_feature_flags", $message) && $message["send_feature_flags"]) { + if ($flagsSnapshot instanceof FeatureFlagEvaluations) { + // The snapshot already has every flag value cached. No /flags request, no override of + // properties already set on the event. + $message["properties"] = array_merge( + $flagsSnapshot->getEventProperties(), + $message["properties"] + ); + } elseif (array_key_exists("send_feature_flags", $message) && $message["send_feature_flags"]) { $extraProperties = []; $flags = []; @@ -444,7 +460,7 @@ public function getFeatureFlagResult( } } - if ($sendFeatureFlagEvents && !$this->distinctIdsFeatureFlagsReported->contains($key, $distinctId)) { + if ($sendFeatureFlagEvents) { $properties = [ '$feature_flag' => $key, '$feature_flag_response' => $result, @@ -468,13 +484,7 @@ public function getFeatureFlagResult( $properties['$feature_flag_error'] = $featureFlagError; } - $this->capture([ - "properties" => $properties, - "distinct_id" => $distinctId, - "event" => '$feature_flag_called', - '$groups' => $groups - ]); - $this->distinctIdsFeatureFlagsReported->add($key, $distinctId); + $this->captureFlagCalledIfNeeded($distinctId, $key, $properties, $groups); } if (is_null($result)) { @@ -581,6 +591,184 @@ public function getAllFlags( return $response; } + /** + * Evaluate every feature flag for a distinct id in a single round trip and return a + * FeatureFlagEvaluations snapshot. Reads on the snapshot do not trigger additional /flags + * requests; access via isEnabled() or getFlag() fires a deduped $feature_flag_called event the + * first time each key is touched. + * + * @param array $groups + * @param array $personProperties + * @param array> $groupProperties + * @param list|null $flagKeys When set, scope the underlying /flags request to these keys. + */ + public function evaluateFlags( + string $distinctId, + array $groups = [], + array $personProperties = [], + array $groupProperties = [], + bool $onlyEvaluateLocally = false, + bool $disableGeoip = false, + ?array $flagKeys = null + ): FeatureFlagEvaluations { + if ($distinctId === '') { + return new FeatureFlagEvaluations( + $distinctId, + [], + $groups, + $this, + null, + $this->featureFlagsLogWarnings, + ); + } + + [$personProperties, $groupProperties] = $this->addLocalPersonAndGroupProperties( + $distinctId, + $groups, + $personProperties, + $groupProperties + ); + + $records = []; + + // Local pass: try to resolve any flag we can without going to the server. + foreach ($this->featureFlags as $flag) { + $key = $flag['key'] ?? null; + if (!is_string($key) || $key === '') { + continue; + } + + if ($flagKeys !== null && !in_array($key, $flagKeys, true)) { + continue; + } + + try { + $value = $this->computeFlagLocally( + $flag, + $distinctId, + $groups, + $personProperties, + $groupProperties + ); + } catch (RequiresServerEvaluationException $e) { + continue; + } catch (InconclusiveMatchException $e) { + continue; + } catch (Exception $e) { + error_log("[PostHog][Client] Error while computing variant: " . $e->getMessage()); + continue; + } + + $variant = is_string($value) ? $value : null; + $enabled = is_string($value) ? true : (bool) $value; + $id = isset($flag['id']) ? (int) $flag['id'] : null; + + $records[$key] = new EvaluatedFlagRecord( + key: $key, + enabled: $enabled, + variant: $variant, + payload: null, + id: $id, + version: null, + reason: 'Evaluated locally', + locallyEvaluated: true, + ); + } + + $requestId = null; + + if (!$onlyEvaluateLocally) { + try { + $response = $this->flags( + $distinctId, + $groups, + $personProperties, + $groupProperties, + $disableGeoip, + $flagKeys + ); + + $requestId = $response['requestId'] ?? null; + $remoteFlags = $response['flags'] ?? []; + + foreach ($remoteFlags as $key => $flagDetail) { + if (!is_string($key) || $key === '' || isset($records[$key])) { + continue; + } + if (!is_array($flagDetail) || ($flagDetail['failed'] ?? false)) { + continue; + } + + $variant = $flagDetail['variant'] ?? null; + $enabled = (bool) ($flagDetail['enabled'] ?? false); + $rawPayload = $flagDetail['metadata']['payload'] ?? null; + $payload = $rawPayload !== null ? json_decode($rawPayload, true) : null; + + $records[$key] = new EvaluatedFlagRecord( + key: $key, + enabled: $enabled, + variant: is_string($variant) ? $variant : null, + payload: $payload, + id: isset($flagDetail['metadata']['id']) + ? (int) $flagDetail['metadata']['id'] + : null, + version: isset($flagDetail['metadata']['version']) + ? (int) $flagDetail['metadata']['version'] + : null, + reason: $flagDetail['reason']['description'] ?? null, + locallyEvaluated: false, + ); + } + } catch (Exception $e) { + error_log("[PostHog][Client] Unable to evaluate flags: " . $e->getMessage()); + } + } + + return new FeatureFlagEvaluations( + $distinctId, + $records, + $groups, + $this, + $requestId, + $this->featureFlagsLogWarnings, + ); + } + + /** + * Fire a $feature_flag_called event the first time a (flag key, distinct id) pair is seen by + * this Client, deduped via the per-distinct_id cache shared with every other flag-reading code + * path. Properties are built by the caller so each call site can shape the payload to match its + * available metadata. + * + * @param array $properties + * @param array $groups + */ + public function captureFlagCalledIfNeeded( + string $distinctId, + string $key, + array $properties, + array $groups = [] + ): void { + if ($this->distinctIdsFeatureFlagsReported->contains($key, $distinctId)) { + return; + } + + $this->capture([ + 'properties' => $properties, + 'distinct_id' => $distinctId, + 'event' => '$feature_flag_called', + '$groups' => $groups, + ]); + $this->distinctIdsFeatureFlagsReported->add($key, $distinctId); + } + + public function logWarning(string $message): void + { + if ($this->featureFlagsLogWarnings) { + error_log("[PostHog][Client] " . $message); + } + } + private function computeFlagLocally( array $featureFlag, string $distinctId, @@ -821,7 +1009,9 @@ public function flags( string $distinctId, array $groups = array(), array $personProperties = [], - array $groupProperties = [] + array $groupProperties = [], + bool $disableGeoip = false, + ?array $flagKeys = null ): array { $payload = array( 'api_key' => $this->apiKey, @@ -840,6 +1030,14 @@ public function flags( $payload["group_properties"] = $groupProperties; } + if ($disableGeoip) { + $payload["geoip_disable"] = true; + } + + if ($flagKeys !== null) { + $payload["flag_keys_to_evaluate"] = array_values($flagKeys); + } + $httpResponse = $this->httpClient->sendRequest( '/flags/?v=2', json_encode($payload), diff --git a/lib/EvaluatedFlagRecord.php b/lib/EvaluatedFlagRecord.php new file mode 100644 index 0000000..f45f16a --- /dev/null +++ b/lib/EvaluatedFlagRecord.php @@ -0,0 +1,29 @@ +variant ?? $this->enabled; + } +} diff --git a/lib/FeatureFlagEvaluations.php b/lib/FeatureFlagEvaluations.php new file mode 100644 index 0000000..137043f --- /dev/null +++ b/lib/FeatureFlagEvaluations.php @@ -0,0 +1,218 @@ + */ + private array $accessed; + + /** + * @param array $flags + * @param array $groups + */ + public function __construct( + private readonly string $distinctId, + private readonly array $flags, + private readonly array $groups, + private readonly FeatureFlagEvaluationsHost $host, + private readonly ?string $requestId = null, + private readonly bool $logWarnings = true, + ?array $accessed = null, + ) { + $this->accessed = $accessed ?? []; + } + + /** + * @return list + */ + public function getKeys(): array + { + return array_keys($this->flags); + } + + /** + * Whether the flag is enabled for the snapshot's distinct id. Returns false for unknown keys. + */ + public function isEnabled(string $key): bool + { + $record = $this->flags[$key] ?? null; + $this->recordAccess($key, $record); + + return $record?->enabled ?? false; + } + + /** + * Returns the variant (string), enabled state (bool), or null for unknown keys. + */ + public function getFlag(string $key): bool|string|null + { + $record = $this->flags[$key] ?? null; + $this->recordAccess($key, $record); + + if ($record === null) { + return null; + } + + return $record->getValue(); + } + + /** + * Returns the decoded payload for a flag without recording access or firing a $feature_flag_called event. + * Returns null for unknown keys or flags without a payload. + */ + public function getFlagPayload(string $key): mixed + { + return $this->flags[$key]->payload ?? null; + } + + /** + * Returns a clone of this snapshot containing only flags that were previously accessed via + * isEnabled() or getFlag(). When nothing has been accessed yet, logs a warning and returns a + * full clone so callers don't silently emit empty $feature/* property bags. + */ + public function onlyAccessed(): self + { + if (count($this->accessed) === 0) { + $this->emitWarning( + 'FeatureFlagEvaluations::onlyAccessed() called before any flag was accessed; returning all flags.' + ); + + return $this->cloneWith($this->flags); + } + + $filtered = []; + foreach ($this->accessed as $key => $_) { + if (isset($this->flags[$key])) { + $filtered[$key] = $this->flags[$key]; + } + } + + return $this->cloneWith($filtered); + } + + /** + * Returns a clone of this snapshot filtered to the given keys. Unknown keys are dropped with a + * warning so silent typos don't slip into captured events. + * + * @param list $keys + */ + public function only(array $keys): self + { + $filtered = []; + foreach ($keys as $key) { + if (isset($this->flags[$key])) { + $filtered[$key] = $this->flags[$key]; + } else { + $this->emitWarning( + sprintf('FeatureFlagEvaluations::only() dropped unknown flag key "%s".', $key) + ); + } + } + + return $this->cloneWith($filtered); + } + + /** + * Properties to merge onto a captured event when it carries this snapshot. Adds $feature/ + * for every flag and a sorted $active_feature_flags list for enabled flags. + * + * @return array + */ + public function getEventProperties(): array + { + $properties = []; + $active = []; + foreach ($this->flags as $key => $record) { + $properties['$feature/' . $key] = $record->getValue(); + if ($record->enabled) { + $active[] = $key; + } + } + sort($active); + $properties['$active_feature_flags'] = $active; + + return $properties; + } + + /** + * Records that $key was accessed and fires a deduped $feature_flag_called event when the + * snapshot is bound to a real distinct id. Empty distinct ids short-circuit so we never leak + * events with an empty actor. + */ + private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void + { + $this->accessed[$key] = true; + + if ($this->distinctId === '') { + return; + } + + $properties = [ + '$feature_flag' => $key, + '$feature_flag_response' => $record?->getValue() ?? false, + ]; + + if ($record === null) { + $properties['$feature_flag_error'] = FeatureFlagError::FLAG_MISSING; + } else { + if ($record->id !== null) { + $properties['$feature_flag_id'] = $record->id; + } + if ($record->version !== null) { + $properties['$feature_flag_version'] = $record->version; + } + if ($record->reason !== null) { + $properties['$feature_flag_reason'] = $record->reason; + } + if ($record->locallyEvaluated) { + $properties['locally_evaluated'] = true; + } + } + + // request_id is per /flags response; locally-evaluated records aren't tied to a remote + // call so we omit it for them, matching the existing single-flag local path. + if ($this->requestId !== null && !($record?->locallyEvaluated ?? false)) { + $properties['$feature_flag_request_id'] = $this->requestId; + } + + $this->host->captureFlagCalledIfNeeded( + $this->distinctId, + $key, + $properties, + $this->groups + ); + } + + /** + * @param array $flags + */ + private function cloneWith(array $flags): self + { + // Filtered views start with an empty access set so reads on the child don't propagate back + // into the parent's view. PHP's value-copy semantics on arrays already give us isolation. + return new self( + $this->distinctId, + $flags, + $this->groups, + $this->host, + $this->requestId, + $this->logWarnings, + [], + ); + } + + private function emitWarning(string $message): void + { + if ($this->logWarnings) { + $this->host->logWarning($message); + } + } +} diff --git a/lib/FeatureFlagEvaluationsHost.php b/lib/FeatureFlagEvaluationsHost.php new file mode 100644 index 0000000..949adb5 --- /dev/null +++ b/lib/FeatureFlagEvaluationsHost.php @@ -0,0 +1,35 @@ + $properties + * @param array $groups + */ + public function captureFlagCalledIfNeeded( + string $distinctId, + string $key, + array $properties, + array $groups + ): void; + + /** + * Emit a non-fatal warning. Implementations may suppress these when feature_flags_log_warnings + * is disabled in the client configuration. + */ + public function logWarning(string $message): void; +} diff --git a/lib/PostHog.php b/lib/PostHog.php index 9686279..e45ba57 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -265,6 +265,38 @@ public static function getFeatureFlagPayload( ); } + /** + * Evaluate every feature flag for a distinct id in a single round trip and return a snapshot. + * Pass the snapshot to capture() via the `flags` key to attach $feature/ properties + * without making another /flags request. + * + * @param array $groups + * @param array $personProperties + * @param array $groupProperties + * @param list|null $flagKeys When set, scope the underlying /flags request to these keys. + * @throws Exception + */ + public static function evaluateFlags( + string $distinctId, + array $groups = array(), + array $personProperties = array(), + array $groupProperties = array(), + bool $onlyEvaluateLocally = false, + bool $disableGeoip = false, + ?array $flagKeys = null + ): FeatureFlagEvaluations { + self::checkClient(); + return self::$client->evaluateFlags( + $distinctId, + $groups, + $personProperties, + $groupProperties, + $onlyEvaluateLocally, + $disableGeoip, + $flagKeys + ); + } + /** * get all enabled flags for distinct_id * diff --git a/lib/SizeLimitedHash.php b/lib/SizeLimitedHash.php index aaa9cdc..9a6457c 100644 --- a/lib/SizeLimitedHash.php +++ b/lib/SizeLimitedHash.php @@ -28,19 +28,15 @@ public function add($key, $element) } if (array_key_exists($key, $this->mapping)) { - array_push($this->mapping, $element); + $this->mapping[$key][$element] = true; } else { - $this->mapping[$key] = [$element]; + $this->mapping[$key] = [$element => true]; } } public function contains($key, $element) { - if (array_key_exists($key, $this->mapping) && array_key_exists($element, $this->mapping[$key])) { - return true; - } - - return false; + return isset($this->mapping[$key][$element]); } public function count() diff --git a/test/FakeFlagEvaluationsHost.php b/test/FakeFlagEvaluationsHost.php new file mode 100644 index 0000000..92e634e --- /dev/null +++ b/test/FakeFlagEvaluationsHost.php @@ -0,0 +1,43 @@ +> */ + public array $captures = []; + /** @var list */ + public array $warnings = []; + /** @var array */ + private array $seen = []; + + public function captureFlagCalledIfNeeded( + string $distinctId, + string $key, + array $properties, + array $groups + ): void { + $cacheKey = $distinctId . "\0" . $key; + if (isset($this->seen[$cacheKey])) { + return; + } + $this->seen[$cacheKey] = true; + $this->captures[] = [ + 'distinct_id' => $distinctId, + 'key' => $key, + 'properties' => $properties, + 'groups' => $groups, + ]; + } + + public function logWarning(string $message): void + { + $this->warnings[] = $message; + } +} diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php new file mode 100644 index 0000000..6f78596 --- /dev/null +++ b/test/FeatureFlagEvaluationsTest.php @@ -0,0 +1,450 @@ +http_client = new MockedHttpClient( + 'app.posthog.com', + flagEndpointResponse: $localEvaluationResponse, + flagsEndpointResponse: $flagsEndpointResponse + ); + $this->client = new Client( + self::FAKE_API_KEY, + $options, + $this->http_client, + $personalApiKey + ); + PostHog::init(null, null, $this->client); + } + + private function flagsRequestCount(): int + { + $count = 0; + foreach ($this->http_client->calls ?? [] as $call) { + if (str_starts_with($call['path'], '/flags/')) { + $count++; + } + } + return $count; + } + + private function batchRequests(): array + { + $batches = []; + foreach ($this->http_client->calls ?? [] as $call) { + if (str_starts_with($call['path'], '/batch/')) { + $batches[] = json_decode($call['payload'], true); + } + } + return $batches; + } + + private function makeRecord( + string $key, + bool $enabled, + ?string $variant = null, + mixed $payload = null, + ?int $id = null, + ?int $version = null, + ?string $reason = null, + bool $locallyEvaluated = false + ): EvaluatedFlagRecord { + return new EvaluatedFlagRecord( + key: $key, + enabled: $enabled, + variant: $variant, + payload: $payload, + id: $id, + version: $version, + reason: $reason, + locallyEvaluated: $locallyEvaluated, + ); + } + + public function testEvaluateFlagsReturnsSnapshotAndMakesOneFlagsRequest(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $this->assertInstanceOf(FeatureFlagEvaluations::class, $snapshot); + $this->assertSame(1, $this->flagsRequestCount()); + $this->assertContains('simple-test', $snapshot->getKeys()); + $this->assertContains('multivariate-test', $snapshot->getKeys()); + } + + public function testNoFeatureFlagCalledEventsUntilAccess(): void + { + $this->makeClient(); + PostHog::evaluateFlags('user-1'); + PostHog::flush(); + + $this->assertSame([], $this->batchRequests()); + } + + public function testIsEnabledFiresEventWithFullMetadata(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $this->assertTrue($snapshot->isEnabled('simple-test')); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $event = $batches[0]['batch'][0]; + + $this->assertSame('$feature_flag_called', $event['event']); + $this->assertSame('user-1', $event['distinct_id']); + $properties = $event['properties']; + $this->assertSame('simple-test', $properties['$feature_flag']); + $this->assertTrue($properties['$feature_flag_response']); + $this->assertSame(6, $properties['$feature_flag_id']); + $this->assertSame(1, $properties['$feature_flag_version']); + $this->assertSame('Matched condition set 1', $properties['$feature_flag_reason']); + $this->assertSame('98487c8a-287a-4451-a085-299cd76228dd', $properties['$feature_flag_request_id']); + $this->assertArrayNotHasKey('locally_evaluated', $properties); + } + + public function testGetFlagFiresEventOnFirstAccessDedupedOnSecond(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $snapshot->getFlag('multivariate-test'); + $snapshot->getFlag('multivariate-test'); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $this->assertCount(1, $batches[0]['batch']); + $this->assertSame('multivariate-test', $batches[0]['batch'][0]['properties']['$feature_flag']); + $this->assertSame('variant-value', $batches[0]['batch'][0]['properties']['$feature_flag_response']); + } + + public function testGetFlagPayloadDoesNotFireEvent(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $payload = $snapshot->getFlagPayload('json-payload'); + PostHog::flush(); + + $this->assertSame(['key' => 'value'], $payload); + $this->assertSame([], $this->batchRequests()); + } + + public function testUnknownKeyAccessRecordsFlagMissingError(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $this->assertNull($snapshot->getFlag('does-not-exist')); + $this->assertFalse($snapshot->isEnabled('does-not-exist')); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $properties = $batches[0]['batch'][0]['properties']; + $this->assertSame('flag_missing', $properties['$feature_flag_error']); + } + + public function testOnlyWarnsOnUnknownKeys(): void + { + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + ['flag-a' => $this->makeRecord('flag-a', true)], + [], + $host, + ); + + $filtered = $snapshot->only(['flag-a', 'unknown-flag']); + + $this->assertSame(['flag-a'], $filtered->getKeys()); + $this->assertCount(1, $host->warnings); + $this->assertStringContainsString('unknown-flag', $host->warnings[0]); + } + + public function testOnlyAccessedFallsBackAndWarnsWhenEmpty(): void + { + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + [ + 'flag-a' => $this->makeRecord('flag-a', true), + 'flag-b' => $this->makeRecord('flag-b', false), + ], + [], + $host, + ); + + $filtered = $snapshot->onlyAccessed(); + + $this->assertEqualsCanonicalizing(['flag-a', 'flag-b'], $filtered->getKeys()); + $this->assertCount(1, $host->warnings); + } + + public function testOnlyAccessedReturnsSubsetWhenAccessed(): void + { + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + [ + 'flag-a' => $this->makeRecord('flag-a', true), + 'flag-b' => $this->makeRecord('flag-b', false), + ], + [], + $host, + ); + + $snapshot->isEnabled('flag-a'); + $filtered = $snapshot->onlyAccessed(); + + $this->assertSame(['flag-a'], $filtered->getKeys()); + } + + public function testFilteredSnapshotsDoNotBackPropagateAccess(): void + { + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + [ + 'flag-a' => $this->makeRecord('flag-a', true), + 'flag-b' => $this->makeRecord('flag-b', true), + ], + [], + $host, + ); + + $snapshot->isEnabled('flag-a'); + $filtered = $snapshot->onlyAccessed(); + + // Touch flag-b on the child; the parent's accessed set should still be {flag-a}. + $filtered->isEnabled('flag-b'); + + $parentAccessed = $snapshot->onlyAccessed(); + $this->assertSame(['flag-a'], $parentAccessed->getKeys()); + } + + public function testCaptureFlagsAttachesFeaturePropertiesWithoutHttpRequest(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + + $callsBefore = count($this->http_client->calls ?? []); + + PostHog::capture([ + 'distinctId' => 'user-1', + 'event' => 'page_view', + 'flags' => $snapshot, + ]); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $event = $batches[0]['batch'][0]; + $this->assertSame('page_view', $event['event']); + $this->assertTrue($event['properties']['$feature/simple-test']); + $this->assertSame('variant-value', $event['properties']['$feature/multivariate-test']); + $this->assertContains('simple-test', $event['properties']['$active_feature_flags']); + $this->assertNotContains('having_fun', $event['properties']['$active_feature_flags']); + + // Capture only added one /batch/ call; no extra /flags/ request. + $newCalls = array_slice($this->http_client->calls, $callsBefore); + foreach ($newCalls as $call) { + $this->assertStringStartsNotWith('/flags/', $call['path']); + } + } + + public function testFlagKeysIsForwardedInRequestBody(): void + { + $this->makeClient(); + PostHog::evaluateFlags('user-1', flagKeys: ['simple-test', 'multivariate-test']); + + $flagsCall = null; + foreach ($this->http_client->calls as $call) { + if (str_starts_with($call['path'], '/flags/')) { + $flagsCall = $call; + break; + } + } + + $this->assertNotNull($flagsCall); + $payload = json_decode($flagsCall['payload'], true); + $this->assertSame(['simple-test', 'multivariate-test'], $payload['flag_keys_to_evaluate']); + } + + public function testDisableGeoipIsForwardedInRequestBody(): void + { + $this->makeClient(); + PostHog::evaluateFlags('user-1', disableGeoip: true); + + $payload = null; + foreach ($this->http_client->calls as $call) { + if (str_starts_with($call['path'], '/flags/')) { + $payload = json_decode($call['payload'], true); + break; + } + } + + $this->assertNotNull($payload); + $this->assertTrue($payload['geoip_disable']); + } + + public function testEmptyDistinctIdSnapshotDoesNotFireEvents(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags(''); + + $this->assertSame([], $snapshot->getKeys()); + $this->assertSame(0, $this->flagsRequestCount()); + + $snapshot->isEnabled('simple-test'); + $snapshot->getFlag('multivariate-test'); + PostHog::flush(); + + $this->assertSame([], $this->batchRequests()); + } + + public function testLocallyEvaluatedFlagTagsLocallyEvaluatedAndReason(): void + { + $this->makeClient( + personalApiKey: 'test-personal-key', + localEvaluationResponse: MockedResponses::LOCAL_EVALUATION_REQUEST, + ); + $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->assertSame('person-flag', $properties['$feature_flag']); + $this->assertTrue($properties['$feature_flag_response']); + $this->assertSame('Evaluated locally', $properties['$feature_flag_reason']); + $this->assertTrue($properties['locally_evaluated']); + $this->assertSame(1, $properties['$feature_flag_id']); + $this->assertArrayNotHasKey('$feature_flag_version', $properties); + $this->assertArrayNotHasKey('$feature_flag_request_id', $properties); + } + + public function testCaptureFlagsTakesPrecedenceOverSendFeatureFlags(): void + { + $this->makeClient(); + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + ['flag-a' => $this->makeRecord('flag-a', true)], + [], + $host, + ); + + PostHog::capture([ + 'distinctId' => 'user-1', + 'event' => 'page_view', + 'flags' => $snapshot, + 'send_feature_flags' => true, + ]); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $properties = $batches[0]['batch'][0]['properties']; + + // Only the snapshot's single flag is attached; send_feature_flags would have produced more. + $this->assertTrue($properties['$feature/flag-a']); + $this->assertSame(['flag-a'], $properties['$active_feature_flags']); + $this->assertSame(0, $this->flagsRequestCount()); + } + + public function testFlagsArgumentNotSerializedIntoEventPayload(): void + { + $this->makeClient(); + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + ['flag-a' => $this->makeRecord('flag-a', true)], + [], + $host, + ); + + PostHog::capture([ + 'distinctId' => 'user-1', + 'event' => 'page_view', + 'flags' => $snapshot, + ]); + PostHog::flush(); + + $rawPayload = $this->http_client->calls[0]['payload']; + $this->assertStringNotContainsString('FeatureFlagEvaluations', $rawPayload); + $batches = $this->batchRequests(); + $this->assertArrayNotHasKey('flags', $batches[0]['batch'][0]); + } + + public function testFeatureFlagsLogWarningsFalseSilencesFilterWarnings(): void + { + $host = new FakeFlagEvaluationsHost(); + $snapshot = new FeatureFlagEvaluations( + 'user-1', + ['flag-a' => $this->makeRecord('flag-a', true)], + [], + $host, + null, + false, + ); + + $snapshot->only(['unknown']); + $snapshot->onlyAccessed(); + + $this->assertSame([], $host->warnings); + } + + public function testSnapshotDedupesAcrossClientPaths(): void + { + $this->makeClient(); + $snapshot = PostHog::evaluateFlags('user-1'); + $snapshot->isEnabled('simple-test'); + + // The single-flag path should be deduped against the snapshot's earlier event because + // both share the Client's distinctIdsFeatureFlagsReported cache. + PostHog::isFeatureEnabled('simple-test', 'user-1'); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $this->assertCount(1, $batches[0]['batch']); + } +} From c4097425408dbc8aa276fd1c44f4f4da3585d7d6 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 13:38:42 -0700 Subject: [PATCH 2/2] chore: silence PSR1.SideEffects warning in evaluate_flags test The file pairs `require_once 'test/error_log_mock.php'` with class declarations, matching the pattern in FeatureFlagLocalEvaluationTest. The existing tests do the same thing; suppressing the rule per-file is consistent with that precedent. Generated-By: PostHog Code Task-Id: 1f29305a-ee56-456e-a341-8faa4eb8716d --- test/FeatureFlagEvaluationsTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 6f78596..d3abcb8 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -1,5 +1,6 @@