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}
35
36/// Parameters dispatched to [`FeatureFlagEvaluationsHost::capture_flag_called_event_if_needed`]
37/// each time a snapshot method records a flag access.
38#[derive(Debug, Clone)]
39pub(crate) struct FlagCalledEventParams {
40    pub distinct_id: String,
41    pub key: String,
42    pub response: Option<FlagValue>,
43    pub groups: HashMap<String, String>,
44    pub disable_geoip: Option<bool>,
45    pub properties: HashMap<String, Value>,
46}
47
48/// Dependency-inverted host interface used by [`FeatureFlagEvaluations`] to
49/// emit dedup-aware `$feature_flag_called` events. The client constructs one
50/// of these once and shares it across all snapshots it produces.
51pub(crate) trait FeatureFlagEvaluationsHost: Send + Sync {
52    fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams);
53    fn log_warning(&self, message: &str);
54}
55
56/// Optional inputs for [`Client::evaluate_flags`](crate::Client::evaluate_flags).
57#[derive(Default, Clone, Debug)]
58pub struct EvaluateFlagsOptions {
59    /// Group keys for group-targeted feature flags, keyed by group type (for
60    /// example `{ "company": "company_123" }`). These groups are also
61    /// attached to `$feature_flag_called` events emitted by the returned
62    /// snapshot.
63    pub groups: Option<HashMap<String, String>>,
64    /// Person properties used by remote or local flag evaluation. Provide any
65    /// properties referenced by release conditions when using local evaluation.
66    pub person_properties: Option<HashMap<String, Value>>,
67    /// Group properties used by group-targeted or mixed-targeting flags, keyed
68    /// first by group type and then by property name.
69    pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
70    /// When `true`, skip the remote `/flags` request and return only locally
71    /// evaluated results. If local evaluation is not configured, the snapshot is
72    /// empty.
73    pub only_evaluate_locally: bool,
74    /// Per-call override for GeoIP behavior on `/flags` and
75    /// `$feature_flag_called` requests. `None` uses the client-level setting.
76    pub disable_geoip: Option<bool>,
77    /// Optional list of flag keys. When provided, only these flags are
78    /// evaluated — the underlying `/flags` request asks the server for just
79    /// this subset, which makes the response smaller and the request cheaper.
80    /// Use this when you only need a handful of flags out of many.
81    ///
82    /// Distinct from [`FeatureFlagEvaluations::only`]: `flag_keys` trims the
83    /// network call, [`only`](FeatureFlagEvaluations::only) trims which flags
84    /// get attached to a captured event after evaluation.
85    pub flag_keys: Option<Vec<String>>,
86}
87
88/// A snapshot of evaluated feature flags for one `distinct_id`.
89///
90/// Returned by [`Client::evaluate_flags`](crate::Client::evaluate_flags). Reading
91/// flags via [`is_enabled`] or [`get_flag`] both records the access (so it can be
92/// later attached to a capture event) and emits a deduplicated
93/// `$feature_flag_called` event. [`get_flag_payload`] is intentionally event-free.
94///
95/// [`is_enabled`]: FeatureFlagEvaluations::is_enabled
96/// [`get_flag`]: FeatureFlagEvaluations::get_flag
97/// [`get_flag_payload`]: FeatureFlagEvaluations::get_flag_payload
98pub struct FeatureFlagEvaluations {
99    host: Arc<dyn FeatureFlagEvaluationsHost>,
100    distinct_id: String,
101    flags: HashMap<String, EvaluatedFlagRecord>,
102    groups: HashMap<String, String>,
103    disable_geoip: Option<bool>,
104    request_id: Option<String>,
105    evaluated_at: Option<i64>,
106    errors_while_computing: bool,
107    quota_limited: bool,
108    accessed: Mutex<HashSet<String>>,
109}
110
111impl FeatureFlagEvaluations {
112    #[allow(clippy::too_many_arguments)]
113    pub(crate) fn new(
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    ) -> Self {
124        Self {
125            host,
126            distinct_id,
127            flags,
128            groups,
129            disable_geoip,
130            request_id,
131            evaluated_at,
132            errors_while_computing,
133            quota_limited,
134            accessed: Mutex::new(HashSet::new()),
135        }
136    }
137
138    /// Construct an empty snapshot used when no `distinct_id` was resolvable.
139    /// The empty `distinct_id` short-circuits event firing inside
140    /// [`record_access`](Self::record_access).
141    pub(crate) fn empty(host: Arc<dyn FeatureFlagEvaluationsHost>) -> Self {
142        Self::new(
143            host,
144            String::new(),
145            HashMap::new(),
146            HashMap::new(),
147            None,
148            None,
149            None,
150            false,
151            false,
152        )
153    }
154
155    /// Whether `key` is enabled. Records the access and fires (deduplicated)
156    /// `$feature_flag_called`.
157    ///
158    /// # Returns
159    ///
160    /// `true` for enabled boolean flags or matched multivariate variants, and
161    /// `false` for disabled or missing flags.
162    #[must_use]
163    pub fn is_enabled(&self, key: &str) -> bool {
164        self.record_access(key);
165        self.flags.get(key).is_some_and(|f| f.enabled)
166    }
167
168    /// Look up the value of `key`.
169    ///
170    /// # Returns
171    ///
172    /// - `None` when the flag is not in the snapshot,
173    /// - `Some(FlagValue::Boolean(false))` when disabled,
174    /// - `Some(FlagValue::String(variant))` for a multivariate match,
175    /// - `Some(FlagValue::Boolean(true))` when enabled with no variant.
176    ///
177    /// Records the access and fires (deduplicated) `$feature_flag_called`.
178    #[must_use]
179    pub fn get_flag(&self, key: &str) -> Option<FlagValue> {
180        self.record_access(key);
181        let flag = self.flags.get(key)?;
182        Some(flag_value_for(flag))
183    }
184
185    /// Return the JSON payload associated with `key`, if any.
186    ///
187    /// # Remarks
188    ///
189    /// This call does **not** count as an access and does **not** fire any
190    /// event, matching the behavior documented for server-side SDKs.
191    #[must_use]
192    pub fn get_flag_payload(&self, key: &str) -> Option<Value> {
193        self.flags.get(key).and_then(|f| f.payload.clone())
194    }
195
196    /// All flag keys present in this snapshot.
197    #[must_use]
198    pub fn keys(&self) -> Vec<String> {
199        self.flags.keys().cloned().collect()
200    }
201
202    /// A clone of the snapshot containing only flags whose values were read via
203    /// [`is_enabled`](Self::is_enabled) or [`get_flag`](Self::get_flag) before
204    /// this call.
205    ///
206    /// Order-dependent: if nothing has been accessed yet, the returned snapshot
207    /// is empty. Pre-access the flags you want to attach before calling this.
208    #[must_use]
209    pub fn only_accessed(&self) -> Self {
210        let accessed = self.snapshot_accessed();
211        let filtered = self
212            .flags
213            .iter()
214            .filter(|(k, _)| accessed.contains(k.as_str()))
215            .map(|(k, v)| (k.clone(), v.clone()))
216            .collect();
217        self.clone_with(filtered)
218    }
219
220    /// A clone of the snapshot containing only the listed `keys` (preserving
221    /// records). Unknown keys are dropped and surfaced via a single warning.
222    ///
223    /// Use this before [`Event::with_flags`](crate::Event::with_flags) to limit
224    /// the `$feature/<key>` properties attached to a captured event.
225    #[must_use]
226    pub fn only(&self, keys: &[&str]) -> Self {
227        let mut filtered: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
228        let mut missing: Vec<&str> = Vec::new();
229        for key in keys {
230            match self.flags.get(*key) {
231                Some(record) => {
232                    filtered.insert((*key).to_string(), record.clone());
233                }
234                None => missing.push(*key),
235            }
236        }
237        if !missing.is_empty() {
238            self.host.log_warning(&format!(
239                "FeatureFlagEvaluations::only() was called with flag keys that are not in the \
240                 evaluation set and will be dropped: {}",
241                missing.join(", ")
242            ));
243        }
244        self.clone_with(filtered)
245    }
246
247    /// Build the property map for capture integration: `$feature/<key>` for
248    /// every flag, plus a sorted `$active_feature_flags` list of enabled keys.
249    pub(crate) fn event_properties(&self) -> HashMap<String, Value> {
250        let mut props: HashMap<String, Value> = HashMap::with_capacity(self.flags.len() + 1);
251        let mut active: Vec<String> = Vec::new();
252        for (key, flag) in &self.flags {
253            let value = flag_value_json(flag);
254            props.insert(format!("$feature/{key}"), value);
255            if flag.enabled {
256                active.push(key.clone());
257            }
258        }
259        if !active.is_empty() {
260            active.sort();
261            props.insert("$active_feature_flags".into(), json!(active));
262        }
263        props
264    }
265
266    fn snapshot_accessed(&self) -> HashSet<String> {
267        match self.accessed.lock() {
268            Ok(g) => g.clone(),
269            Err(p) => p.into_inner().clone(),
270        }
271    }
272
273    fn clone_with(&self, flags: HashMap<String, EvaluatedFlagRecord>) -> Self {
274        Self {
275            host: Arc::clone(&self.host),
276            distinct_id: self.distinct_id.clone(),
277            flags,
278            groups: self.groups.clone(),
279            disable_geoip: self.disable_geoip,
280            request_id: self.request_id.clone(),
281            evaluated_at: self.evaluated_at,
282            errors_while_computing: self.errors_while_computing,
283            quota_limited: self.quota_limited,
284            accessed: Mutex::new(self.snapshot_accessed()),
285        }
286    }
287
288    fn record_access(&self, key: &str) {
289        if let Ok(mut accessed) = self.accessed.lock() {
290            accessed.insert(key.to_string());
291        }
292
293        // Snapshots created without a resolvable distinct_id must never emit
294        // `$feature_flag_called` — those events would land with an empty
295        // distinct_id and pollute downstream analytics.
296        if self.distinct_id.is_empty() {
297            return;
298        }
299
300        let flag = self.flags.get(key);
301        let response = flag.map(flag_value_for);
302        let properties = self.build_called_event_properties(key, flag, &response);
303
304        self.host
305            .capture_flag_called_event_if_needed(FlagCalledEventParams {
306                distinct_id: self.distinct_id.clone(),
307                key: key.to_string(),
308                response,
309                groups: self.groups.clone(),
310                disable_geoip: self.disable_geoip,
311                properties,
312            });
313    }
314
315    fn build_called_event_properties(
316        &self,
317        key: &str,
318        flag: Option<&EvaluatedFlagRecord>,
319        response: &Option<FlagValue>,
320    ) -> HashMap<String, Value> {
321        let mut props: HashMap<String, Value> = HashMap::new();
322        props.insert("$feature_flag".into(), json!(key));
323        let response_json = match response {
324            Some(v) => flag_value_to_json(v),
325            None => Value::Null,
326        };
327        props.insert("$feature_flag_response".into(), response_json.clone());
328        props.insert(format!("$feature/{key}"), response_json);
329
330        let locally_evaluated = flag.is_some_and(|f| f.locally_evaluated);
331        props.insert("locally_evaluated".into(), json!(locally_evaluated));
332
333        if let Some(flag) = flag {
334            if let Some(payload) = &flag.payload {
335                props.insert("$feature_flag_payload".into(), payload.clone());
336            }
337            if let Some(id) = flag.id {
338                if id != 0 {
339                    props.insert("$feature_flag_id".into(), json!(id));
340                }
341            }
342            if let Some(version) = flag.version {
343                if version != 0 {
344                    props.insert("$feature_flag_version".into(), json!(version));
345                }
346            }
347            if let Some(reason) = &flag.reason {
348                if !reason.is_empty() {
349                    props.insert("$feature_flag_reason".into(), json!(reason));
350                }
351            }
352        }
353
354        if let Some(request_id) = &self.request_id {
355            props.insert("$feature_flag_request_id".into(), json!(request_id));
356        }
357
358        if !locally_evaluated {
359            if let Some(evaluated_at) = self.evaluated_at {
360                props.insert("$feature_flag_evaluated_at".into(), json!(evaluated_at));
361            }
362        }
363
364        // Comma-joined `$feature_flag_error` matching the single-flag path's
365        // granularity: response-level errors (errors-while-computing,
366        // quota-limited) combine with per-flag errors (flag-missing) so
367        // consumers can filter by type.
368        let mut errors: Vec<&str> = Vec::new();
369        if self.errors_while_computing {
370            errors.push("errors_while_computing_flags");
371        }
372        if self.quota_limited {
373            errors.push("quota_limited");
374        }
375        if flag.is_none() {
376            errors.push("flag_missing");
377        }
378        if !errors.is_empty() {
379            props.insert("$feature_flag_error".into(), json!(errors.join(",")));
380        }
381
382        props
383    }
384}
385
386impl std::fmt::Debug for FeatureFlagEvaluations {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        f.debug_struct("FeatureFlagEvaluations")
389            .field("distinct_id", &self.distinct_id)
390            .field("flags", &self.flags)
391            .field("groups", &self.groups)
392            .field("disable_geoip", &self.disable_geoip)
393            .field("request_id", &self.request_id)
394            .field("evaluated_at", &self.evaluated_at)
395            .field("errors_while_computing", &self.errors_while_computing)
396            .field("quota_limited", &self.quota_limited)
397            .finish_non_exhaustive()
398    }
399}
400
401fn flag_value_for(flag: &EvaluatedFlagRecord) -> FlagValue {
402    if !flag.enabled {
403        FlagValue::Boolean(false)
404    } else if let Some(variant) = &flag.variant {
405        FlagValue::String(variant.clone())
406    } else {
407        FlagValue::Boolean(true)
408    }
409}
410
411fn flag_value_to_json(value: &FlagValue) -> Value {
412    match value {
413        FlagValue::Boolean(b) => json!(b),
414        FlagValue::String(s) => json!(s),
415    }
416}
417
418fn flag_value_json(flag: &EvaluatedFlagRecord) -> Value {
419    flag_value_to_json(&flag_value_for(flag))
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425    use std::sync::Mutex as StdMutex;
426
427    #[derive(Default)]
428    struct RecordingHost {
429        captured: StdMutex<Vec<FlagCalledEventParams>>,
430        warnings: StdMutex<Vec<String>>,
431    }
432
433    impl FeatureFlagEvaluationsHost for RecordingHost {
434        fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
435            self.captured.lock().unwrap().push(params);
436        }
437        fn log_warning(&self, message: &str) {
438            self.warnings.lock().unwrap().push(message.to_string());
439        }
440    }
441
442    fn record(
443        _key: &str,
444        enabled: bool,
445        variant: Option<&str>,
446        locally_evaluated: bool,
447    ) -> EvaluatedFlagRecord {
448        EvaluatedFlagRecord {
449            enabled,
450            variant: variant.map(str::to_string),
451            payload: None,
452            id: Some(42),
453            version: Some(7),
454            reason: Some("condition match".into()),
455            locally_evaluated,
456        }
457    }
458
459    fn build(
460        host: Arc<dyn FeatureFlagEvaluationsHost>,
461        distinct_id: &str,
462    ) -> FeatureFlagEvaluations {
463        let mut flags = HashMap::new();
464        flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
465        flags.insert("beta".into(), record("beta", false, None, false));
466        flags.insert("gamma".into(), record("gamma", true, None, true));
467        FeatureFlagEvaluations::new(
468            host,
469            distinct_id.into(),
470            flags,
471            HashMap::new(),
472            None,
473            Some("req-1".into()),
474            Some(1700000000),
475            false,
476            false,
477        )
478    }
479
480    #[test]
481    fn is_enabled_records_access_and_fires_event() {
482        let host = Arc::new(RecordingHost::default());
483        let snap = build(
484            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
485            "u1",
486        );
487        assert!(snap.is_enabled("alpha"));
488        let captured = host.captured.lock().unwrap();
489        assert_eq!(captured.len(), 1);
490        assert_eq!(captured[0].key, "alpha");
491        let props = &captured[0].properties;
492        assert_eq!(props.get("$feature_flag_id"), Some(&json!(42_u64)));
493        assert_eq!(props.get("$feature_flag_version"), Some(&json!(7_u32)));
494        assert_eq!(
495            props.get("$feature_flag_reason"),
496            Some(&json!("condition match"))
497        );
498        assert_eq!(props.get("$feature_flag_request_id"), Some(&json!("req-1")));
499    }
500
501    #[test]
502    fn get_flag_payload_does_not_record_access_or_fire_event() {
503        let host = Arc::new(RecordingHost::default());
504        let snap = build(
505            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
506            "u1",
507        );
508        assert!(snap.get_flag_payload("alpha").is_none());
509        assert!(host.captured.lock().unwrap().is_empty());
510    }
511
512    #[test]
513    fn empty_distinct_id_does_not_fire_events() {
514        let host = Arc::new(RecordingHost::default());
515        let snap =
516            FeatureFlagEvaluations::empty(Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>);
517        assert!(!snap.is_enabled("anything"));
518        assert!(host.captured.lock().unwrap().is_empty());
519    }
520
521    #[test]
522    fn locally_evaluated_event_omits_evaluated_at_and_carries_locally_evaluated_flag() {
523        let host = Arc::new(RecordingHost::default());
524        let mut flags = HashMap::new();
525        flags.insert(
526            "gamma".into(),
527            EvaluatedFlagRecord {
528                reason: Some("Evaluated locally".into()),
529                ..record("gamma", true, None, true)
530            },
531        );
532        let snap = FeatureFlagEvaluations::new(
533            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
534            "u1".into(),
535            flags,
536            HashMap::new(),
537            None,
538            None,
539            Some(1700000000),
540            false,
541            false,
542        );
543        let _ = snap.is_enabled("gamma");
544        let captured = host.captured.lock().unwrap();
545        let props = &captured[0].properties;
546        assert_eq!(props.get("locally_evaluated"), Some(&json!(true)));
547        assert_eq!(
548            props.get("$feature_flag_reason"),
549            Some(&json!("Evaluated locally"))
550        );
551        assert!(!props.contains_key("$feature_flag_evaluated_at"));
552    }
553
554    #[test]
555    fn errors_while_computing_propagates_to_event() {
556        let host = Arc::new(RecordingHost::default());
557        let mut flags = HashMap::new();
558        flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
559        let snap = FeatureFlagEvaluations::new(
560            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
561            "u1".into(),
562            flags,
563            HashMap::new(),
564            None,
565            Some("req-1".into()),
566            Some(1700000000),
567            true,  // errors_while_computing
568            false, // quota_limited
569        );
570        let _ = snap.is_enabled("alpha");
571        let captured = host.captured.lock().unwrap();
572        assert_eq!(
573            captured[0].properties.get("$feature_flag_error"),
574            Some(&json!("errors_while_computing_flags"))
575        );
576    }
577
578    #[test]
579    fn payload_can_be_set_directly() {
580        let mut flags = HashMap::new();
581        flags.insert(
582            "alpha".into(),
583            EvaluatedFlagRecord {
584                payload: Some(json!({"hello": "world"})),
585                ..record("alpha", true, None, false)
586            },
587        );
588        let host = Arc::new(RecordingHost::default());
589        let snap = FeatureFlagEvaluations::new(
590            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
591            "u1".into(),
592            flags,
593            HashMap::new(),
594            None,
595            None,
596            None,
597            false,
598            false,
599        );
600        assert_eq!(
601            snap.get_flag_payload("alpha"),
602            Some(json!({"hello": "world"}))
603        );
604    }
605
606    #[test]
607    fn quota_limited_combines_with_flag_missing_in_error_string() {
608        let host = Arc::new(RecordingHost::default());
609        let snap = FeatureFlagEvaluations::new(
610            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
611            "u1".into(),
612            HashMap::new(),
613            HashMap::new(),
614            None,
615            None,
616            None,
617            false,
618            true, // quota_limited
619        );
620        assert!(snap.get_flag("does-not-exist").is_none());
621        let captured = host.captured.lock().unwrap();
622        assert_eq!(
623            captured[0].properties.get("$feature_flag_error"),
624            Some(&json!("quota_limited,flag_missing"))
625        );
626    }
627
628    #[test]
629    fn missing_flag_records_flag_missing_error() {
630        let host = Arc::new(RecordingHost::default());
631        let snap = build(
632            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
633            "u1",
634        );
635        assert!(snap.get_flag("does-not-exist").is_none());
636        let captured = host.captured.lock().unwrap();
637        assert_eq!(
638            captured[0].properties.get("$feature_flag_error"),
639            Some(&json!("flag_missing"))
640        );
641    }
642
643    #[test]
644    fn missing_flag_with_no_response_errors_emits_no_error_for_present_flag() {
645        let host = Arc::new(RecordingHost::default());
646        let snap = build(
647            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
648            "u1",
649        );
650        assert!(snap.is_enabled("alpha"));
651        let captured = host.captured.lock().unwrap();
652        assert!(!captured[0].properties.contains_key("$feature_flag_error"));
653    }
654
655    #[test]
656    fn only_accessed_filters_to_accessed_keys() {
657        let host = Arc::new(RecordingHost::default());
658        let snap = build(
659            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
660            "u1",
661        );
662        let _ = snap.is_enabled("alpha");
663        let filtered = snap.only_accessed();
664        let mut keys = filtered.keys();
665        keys.sort();
666        assert_eq!(keys, vec!["alpha".to_string()]);
667    }
668
669    #[test]
670    fn only_accessed_returns_empty_when_nothing_accessed() {
671        let host = Arc::new(RecordingHost::default());
672        let snap = build(
673            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
674            "u1",
675        );
676        let filtered = snap.only_accessed();
677        assert!(filtered.keys().is_empty());
678        assert!(host.warnings.lock().unwrap().is_empty());
679    }
680
681    #[test]
682    fn only_drops_unknown_keys_with_warning() {
683        let host = Arc::new(RecordingHost::default());
684        let snap = build(
685            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
686            "u1",
687        );
688        let filtered = snap.only(&["alpha", "missing"]);
689        assert_eq!(filtered.keys(), vec!["alpha".to_string()]);
690        let warnings = host.warnings.lock().unwrap();
691        assert_eq!(warnings.len(), 1);
692        assert!(warnings[0].contains("missing"));
693    }
694
695    #[test]
696    fn filtered_snapshots_do_not_back_propagate_access_to_parent() {
697        let host = Arc::new(RecordingHost::default());
698        let snap = build(
699            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
700            "u1",
701        );
702        let _ = snap.is_enabled("alpha");
703        let child = snap.only_accessed();
704        let _ = child.is_enabled("alpha");
705        // Parent's accessed set is still {"alpha"}, not affected by child reads.
706        assert_eq!(snap.snapshot_accessed().len(), 1);
707    }
708
709    #[test]
710    fn event_properties_attaches_active_flags_sorted() {
711        let host = Arc::new(RecordingHost::default());
712        let snap = build(
713            Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
714            "u1",
715        );
716        let props = snap.event_properties();
717        assert_eq!(props.get("$feature/alpha"), Some(&json!("test")));
718        assert_eq!(props.get("$feature/beta"), Some(&json!(false)));
719        assert_eq!(props.get("$feature/gamma"), Some(&json!(true)));
720        let active = props.get("$active_feature_flags").unwrap();
721        assert_eq!(active, &json!(["alpha", "gamma"]));
722    }
723}