Skip to content

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

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

feat: add evaluateFlags() API for single-call flag evaluation#131
dmarticus wants to merge 4 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; 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, 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(), getFeatureFlagPayload(), and the send_feature_flags capture option now 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.
  • Locally-evaluated records omit $feature_flag_request_id (it's per-/flags-response) but emit locally_evaluated=true and reason "Evaluated locally" to match the existing single-flag local path.
  • 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), matching the granularity the legacy single-flag path emits today.
  • flags= on capture() takes precedence over send_feature_flags; passing both logs a warning so the conflict isn't silent.
  • isFeatureEnabled() calls getFeatureFlagResult() directly (skipping the public getFeatureFlag()) so a single user-level call surfaces exactly one deprecation warning, not two.
  • getFeatureFlagResult() and getAllFlags() are intentionally not deprecated — they expose data (rich single-flag result, arbitrary 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
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.

1 participant