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