From 2556d45b7834a83bac8de89134952ceedd5b79b4 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:54:37 -0700 Subject: [PATCH 1/4] 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/4] 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 @@ Date: Wed, 29 Apr 2026 14:08:25 -0700 Subject: [PATCH 3/4] fix: address review feedback on evaluate_flags() PR Mirrors the fixes applied to posthog-python#539 after review: * `onlyAccessed()` returns an empty snapshot when nothing has been accessed instead of warning + falling back to all flags. The fallback was contradictory with the method's name and surprising for callers doing `capture(flags: $snapshot->onlyAccessed())` early in a request before any flag had been read. * `FeatureFlagEvaluations` tracks `errorsWhileComputingFlags` and `quotaLimited` from the /flags response and combines them with the per-flag `flag_missing` error in `$feature_flag_called`, matching the granularity the single-flag path emits today. * `capture()` now logs a warning when both `flags` and `send_feature_flags` are passed; precedence is unchanged (snapshot wins) but the conflict is no longer silent. * Tightened the `flagKeys` docstring on `Client::evaluateFlags()` and the `PostHog::evaluateFlags()` facade so it's clear it scopes the underlying /flags request, distinct from the in-memory `only([keys])` filter. Generated-By: PostHog Code Task-Id: 1f29305a-ee56-456e-a341-8faa4eb8716d --- lib/Client.php | 31 ++++++++++-- lib/FeatureFlagEvaluations.php | 36 +++++++++----- lib/PostHog.php | 6 ++- test/FeatureFlagEvaluationsTest.php | 77 +++++++++++++++++++++++++++-- 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/lib/Client.php b/lib/Client.php index adfb442..6712291 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -172,8 +172,15 @@ public function capture(array $message) } if ($flagsSnapshot instanceof FeatureFlagEvaluations) { - // The snapshot already has every flag value cached. No /flags request, no override of - // properties already set on the event. + // Precedence: an explicit `flags` snapshot always wins over `send_feature_flags`. The + // snapshot guarantees the event carries the same values the developer branched on, with + // no additional /flags request. + if (!empty($message["send_feature_flags"])) { + error_log( + "[PostHog][Client] Both `flags` and `send_feature_flags` were passed to " + . "capture(); using `flags` and ignoring `send_feature_flags`." + ); + } $message["properties"] = array_merge( $flagsSnapshot->getEventProperties(), $message["properties"] @@ -600,7 +607,11 @@ public function getAllFlags( * @param array $groups * @param array $personProperties * @param array> $groupProperties - * @param list|null $flagKeys When set, scope the underlying /flags request to these keys. + * @param list|null $flagKeys Optional list of flag keys. When provided, only these + * flags are evaluated — the underlying /flags request asks the server for just this + * subset, which makes the response smaller and the request cheaper. Use this when you + * only need a handful of flags out of many. Distinct from FeatureFlagEvaluations::only(), + * which scopes which already-evaluated flags get attached to a captured event. */ public function evaluateFlags( string $distinctId, @@ -630,6 +641,9 @@ public function evaluateFlags( ); $records = []; + $requestId = null; + $errorsWhileComputing = false; + $quotaLimited = false; // Local pass: try to resolve any flag we can without going to the server. foreach ($this->featureFlags as $flag) { @@ -675,8 +689,6 @@ public function evaluateFlags( ); } - $requestId = null; - if (!$onlyEvaluateLocally) { try { $response = $this->flags( @@ -689,6 +701,7 @@ public function evaluateFlags( ); $requestId = $response['requestId'] ?? null; + $errorsWhileComputing = (bool) ($response['errorsWhileComputingFlags'] ?? false); $remoteFlags = $response['flags'] ?? []; foreach ($remoteFlags as $key => $flagDetail) { @@ -719,6 +732,11 @@ public function evaluateFlags( locallyEvaluated: false, ); } + } catch (HttpException $e) { + if ($e->getErrorType() === HttpException::QUOTA_LIMITED) { + $quotaLimited = true; + } + error_log("[PostHog][Client] Unable to evaluate flags: " . $e->getMessage()); } catch (Exception $e) { error_log("[PostHog][Client] Unable to evaluate flags: " . $e->getMessage()); } @@ -731,6 +749,9 @@ public function evaluateFlags( $this, $requestId, $this->featureFlagsLogWarnings, + null, + $errorsWhileComputing, + $quotaLimited, ); } diff --git a/lib/FeatureFlagEvaluations.php b/lib/FeatureFlagEvaluations.php index 137043f..a051c03 100644 --- a/lib/FeatureFlagEvaluations.php +++ b/lib/FeatureFlagEvaluations.php @@ -26,6 +26,8 @@ public function __construct( private readonly ?string $requestId = null, private readonly bool $logWarnings = true, ?array $accessed = null, + private readonly bool $errorsWhileComputing = false, + private readonly bool $quotaLimited = false, ) { $this->accessed = $accessed ?? []; } @@ -75,19 +77,11 @@ public function getFlagPayload(string $key): mixed /** * 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. + * isEnabled() or getFlag(). Order-dependent: if nothing has been accessed yet, the returned + * snapshot is empty. The method honors its name — pre-access if you want a populated result. */ 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])) { @@ -160,9 +154,7 @@ private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void '$feature_flag_response' => $record?->getValue() ?? false, ]; - if ($record === null) { - $properties['$feature_flag_error'] = FeatureFlagError::FLAG_MISSING; - } else { + if ($record !== null) { if ($record->id !== null) { $properties['$feature_flag_id'] = $record->id; } @@ -183,6 +175,22 @@ private function recordAccess(string $key, ?EvaluatedFlagRecord $record): void $properties['$feature_flag_request_id'] = $this->requestId; } + // Build a comma-joined $feature_flag_error matching the single-flag path's granularity: + // response-level errors combine with per-flag errors so consumers can filter by type. + $errors = []; + if ($this->errorsWhileComputing) { + $errors[] = FeatureFlagError::ERRORS_WHILE_COMPUTING_FLAGS; + } + if ($this->quotaLimited) { + $errors[] = FeatureFlagError::QUOTA_LIMITED; + } + if ($record === null) { + $errors[] = FeatureFlagError::FLAG_MISSING; + } + if (!empty($errors)) { + $properties['$feature_flag_error'] = implode(',', $errors); + } + $this->host->captureFlagCalledIfNeeded( $this->distinctId, $key, @@ -206,6 +214,8 @@ private function cloneWith(array $flags): self $this->requestId, $this->logWarnings, [], + $this->errorsWhileComputing, + $this->quotaLimited, ); } diff --git a/lib/PostHog.php b/lib/PostHog.php index e45ba57..0eea96d 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -273,7 +273,11 @@ public static function getFeatureFlagPayload( * @param array $groups * @param array $personProperties * @param array $groupProperties - * @param list|null $flagKeys When set, scope the underlying /flags request to these keys. + * @param list|null $flagKeys Optional list of flag keys. When provided, only these + * flags are evaluated — the underlying /flags request asks the server for just this + * subset, which makes the response smaller and the request cheaper. Use this when you + * only need a handful of flags out of many. Distinct from FeatureFlagEvaluations::only(), + * which scopes which already-evaluated flags get attached to a captured event. * @throws Exception */ public static function evaluateFlags( diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index d3abcb8..292f288 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -195,7 +195,7 @@ public function testOnlyWarnsOnUnknownKeys(): void $this->assertStringContainsString('unknown-flag', $host->warnings[0]); } - public function testOnlyAccessedFallsBackAndWarnsWhenEmpty(): void + public function testOnlyAccessedReturnsEmptyWhenNoFlagsAccessed(): void { $host = new FakeFlagEvaluationsHost(); $snapshot = new FeatureFlagEvaluations( @@ -210,8 +210,8 @@ public function testOnlyAccessedFallsBackAndWarnsWhenEmpty(): void $filtered = $snapshot->onlyAccessed(); - $this->assertEqualsCanonicalizing(['flag-a', 'flag-b'], $filtered->getKeys()); - $this->assertCount(1, $host->warnings); + $this->assertSame([], $filtered->getKeys()); + $this->assertSame([], $host->warnings); } public function testOnlyAccessedReturnsSubsetWhenAccessed(): void @@ -433,6 +433,77 @@ public function testFeatureFlagsLogWarningsFalseSilencesFilterWarnings(): void $this->assertSame([], $host->warnings); } + public function testCaptureWarnsWhenBothFlagsAndSendFeatureFlagsSet(): void + { + global $errorMessages; + $errorMessages = []; + + $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(); + + $warned = false; + foreach ($errorMessages as $message) { + if (str_contains($message, 'Both `flags` and `send_feature_flags`')) { + $warned = true; + break; + } + } + $this->assertTrue($warned, 'Expected warning when both `flags` and `send_feature_flags` are set'); + + // No extra /flags request. + $this->assertSame(0, $this->flagsRequestCount()); + } + + public function testErrorsWhileComputingFlagsPropagatesToEvent(): void + { + $errorResponse = MockedResponses::FLAGS_V2_RESPONSE; + $errorResponse['errorsWhileComputingFlags'] = true; + $this->makeClient(flagsEndpointResponse: $errorResponse); + + $snapshot = PostHog::evaluateFlags('user-1'); + $snapshot->isEnabled('simple-test'); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $this->assertSame( + 'errors_while_computing_flags', + $batches[0]['batch'][0]['properties']['$feature_flag_error'] + ); + } + + public function testErrorsWhileComputingFlagsCombinesWithFlagMissing(): void + { + $errorResponse = MockedResponses::FLAGS_V2_RESPONSE; + $errorResponse['errorsWhileComputingFlags'] = true; + $this->makeClient(flagsEndpointResponse: $errorResponse); + + $snapshot = PostHog::evaluateFlags('user-1'); + $snapshot->isEnabled('does-not-exist'); + PostHog::flush(); + + $batches = $this->batchRequests(); + $this->assertCount(1, $batches); + $this->assertSame( + 'errors_while_computing_flags,flag_missing', + $batches[0]['batch'][0]['properties']['$feature_flag_error'] + ); + } + public function testSnapshotDedupesAcrossClientPaths(): void { $this->makeClient(); From 017a3bd7c4b3097c66eee50264526146b0b4a92a Mon Sep 17 00:00:00 2001 From: dylan Date: Wed, 29 Apr 2026 14:18:45 -0700 Subject: [PATCH 4/4] feat: deprecate legacy single-flag methods and capture send_feature_flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Server SDK Feature Flag Evaluations RFC, shipped alongside Phase 1 (mirroring posthog-python#539's eda573d). * `Client::isFeatureEnabled()`, `Client::getFeatureFlag()`, and `Client::getFeatureFlagPayload()` (along with their `PostHog::*` static facades) now emit `E_USER_DEPRECATED` pointing at `evaluateFlags()`. * `capture(['send_feature_flags' => true])` emits the same deprecation when the legacy block actually runs (the existing precedence — snapshot wins over `send_feature_flags` — is unchanged). * `Client::isFeatureEnabled()` now calls `getFeatureFlagResult()` directly instead of routing through the public `getFeatureFlag()`, so a single user-level call surfaces exactly one deprecation warning, not two. * `Client::getFeatureFlagResult()` and `Client::getAllFlags()` are intentionally NOT deprecated — they expose data (rich single-flag result, arbitrary key list) that the new snapshot API doesn't yet cover. `getFeatureFlagPayload()` was already marked `@deprecated` in v4.0.0 with a message pointing at `getFeatureFlagResult()`. Updated the message to point at `evaluateFlags()` and added the runtime `trigger_error` so users who pin warnings to errors (or read PHP error logs) get the heads-up. Generated-By: PostHog Code Task-Id: 1f29305a-ee56-456e-a341-8faa4eb8716d --- CHANGELOG.md | 5 ++ lib/Client.php | 57 ++++++++++++++--- lib/PostHog.php | 13 ++-- test/FeatureFlagEvaluationsTest.php | 98 ++++++++++++++++++++++++++++- 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ac08ad..a572908 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ `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. +* feat(flags): Deprecate `isFeatureEnabled()`, `getFeatureFlag()`, `getFeatureFlagPayload()`, and + the `send_feature_flags` `capture()` option in favor of `evaluateFlags()`. Each emits an + `E_USER_DEPRECATED` warning pointing at the new API; existing callers keep working unchanged + until the next major version. `getFeatureFlagResult()` and `getAllFlags()` are intentionally + *not* deprecated — they expose data the snapshot API doesn't yet cover. * 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. diff --git a/lib/Client.php b/lib/Client.php index 6712291..a4b186d 100644 --- a/lib/Client.php +++ b/lib/Client.php @@ -186,6 +186,14 @@ public function capture(array $message) $message["properties"] ); } elseif (array_key_exists("send_feature_flags", $message) && $message["send_feature_flags"]) { + trigger_error( + 'capture()\'s `send_feature_flags` option is deprecated and will be removed in a ' + . 'future major version. Pass a `flags` snapshot from Client::evaluateFlags(...) ' + . 'instead — it avoids a second /flags request per capture and guarantees the ' + . 'event carries the exact flag values your code branched on.', + E_USER_DEPRECATED + ); + $extraProperties = []; $flags = []; @@ -279,7 +287,9 @@ public function identify(array $message) } /** - * decide if the feature flag is enabled for this distinct id. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->isEnabled($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId @@ -298,7 +308,17 @@ public function isFeatureEnabled( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true ): null | bool { - $result = $this->getFeatureFlag( + trigger_error( + 'Client::isFeatureEnabled() is deprecated and will be removed in a future major ' + . 'version. Use Client::evaluateFlags($distinctId, ...) and call ' + . '$flags->isEnabled($key) instead — this consolidates flag evaluation into a ' + . 'single /flags request per incoming request.', + E_USER_DEPRECATED + ); + + // Bypass the public getFeatureFlag() so the user only sees a single deprecation + // warning per call, not two. + $result = $this->getFeatureFlagResult( $key, $distinctId, $groups, @@ -308,15 +328,17 @@ public function isFeatureEnabled( $sendFeatureFlagEvents ); - if (is_null($result)) { - return $result; - } else { - return boolval($result); + if ($result === null) { + return null; } + + return boolval($result->getValue()); } /** - * get the feature flag value for this distinct id. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->getFlag($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId @@ -335,6 +357,14 @@ public function getFeatureFlag( bool $onlyEvaluateLocally = false, bool $sendFeatureFlagEvents = true ): null | bool | string { + trigger_error( + 'Client::getFeatureFlag() is deprecated and will be removed in a future major ' + . 'version. Use Client::evaluateFlags($distinctId, ...) and call ' + . '$flags->getFlag($key) instead — this consolidates flag evaluation into a ' + . 'single /flags request per incoming request.', + E_USER_DEPRECATED + ); + $result = $this->getFeatureFlagResult( $key, $distinctId, @@ -507,8 +537,9 @@ public function getFeatureFlagResult( } /** - * @deprecated Use `getFeatureFlagResult()` instead which properly tracks the feature flag call, - * and includes both the flag value and payload in a single method. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->getFlagPayload($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId @@ -524,6 +555,14 @@ public function getFeatureFlagPayload( array $personProperties = array(), array $groupProperties = array(), ): mixed { + trigger_error( + 'Client::getFeatureFlagPayload() is deprecated and will be removed in a future major ' + . 'version. Use Client::evaluateFlags($distinctId, ...) and call ' + . '$flags->getFlagPayload($key) instead — this consolidates flag evaluation into a ' + . 'single /flags request per incoming request.', + E_USER_DEPRECATED + ); + $result = $this->getFeatureFlagResult( $key, $distinctId, diff --git a/lib/PostHog.php b/lib/PostHog.php index 0eea96d..e7fbee5 100644 --- a/lib/PostHog.php +++ b/lib/PostHog.php @@ -138,7 +138,9 @@ public static function groupIdentify(array $message) } /** - * decide if the feature flag is enabled for this distinct id. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->isEnabled($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId @@ -170,7 +172,9 @@ public static function isFeatureEnabled( } /** - * get the feature flag value for this distinct id. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->getFlag($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId @@ -239,8 +243,9 @@ public static function getFeatureFlagResult( } /** - * @deprecated Use getFeatureFlagResult() instead. This method does not send - * the $feature_flag_called event, leading to missing analytics. + * @deprecated Use `evaluateFlags($distinctId, ...)` and call + * `$flags->getFlagPayload($key)` instead. This consolidates flag evaluation into a single + * `/flags` request per incoming request. * * @param string $key * @param string $distinctId diff --git a/test/FeatureFlagEvaluationsTest.php b/test/FeatureFlagEvaluationsTest.php index 292f288..8c4b7b8 100644 --- a/test/FeatureFlagEvaluationsTest.php +++ b/test/FeatureFlagEvaluationsTest.php @@ -511,12 +511,106 @@ public function testSnapshotDedupesAcrossClientPaths(): void $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'); + // both share the Client's distinctIdsFeatureFlagsReported cache. The legacy single-flag + // method also emits a deprecation; we suppress it here so the test focuses on dedup. + $this->captureDeprecations(fn() => PostHog::isFeatureEnabled('simple-test', 'user-1')); PostHog::flush(); $batches = $this->batchRequests(); $this->assertCount(1, $batches); $this->assertCount(1, $batches[0]['batch']); } + + /** + * @return list the deprecation messages emitted while running $callable + */ + private function captureDeprecations(callable $callable): array + { + $messages = []; + $previous = set_error_handler( + function (int $errno, string $errstr) use (&$messages, &$previous) { + if ($errno === E_USER_DEPRECATED) { + $messages[] = $errstr; + return true; + } + if ($previous !== null) { + return ($previous)($errno, $errstr); + } + return false; + }, + E_USER_DEPRECATED + ); + + try { + $callable(); + } finally { + restore_error_handler(); + } + + return $messages; + } + + public function testIsFeatureEnabledEmitsDeprecationWarning(): void + { + $this->makeClient(); + $messages = $this->captureDeprecations( + fn() => $this->client->isFeatureEnabled('simple-test', 'user-1') + ); + + $this->assertCount(1, $messages, 'Expected exactly one deprecation warning'); + $this->assertStringContainsString('isFeatureEnabled', $messages[0]); + $this->assertStringContainsString('evaluateFlags', $messages[0]); + } + + public function testGetFeatureFlagEmitsDeprecationWarning(): void + { + $this->makeClient(); + $messages = $this->captureDeprecations( + fn() => $this->client->getFeatureFlag('simple-test', 'user-1') + ); + + $this->assertCount(1, $messages); + $this->assertStringContainsString('getFeatureFlag', $messages[0]); + $this->assertStringContainsString('evaluateFlags', $messages[0]); + } + + public function testGetFeatureFlagPayloadEmitsDeprecationWarning(): void + { + $this->makeClient(); + $messages = $this->captureDeprecations( + fn() => $this->client->getFeatureFlagPayload('json-payload', 'user-1') + ); + + $this->assertCount(1, $messages); + $this->assertStringContainsString('getFeatureFlagPayload', $messages[0]); + $this->assertStringContainsString('evaluateFlags', $messages[0]); + } + + public function testCaptureSendFeatureFlagsEmitsDeprecationWarning(): void + { + $this->makeClient(); + $messages = $this->captureDeprecations( + fn() => $this->client->capture([ + 'distinct_id' => 'user-1', + 'event' => 'page_view', + 'send_feature_flags' => true, + ]) + ); + + $this->assertCount(1, $messages); + $this->assertStringContainsString('send_feature_flags', $messages[0]); + $this->assertStringContainsString('evaluateFlags', $messages[0]); + } + + public function testIsFeatureEnabledDoesNotCascadeDeprecationWarnings(): void + { + // isFeatureEnabled bypasses the public getFeatureFlag() so users see only one + // warning per call, not two (or three if getFeatureFlagResult were also deprecated). + $this->makeClient(); + $messages = $this->captureDeprecations( + fn() => $this->client->isFeatureEnabled('simple-test', 'user-1') + ); + + $this->assertCount(1, $messages); + } }