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>>,
101}
102
103pub 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 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 #[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 #[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 #[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 #[must_use]
213 pub fn keys(&self) -> Vec<String> {
214 self.flags.keys().cloned().collect()
215 }
216
217 #[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 #[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 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 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 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 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 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, false, );
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, );
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 assert_eq!(snap.snapshot_accessed().len(), 1);
739 }
740
741 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 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 let cases = [
823 (Some(false), true, true), (Some(true), true, false), (None, true, false), (Some(false), false, false), ];
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 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 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}