Skip to content

feat: add evaluate_flags() API for single-call flag evaluation#105

Merged
dmarticus merged 6 commits intomainfrom
posthog-code/evaluate-flags-api
May 1, 2026
Merged

feat: add evaluate_flags() API for single-call flag evaluation#105
dmarticus merged 6 commits intomainfrom
posthog-code/evaluate-flags-api

Conversation

@dmarticus
Copy link
Copy Markdown
Contributor

@dmarticus dmarticus commented Apr 27, 2026

Summary

Adds evaluate_flags(distinct_id, options) — one /flags?v=2 round-trip returns a FeatureFlagEvaluations snapshot. From the snapshot:

  • is_enabled(key) / get_flag(key) read the cached value and fire a deduplicated $feature_flag_called event with full metadata ($feature_flag_id, $feature_flag_version, $feature_flag_reason, $feature_flag_request_id).
  • get_flag_payload(key) reads the payload without firing an event.
  • only_accessed() and only([keys]) produce filtered clones for narrower capture attribution.
  • Event::with_flags(&snapshot) attaches $feature/<key> and $active_feature_flags to a captured event with no second /flags call.

Implements RFC #1020 — Server SDK Feature Flag Evaluations API. Mirrors posthog-python#539 and posthog-js#3476.

Deprecations

The legacy single-flag methods are now #[deprecated] in favor of evaluate_flags() — they keep working but emit a compile warning at every call site:

  • Client::get_feature_flag
  • Client::is_feature_enabled
  • Client::get_feature_flag_payload

Design decisions

  • Capture wiring: exposed as event.with_flags(&snapshot) rather than a new capture parameter — keeps capture(event) unchanged and reads naturally in Rust.
  • Empty-distinct_id snapshots short-circuit access events so $feature_flag_called never lands with an empty distinct_id.
  • Filter clones don't back-propagate access to the parent snapshot (each clone gets its own copy of the accessed set).
  • only_accessed() returns empty when nothing was accessed (no fallback-to-all, no warning) — pre-access the flags you want attached.
  • $feature_flag_error is comma-joined combining response-level errors (errors_while_computing_flags, quota_limited) with per-flag errors (flag_missing) so consumers can filter by type.
  • Warning suppression: new feature_flags_log_warnings client option silences the only(...) misuse warnings.

Replaces #104, which was closed when the branch was recreated to clear merge conflicts after rebasing onto main.


Created with PostHog Code

