From 630e9e8c4150df25a3f78d18c1f1ac5cbf89ff42 Mon Sep 17 00:00:00 2001 From: dylan Date: Mon, 27 Apr 2026 12:51:19 -0700 Subject: [PATCH] feat: add evaluate_flags() API for single-call flag evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ 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 --- .sampo/changesets/evaluate-flags-api.md | 5 + examples/evaluate_flags.rs | 69 +++ src/client/async_client.rs | 359 +++++++++++++- src/client/blocking.rs | 357 +++++++++++++- src/client/mod.rs | 11 + src/event.rs | 13 + src/feature_flag_evaluations.rs | 610 ++++++++++++++++++++++++ src/feature_flags.rs | 6 + src/lib.rs | 5 + tests/test_evaluate_flags.rs | 458 ++++++++++++++++++ 10 files changed, 1889 insertions(+), 4 deletions(-) create mode 100644 .sampo/changesets/evaluate-flags-api.md create mode 100644 examples/evaluate_flags.rs create mode 100644 src/feature_flag_evaluations.rs create mode 100644 tests/test_evaluate_flags.rs diff --git a/.sampo/changesets/evaluate-flags-api.md b/.sampo/changesets/evaluate-flags-api.md new file mode 100644 index 00000000..a372b2b4 --- /dev/null +++ b/.sampo/changesets/evaluate-flags-api.md @@ -0,0 +1,5 @@ +--- +cargo/posthog-rs: minor +--- + +Add `evaluate_flags(distinct_id, options)` for single-call snapshot-based feature flag evaluation. Returns a `FeatureFlagEvaluations` whose `is_enabled` / `get_flag` / `get_flag_payload` methods read from the cached evaluation. `is_enabled` and `get_flag` fire deduplicated `$feature_flag_called` events with full metadata (`$feature_flag_id`, `$feature_flag_version`, `$feature_flag_reason`, `$feature_flag_request_id`). Pass the snapshot to `Event::with_flags(&snapshot)` to attach `$feature/` and `$active_feature_flags` to a captured event without an extra `/flags` call. diff --git a/examples/evaluate_flags.rs b/examples/evaluate_flags.rs new file mode 100644 index 00000000..2c1736c8 --- /dev/null +++ b/examples/evaluate_flags.rs @@ -0,0 +1,69 @@ +/// Snapshot-based feature flag evaluation +/// +/// Demonstrates the `evaluate_flags()` API: one round-trip to PostHog produces a +/// `FeatureFlagEvaluations` cache that you can read many times without further +/// network traffic. Reads through `is_enabled` / `get_flag` fire a deduplicated +/// `$feature_flag_called` event with full metadata. Pass the snapshot to +/// `Event::with_flags(&snapshot)` so a captured event inherits `$feature/` +/// and `$active_feature_flags` without a second `/flags` round-trip. +/// +/// Run: +/// export POSTHOG_API_TOKEN=phc_your_key +/// cargo run --example evaluate_flags --features async-client +use posthog_rs::{EvaluateFlagsOptions, Event}; + +#[cfg(feature = "async-client")] +#[tokio::main] +async fn main() { + let api_key = std::env::var("POSTHOG_API_TOKEN").unwrap_or_else(|_| { + println!("No POSTHOG_API_TOKEN found. Demo mode — calls will fail without a key.\n"); + "demo_api_key".to_string() + }); + + let client = posthog_rs::client(api_key.as_str()).await; + + let user_id = "user-123"; + + let snapshot = match client + .evaluate_flags(user_id, EvaluateFlagsOptions::default()) + .await + { + Ok(s) => s, + Err(e) => { + println!("evaluate_flags failed: {e}"); + return; + } + }; + + println!("Loaded {} flag(s) in one request:", snapshot.keys().len()); + for key in snapshot.keys() { + println!(" - {key}"); + } + + if snapshot.is_enabled("new-dashboard") { + println!("\nnew-dashboard is enabled — render the new layout."); + } + + if let Some(payload) = snapshot.get_flag_payload("onboarding-config") { + println!("\nonboarding-config payload (no event fired): {payload}"); + } + + // Capture an event that inherits the snapshot's flag context. No second + // /flags round-trip happens here. + let mut event = Event::new("checkout-started", user_id); + event.with_flags(&snapshot); + if let Err(e) = client.capture(event).await { + println!("capture failed: {e}"); + } + + // Optional: only attach the flags actually consulted on this request path. + let mut narrow = Event::new("checkout-completed", user_id); + narrow.with_flags(&snapshot.only_accessed()); + let _ = client.capture(narrow).await; +} + +#[cfg(not(feature = "async-client"))] +fn main() { + println!("This example requires the async-client feature."); + println!("Run with: cargo run --example evaluate_flags --features async-client"); +} diff --git a/src/client/async_client.rs b/src/client/async_client.rs index 60aeac45..c6f06a34 100644 --- a/src/client/async_client.rs +++ b/src/client/async_client.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use reqwest::{header::CONTENT_TYPE, Client as HttpClient}; @@ -7,12 +8,22 @@ use tracing::{debug, instrument, trace, warn}; use crate::endpoints::{Endpoint, EndpointManager}; use crate::event::BatchRequest; -use crate::feature_flags::{match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagValue}; +use crate::feature_flag_evaluations::{ + EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost, + FlagCalledEventParams, +}; +use crate::feature_flags::{ + match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagDetail, FlagValue, +}; use crate::local_evaluation::{AsyncFlagPoller, FlagCache, LocalEvaluationConfig, LocalEvaluator}; use crate::{event::InnerEvent, Error, Event}; use super::ClientOptions; +/// Cap on the number of `distinct_id` entries in the `$feature_flag_called` +/// dedup cache. On overflow the entire map is reset (matches the JS SDK). +const MAX_FLAG_CALLED_CACHE_SIZE: usize = 50_000; + async fn check_response(response: reqwest::Response) -> Result<(), Error> { let status = response.status().as_u16(); let body = response @@ -32,6 +43,129 @@ pub struct Client { client: HttpClient, local_evaluator: Option, _flag_poller: Option, + flag_event_host: OnceLock>, +} + +/// Implementation of [`FeatureFlagEvaluationsHost`] that emits dedup-aware +/// `$feature_flag_called` events through a clone of the async [`Client`]'s +/// HTTP transport. The event ship is fire-and-forget: errors are logged at +/// `debug` level but do not surface to the caller, matching the JS SDK. +struct AsyncFlagEventHost { + http_client: HttpClient, + api_key: String, + capture_url: String, + disabled: bool, + disable_geoip: bool, + log_warnings: bool, + dedup_cache: Mutex>>, +} + +impl AsyncFlagEventHost { + fn from_options(options: &ClientOptions, http_client: HttpClient) -> Self { + let capture_url = options.endpoints().build_url(Endpoint::Capture); + Self { + http_client, + api_key: options.api_key.clone(), + capture_url, + disabled: options.is_disabled(), + disable_geoip: options.disable_geoip, + log_warnings: options.feature_flags_log_warnings(), + dedup_cache: Mutex::new(HashMap::new()), + } + } + + /// Returns `true` when the helper has already shipped this + /// `(distinct_id, key, response)` combination and the caller should skip. + fn already_reported(&self, distinct_id: &str, dedup_key: &str) -> bool { + let mut cache = self.dedup_cache.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(seen) = cache.get(distinct_id) { + if seen.contains(dedup_key) { + return true; + } + } + if cache.len() >= MAX_FLAG_CALLED_CACHE_SIZE { + cache.clear(); + } + cache + .entry(distinct_id.to_string()) + .or_default() + .insert(dedup_key.to_string()); + false + } + + fn spawn_ship(&self, event: Event) { + if self.disabled { + return; + } + let inner_event = InnerEvent::new(event, self.api_key.clone()); + let payload = match serde_json::to_string(&inner_event) { + Ok(p) => p, + Err(e) => { + debug!(error = %e, "failed to serialize $feature_flag_called event"); + return; + } + }; + let http_client = self.http_client.clone(); + let url = self.capture_url.clone(); + tokio::spawn(async move { + match http_client + .post(&url) + .header(CONTENT_TYPE, "application/json") + .body(payload) + .send() + .await + { + Ok(response) => { + if let Err(e) = check_response(response).await { + debug!(error = %e, "$feature_flag_called event rejected by server"); + } + } + Err(e) => debug!(error = %e, "failed to send $feature_flag_called event"), + } + }); + } +} + +impl FeatureFlagEvaluationsHost for AsyncFlagEventHost { + fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) { + let dedup_key = build_dedup_key(¶ms.key, params.response.as_ref()); + if self.already_reported(¶ms.distinct_id, &dedup_key) { + return; + } + + let mut event = Event::new( + "$feature_flag_called".to_string(), + params.distinct_id.clone(), + ); + for (k, v) in params.properties { + if event.insert_prop(k, v).is_err() { + return; + } + } + for (group_name, group_id) in ¶ms.groups { + event.add_group(group_name, group_id); + } + if params.disable_geoip.unwrap_or(self.disable_geoip) { + let _ = event.insert_prop("$geoip_disable", true); + } + self.spawn_ship(event); + } + + fn log_warning(&self, message: &str) { + if self.log_warnings { + warn!("{message}"); + } + } +} + +fn build_dedup_key(flag_key: &str, response: Option<&FlagValue>) -> String { + let response_repr = match response { + Some(FlagValue::Boolean(true)) => "true".to_string(), + Some(FlagValue::Boolean(false)) => "false".to_string(), + Some(FlagValue::String(s)) => s.clone(), + None => "::null::".to_string(), + }; + format!("{flag_key}_{response_repr}") } /// This function constructs a new client using the options provided. @@ -73,6 +207,7 @@ pub async fn client>(options: C) -> Client { client, local_evaluator, _flag_poller: flag_poller, + flag_event_host: OnceLock::new(), } } @@ -343,4 +478,224 @@ impl Client { match_feature_flag(flag, distinct_id, person_properties) .map_err(|e| Error::InconclusiveMatch(e.message)) } + + /// Evaluate every feature flag for `distinct_id` in a single round-trip, + /// returning a [`FeatureFlagEvaluations`] snapshot. + /// + /// Each `is_enabled` / `get_flag` call on the returned snapshot fires a + /// dedup-aware `$feature_flag_called` event with full metadata, and the + /// snapshot can be passed to [`Event::with_flags`] so a downstream + /// [`Client::capture`] inherits `$feature/` and `$active_feature_flags` + /// without an extra `/flags` request. + /// + /// [`Event::with_flags`]: crate::Event::with_flags + pub async fn evaluate_flags>( + &self, + distinct_id: S, + options: EvaluateFlagsOptions, + ) -> Result { + let distinct_id: String = distinct_id.into(); + let host = self.flag_event_host(); + + if distinct_id.is_empty() || self.options.is_disabled() { + return Ok(FeatureFlagEvaluations::empty(host)); + } + + let mut records: HashMap = HashMap::new(); + let mut locally_evaluated_keys: HashSet = HashSet::new(); + + if let Some(evaluator) = &self.local_evaluator { + let person_props_owned = options.person_properties.clone().unwrap_or_default(); + let local_results = evaluator.evaluate_all_flags(&distinct_id, &person_props_owned); + for (key, result) in local_results { + if let Some(filter) = &options.flag_keys { + if !filter.iter().any(|k| k == &key) { + continue; + } + } + if let Ok(value) = result { + records.insert(key.clone(), local_record(key.clone(), value)); + locally_evaluated_keys.insert(key); + } + } + } + + let mut request_id: Option = None; + + if !options.only_evaluate_locally { + let response = self.fetch_flag_details(&distinct_id, &options).await?; + request_id = response.request_id; + for (key, detail) in response.flags { + if locally_evaluated_keys.contains(&key) { + continue; + } + records.insert(key.clone(), remote_record_from_detail(key, detail)); + } + } + + Ok(FeatureFlagEvaluations::new( + host, + distinct_id, + records, + options.groups.unwrap_or_default(), + options.disable_geoip, + request_id, + None, + None, + )) + } + + fn flag_event_host(&self) -> Arc { + self.flag_event_host + .get_or_init(|| { + Arc::new(AsyncFlagEventHost::from_options( + &self.options, + self.client.clone(), + )) as Arc + }) + .clone() + } + + async fn fetch_flag_details( + &self, + distinct_id: &str, + options: &EvaluateFlagsOptions, + ) -> Result { + let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags); + + let mut payload = json!({ + "api_key": self.options.api_key, + "distinct_id": distinct_id, + }); + if let Some(groups) = &options.groups { + payload["groups"] = json!(groups); + } + if let Some(person_properties) = &options.person_properties { + payload["person_properties"] = json!(person_properties); + } + if let Some(group_properties) = &options.group_properties { + payload["group_properties"] = json!(group_properties); + } + let effective_disable_geoip = options.disable_geoip.unwrap_or(self.options.disable_geoip); + if effective_disable_geoip { + payload["disable_geoip"] = json!(true); + } + if let Some(flag_keys) = &options.flag_keys { + payload["flag_keys_to_evaluate"] = json!(flag_keys); + } + + let response = self + .client + .post(&flags_endpoint) + .header(CONTENT_TYPE, "application/json") + .json(&payload) + .timeout(Duration::from_secs( + self.options.feature_flags_request_timeout_seconds, + )) + .send() + .await + .map_err(|e| Error::Connection(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::Connection(format!( + "API request failed with status {status}: {text}" + ))); + } + + let parsed = response.json::().await.map_err(|e| { + Error::Serialization(format!("Failed to parse feature flags response: {e}")) + })?; + Ok(extract_flag_details(parsed)) + } +} + +/// Normalised view of a `/flags?v=2` response surfacing the per-flag detail +/// shape needed by the snapshot path. +struct DetailedFlagsResponse { + flags: HashMap, + request_id: Option, +} + +fn extract_flag_details(response: FeatureFlagsResponse) -> DetailedFlagsResponse { + match response { + FeatureFlagsResponse::V2 { + flags, request_id, .. + } => DetailedFlagsResponse { flags, request_id }, + FeatureFlagsResponse::Legacy { + feature_flags, + feature_flag_payloads, + .. + } => { + let mut flags = HashMap::new(); + for (key, value) in feature_flags { + let (enabled, variant) = match value { + FlagValue::Boolean(b) => (b, None), + FlagValue::String(s) => (true, Some(s)), + }; + let payload = feature_flag_payloads.get(&key).cloned(); + flags.insert( + key.clone(), + FlagDetail { + key, + enabled, + variant, + reason: None, + metadata: payload.map(|payload| crate::feature_flags::FlagMetadata { + id: 0, + version: 0, + description: None, + payload: Some(payload), + }), + }, + ); + } + DetailedFlagsResponse { + flags, + request_id: None, + } + } + } +} + +fn local_record(key: String, value: FlagValue) -> EvaluatedFlagRecord { + let (enabled, variant) = match value { + FlagValue::Boolean(b) => (b, None), + FlagValue::String(s) => (true, Some(s)), + }; + EvaluatedFlagRecord { + key, + enabled, + variant, + payload: None, + id: None, + version: None, + reason: Some("Evaluated locally".to_string()), + locally_evaluated: true, + } +} + +fn remote_record_from_detail(key: String, detail: FlagDetail) -> EvaluatedFlagRecord { + let metadata = detail.metadata; + let reason = detail + .reason + .and_then(|r| r.description.or(Some(r.code))) + .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); + EvaluatedFlagRecord { + key, + enabled: detail.enabled, + variant: detail.variant, + payload, + id, + version, + reason, + locally_evaluated: false, + } } diff --git a/src/client/blocking.rs b/src/client/blocking.rs index 2186a968..cc5397d2 100644 --- a/src/client/blocking.rs +++ b/src/client/blocking.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; use reqwest::{blocking::Client as HttpClient, header::CONTENT_TYPE}; @@ -7,12 +8,22 @@ use tracing::{debug, instrument, trace, warn}; use crate::endpoints::{Endpoint, EndpointManager}; use crate::event::BatchRequest; -use crate::feature_flags::{match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagValue}; +use crate::feature_flag_evaluations::{ + EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost, + FlagCalledEventParams, +}; +use crate::feature_flags::{ + match_feature_flag, FeatureFlag, FeatureFlagsResponse, FlagDetail, FlagValue, +}; use crate::local_evaluation::{FlagCache, FlagPoller, LocalEvaluationConfig, LocalEvaluator}; use crate::{event::InnerEvent, Error, Event}; use super::ClientOptions; +/// Cap on the number of `distinct_id` entries in the `$feature_flag_called` +/// dedup cache. On overflow the entire map is reset (matches the JS SDK). +const MAX_FLAG_CALLED_CACHE_SIZE: usize = 50_000; + fn check_response(response: reqwest::blocking::Response) -> Result<(), Error> { let status = response.status().as_u16(); let body = response @@ -31,6 +42,125 @@ pub struct Client { client: HttpClient, local_evaluator: Option, _flag_poller: Option, + flag_event_host: OnceLock>, +} + +/// Implementation of [`FeatureFlagEvaluationsHost`] that emits dedup-aware +/// `$feature_flag_called` events through a clone of the blocking [`Client`]'s +/// HTTP transport. Constructed lazily and cached on the [`Client`] so all +/// snapshots share a single dedup cache. +struct BlockingFlagEventHost { + http_client: HttpClient, + api_key: String, + endpoints: EndpointManager, + disabled: bool, + disable_geoip: bool, + log_warnings: bool, + dedup_cache: Mutex>>, +} + +impl BlockingFlagEventHost { + fn from_options(options: &ClientOptions, http_client: HttpClient) -> Self { + Self { + http_client, + api_key: options.api_key.clone(), + endpoints: options.endpoints().clone(), + disabled: options.is_disabled(), + disable_geoip: options.disable_geoip, + log_warnings: options.feature_flags_log_warnings(), + dedup_cache: Mutex::new(HashMap::new()), + } + } + + /// Returns `true` when the helper has already shipped this + /// `(distinct_id, key, response)` combination and the caller should skip. + fn already_reported(&self, distinct_id: &str, dedup_key: &str) -> bool { + let mut cache = self.dedup_cache.lock().unwrap_or_else(|p| p.into_inner()); + if let Some(seen) = cache.get(distinct_id) { + if seen.contains(dedup_key) { + return true; + } + } + if cache.len() >= MAX_FLAG_CALLED_CACHE_SIZE { + cache.clear(); + } + cache + .entry(distinct_id.to_string()) + .or_default() + .insert(dedup_key.to_string()); + false + } + + fn ship_event(&self, event: Event) { + if self.disabled { + return; + } + let inner_event = InnerEvent::new(event, self.api_key.clone()); + let payload = match serde_json::to_string(&inner_event) { + Ok(p) => p, + Err(e) => { + debug!(error = %e, "failed to serialize $feature_flag_called event"); + return; + } + }; + let url = self.endpoints.build_url(Endpoint::Capture); + let result = self + .http_client + .post(&url) + .header(CONTENT_TYPE, "application/json") + .body(payload) + .send(); + match result { + Ok(response) => { + if let Err(e) = check_response(response) { + debug!(error = %e, "$feature_flag_called event rejected by server"); + } + } + Err(e) => debug!(error = %e, "failed to send $feature_flag_called event"), + } + } +} + +impl FeatureFlagEvaluationsHost for BlockingFlagEventHost { + fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) { + let dedup_key = build_dedup_key(¶ms.key, params.response.as_ref()); + if self.already_reported(¶ms.distinct_id, &dedup_key) { + return; + } + + let mut event = Event::new( + "$feature_flag_called".to_string(), + params.distinct_id.clone(), + ); + for (k, v) in params.properties { + if event.insert_prop(k, v).is_err() { + return; + } + } + for (group_name, group_id) in ¶ms.groups { + event.add_group(group_name, group_id); + } + if params.disable_geoip.unwrap_or(self.disable_geoip) { + let _ = event.insert_prop("$geoip_disable", true); + } + self.ship_event(event); + } + + fn log_warning(&self, message: &str) { + if self.log_warnings { + warn!("{message}"); + } + } +} + +fn build_dedup_key(flag_key: &str, response: Option<&FlagValue>) -> String { + let response_repr = match response { + Some(FlagValue::Boolean(true)) => "true".to_string(), + Some(FlagValue::Boolean(false)) => "false".to_string(), + Some(FlagValue::String(s)) => s.clone(), + None => "::null::".to_string(), + }; + format!("{flag_key}_{response_repr}") } /// This function constructs a new client using the options provided. @@ -72,6 +202,7 @@ pub fn client>(options: C) -> Client { client, local_evaluator, _flag_poller: flag_poller, + flag_event_host: OnceLock::new(), } } @@ -334,4 +465,226 @@ impl Client { match_feature_flag(flag, distinct_id, person_properties) .map_err(|e| Error::InconclusiveMatch(e.message)) } + + /// Evaluate every feature flag for `distinct_id` in a single round-trip, + /// returning a [`FeatureFlagEvaluations`] snapshot. + /// + /// Each `is_enabled` / `get_flag` call on the returned snapshot fires a + /// dedup-aware `$feature_flag_called` event with full metadata, and the + /// snapshot can be passed to [`Event::with_flags`] so a downstream + /// [`Client::capture`] inherits `$feature/` and `$active_feature_flags` + /// without an extra `/flags` request. + /// + /// [`Event::with_flags`]: crate::Event::with_flags + pub fn evaluate_flags>( + &self, + distinct_id: S, + options: EvaluateFlagsOptions, + ) -> Result { + let distinct_id: String = distinct_id.into(); + let host = self.flag_event_host(); + + if distinct_id.is_empty() || self.options.is_disabled() { + return Ok(FeatureFlagEvaluations::empty(host)); + } + + let mut records: HashMap = HashMap::new(); + let mut locally_evaluated_keys: HashSet = HashSet::new(); + + if let Some(evaluator) = &self.local_evaluator { + let person_props_owned = options.person_properties.clone().unwrap_or_default(); + let local_results = evaluator.evaluate_all_flags(&distinct_id, &person_props_owned); + for (key, result) in local_results { + if let Some(filter) = &options.flag_keys { + if !filter.iter().any(|k| k == &key) { + continue; + } + } + if let Ok(value) = result { + records.insert(key.clone(), local_record(key.clone(), value)); + locally_evaluated_keys.insert(key); + } + } + } + + let mut request_id: Option = None; + + if !options.only_evaluate_locally { + let response = self.fetch_flag_details(&distinct_id, &options)?; + request_id = response.request_id; + for (key, detail) in response.flags { + if locally_evaluated_keys.contains(&key) { + continue; + } + records.insert(key.clone(), remote_record_from_detail(key, detail)); + } + } + + Ok(FeatureFlagEvaluations::new( + host, + distinct_id, + records, + options.groups.unwrap_or_default(), + options.disable_geoip, + request_id, + None, + None, + )) + } + + fn flag_event_host(&self) -> Arc { + self.flag_event_host + .get_or_init(|| { + Arc::new(BlockingFlagEventHost::from_options( + &self.options, + self.client.clone(), + )) as Arc + }) + .clone() + } + + fn fetch_flag_details( + &self, + distinct_id: &str, + options: &EvaluateFlagsOptions, + ) -> Result { + let flags_endpoint = self.options.endpoints().build_url(Endpoint::Flags); + + let mut payload = json!({ + "api_key": self.options.api_key, + "distinct_id": distinct_id, + }); + if let Some(groups) = &options.groups { + payload["groups"] = json!(groups); + } + if let Some(person_properties) = &options.person_properties { + payload["person_properties"] = json!(person_properties); + } + if let Some(group_properties) = &options.group_properties { + payload["group_properties"] = json!(group_properties); + } + let effective_disable_geoip = options.disable_geoip.unwrap_or(self.options.disable_geoip); + if effective_disable_geoip { + payload["disable_geoip"] = json!(true); + } + if let Some(flag_keys) = &options.flag_keys { + payload["flag_keys_to_evaluate"] = json!(flag_keys); + } + + let response = self + .client + .post(&flags_endpoint) + .header(CONTENT_TYPE, "application/json") + .json(&payload) + .timeout(Duration::from_secs( + self.options.feature_flags_request_timeout_seconds, + )) + .send() + .map_err(|e| Error::Connection(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let text = response + .text() + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(Error::Connection(format!( + "API request failed with status {status}: {text}" + ))); + } + + let parsed = response.json::().map_err(|e| { + Error::Serialization(format!("Failed to parse feature flags response: {e}")) + })?; + Ok(extract_flag_details(parsed)) + } +} + +/// Normalised view of a `/flags?v=2` response surfacing the per-flag detail +/// shape needed by the snapshot path. +struct DetailedFlagsResponse { + flags: HashMap, + request_id: Option, +} + +fn extract_flag_details(response: FeatureFlagsResponse) -> DetailedFlagsResponse { + match response { + FeatureFlagsResponse::V2 { + flags, request_id, .. + } => DetailedFlagsResponse { flags, request_id }, + // The legacy decide format does not surface metadata; build a synthetic + // detail so the snapshot still carries enabled/variant for each flag. + FeatureFlagsResponse::Legacy { + feature_flags, + feature_flag_payloads, + .. + } => { + let mut flags = HashMap::new(); + for (key, value) in feature_flags { + let (enabled, variant) = match value { + FlagValue::Boolean(b) => (b, None), + FlagValue::String(s) => (true, Some(s)), + }; + let payload = feature_flag_payloads.get(&key).cloned(); + flags.insert( + key.clone(), + FlagDetail { + key, + enabled, + variant, + reason: None, + metadata: payload.map(|payload| crate::feature_flags::FlagMetadata { + id: 0, + version: 0, + description: None, + payload: Some(payload), + }), + }, + ); + } + DetailedFlagsResponse { + flags, + request_id: None, + } + } + } +} + +fn local_record(key: String, value: FlagValue) -> EvaluatedFlagRecord { + let (enabled, variant) = match value { + FlagValue::Boolean(b) => (b, None), + FlagValue::String(s) => (true, Some(s)), + }; + EvaluatedFlagRecord { + key, + enabled, + variant, + // Local definitions do not carry a payload — this could be plumbed + // through in a follow-up alongside `flag_definitions_loaded_at`. + payload: None, + id: None, + version: None, + reason: Some("Evaluated locally".to_string()), + locally_evaluated: true, + } +} + +fn remote_record_from_detail(key: String, detail: FlagDetail) -> EvaluatedFlagRecord { + let metadata = detail.metadata; + let reason = detail + .reason + .and_then(|r| r.description.or(Some(r.code))) + .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); + EvaluatedFlagRecord { + key, + enabled: detail.enabled, + variant: detail.variant, + payload, + id, + version, + reason, + locally_evaluated: false, + } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 070f6a43..e13b9142 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -68,6 +68,12 @@ pub struct ClientOptions { #[builder(default = "3")] feature_flags_request_timeout_seconds: u64, + /// Whether to emit warnings for misuse of `FeatureFlagEvaluations` filter + /// 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, + #[builder(setter(skip))] #[builder(default = "EndpointManager::new(None)")] endpoint_manager: EndpointManager, @@ -84,6 +90,11 @@ impl ClientOptions { self.disabled } + /// Whether `FeatureFlagEvaluations` filter helpers should emit warnings. + pub(crate) fn feature_flags_log_warnings(&self) -> bool { + self.feature_flags_log_warnings + } + /// Create ClientOptions with properly initialized endpoint_manager fn with_endpoint_manager(mut self) -> Self { self.endpoint_manager = EndpointManager::new(self.host.clone()); diff --git a/src/event.rs b/src/event.rs index 8a8f35d8..bc8f424e 100644 --- a/src/event.rs +++ b/src/event.rs @@ -5,6 +5,7 @@ use semver::Version; use serde::Serialize; use uuid::Uuid; +use crate::feature_flag_evaluations::FeatureFlagEvaluations; use crate::Error; /// An [`Event`] represents an interaction a user has with your app or @@ -97,6 +98,18 @@ impl Event { pub fn set_uuid(&mut self, uuid: Uuid) { self.uuid = uuid; } + + /// Attach the flag state captured by a [`FeatureFlagEvaluations`] snapshot + /// to this event. Adds `$feature/` for every evaluated flag plus a + /// sorted `$active_feature_flags` list of enabled keys, mirroring what + /// `send_feature_flags` would otherwise fetch — but without making an + /// extra `/flags` request. + pub fn with_flags(&mut self, flags: &FeatureFlagEvaluations) -> &mut Self { + for (key, value) in flags.event_properties() { + self.properties.insert(key, value); + } + self + } } /// Wrapper for the `/batch/` endpoint that includes the API key and options diff --git a/src/feature_flag_evaluations.rs b/src/feature_flag_evaluations.rs new file mode 100644 index 00000000..74137bec --- /dev/null +++ b/src/feature_flag_evaluations.rs @@ -0,0 +1,610 @@ +//! Snapshot-based feature flag evaluations. +//! +//! [`FeatureFlagEvaluations`] is the result of [`Client::evaluate_flags`] — a +//! cache of evaluated flag values for a single `distinct_id` plus the rich +//! metadata returned by `/flags?v=2` (request id, evaluated-at timestamp, per-flag +//! id/version/reason/payload). Repeated `is_enabled`/`get_flag` calls on the same +//! snapshot are deduplicated client-side, so server-side feature gating no longer +//! costs an HTTP round-trip per branch. +//! +//! The companion [`Event::with_flags`](crate::Event::with_flags) builder attaches +//! the snapshot's flag state (`$feature/` and `$active_feature_flags`) to a +//! capture event without making another `/flags` call. + +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, Mutex}; + +use serde_json::{json, Value}; + +use crate::feature_flags::FlagValue; + +/// One evaluated flag inside a [`FeatureFlagEvaluations`] snapshot. +/// +/// Carries everything needed to emit a fully-detailed `$feature_flag_called` +/// event without a follow-up network call. +#[derive(Debug, Clone)] +pub struct EvaluatedFlagRecord { + pub key: String, + pub enabled: bool, + pub variant: Option, + pub payload: Option, + pub id: Option, + pub version: Option, + pub reason: Option, + pub locally_evaluated: bool, +} + +/// Parameters dispatched to [`FeatureFlagEvaluationsHost::capture_flag_called_event_if_needed`] +/// each time a snapshot method records a flag access. +#[derive(Debug, Clone)] +pub struct FlagCalledEventParams { + pub distinct_id: String, + pub key: String, + pub response: Option, + pub groups: HashMap, + pub disable_geoip: Option, + pub properties: HashMap, +} + +/// Dependency-inverted host interface used by [`FeatureFlagEvaluations`] to +/// emit dedup-aware `$feature_flag_called` events and surface filter-helper +/// warnings. The client constructs one of these once and shares it across all +/// snapshots it produces. +pub trait FeatureFlagEvaluationsHost: Send + Sync { + fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams); + fn log_warning(&self, message: &str); +} + +/// Optional inputs for [`Client::evaluate_flags`](crate::Client::evaluate_flags). +/// +/// `flag_keys` scopes the underlying `/flags` request and is distinct from +/// [`FeatureFlagEvaluations::only`], which filters an in-memory snapshot. +#[derive(Default, Clone, Debug)] +pub struct EvaluateFlagsOptions { + pub groups: Option>, + pub person_properties: Option>, + pub group_properties: Option>>, + pub only_evaluate_locally: bool, + pub disable_geoip: Option, + pub flag_keys: Option>, +} + +/// A snapshot of evaluated feature flags for one `distinct_id`. +/// +/// Returned by [`Client::evaluate_flags`](crate::Client::evaluate_flags). Reading +/// flags via [`is_enabled`] or [`get_flag`] both records the access (so it can be +/// later attached to a capture event) and emits a deduplicated +/// `$feature_flag_called` event. [`get_flag_payload`] is intentionally event-free. +/// +/// [`is_enabled`]: FeatureFlagEvaluations::is_enabled +/// [`get_flag`]: FeatureFlagEvaluations::get_flag +/// [`get_flag_payload`]: FeatureFlagEvaluations::get_flag_payload +pub struct FeatureFlagEvaluations { + host: Arc, + distinct_id: String, + flags: HashMap, + groups: HashMap, + disable_geoip: Option, + request_id: Option, + evaluated_at: Option, + flag_definitions_loaded_at: Option, + accessed: Mutex>, +} + +impl FeatureFlagEvaluations { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + host: Arc, + distinct_id: String, + flags: HashMap, + groups: HashMap, + disable_geoip: Option, + request_id: Option, + evaluated_at: Option, + flag_definitions_loaded_at: Option, + ) -> Self { + Self { + host, + distinct_id, + flags, + groups, + disable_geoip, + request_id, + evaluated_at, + flag_definitions_loaded_at, + accessed: Mutex::new(HashSet::new()), + } + } + + /// Construct an empty snapshot used when no `distinct_id` was resolvable. + /// The empty `distinct_id` short-circuits event firing inside + /// [`record_access`](Self::record_access). + pub(crate) fn empty(host: Arc) -> Self { + Self::new( + host, + String::new(), + HashMap::new(), + HashMap::new(), + None, + None, + None, + None, + ) + } + + /// Whether `key` is enabled. Records the access and fires (deduplicated) + /// `$feature_flag_called`. + #[must_use] + pub fn is_enabled(&self, key: &str) -> bool { + self.record_access(key); + self.flags.get(key).is_some_and(|f| f.enabled) + } + + /// Look up the value of `key`. Returns: + /// - `None` when the flag is not in the snapshot, + /// - `Some(FlagValue::Boolean(false))` when disabled, + /// - `Some(FlagValue::String(variant))` for a multivariate match, + /// - `Some(FlagValue::Boolean(true))` when enabled with no variant. + /// + /// Records the access and fires (deduplicated) `$feature_flag_called`. + #[must_use] + pub fn get_flag(&self, key: &str) -> Option { + self.record_access(key); + let flag = self.flags.get(key)?; + Some(flag_value_for(flag)) + } + + /// Return the JSON payload associated with `key`, if any. This call does + /// **not** count as an access and does **not** fire any event. + #[must_use] + pub fn get_flag_payload(&self, key: &str) -> Option { + self.flags.get(key).and_then(|f| f.payload.clone()) + } + + /// All flag keys present in this snapshot. + #[must_use] + pub fn keys(&self) -> Vec { + self.flags.keys().cloned().collect() + } + + /// A clone of the snapshot containing only flags whose values were read via + /// [`is_enabled`](Self::is_enabled) or [`get_flag`](Self::get_flag) before + /// this call. + /// + /// If nothing has been accessed, logs a warning and falls back to returning + /// a clone with all evaluated flags (so the captured event still carries + /// flag context). Configure with + /// [`ClientOptions::feature_flags_log_warnings`](crate::ClientOptionsBuilder) + /// to silence the warning. + #[must_use] + pub fn only_accessed(&self) -> Self { + let accessed = self.snapshot_accessed(); + if accessed.is_empty() { + self.host.log_warning( + "FeatureFlagEvaluations::only_accessed() was called before any flags were \ + accessed — attaching all evaluated flags as a fallback. \ + See https://posthog.com/docs/feature-flags/server-sdks for details.", + ); + return self.clone_with(self.flags.clone()); + } + let filtered = self + .flags + .iter() + .filter(|(k, _)| accessed.contains(k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + self.clone_with(filtered) + } + + /// A clone of the snapshot containing only the listed `keys` (preserving + /// records). Unknown keys are dropped and surfaced via a single warning. + #[must_use] + pub fn only(&self, keys: &[&str]) -> Self { + let mut filtered: HashMap = HashMap::new(); + let mut missing: Vec<&str> = Vec::new(); + for key in keys { + match self.flags.get(*key) { + Some(record) => { + filtered.insert((*key).to_string(), record.clone()); + } + None => missing.push(*key), + } + } + if !missing.is_empty() { + self.host.log_warning(&format!( + "FeatureFlagEvaluations::only() was called with flag keys that are not in the \ + evaluation set and will be dropped: {}", + missing.join(", ") + )); + } + self.clone_with(filtered) + } + + /// Build the property map for capture integration: `$feature/` for + /// every flag, plus a sorted `$active_feature_flags` list of enabled keys. + pub(crate) fn event_properties(&self) -> HashMap { + let mut props: HashMap = HashMap::with_capacity(self.flags.len() + 1); + let mut active: Vec = Vec::new(); + for (key, flag) in &self.flags { + let value = flag_value_json(flag); + props.insert(format!("$feature/{key}"), value); + if flag.enabled { + active.push(key.clone()); + } + } + if !active.is_empty() { + active.sort(); + props.insert("$active_feature_flags".into(), json!(active)); + } + props + } + + fn snapshot_accessed(&self) -> HashSet { + match self.accessed.lock() { + Ok(g) => g.clone(), + Err(p) => p.into_inner().clone(), + } + } + + fn clone_with(&self, flags: HashMap) -> Self { + Self { + host: Arc::clone(&self.host), + distinct_id: self.distinct_id.clone(), + flags, + groups: self.groups.clone(), + disable_geoip: self.disable_geoip, + request_id: self.request_id.clone(), + evaluated_at: self.evaluated_at, + flag_definitions_loaded_at: self.flag_definitions_loaded_at, + accessed: Mutex::new(self.snapshot_accessed()), + } + } + + fn record_access(&self, key: &str) { + if let Ok(mut accessed) = self.accessed.lock() { + accessed.insert(key.to_string()); + } + + // Snapshots created without a resolvable distinct_id must never emit + // `$feature_flag_called` — those events would land with an empty + // distinct_id and pollute downstream analytics. + if self.distinct_id.is_empty() { + return; + } + + let flag = self.flags.get(key); + let response = flag.map(flag_value_for); + let properties = self.build_called_event_properties(key, flag, &response); + + self.host + .capture_flag_called_event_if_needed(FlagCalledEventParams { + distinct_id: self.distinct_id.clone(), + key: key.to_string(), + response, + groups: self.groups.clone(), + disable_geoip: self.disable_geoip, + properties, + }); + } + + fn build_called_event_properties( + &self, + key: &str, + flag: Option<&EvaluatedFlagRecord>, + response: &Option, + ) -> HashMap { + let mut props: HashMap = HashMap::new(); + props.insert("$feature_flag".into(), json!(key)); + let response_json = match response { + Some(v) => flag_value_to_json(v), + None => Value::Null, + }; + props.insert("$feature_flag_response".into(), response_json.clone()); + props.insert(format!("$feature/{key}"), response_json); + + let locally_evaluated = flag.is_some_and(|f| f.locally_evaluated); + props.insert("locally_evaluated".into(), json!(locally_evaluated)); + + if let Some(flag) = flag { + if let Some(payload) = &flag.payload { + props.insert("$feature_flag_payload".into(), payload.clone()); + } + if let Some(id) = flag.id { + if id != 0 { + props.insert("$feature_flag_id".into(), json!(id)); + } + } + if let Some(version) = flag.version { + if version != 0 { + props.insert("$feature_flag_version".into(), json!(version)); + } + } + if let Some(reason) = &flag.reason { + if !reason.is_empty() { + props.insert("$feature_flag_reason".into(), json!(reason)); + } + } + } else { + props.insert("$feature_flag_error".into(), json!("flag_missing")); + } + + if locally_evaluated { + if let Some(loaded_at) = self.flag_definitions_loaded_at { + props.insert( + "$feature_flag_definitions_loaded_at".into(), + json!(loaded_at), + ); + } + } + + if let Some(request_id) = &self.request_id { + props.insert("$feature_flag_request_id".into(), json!(request_id)); + } + + if !locally_evaluated { + if let Some(evaluated_at) = self.evaluated_at { + props.insert("$feature_flag_evaluated_at".into(), json!(evaluated_at)); + } + } + + props + } +} + +impl std::fmt::Debug for FeatureFlagEvaluations { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FeatureFlagEvaluations") + .field("distinct_id", &self.distinct_id) + .field("flags", &self.flags) + .field("groups", &self.groups) + .field("disable_geoip", &self.disable_geoip) + .field("request_id", &self.request_id) + .field("evaluated_at", &self.evaluated_at) + .field( + "flag_definitions_loaded_at", + &self.flag_definitions_loaded_at, + ) + .finish_non_exhaustive() + } +} + +fn flag_value_for(flag: &EvaluatedFlagRecord) -> FlagValue { + if !flag.enabled { + FlagValue::Boolean(false) + } else if let Some(variant) = &flag.variant { + FlagValue::String(variant.clone()) + } else { + FlagValue::Boolean(true) + } +} + +fn flag_value_to_json(value: &FlagValue) -> Value { + match value { + FlagValue::Boolean(b) => json!(b), + FlagValue::String(s) => json!(s), + } +} + +fn flag_value_json(flag: &EvaluatedFlagRecord) -> Value { + flag_value_to_json(&flag_value_for(flag)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex as StdMutex; + + #[derive(Default)] + struct RecordingHost { + captured: StdMutex>, + warnings: StdMutex>, + } + + impl FeatureFlagEvaluationsHost for RecordingHost { + fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) { + self.captured.lock().unwrap().push(params); + } + fn log_warning(&self, message: &str) { + self.warnings.lock().unwrap().push(message.to_string()); + } + } + + fn record( + key: &str, + enabled: bool, + variant: Option<&str>, + locally_evaluated: bool, + ) -> EvaluatedFlagRecord { + EvaluatedFlagRecord { + key: key.into(), + enabled, + variant: variant.map(str::to_string), + payload: None, + id: Some(42), + version: Some(7), + reason: Some("condition match".into()), + locally_evaluated, + } + } + + fn build( + host: Arc, + distinct_id: &str, + ) -> FeatureFlagEvaluations { + let mut flags = HashMap::new(); + flags.insert("alpha".into(), record("alpha", true, Some("test"), false)); + flags.insert("beta".into(), record("beta", false, None, false)); + flags.insert("gamma".into(), record("gamma", true, None, true)); + FeatureFlagEvaluations::new( + host, + distinct_id.into(), + flags, + HashMap::new(), + None, + Some("req-1".into()), + Some(1700000000), + None, + ) + } + + #[test] + fn is_enabled_records_access_and_fires_event() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + assert!(snap.is_enabled("alpha")); + let captured = host.captured.lock().unwrap(); + assert_eq!(captured.len(), 1); + assert_eq!(captured[0].key, "alpha"); + let props = &captured[0].properties; + assert_eq!(props.get("$feature_flag_id"), Some(&json!(42_u64))); + assert_eq!(props.get("$feature_flag_version"), Some(&json!(7_u32))); + assert_eq!( + props.get("$feature_flag_reason"), + Some(&json!("condition match")) + ); + assert_eq!(props.get("$feature_flag_request_id"), Some(&json!("req-1"))); + } + + #[test] + fn get_flag_payload_does_not_record_access_or_fire_event() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + assert!(snap.get_flag_payload("alpha").is_none()); + assert!(host.captured.lock().unwrap().is_empty()); + } + + #[test] + fn empty_distinct_id_does_not_fire_events() { + let host = Arc::new(RecordingHost::default()); + let snap = + FeatureFlagEvaluations::empty(Arc::clone(&host) as Arc); + assert!(!snap.is_enabled("anything")); + assert!(host.captured.lock().unwrap().is_empty()); + } + + #[test] + fn locally_evaluated_event_omits_evaluated_at_and_includes_definitions_loaded_at() { + let host = Arc::new(RecordingHost::default()); + let mut flags = HashMap::new(); + flags.insert( + "gamma".into(), + EvaluatedFlagRecord { + reason: Some("Evaluated locally".into()), + ..record("gamma", true, None, true) + }, + ); + let snap = FeatureFlagEvaluations::new( + Arc::clone(&host) as Arc, + "u1".into(), + flags, + HashMap::new(), + None, + None, + Some(1700000000), + Some(1699999000), + ); + let _ = snap.is_enabled("gamma"); + let captured = host.captured.lock().unwrap(); + let props = &captured[0].properties; + assert_eq!(props.get("locally_evaluated"), Some(&json!(true))); + assert_eq!( + props.get("$feature_flag_reason"), + Some(&json!("Evaluated locally")) + ); + assert_eq!( + props.get("$feature_flag_definitions_loaded_at"), + Some(&json!(1699999000_i64)) + ); + assert!(!props.contains_key("$feature_flag_evaluated_at")); + } + + #[test] + fn missing_flag_records_flag_missing_error() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + assert!(snap.get_flag("does-not-exist").is_none()); + let captured = host.captured.lock().unwrap(); + assert_eq!( + captured[0].properties.get("$feature_flag_error"), + Some(&json!("flag_missing")) + ); + } + + #[test] + fn only_accessed_filters_to_accessed_keys() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + let _ = snap.is_enabled("alpha"); + let filtered = snap.only_accessed(); + let mut keys = filtered.keys(); + keys.sort(); + assert_eq!(keys, vec!["alpha".to_string()]); + } + + #[test] + fn only_accessed_falls_back_to_all_with_warning_when_empty() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + let filtered = snap.only_accessed(); + assert_eq!(filtered.keys().len(), 3); + assert_eq!(host.warnings.lock().unwrap().len(), 1); + } + + #[test] + fn only_drops_unknown_keys_with_warning() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + let filtered = snap.only(&["alpha", "missing"]); + assert_eq!(filtered.keys(), vec!["alpha".to_string()]); + let warnings = host.warnings.lock().unwrap(); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("missing")); + } + + #[test] + fn filtered_snapshots_do_not_back_propagate_access_to_parent() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + let _ = snap.is_enabled("alpha"); + let child = snap.only_accessed(); + let _ = child.is_enabled("alpha"); + // Parent's accessed set is still {"alpha"}, not affected by child reads. + assert_eq!(snap.snapshot_accessed().len(), 1); + } + + #[test] + fn event_properties_attaches_active_flags_sorted() { + let host = Arc::new(RecordingHost::default()); + let snap = build( + Arc::clone(&host) as Arc, + "u1", + ); + let props = snap.event_properties(); + assert_eq!(props.get("$feature/alpha"), Some(&json!("test"))); + assert_eq!(props.get("$feature/beta"), Some(&json!(false))); + assert_eq!(props.get("$feature/gamma"), Some(&json!(true))); + let active = props.get("$active_feature_flags").unwrap(); + assert_eq!(active, &json!(["alpha", "gamma"])); + } +} diff --git a/src/feature_flags.rs b/src/feature_flags.rs index 002d98b5..2a7d1fa6 100644 --- a/src/feature_flags.rs +++ b/src/feature_flags.rs @@ -257,6 +257,12 @@ pub enum FeatureFlagsResponse { #[serde(rename = "errorsWhileComputingFlags")] #[serde(default)] errors_while_computing_flags: bool, + /// Unique identifier for this evaluation request, propagated to + /// `$feature_flag_called` events as `$feature_flag_request_id` + /// for experiment exposure tracking. + #[serde(rename = "requestId")] + #[serde(default)] + request_id: Option, }, /// Legacy format from older decide endpoint Legacy { diff --git a/src/lib.rs b/src/lib.rs index f7ff1e17..f7433c49 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,6 +2,7 @@ mod client; mod endpoints; mod error; mod event; +mod feature_flag_evaluations; mod feature_flags; mod global; mod local_evaluation; @@ -26,6 +27,10 @@ pub use error::Error; pub use event::Event; // Feature Flags +pub use feature_flag_evaluations::{ + EvaluateFlagsOptions, EvaluatedFlagRecord, FeatureFlagEvaluations, FeatureFlagEvaluationsHost, + FlagCalledEventParams, +}; pub use feature_flags::{ match_feature_flag, match_feature_flag_with_context, match_property_with_context, CohortDefinition, EvaluationContext, FeatureFlag, FeatureFlagCondition, FeatureFlagFilters, diff --git a/tests/test_evaluate_flags.rs b/tests/test_evaluate_flags.rs new file mode 100644 index 00000000..484da5cb --- /dev/null +++ b/tests/test_evaluate_flags.rs @@ -0,0 +1,458 @@ +use httpmock::prelude::*; +use serde_json::{json, Value}; + +#[cfg(feature = "async-client")] +use std::time::Duration; + +fn flags_response_fixture() -> Value { + json!({ + "flags": { + "alpha": { + "key": "alpha", + "enabled": true, + "variant": null, + "reason": { + "code": "condition_match", + "description": "Matched condition set 1", + "condition_index": 0 + }, + "metadata": { + "id": 101, + "version": 4, + "description": null, + "payload": null + } + }, + "beta": { + "key": "beta", + "enabled": false, + "variant": null, + "reason": { + "code": "out_of_rollout_bound", + "description": null, + "condition_index": null + }, + "metadata": { + "id": 202, + "version": 1, + "description": null, + "payload": null + } + }, + "variant-flag": { + "key": "variant-flag", + "enabled": true, + "variant": "test", + "reason": { + "code": "condition_match", + "description": null, + "condition_index": 0 + }, + "metadata": { + "id": 303, + "version": 7, + "description": null, + "payload": {"hello": "world"} + } + } + }, + "errorsWhileComputingFlags": false, + "requestId": "req-abc-123" + }) +} + +// ---------- blocking ---------- + +#[cfg(not(feature = "async-client"))] +mod blocking { + use super::*; + use posthog_rs::{EvaluateFlagsOptions, Event, FlagValue}; + + fn create_test_client(base_url: String) -> posthog_rs::Client { + let options: posthog_rs::ClientOptions = ("test_api_key", base_url.as_str()).into(); + posthog_rs::client(options) + } + + #[test] + fn evaluate_flags_returns_snapshot_with_one_request() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/").query_param("v", "2"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .expect("evaluate_flags"); + + let mut keys = snapshot.keys(); + keys.sort(); + assert_eq!(keys, vec!["alpha", "beta", "variant-flag"]); + flags_mock.assert_hits(1); + capture_mock.assert_hits(0); + } + + #[test] + fn unaccessed_flags_do_not_fire_events() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let _snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + flags_mock.assert_hits(1); + capture_mock.assert_hits(0); + } + + #[test] + fn is_enabled_fires_event_with_full_metadata_and_dedupes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + + assert!(snapshot.is_enabled("alpha")); + assert!(snapshot.is_enabled("alpha")); + assert_eq!( + snapshot.get_flag("variant-flag"), + Some(FlagValue::String("test".into())) + ); + assert_eq!( + snapshot.get_flag("variant-flag"), + Some(FlagValue::String("test".into())) + ); + + // Two unique (flag, value) combos => two events; repeats deduped. + capture_mock.assert_hits(2); + } + + #[test] + fn get_flag_payload_does_not_fire_event() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + let payload = snapshot.get_flag_payload("variant-flag"); + assert_eq!(payload, Some(json!({"hello": "world"}))); + capture_mock.assert_hits(0); + } + + #[test] + fn flag_keys_forwarded_to_request_body() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST) + .path("/flags/") + .json_body_partial(json!({"flag_keys_to_evaluate": ["alpha", "beta"]}).to_string()); + then.status(200).json_body(flags_response_fixture()); + }); + let client = create_test_client(server.base_url()); + let opts = EvaluateFlagsOptions { + flag_keys: Some(vec!["alpha".into(), "beta".into()]), + ..Default::default() + }; + let _ = client.evaluate_flags("user-1", opts).unwrap(); + flags_mock.assert_hits(1); + } + + #[test] + fn empty_distinct_id_returns_empty_snapshot_without_request_or_events() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("", EvaluateFlagsOptions::default()) + .unwrap(); + assert!(snapshot.keys().is_empty()); + assert!(!snapshot.is_enabled("alpha")); + flags_mock.assert_hits(0); + capture_mock.assert_hits(0); + } + + #[test] + fn event_with_flags_attaches_properties_without_extra_request() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + let mut event = Event::new("checkout-started", "user-1"); + event.with_flags(&snapshot); + client.capture(event).expect("capture should succeed"); + // One /flags request, one /i/v0/e/ request — no second flag fetch. + flags_mock.assert_hits(1); + capture_mock.assert_hits(1); + } + + #[test] + fn only_filters_to_named_keys() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + let filtered = snapshot.only(&["alpha", "missing"]); + assert_eq!(filtered.keys(), vec!["alpha".to_string()]); + } + + #[test] + fn only_accessed_returns_only_accessed_subset() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + let _ = snapshot.is_enabled("alpha"); + let filtered = snapshot.only_accessed(); + assert_eq!(filtered.keys(), vec!["alpha".to_string()]); + } + + // Demonstrates that the snapshot can deserialise the legacy shape too; + // metadata is absent so the per-flag id/version/reason/request_id will + // be missing, but enabled/variant still propagate. + #[test] + fn legacy_response_shape_still_yields_a_snapshot() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(json!({ + "featureFlags": {"alpha": true, "beta": false}, + "featureFlagPayloads": {} + })); + }); + let client = create_test_client(server.base_url()); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + assert!(snapshot.is_enabled("alpha")); + assert!(!snapshot.is_enabled("beta")); + } + + #[test] + fn disabled_client_returns_empty_snapshot() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let options = posthog_rs::ClientOptionsBuilder::default() + .api_key("test_api_key".to_string()) + .host(server.base_url()) + .disabled(true) + .build() + .unwrap(); + let client = posthog_rs::client(options); + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .unwrap(); + assert!(snapshot.keys().is_empty()); + flags_mock.assert_hits(0); + } +} + +// ---------- async ---------- + +#[cfg(feature = "async-client")] +mod async_tests { + use super::*; + use posthog_rs::{EvaluateFlagsOptions, Event, FlagValue}; + + async fn create_test_client(base_url: String) -> posthog_rs::Client { + let options: posthog_rs::ClientOptions = ("test_api_key", base_url.as_str()).into(); + posthog_rs::client(options).await + } + + /// Wait briefly for any `$feature_flag_called` events that the host + /// `tokio::spawn`'d in the background to land at the mock. + async fn flush_spawned_events() { + tokio::time::sleep(Duration::from_millis(150)).await; + } + + #[tokio::test] + async fn evaluate_flags_returns_snapshot_with_one_request() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let client = create_test_client(server.base_url()).await; + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .await + .unwrap(); + let mut keys = snapshot.keys(); + keys.sort(); + assert_eq!(keys, vec!["alpha", "beta", "variant-flag"]); + flags_mock.assert_hits(1); + } + + #[tokio::test] + async fn is_enabled_fires_event_and_dedupes() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()).await; + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .await + .unwrap(); + assert!(snapshot.is_enabled("alpha")); + assert!(snapshot.is_enabled("alpha")); + assert_eq!( + snapshot.get_flag("variant-flag"), + Some(FlagValue::String("test".into())) + ); + flush_spawned_events().await; + capture_mock.assert_hits(2); + } + + #[tokio::test] + async fn get_flag_payload_does_not_fire_event() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()).await; + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .await + .unwrap(); + assert_eq!( + snapshot.get_flag_payload("variant-flag"), + Some(json!({"hello": "world"})) + ); + flush_spawned_events().await; + capture_mock.assert_hits(0); + } + + #[tokio::test] + async fn flag_keys_forwarded_to_request_body() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST) + .path("/flags/") + .json_body_partial(json!({"flag_keys_to_evaluate": ["alpha"]}).to_string()); + then.status(200).json_body(flags_response_fixture()); + }); + let client = create_test_client(server.base_url()).await; + let opts = EvaluateFlagsOptions { + flag_keys: Some(vec!["alpha".into()]), + ..Default::default() + }; + let _ = client.evaluate_flags("user-1", opts).await.unwrap(); + flags_mock.assert_hits(1); + } + + #[tokio::test] + async fn empty_distinct_id_returns_empty_snapshot_without_events() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()).await; + let snapshot = client + .evaluate_flags("", EvaluateFlagsOptions::default()) + .await + .unwrap(); + assert!(!snapshot.is_enabled("alpha")); + flush_spawned_events().await; + flags_mock.assert_hits(0); + capture_mock.assert_hits(0); + } + + #[tokio::test] + async fn event_with_flags_attaches_properties_without_extra_request() { + let server = MockServer::start(); + let flags_mock = server.mock(|when, then| { + when.method(POST).path("/flags/"); + then.status(200).json_body(flags_response_fixture()); + }); + let capture_mock = server.mock(|when, then| { + when.method(POST).path("/i/v0/e/"); + then.status(200); + }); + let client = create_test_client(server.base_url()).await; + let snapshot = client + .evaluate_flags("user-1", EvaluateFlagsOptions::default()) + .await + .unwrap(); + let mut event = Event::new("checkout-started", "user-1"); + event.with_flags(&snapshot); + client.capture(event).await.unwrap(); + flags_mock.assert_hits(1); + capture_mock.assert_hits(1); + } +}