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}
35
36#[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
48pub(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#[derive(Default, Clone, Debug)]
58pub struct EvaluateFlagsOptions {
59 pub groups: Option<HashMap<String, String>>,
64 pub person_properties: Option<HashMap<String, Value>>,
67 pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
70 pub only_evaluate_locally: bool,
74 pub disable_geoip: Option<bool>,
77 pub flag_keys: Option<Vec<String>>,
86}
87
88pub 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 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 #[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 #[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 #[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 #[must_use]
198 pub fn keys(&self) -> Vec<String> {
199 self.flags.keys().cloned().collect()
200 }
201
202 #[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 #[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 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 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 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, false, );
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, );
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 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}