feat: add evaluate_flags() API for single-call flag evaluation#105
feat: add evaluate_flags() API for single-call flag evaluation#105
Conversation
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
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
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
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
dustinbyrne
left a comment
There was a problem hiding this comment.
Made comments on the async client, but some of these will apply to the blocking client as well
| let mut quota_limited = false; | ||
|
|
||
| if !options.only_evaluate_locally { | ||
| let response = self.fetch_flag_details(&distinct_id, &options).await?; |
There was a problem hiding this comment.
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?
| let mut errors_while_computing = false; | ||
| let mut quota_limited = false; | ||
|
|
||
| if !options.only_evaluate_locally { |
There was a problem hiding this comment.
If local evaluation is able to evaluate everything, we'll still fall through to hitting /flags unless only_evaluate_locally is true
| .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); |
There was a problem hiding this comment.
We should normalize the payload to a JSON value
| key, | ||
| enabled, | ||
| variant, | ||
| payload: None, |
There was a problem hiding this comment.
We're skipping payloads for locally evaluated flags here
There was a problem hiding this comment.
Looks like this is a current gap in the SDK, so this is fine
| }; | ||
| let http_client = self.http_client.clone(); | ||
| let url = self.capture_url.clone(); | ||
| tokio::spawn(async move { |
There was a problem hiding this comment.
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.
|
|
||
| // Feature Flags | ||
| pub use feature_flag_evaluations::{ | ||
| EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost, |
There was a problem hiding this comment.
EvaluatedFlagRecord, FlagCalledEventParams, FeatureFlagEvaluationsHost look like they're private implementation details
| /// 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, |
There was a problem hiding this comment.
- 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
Summary
Adds
evaluate_flags(distinct_id, options)— one/flags?v=2round-trip returns aFeatureFlagEvaluationssnapshot. From the snapshot:is_enabled(key)/get_flag(key)read the cached value and fire a deduplicated$feature_flag_calledevent 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()andonly([keys])produce filtered clones for narrower capture attribution.Event::with_flags(&snapshot)attaches$feature/<key>and$active_feature_flagsto a captured event with no second/flagscall.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 ofevaluate_flags()— they keep working but emit a compile warning at every call site:Client::get_feature_flagClient::is_feature_enabledClient::get_feature_flag_payloadDesign decisions
event.with_flags(&snapshot)rather than a newcaptureparameter — keepscapture(event)unchanged and reads naturally in Rust.$feature_flag_callednever lands with an emptydistinct_id.only_accessed()returns empty when nothing was accessed (no fallback-to-all, no warning) — pre-access the flags you want attached.$feature_flag_erroris comma-joined combining response-level errors (errors_while_computing_flags,quota_limited) with per-flag errors (flag_missing) so consumers can filter by type.feature_flags_log_warningsclient option silences theonly(...)misuse warnings.Created with PostHog Code