feat: add evaluateFlags() API for single-call flag evaluation#131
feat: add evaluateFlags() API for single-call flag evaluation#131
Conversation
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/<key>` 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
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
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
…lags 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
| if (!$onlyEvaluateLocally) { | ||
| try { | ||
| $response = $this->flags( |
There was a problem hiding this comment.
We still hit /flags even if everything evaluated locally (unless onlyEvaluateLocally is set)
There was a problem hiding this comment.
Fixed in 4f0b3ab. We now track whether any local flag was inconclusive (or any requested flagKey isn't covered by local definitions), and skip the /flags request when local evaluation answered everything. Added testLocalEvaluationSkipsRemoteFlagsRequestWhenAllResolved.
| $properties = [ | ||
| '$feature_flag' => $key, | ||
| '$feature_flag_response' => $record?->getValue() ?? false, | ||
| ]; |
There was a problem hiding this comment.
We're probably just lacking the data from this scope, but complete parity would also set
locally_evaluatedasfalsefor remote/missing flags$feature/<key>with the evaluated response$feature_flag_payloadwhen present$feature_flag_evaluated_atfor remote/flagsresponses where available
pretty minor imo
There was a problem hiding this comment.
Fixed in 4f0b3ab. The snapshot now stores evaluatedAt from the /flags response, and recordAccess always emits locally_evaluated (true for local, false for remote/missing) plus $feature_flag_evaluated_at for remote records. Skipped $feature_flag_payload since the existing single-flag path doesn't emit it either — happy to add if you want full parity, just felt like scope creep.
|
|
||
| $properties = [ | ||
| '$feature_flag' => $key, | ||
| '$feature_flag_response' => $record?->getValue() ?? false, |
There was a problem hiding this comment.
nit: missing flag will be reported as false
There was a problem hiding this comment.
Fixed in 4f0b3ab. Missing flags now emit $feature_flag_response: null instead of false, matching the legacy single-flag path. Added testMissingFlagResponseIsNullNotFalse.
| $rawPayload = $flagDetail['metadata']['payload'] ?? null; | ||
| $payload = $rawPayload !== null ? json_decode($rawPayload, true) : null; |
There was a problem hiding this comment.
payloads aren't guaranteed to be strings (unless this changed)
There was a problem hiding this comment.
Fixed in 4f0b3ab. The remote-pass code now checks the type before json_decode-ing — string gets decoded, anything else (array/object/scalar) is taken as-is. Added testRemotePayloadHandlesPreDecodedValue.
| $requestId = $response['requestId'] ?? null; | ||
| $errorsWhileComputing = (bool) ($response['errorsWhileComputingFlags'] ?? false); | ||
| $remoteFlags = $response['flags'] ?? []; |
There was a problem hiding this comment.
should we store evaluatedAt?
There was a problem hiding this comment.
Yes — done in 4f0b3ab. evaluatedAt is now extracted from the /flags response, stored on the snapshot, and emitted as $feature_flag_evaluated_at on $feature_flag_called events for remote records. Added testRemoteEvaluatedAtPropagatesToEvent.
| /** | ||
| * Get the feature flag result including value and payload. | ||
| * | ||
| * This is the recommended method for getting feature flag data as it returns | ||
| * both the flag value and payload in a single call, while properly tracking analytics. | ||
| * | ||
| * @param string $key | ||
| * @param string $distinctId | ||
| * @param array $groups | ||
| * @param array $personProperties | ||
| * @param array $groupProperties | ||
| * @param bool $onlyEvaluateLocally | ||
| * @param bool $sendFeatureFlagEvents | ||
| * @return FeatureFlagResult|null | ||
| * @throws Exception | ||
| */ | ||
| public static function getFeatureFlagResult( |
There was a problem hiding this comment.
We should deprecate this too
There was a problem hiding this comment.
Done in 4f0b3ab. getFeatureFlagResult() is now deprecated on both Client::* and PostHog::*. Extracted the body into a private doGetFeatureFlagResult helper so isFeatureEnabled / getFeatureFlag / getFeatureFlagPayload route through it without cascading multiple deprecation warnings on a single user call.
| (int) ($options['timeout'] ?? 10000) | ||
| ); | ||
| $this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000); | ||
| $this->featureFlagsLogWarnings = (bool) ($options['feature_flags_log_warnings'] ?? true); |
There was a problem hiding this comment.
There was a problem hiding this comment.
Dropped in 4f0b3ab. onlyAccessed() no longer warns (already fixed earlier), so the only remaining warning is only(['typo']), which isn't worth its own config knob — if the warning bothers you, fix the typo. Removed feature_flags_log_warnings from the constructor and the FeatureFlagEvaluations class.
Seven items from the review on #131: * Skip the /flags request when local definitions cover every flag and they all evaluate without inconclusive results. The previous code always hit the server unless `onlyEvaluateLocally` was set, even when local eval could have answered everything. * Bring `$feature_flag_called` to parity with the legacy single-flag path: always emit `locally_evaluated` (true or false, not omitted for remote), and propagate `$feature_flag_evaluated_at` from the /flags response for remote records. * Missing flags now report `$feature_flag_response: null` (not `false`), matching the legacy single-flag path so consumers can distinguish "flag exists and is disabled" from "flag not found". * Defensively handle pre-decoded payloads in the /flags response. Some middleware deserializes JSON transparently; the snapshot now accepts an array/object payload as-is instead of feeding it to `json_decode`. * Deprecate `getFeatureFlagResult()` (and its `PostHog::*` static facade). Refactored `getFeatureFlagResult` body into a private `doGetFeatureFlagResult` helper so `isFeatureEnabled()` / `getFeatureFlag()` / `getFeatureFlagPayload()` can route through it without cascading deprecation warnings. * Drop the `feature_flags_log_warnings` config option. The only remaining warning (unknown keys passed to `only([...])`) doesn't warrant a dedicated knob; if you don't want the warning, fix the typo. Generated-By: PostHog Code Task-Id: 1f29305a-ee56-456e-a341-8faa4eb8716d
Summary
Adds
evaluateFlags(), a single-call flag evaluation API that returns aFeatureFlagEvaluationssnapshot for one distinct id, and deprecates the legacy single-flag entry points in favor of it:/flagsrequest per call, and only when local evaluation can't cover everything; reads on the snapshot make zero further requests.isEnabled($key)/getFlag($key)fire a deduped$feature_flag_calledevent with full metadata (id, version, reason, request_id, evaluated_at, locally_evaluated, plus combined response-level errors) on first access.getFlagPayload($key)is silent.only([...])/onlyAccessed()return filtered clones for scoping which flags attach to a captured event.capture(['flags' => $snapshot, …])attaches$feature/<key>and$active_feature_flagswithout a fresh/flagsround trip; warns when bothflagsandsend_feature_flagsare passed.isFeatureEnabled(),getFeatureFlag(),getFeatureFlagResult(),getFeatureFlagPayload(), and thesend_feature_flagscapture option emitE_USER_DEPRECATEDpointing atevaluateFlags(). Existing callers keep working unchanged until the next major.RFC · reference SDK PRs: posthog-python#539, posthog-js#3476.
Design decisions
FeatureFlagEvaluationsHostinterface (captureFlagCalledIfNeeded,logWarning) instead of a fullClient, so unit tests can use a fake without spinning up the SDK.onlyAccessed()returns an empty snapshot when nothing has been accessed (no warning, no fallback). The earlier "warn + return all flags" fallback was contradictory with the method's name and surprised callers doingcapture(flags: $snapshot->onlyAccessed())early in a request.recordAccessrather than at every call site, soisEnabled/getFlagstill return sane values without leaking events with empty actors.flagKeysaren't covered locally); when local evaluation answered everything, we skip the/flagsrequest entirely.$feature_flag_request_idand$feature_flag_evaluated_at(both are per-/flags-response) but always emitlocally_evaluated=true|falseand reason"Evaluated locally"for local records, matching the legacy single-flag path.$feature_flag_response: null(notfalse), so consumers can distinguish "flag exists and is disabled" from "flag not found".errorsWhileComputingFlags,quota_limited) are tracked at snapshot construction and combined with per-flagflag_missingin$feature_flag_error(e.g.errors_while_computing_flags,flag_missing).flags=oncapture()takes precedence oversend_feature_flags; passing both logs a warning so the conflict isn't silent.getFeatureFlagResult()is now deprecated, butisFeatureEnabled()/getFeatureFlag()/getFeatureFlagPayload()route through a privatedoGetFeatureFlagResult()helper so a single user call surfaces exactly one warning, not two or three.getAllFlags()is intentionally not deprecated — it returns a value-only key list the snapshot API doesn't yet cover.SizeLimitedHash::contains/addwere comparing values to keys and pushing onto the outer map, so the per-distinct_id$feature_flag_calleddedup never matched after the first event. The new snapshot path requires real dedup, so it's fixed here.Out of scope (explicit non-goals)
flag_definitions_loaded_atthrough the snapshot. posthog-php doesn't track that timestamp on the local poller today; left for a follow-up.Created with PostHog Code