Skip to main content

posthog_rs/
feature_flag_evaluations.rs

1//! Snapshot-based feature flag evaluations.
2//!
3//! [`FeatureFlagEvaluations`] is the result of [`Client::evaluate_flags`] — a
4//! cache of evaluated flag values for a single `distinct_id` plus the rich
5//! metadata returned by `/flags?v=2` (request id, evaluated-at timestamp, per-flag
6//! id/version/reason/payload). Repeated `is_enabled`/`get_flag` calls on the same
7//! snapshot are deduplicated client-side, so server-side feature gating no longer
8//! costs an HTTP round-trip per branch.
9//!
10//! The companion [`Event::with_flags`](crate::Event::with_flags) builder attaches
11//! the snapshot's flag state (`$feature/<key>` and `$active_feature_flags`) to a
12//! capture event without making another `/flags` call.
13
14use std::collections::{HashMap, HashSet};
15use std::sync::{Arc, Mutex};
16
17use serde_json::{json, Value};
18
19use crate::feature_flags::FlagValue;
20
21/// One evaluated flag inside a [`FeatureFlagEvaluations`] snapshot.
22///
23/// Carries everything needed to emit a fully-detailed `$feature_flag_called`
24/// event without a follow-up network call.
25#[derive(Debug, Clone)]
26pub(crate) struct EvaluatedFlagRecord {
27    pub enabled: bool,
28    pub variant: Option<String>,
29    pub payload: Option<Value>,
30    pub id: Option<u64>,
31    pub version: Option<u32>,
32    pub reason: Option<String>,
33    pub locally_evaluated: bool,
34    /// Server-reported experiment linkage for this flag. Tri-state: `Some(bool)`
35    /// when reported, `None` when unknown. Drives `$feature_flag_has_experiment`
36    /// and, with the gate below, event minimization.
37    pub has_experiment: Option<bool>,
38    /// The minimal-`$feature_flag_called` gate captured from the source that
39    /// produced this record (the poller's local definitions or the remote
40    /// `/flags` response). Pinned per record so the minimization decision uses
41    /// the value tied to this flag's evaluation, never a later read of shared
42    /// mutable client state.
43    pub minimal_flag_called_events: bool,
44}
45
46/// Parameters dispatched to [`FeatureFlagEvaluationsHost::capture_flag_called_event_if_needed`]
47/// each time a snapshot method records a flag access.
48#[derive(Debug, Clone)]
49pub(crate) struct FlagCalledEventParams {
50    pub distinct_id: String,
51    pub key: String,
52    pub response: Option<FlagValue>,
53    pub groups: HashMap<String, String>,
54    pub disable_geoip: Option<bool>,
55    pub properties: HashMap<String, Value>,
56    /// Whether this event should be minimized to the strict property allowlist.
57    /// Decided by [`FeatureFlagEvaluations::record_access`] from the flag's own
58    /// pinned gate and experiment signal, then applied as the final capture step.
59    pub minimal: bool,
60}
61
62/// Dependency-inverted host interface used by [`FeatureFlagEvaluations`] to
63/// emit dedup-aware `$feature_flag_called` events. The client constructs one
64/// of these once and shares it across all snapshots it produces.
65pub(crate) trait FeatureFlagEvaluationsHost: Send + Sync {
66    fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams);
67    fn log_warning(&self, message: &str);
68}
69
70/// Optional inputs for [`Client::evaluate_flags`](crate::Client::evaluate_flags).
71#[derive(Default, Clone, Debug)]
72pub struct EvaluateFlagsOptions {
73    /// Group keys for group-targeted feature flags, keyed by group type (for
74    /// example `{ "company": "company_123" }`). These groups are also
75    /// attached to `$feature_flag_called` events emitted by the returned
76    /// snapshot.
77    pub groups: Option<HashMap<String, String>>,
78    /// Person properties used by remote or local flag evaluation. Provide any
79    /// properties referenced by release conditions when using local evaluation.
80    pub person_properties: Option<HashMap<String, Value>>,
81    /// Group properties used by group-targeted or mixed-targeting flags, keyed
82    /// first by group type and then by property name.
83    pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
84    /// When `true`, skip the remote `/flags` request and return only locally
85    /// evaluated results. If local evaluation is not configured, the snapshot is
86    /// empty.
87    pub only_evaluate_locally: bool,
88    /// Per-call override for GeoIP behavior on `/flags` and
89    /// `$feature_flag_called` requests. `None` uses the client-level setting.
90    pub disable_geoip: Option<bool>,
91    /// Optional list of flag keys. When provided, only these flags are
92    /// evaluated — the underlying `/flags` request asks the server for just
93    /// this subset, which makes the response smaller and the request cheaper.
94    /// Use this when you only need a handful of flags out of many.
95    ///
96    /// Distinct from [`FeatureFlagEvaluations::only`]: `flag_keys` trims the
97    /// network call, [`only`](FeatureFlagEvaluations::only) trims which flags
98    /// get attached to a captured event after evaluation.
99    pub flag_keys: Option<Vec<String>>,
100}
101
102/// A snapshot of evaluated feature flags for one `distinct_id`.
103///
104/// Returned by [`Client::evaluate_flags`](crate::Client::evaluate_flags). Reading
105/// flags via [`is_enabled`] or [`get_flag`] both records the access (so it can be
106/// later attached to a capture event) and emits a deduplicated
107/// `$feature_flag_called` event. [`get_flag_payload`] is intentionally event-free.
108///
109/// [`is_enabled`]: FeatureFlagEvaluations::is_enabled
110/// [`get_flag`]: FeatureFlagEvaluations::get_flag
111/// [`get_flag_payload`]: FeatureFlagEvaluations::get_flag_payload
112pub struct FeatureFlagEvaluations {
113    host: Arc<dyn FeatureFlagEvaluationsHost>,
114    distinct_id: String,
115    flags: HashMap<String, EvaluatedFlagRecord>,
116    groups: HashMap<String, String>,
117    disable_geoip: Option<bool>,
118    request_id: Option<String>,
119    evaluated_at: Option<i64>,
120    errors_while_computing: bool,
121    quota_limited: bool,
122    accessed: Mutex<HashSet<String>>,
123}
124
125impl FeatureFlagEvaluations {
126    #[allow(clippy::too_many_arguments)]
127    pub(crate) fn new(
128        host: Arc<dyn FeatureFlagEvaluationsHost>,
129        distinct_id: String,
130        flags: HashMap<String, EvaluatedFlagRecord>,
131        groups: HashMap<String, String>,
132        disable_geoip: Option<bool>,
133        request_id: Option<String>,
134        evaluated_at: Option<i64>,
135        errors_while_computing: bool,
136        quota_limited: bool,
137    ) -> Self {
138        Self {
139            host,
140            distinct_id,
141            flags,
142            groups,
143            disable_geoip,
144            request_id,
145            evaluated_at,
146            errors_while_computing,
147            quota_limited,
148            accessed: Mutex::new(HashSet::new()),
149        }
150    }
151
152    /// Construct an empty snapshot used when no `distinct_id` was resolvable.
153    /// The empty `distinct_id` short-circuits event firing inside
154    /// [`record_access`](Self::record_access).
155    pub(crate) fn empty(host: Arc<dyn FeatureFlagEvaluationsHost>) -> Self {
156        Self::new(
157            host,
158            String::new(),
159            HashMap::new(),
160            HashMap::new(),
161            None,
162            None,
163            None,
164            false,
165            false,
166        )
167    }
168
169    /// Whether `key` is enabled. Records the access and fires (deduplicated)
170    /// `$feature_flag_called`.
171    ///
172    /// # Returns
173    ///
174    /// `true` for enabled boolean flags or matched multivariate variants, and
175    /// `false` for disabled or missing flags.
176    #[must_use]
177    pub fn is_enabled(&self, key: &str) -> bool {
178        self.record_access(key);
179        self.flags.get(key).is_some_and(|f| f.enabled)
180    }
181
182    /// Look up the value of `key`.
183    ///
184    /// # Returns
185    ///
186    /// - `None` when the flag is not in the snapshot,
187    /// - `Some(FlagValue::Boolean(false))` when disabled,
188    /// - `Some(FlagValue::String(variant))` for a multivariate match,
189    /// - `Some(FlagValue::Boolean(true))` when enabled with no variant.
190    ///
191    /// Records the access and fires (deduplicated) `$feature_flag_called`.
192    #[must_use]
193    pub fn get_flag(&self, key: &str) -> Option<FlagValue> {
194        self.record_access(key);
195        let flag = self.flags.get(key)?;
196        Some(flag_value_for(flag))
197    }
198
199    /// Return the JSON payload associated with `key`, if any.
200    ///
201    /// # Remarks
202    ///
203    /// This call does **not** count as an access and does **not** fire any
204    /// event, matching the behavior documented for server-side SDKs.
205    #[must_use]
206    pub fn get_flag_payload(&self, key: &str) -> Option<Value> {
207        self.flags.get(key).and_then(|f| f.payload.clone())
208    }
209
210    /// All flag keys present in this snapshot.
211    #[must_use]
212    pub fn keys(&self) -> Vec<String> {
213        self.flags.keys().cloned().collect()
214    }
215
216    /// A clone of the snapshot containing only flags whose values were read via
217    /// [`is_enabled`](Self::is_enabled) or [`get_flag`](Self::get_flag) before
218    /// this call.
219    ///
220    /// Order-dependent: if nothing has been accessed yet, the returned snapshot
221    /// is empty. Pre-access the flags you want to attach before calling this.
222    #[must_use]
223    pub fn only_accessed(&self) -> Self {
224        let accessed = self.snapshot_accessed();
225        let filtered = self
226            .flags
227            .iter()
228            .filter(|(k, _)| accessed.contains(k.as_str()))
229            .map(|(k, v)| (k.clone(), v.clone()))
230            .collect();
231        self.clone_with(filtered)
232    }
233
234    /// A clone of the snapshot containing only the listed `keys` (preserving
235    /// records). Unknown keys are dropped and surfaced via a single warning.
236    ///
237    /// Use this before [`Event::with_flags`](crate::Event::with_flags) to limit
238    /// the `$feature/<key>` properties attached to a captured event.
239    #[must_use]
240    pub fn only(&self, keys: &[&str]) -> Self {
241        let mut filtered: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
242        let mut missing: Vec<&str> = Vec::new();
243        for key in keys {
244            match self.flags.get(*key) {
245                Some(record) => {
246                    filtered.insert((*key).to_string(), record.clone());
247                }
248                None => missing.push(*key),
249            }
250        }
251        if !missing.is_empty() {
252            self.host.log_warning(&format!(
253                "FeatureFlagEvaluations::only() was called with flag keys that are not in the \
254                 evaluation set and will be dropped: {}",
255                missing.join(", ")
256            ));
257        }
258        self.clone_with(filtered)
259    }
260
261    /// Build the property map for capture integration: `$feature/<key>` for
262    /// every flag, plus a sorted `$active_feature_flags` list of enabled keys.
263    pub(crate) fn event_properties(&self) -> HashMap<String, Value> {
264        let mut props: HashMap<String, Value> = HashMap::with_capacity(self.flags.len() + 1);
265        let mut active: Vec<String> = Vec::new();
266        for (key, flag) in &self.flags {
267            let value = flag_value_json(flag);
268            props.insert(format!("$feature/{key}"), value);
269            if flag.enabled {
270                active.push(key.clone());
271            }
272        }
273        if !active.is_empty() {
274            active.sort();
275            props.insert("$active_feature_flags".into(), json!(active));
276        }
277        props
278    }
279
280    fn snapshot_accessed(&self) -> HashSet<String> {
281        match self.accessed.lock() {
282            Ok(g) => g.clone(),
283            Err(p) => p.into_inner().clone(),
284        }
285    }
286
287    fn clone_with(&self, flags: HashMap<String, EvaluatedFlagRecord>) -> Self {
288        Self {
289            host: Arc::clone(&self.host),
290            distinct_id: self.distinct_id.clone(),
291            flags,
292            groups: self.groups.clone(),
293            disable_geoip: self.disable_geoip,
294            request_id: self.request_id.clone(),
295            evaluated_at: self.evaluated_at,
296            errors_while_computing: self.errors_while_computing,
297            quota_limited: self.quota_limited,
298            accessed: Mutex::new(self.snapshot_accessed()),
299        }
300    }
301
302    fn record_access(&self, key: &str) {
303        if let Ok(mut accessed) = self.accessed.lock() {
304            accessed.insert(key.to_string());
305        }
306
307        // Snapshots created without a resolvable distinct_id must never emit
308        // `$feature_flag_called` — those events would land with an empty
309        // distinct_id and pollute downstream analytics.
310        if self.distinct_id.is_empty() {
311            return;
312        }
313
314        let flag = self.flags.get(key);
315        let response = flag.map(flag_value_for);
316        let properties = self.build_called_event_properties(key, flag, &response);
317
318        // Minimize iff the gate pinned on this flag's record is on AND the flag
319        // is known to have no linked experiment. Any missing signal (gate off,
320        // experiment unknown, experiment-linked, or missing flag) keeps the full
321        // event shape. Read from the per-flag record, never from shared state.
322        let minimal =
323            flag.is_some_and(|f| f.minimal_flag_called_events && f.has_experiment == Some(false));
324
325        self.host
326            .capture_flag_called_event_if_needed(FlagCalledEventParams {
327                distinct_id: self.distinct_id.clone(),
328                key: key.to_string(),
329                response,
330                groups: self.groups.clone(),
331                disable_geoip: self.disable_geoip,
332                properties,
333                minimal,
334            });
335    }
336
337    fn build_called_event_properties(
338        &self,
339        key: &str,
340        flag: Option<&EvaluatedFlagRecord>,
341        response: &Option<FlagValue>,
342    ) -> HashMap<String, Value> {
343        let mut props: HashMap<String, Value> = HashMap::new();
344        props.insert("$feature_flag".into(), json!(key));
345        let response_json = match response {
346            Some(v) => flag_value_to_json(v),
347            None => Value::Null,
348        };
349        props.insert("$feature_flag_response".into(), response_json.clone());
350        props.insert(format!("$feature/{key}"), response_json);
351
352        let locally_evaluated = flag.is_some_and(|f| f.locally_evaluated);
353        props.insert("locally_evaluated".into(), json!(locally_evaluated));
354
355        // Record the server's experiment signal when known, so minimization's
356        // impact can be segmented by it. Omitted entirely when unknown (never
357        // fabricated as `false`).
358        if let Some(has_experiment) = flag.and_then(|f| f.has_experiment) {
359            props.insert("$feature_flag_has_experiment".into(), json!(has_experiment));
360        }
361
362        if let Some(flag) = flag {
363            if let Some(payload) = &flag.payload {
364                props.insert("$feature_flag_payload".into(), payload.clone());
365            }
366            if let Some(id) = flag.id {
367                if id != 0 {
368                    props.insert("$feature_flag_id".into(), json!(id));
369                }
370            }
371            if let Some(version) = flag.version {
372                if version != 0 {
373                    props.insert("$feature_flag_version".into(), json!(version));
374                }
375            }
376            if let Some(reason) = &flag.reason {
377                if !reason.is_empty() {
378                    props.insert("$feature_flag_reason".into(), json!(reason));
379                }
380            }
381        }
382
383        if let Some(request_id) = &self.request_id {
384            props.insert("$feature_flag_request_id".into(), json!(request_id));
385        }
386
387        if !locally_evaluated {
388            if let Some(evaluated_at) = self.evaluated_at {
389                props.insert("$feature_flag_evaluated_at".into(), json!(evaluated_at));
390            }
391        }
392
393        // Comma-joined `$feature_flag_error` matching the single-flag path's
394        // granularity: response-level errors (errors-while-computing,
395        // quota-limited) combine with per-flag errors (flag-missing) so
396        // consumers can filter by type.
397        let mut errors: Vec<&str> = Vec::new();
398        if self.errors_while_computing {
399            errors.push("errors_while_computing_flags");
400        }
401        if self.quota_limited {
402            errors.push("quota_limited");
403        }
404        if flag.is_none() {
405            errors.push("flag_missing");
406        }
407        if !errors.is_empty() {
408            props.insert("$feature_flag_error".into(), json!(errors.join(",")));
409        }
410
411        props
412    }
413}
414
415impl std::fmt::Debug for FeatureFlagEvaluations {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        f.debug_struct("FeatureFlagEvaluations")
418            .field("distinct_id", &self.distinct_id)
419            .field("flags", &self.flags)
420            .field("groups", &self.groups)
421            .field("disable_geoip", &self.disable_geoip)
422            .field("request_id", &self.request_id)
423            .field("evaluated_at", &self.evaluated_at)
424            .field("errors_while_computing", &self.errors_while_computing)
425            .field("quota_limited", &self.quota_limited)
426            .finish_non_exhaustive()
427    }
428}
429
430fn flag_value_for(flag: &EvaluatedFlagRecord) -> FlagValue {
431    if !flag.enabled {
432        FlagValue::Boolean(false)
433    } else if let Some(variant) = &flag.variant {
434        FlagValue::String(variant.clone())
435    } else {
436        FlagValue::Boolean(true)
437    }
438}
439
440fn flag_value_to_json(value: &FlagValue) -> Value {
441    match value {
442        FlagValue::Boolean(b) => json!(b),
443        FlagValue::String(s) => json!(s),
444    }
445}
446
447fn flag_value_json(flag: &EvaluatedFlagRecord) -> Value {
448    flag_value_to_json(&flag_value_for(flag))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use std::sync::Mutex as StdMutex;
455
456    #[derive(Default)]
457    struct RecordingHost {
458        captured: StdMutex<Vec<FlagCalledEventParams>>,
459        warnings: StdMutex<Vec<String>>,
460    }
461
462    impl FeatureFlagEvaluationsHost for RecordingHost {
463        fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
464            self.captured.lock().unwrap().push(params);
465        }
466        fn log_warning(&self, message: &str) {
467            self.warnings.lock().unwrap().push(message.to_string());
468        }
469    }
470
471    fn record(
472        _key: &str,
473        enabled: bool,
474        variant: Option<&str>,
475        locally_evaluated: bool,
476    ) -> EvaluatedFlagRecord {
477        EvaluatedFlagRecord {
478            enabled,
479            variant: variant.map(str::to_string),
480            payload: None,
481            id: Some(42),
482            version: Some(7),
483            reason: Some("condition match".into()),
484            locally_evaluated,
485            has_experiment: None,
486            minimal_flag_called_events: false,
487        }
488    }
489
490    fn build(
491        host: Arc<dyn FeatureFlagEvaluationsHost>,
492        distinct_id: &str,
493    ) -> FeatureFlagEvaluations {
494        let mut flags = HashMap::new();
495        flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
496        flags.insert("beta".into(), record("beta", false, None, false));
497        flags.insert("gamma".into(), record("gamma", true, None, true));
498        FeatureFlagEvaluations::new(
499            host,
500            distinct_id.into(),
501            flags,
502            HashMap::new(),
503            None,
504            Some("req-1".into()),
505            Some(1700000000),
506            false,
507            false,
508        )
509    }
510
511    #[test]
512    fn is_enabled_records_access_and_fires_event() {
513        let host = Arc::new(RecordingHost::default());
514        let snap = build(
515            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
516            "u1",
517        );
518        assert!(snap.is_enabled("alpha"));
519        let captured = host.captured.lock().unwrap();
520        assert_eq!(captured.len(), 1);
521        assert_eq!(captured[0].key, "alpha");
522        let props = &captured[0].properties;
523        assert_eq!(props.get("$feature_flag_id"), Some(&json!(42_u64)));
524        assert_eq!(props.get("$feature_flag_version"), Some(&json!(7_u32)));
525        assert_eq!(
526            props.get("$feature_flag_reason"),
527            Some(&json!("condition match"))
528        );
529        assert_eq!(props.get("$feature_flag_request_id"), Some(&json!("req-1")));
530    }
531
532    #[test]
533    fn get_flag_payload_does_not_record_access_or_fire_event() {
534        let host = Arc::new(RecordingHost::default());
535        let snap = build(
536            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
537            "u1",
538        );
539        assert!(snap.get_flag_payload("alpha").is_none());
540        assert!(host.captured.lock().unwrap().is_empty());
541    }
542
543    #[test]
544    fn empty_distinct_id_does_not_fire_events() {
545        let host = Arc::new(RecordingHost::default());
546        let snap =
547            FeatureFlagEvaluations::empty(Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>);
548        assert!(!snap.is_enabled("anything"));
549        assert!(host.captured.lock().unwrap().is_empty());
550    }
551
552    #[test]
553    fn locally_evaluated_event_omits_evaluated_at_and_carries_locally_evaluated_flag() {
554        let host = Arc::new(RecordingHost::default());
555        let mut flags = HashMap::new();
556        flags.insert(
557            "gamma".into(),
558            EvaluatedFlagRecord {
559                reason: Some("Evaluated locally".into()),
560                ..record("gamma", true, None, true)
561            },
562        );
563        let snap = FeatureFlagEvaluations::new(
564            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
565            "u1".into(),
566            flags,
567            HashMap::new(),
568            None,
569            None,
570            Some(1700000000),
571            false,
572            false,
573        );
574        let _ = snap.is_enabled("gamma");
575        let captured = host.captured.lock().unwrap();
576        let props = &captured[0].properties;
577        assert_eq!(props.get("locally_evaluated"), Some(&json!(true)));
578        assert_eq!(
579            props.get("$feature_flag_reason"),
580            Some(&json!("Evaluated locally"))
581        );
582        assert!(!props.contains_key("$feature_flag_evaluated_at"));
583    }
584
585    #[test]
586    fn errors_while_computing_propagates_to_event() {
587        let host = Arc::new(RecordingHost::default());
588        let mut flags = HashMap::new();
589        flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
590        let snap = FeatureFlagEvaluations::new(
591            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
592            "u1".into(),
593            flags,
594            HashMap::new(),
595            None,
596            Some("req-1".into()),
597            Some(1700000000),
598            true,  // errors_while_computing
599            false, // quota_limited
600        );
601        let _ = snap.is_enabled("alpha");
602        let captured = host.captured.lock().unwrap();
603        assert_eq!(
604            captured[0].properties.get("$feature_flag_error"),
605            Some(&json!("errors_while_computing_flags"))
606        );
607    }
608
609    #[test]
610    fn payload_can_be_set_directly() {
611        let mut flags = HashMap::new();
612        flags.insert(
613            "alpha".into(),
614            EvaluatedFlagRecord {
615                payload: Some(json!({"hello": "world"})),
616                ..record("alpha", true, None, false)
617            },
618        );
619        let host = Arc::new(RecordingHost::default());
620        let snap = FeatureFlagEvaluations::new(
621            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
622            "u1".into(),
623            flags,
624            HashMap::new(),
625            None,
626            None,
627            None,
628            false,
629            false,
630        );
631        assert_eq!(
632            snap.get_flag_payload("alpha"),
633            Some(json!({"hello": "world"}))
634        );
635    }
636
637    #[test]
638    fn quota_limited_combines_with_flag_missing_in_error_string() {
639        let host = Arc::new(RecordingHost::default());
640        let snap = FeatureFlagEvaluations::new(
641            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
642            "u1".into(),
643            HashMap::new(),
644            HashMap::new(),
645            None,
646            None,
647            None,
648            false,
649            true, // quota_limited
650        );
651        assert!(snap.get_flag("does-not-exist").is_none());
652        let captured = host.captured.lock().unwrap();
653        assert_eq!(
654            captured[0].properties.get("$feature_flag_error"),
655            Some(&json!("quota_limited,flag_missing"))
656        );
657    }
658
659    #[test]
660    fn missing_flag_records_flag_missing_error() {
661        let host = Arc::new(RecordingHost::default());
662        let snap = build(
663            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
664            "u1",
665        );
666        assert!(snap.get_flag("does-not-exist").is_none());
667        let captured = host.captured.lock().unwrap();
668        assert_eq!(
669            captured[0].properties.get("$feature_flag_error"),
670            Some(&json!("flag_missing"))
671        );
672    }
673
674    #[test]
675    fn missing_flag_with_no_response_errors_emits_no_error_for_present_flag() {
676        let host = Arc::new(RecordingHost::default());
677        let snap = build(
678            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
679            "u1",
680        );
681        assert!(snap.is_enabled("alpha"));
682        let captured = host.captured.lock().unwrap();
683        assert!(!captured[0].properties.contains_key("$feature_flag_error"));
684    }
685
686    #[test]
687    fn only_accessed_filters_to_accessed_keys() {
688        let host = Arc::new(RecordingHost::default());
689        let snap = build(
690            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
691            "u1",
692        );
693        let _ = snap.is_enabled("alpha");
694        let filtered = snap.only_accessed();
695        let mut keys = filtered.keys();
696        keys.sort();
697        assert_eq!(keys, vec!["alpha".to_string()]);
698    }
699
700    #[test]
701    fn only_accessed_returns_empty_when_nothing_accessed() {
702        let host = Arc::new(RecordingHost::default());
703        let snap = build(
704            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
705            "u1",
706        );
707        let filtered = snap.only_accessed();
708        assert!(filtered.keys().is_empty());
709        assert!(host.warnings.lock().unwrap().is_empty());
710    }
711
712    #[test]
713    fn only_drops_unknown_keys_with_warning() {
714        let host = Arc::new(RecordingHost::default());
715        let snap = build(
716            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
717            "u1",
718        );
719        let filtered = snap.only(&["alpha", "missing"]);
720        assert_eq!(filtered.keys(), vec!["alpha".to_string()]);
721        let warnings = host.warnings.lock().unwrap();
722        assert_eq!(warnings.len(), 1);
723        assert!(warnings[0].contains("missing"));
724    }
725
726    #[test]
727    fn filtered_snapshots_do_not_back_propagate_access_to_parent() {
728        let host = Arc::new(RecordingHost::default());
729        let snap = build(
730            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
731            "u1",
732        );
733        let _ = snap.is_enabled("alpha");
734        let child = snap.only_accessed();
735        let _ = child.is_enabled("alpha");
736        // Parent's accessed set is still {"alpha"}, not affected by child reads.
737        assert_eq!(snap.snapshot_accessed().len(), 1);
738    }
739
740    /// Build a snapshot holding a single flag with an explicit experiment
741    /// signal and pinned minimization gate, mirroring what `evaluate_flags`
742    /// produces per source.
743    fn snapshot_with_flag(
744        host: Arc<dyn FeatureFlagEvaluationsHost>,
745        has_experiment: Option<bool>,
746        minimal_flag_called_events: bool,
747    ) -> FeatureFlagEvaluations {
748        let mut flags = HashMap::new();
749        flags.insert(
750            "gated".into(),
751            EvaluatedFlagRecord {
752                has_experiment,
753                minimal_flag_called_events,
754                ..record("gated", true, None, false)
755            },
756        );
757        FeatureFlagEvaluations::new(
758            host,
759            "u1".into(),
760            flags,
761            HashMap::new(),
762            None,
763            Some("req-1".into()),
764            Some(1700000000),
765            false,
766            false,
767        )
768    }
769
770    #[test]
771    fn has_experiment_true_sets_property() {
772        let host = Arc::new(RecordingHost::default());
773        let snap = snapshot_with_flag(
774            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
775            Some(true),
776            false,
777        );
778        let _ = snap.is_enabled("gated");
779        let captured = host.captured.lock().unwrap();
780        assert_eq!(
781            captured[0].properties.get("$feature_flag_has_experiment"),
782            Some(&json!(true))
783        );
784    }
785
786    #[test]
787    fn has_experiment_false_sets_property() {
788        let host = Arc::new(RecordingHost::default());
789        let snap = snapshot_with_flag(
790            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
791            Some(false),
792            false,
793        );
794        let _ = snap.is_enabled("gated");
795        let captured = host.captured.lock().unwrap();
796        assert_eq!(
797            captured[0].properties.get("$feature_flag_has_experiment"),
798            Some(&json!(false))
799        );
800    }
801
802    #[test]
803    fn has_experiment_unknown_omits_property() {
804        let host = Arc::new(RecordingHost::default());
805        let snap = snapshot_with_flag(
806            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
807            None,
808            true,
809        );
810        let _ = snap.is_enabled("gated");
811        let captured = host.captured.lock().unwrap();
812        // Never fabricated as false when the server did not report it.
813        assert!(!captured[0]
814            .properties
815            .contains_key("$feature_flag_has_experiment"));
816    }
817
818    #[test]
819    fn minimizes_only_when_gate_on_and_no_experiment() {
820        // (has_experiment, gate) -> expected `minimal`
821        let cases = [
822            (Some(false), true, true),   // gate on, no experiment -> minimize
823            (Some(true), true, false),   // experiment-linked -> full
824            (None, true, false),         // experiment unknown -> full
825            (Some(false), false, false), // gate off -> full
826        ];
827        for (has_experiment, gate, expected) in cases {
828            let host = Arc::new(RecordingHost::default());
829            let snap = snapshot_with_flag(
830                Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
831                has_experiment,
832                gate,
833            );
834            let _ = snap.is_enabled("gated");
835            let captured = host.captured.lock().unwrap();
836            assert_eq!(
837                captured[0].minimal, expected,
838                "has_experiment={:?} gate={} should minimal={}",
839                has_experiment, gate, expected
840            );
841        }
842    }
843
844    #[test]
845    fn missing_flag_is_never_minimized() {
846        let host = Arc::new(RecordingHost::default());
847        let snap = snapshot_with_flag(
848            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
849            Some(false),
850            true,
851        );
852        // A key absent from the snapshot has no pinned gate/experiment signal,
853        // so it always keeps the full shape.
854        let _ = snap.get_flag("not-present");
855        let captured = host.captured.lock().unwrap();
856        assert!(!captured[0].minimal);
857    }
858
859    #[test]
860    fn gate_is_pinned_per_flag_not_shared_across_a_snapshot() {
861        // Two flags in ONE snapshot with different pinned gates: a local flag
862        // whose definitions had the gate on, and a remote flag whose /flags
863        // response had it off. Each event must reflect its own source's gate.
864        // Guards against collapsing the gate into a single shared snapshot field.
865        let host = Arc::new(RecordingHost::default());
866        let mut flags = HashMap::new();
867        flags.insert(
868            "local-gated".into(),
869            EvaluatedFlagRecord {
870                has_experiment: Some(false),
871                minimal_flag_called_events: true,
872                ..record("local-gated", true, None, true)
873            },
874        );
875        flags.insert(
876            "remote-ungated".into(),
877            EvaluatedFlagRecord {
878                has_experiment: Some(false),
879                minimal_flag_called_events: false,
880                ..record("remote-ungated", true, None, false)
881            },
882        );
883        let snap = FeatureFlagEvaluations::new(
884            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
885            "u1".into(),
886            flags,
887            HashMap::new(),
888            None,
889            Some("req-1".into()),
890            Some(1700000000),
891            false,
892            false,
893        );
894
895        let _ = snap.is_enabled("local-gated");
896        let _ = snap.is_enabled("remote-ungated");
897
898        let captured = host.captured.lock().unwrap();
899        let by_key: HashMap<&str, bool> = captured
900            .iter()
901            .map(|p| (p.key.as_str(), p.minimal))
902            .collect();
903        assert_eq!(by_key.get("local-gated"), Some(&true));
904        assert_eq!(by_key.get("remote-ungated"), Some(&false));
905    }
906
907    #[test]
908    fn event_properties_attaches_active_flags_sorted() {
909        let host = Arc::new(RecordingHost::default());
910        let snap = build(
911            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
912            "u1",
913        );
914        let props = snap.event_properties();
915        assert_eq!(props.get("$feature/alpha"), Some(&json!("test")));
916        assert_eq!(props.get("$feature/beta"), Some(&json!(false)));
917        assert_eq!(props.get("$feature/gamma"), Some(&json!(true)));
918        let active = props.get("$active_feature_flags").unwrap();
919        assert_eq!(active, &json!(["alpha", "gamma"]));
920    }
921}