Skip to content

feat: add evaluateFlags() API for single-call flag evaluation#131

Open
dmarticus wants to merge 5 commits intomainfrom
posthog-code/php-evaluate-flags-api-v2
Open

feat: add evaluateFlags() API for single-call flag evaluation#131
dmarticus wants to merge 5 commits intomainfrom
posthog-code/php-evaluate-flags-api-v2

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Summary

Adds evaluateFlags(), a single-call flag evaluation API that returns a FeatureFlagEvaluations snapshot for one distinct id, and deprecates the legacy single-flag entry points in favor of it:

  • One /flags request 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_called event 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_flags without a fresh /flags round trip; warns when both flags and send_feature_flags are passed.
  • isFeatureEnabled(), getFeatureFlag(), getFeatureFlagResult(), getFeatureFlagPayload(), and the send_feature_flags capture option emit E_USER_DEPRECATED pointing at evaluateFlags(). Existing callers keep working unchanged until the next major.

RFC · reference SDK PRs: posthog-python#539, posthog-js#3476.

Design decisions

  • Snapshot takes a tiny FeatureFlagEvaluationsHost interface (captureFlagCalledIfNeeded, logWarning) instead of a full Client, so unit tests can use a fake without spinning up the SDK.
  • Filtered clones get a fresh access set; reads on the child never back-propagate to the parent.
  • 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 doing capture(flags: $snapshot->onlyAccessed()) early in a request.
  • Empty-distinct_id snapshots short-circuit event emission inside recordAccess rather than at every call site, so isEnabled / getFlag still return sane values without leaking events with empty actors.
  • Local pass tracks whether any flag was inconclusive (or any flagKeys aren't covered locally); when local evaluation answered everything, we skip the /flags request entirely.
  • Locally-evaluated records omit $feature_flag_request_id and $feature_flag_evaluated_at (both are per-/flags-response) but always emit locally_evaluated=true|false and reason "Evaluated locally" for local records, matching the legacy single-flag path.
  • Missing flags emit $feature_flag_response: null (not false), so consumers can distinguish "flag exists and is disabled" from "flag not found".
  • Response-level errors (errorsWhileComputingFlags, quota_limited) are tracked at snapshot construction and combined with per-flag flag_missing in $feature_flag_error (e.g. errors_while_computing_flags,flag_missing).
  • flags= on capture() takes precedence over send_feature_flags; passing both logs a warning so the conflict isn't silent.
  • Deprecation cascade avoidance: getFeatureFlagResult() is now deprecated, but isFeatureEnabled() / getFeatureFlag() / getFeatureFlagPayload() route through a private doGetFeatureFlagResult() 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.
  • Drive-by fix: SizeLimitedHash::contains/add were comparing values to keys and pushing onto the outer map, so the per-distinct_id $feature_flag_called dedup never matched after the first event. The new snapshot path requires real dedup, so it's fixed here.

Out of scope (explicit non-goals)

  • Removing the deprecated methods — that's the next major.
  • Plumbing flag_definitions_loaded_at through the snapshot. posthog-php doesn't track that timestamp on the local poller today; left for a follow-up.

Note: this supersedes #130 (stale base, blocked from rebase by a signature rule on a pre-existing unsigned ancestor).


Created with PostHog Code

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
@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:11
@dmarticus dmarticus requested a review from a team as a code owner April 27, 2026 22:11
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
Comment thread lib/Client.php Outdated
Comment on lines +731 to +733
if (!$onlyEvaluateLocally) {
try {
$response = $this->flags(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still hit /flags even if everything evaluated locally (unless onlyEvaluateLocally is set)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +152 to +155
$properties = [
'$feature_flag' => $key,
'$feature_flag_response' => $record?->getValue() ?? false,
];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're probably just lacking the data from this scope, but complete parity would also set

  • locally_evaluated as false for remote/missing flags
  • $feature/<key> with the evaluated response
  • $feature_flag_payload when present
  • $feature_flag_evaluated_at for remote /flags responses where available

pretty minor imo

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/FeatureFlagEvaluations.php Outdated

$properties = [
'$feature_flag' => $key,
'$feature_flag_response' => $record?->getValue() ?? false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing flag will be reported as false

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4f0b3ab. Missing flags now emit $feature_flag_response: null instead of false, matching the legacy single-flag path. Added testMissingFlagResponseIsNullNotFalse.

Comment thread lib/Client.php Outdated
Comment on lines +756 to +757
$rawPayload = $flagDetail['metadata']['payload'] ?? null;
$payload = $rawPayload !== null ? json_decode($rawPayload, true) : null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

payloads aren't guaranteed to be strings (unless this changed)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/Client.php
Comment on lines +742 to +744
$requestId = $response['requestId'] ?? null;
$errorsWhileComputing = (bool) ($response['errorsWhileComputingFlags'] ?? false);
$remoteFlags = $response['flags'] ?? [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we store evaluatedAt?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/PostHog.php
Comment on lines 208 to 224
/**
* 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(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should deprecate this too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread lib/Client.php Outdated
(int) ($options['timeout'] ?? 10000)
);
$this->featureFlagsRequestTimeout = (int) ($options['feature_flag_request_timeout_ms'] ?? 3000);
$this->featureFlagsLogWarnings = (bool) ($options['feature_flags_log_warnings'] ?? true);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants