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>>,
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 pub flag_keys: Option<Vec<String>>,
73}
74
75pub 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 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 #[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 #[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 #[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 #[must_use]
173 pub fn keys(&self) -> Vec<String> {
174 self.flags.keys().cloned().collect()
175 }
176
177 #[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 #[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 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 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 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, false, );
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, );
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 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}