Adds a snapshot-based feature flag API mirroring posthog-python (#539) and
posthog-node (#3476). One call to evaluate_flags(distinct_id, options) reaches
/flags?v=2 once and returns a FeatureFlagEvaluations cache that:

- Resolves is_enabled / get_flag locally with full metadata propagation
  ($feature_flag_id, $feature_flag_version, $feature_flag_reason,
  $feature_flag_request_id) on the deduplicated $feature_flag_called event
- Treats get_flag_payload as event-free
- Offers only_accessed() / only([keys]) filter helpers with warnings on
  misuse, gated by a new feature_flags_log_warnings client option
- Short-circuits empty-distinct_id snapshots so accesses never emit events

Also adds Event::with_flags(&snapshot) so a captured event inherits
\$feature/<key> and \$active_feature_flags from the snapshot without an extra
/flags request.

Both blocking and async clients implement the host trait that owns the
per-distinct_id dedup cache (cap 50_000, full reset on overflow to match the
JS SDK).

The existing get_feature_flag / is_feature_enabled methods stay silent — a
Phase 2 follow-up will retrofit them onto the same dedup helper.

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
Comment thread src/client/async_client.rs Fixed
Comment thread src/client/async_client.rs Fixed
CodeQL flags `e` as unused inside `tokio::spawn(async move { ... })` even
though tracing's `%e` shorthand uses Display on it. Switch to `{e}` capture
syntax so the use is unambiguous to the analyzer; same telemetry, slightly
less structured but the field was only consumed by the debug log anyway.

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
Comment thread src/client/async_client.rs Fixed
Comment thread src/client/async_client.rs Fixed
The previous attempt swapped tracing's `%e` shorthand for `{e}` capture
syntax but CodeQL still flagged the variables as unused — its Rust
extractor doesn't track identifiers through the format-string macro
expansion inside `tokio::spawn(async move { ... })`. Explicitly bind the
error to a `String` via `.to_string()` and log that, which gives the
analyzer an unambiguous use.

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
@dmarticus dmarticus marked this pull request as ready for review April 27, 2026 22:12
@dmarticus dmarticus requested a review from a team as a code owner April 27, 2026 22:12
Mirrors the changes from PostHog/posthog-python#539 (commit 95eb1e9):

- only_accessed() returns an empty snapshot when nothing was accessed,
  rather than falling back to all flags + a warning. The fallback
  contradicted the method's name and surprised reviewers — pre-access
  any flags you want attached.
- Propagate response-level errors (errors_while_computing_flags,
  quota_limited) into $feature_flag_called events as a comma-joined
  $feature_flag_error so each access carries the same granular error
  codes the single-flag path emits. quota_limited is now parsed from
  the v2 response.
- Drop the unused flag_definitions_loaded_at plumbing (dead code in
  Phase 1 — replaced by the response-level error propagation).
- Clarify the flag_keys docstring on EvaluateFlagsOptions: it scopes
  the network call, distinct from the in-memory only([keys]) helper.

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
Per reviewer feedback on PostHog/posthog-python#539, ship Phase 2 in this
PR alongside Phase 1 instead of splitting into a follow-up. The deprecated
methods continue to work — they just emit a `#[deprecated]` compile warning
pointing at `evaluate_flags()`:

- `Client::get_feature_flag`
- `Client::is_feature_enabled`
- `Client::get_feature_flag_payload`

Both blocking and async clients are covered. `is_feature_enabled` allows
the deprecation lint internally because it still routes through
`get_feature_flag` — that's the implementation detail; user-level call
sites still surface exactly one warning each (one per call to a deprecated
method). The existing tests and examples that exercise these methods get
module-level `#![allow(deprecated)]` with a comment noting the deprecation
window.

The companion `evaluate_flags()` snapshot path covers all three methods'
use cases without an extra `/flags` round-trip per call and emits a
deduped `\$feature_flag_called` event with full metadata.

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
Copy link
Copy Markdown
Contributor

@dustinbyrne dustinbyrne left a comment

Choose a reason for hiding this comment

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

Made comments on the async client, but some of these will apply to the blocking client as well

Comment thread src/client/async_client.rs Outdated
let mut quota_limited = false;

if !options.only_evaluate_locally {
let response = self.fetch_flag_details(&distinct_id, &options).await?;
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'll lose any successful local evaluation here if this throws.

Above, we do this:

if distinct_id.is_empty() || self.options.is_disabled() {
    return Ok(FeatureFlagEvaluations::empty(host));
}

Maybe the error here is okay, but worth considering always returning a FeatureFlagEvaluations?

Comment thread src/client/async_client.rs Outdated
let mut errors_while_computing = false;
let mut quota_limited = false;

if !options.only_evaluate_locally {
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.

If local evaluation is able to evaluate everything, we'll still fall through to hitting /flags unless only_evaluate_locally is true

Comment thread src/client/async_client.rs Outdated
.filter(|s| !s.is_empty());
let id = metadata.as_ref().map(|m| m.id);
let version = metadata.as_ref().map(|m| m.version);
let payload = metadata.and_then(|m| m.payload);
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 normalize the payload to a JSON value

key,
enabled,
variant,
payload: None,
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 skipping payloads for locally evaluated flags here

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.

Looks like this is a current gap in the SDK, so this is fine

Comment thread src/client/async_client.rs Outdated
};
let http_client = self.http_client.clone();
let url = self.capture_url.clone();
tokio::spawn(async move {
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.

Might be worth validating that a tokio runtime is still available in the current context.

Otherwise, evaluate_flags could be called from inside the tokio runtime and the resulting snapshot consumed from a context/thread without tokio.

Seems edge case-y, so I'll let you decide what the right thing to do here is.

Comment thread src/lib.rs Outdated

// Feature Flags
pub use feature_flag_evaluations::{
EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost,
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.

EvaluatedFlagRecord, FlagCalledEventParams, FeatureFlagEvaluationsHost look like they're private implementation details

Comment thread src/client/mod.rs Outdated
/// helpers (e.g. calling `only_accessed()` before any access, or `only(...)`
/// with unknown keys). Set to `false` to silence these warnings.
#[builder(default = "true")]
feature_flags_log_warnings: bool,
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.

- Skip the /flags round-trip when `flag_keys` is set and local evaluation
  already resolved every requested key. Without `flag_keys` we still hit
  the API since the local poller may not know every flag the project has.
- Don't lose successful local evaluations when /flags fails. If the
  remote call errors but we already have local results, return a snapshot
  built from those instead of propagating the error; flag the snapshot's
  errors_while_computing_flags so $feature_flag_called events carry that
  context.
- Normalize `metadata.payload` from /flags?v=2: when it arrives as a
  JSON-encoded string (the API sometimes ships it that way) parse it
  into the equivalent JSON value so callers branch on uniform shapes.
- Capture a tokio runtime Handle when constructing the async event host
  so $feature_flag_called events can be spawned from any context the
  snapshot is consumed in, including threads without an entered runtime.
  Without this, a snapshot moved across threads would panic on access.
- Remove the `feature_flags_log_warnings` client option. The single
  `only(...)` warning surfaces via tracing::warn! and is silenceable
  through normal tracing-subscriber level filters (e.g. `posthog_rs=error`).
- Demote `EvaluatedFlagRecord`, `FlagCalledEventParams`, and
  `FeatureFlagEvaluationsHost` from public re-exports to `pub(crate)` —
  they were implementation details for the snapshot's host plumbing,
  not user-facing API.

Also drops the now-dead `key` field on `EvaluatedFlagRecord` (the HashMap
key already serves that purpose).

Generated-By: PostHog Code
Task-Id: 2b101877-6890-43d1-8dbd-306433cd9d25
@dmarticus dmarticus merged commit 7950daf into main May 1, 2026
11 checks passed
@dmarticus dmarticus deleted the posthog-code/evaluate-flags-api branch May 1, 2026 20:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants