1use std::collections::{HashMap, HashSet};
15use std::sync::{Arc, Mutex};
16
17use serde_json::{json, Value};
18
19use crate::feature_flags::FlagValue;
20
21#[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 pub has_experiment: Option<bool>,
38 pub minimal_flag_called_events: bool,
44}
45
46#[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 pub minimal: bool,
60}
61
62pub(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#[derive(Default, Clone, Debug)]
72pub struct EvaluateFlagsOptions {
73 pub groups: Option<HashMap<String, String>>,
78 pub person_properties: Option<HashMap<String, Value>>,
81 pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
84 pub only_evaluate_locally: bool,
88 pub disable_geoip: Option<bool>,
91 pub flag_keys: Option<Vec<String>>,
100}
101
102pub 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 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 #[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 #[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 #[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 #[must_use]
212 pub fn keys(&self) -> Vec<String> {
213 self.flags.keys().cloned().collect()
214 }
215
216 #[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 #[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 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 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 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 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 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, false, );
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, );
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 assert_eq!(snap.snapshot_accessed().len(), 1);
738 }
739
740 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 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 let cases = [
822 (Some(false), true, true), (Some(true), true, false), (None, true, false), (Some(false), false, false), ];
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 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 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}