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