use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use serde_json::{json, Value};
use crate::feature_flags::FlagValue;
#[derive(Debug, Clone)]
pub(crate) struct EvaluatedFlagRecord {
pub enabled: bool,
pub variant: Option<String>,
pub payload: Option<Value>,
pub id: Option<u64>,
pub version: Option<u32>,
pub reason: Option<String>,
pub locally_evaluated: bool,
}
#[derive(Debug, Clone)]
pub(crate) struct FlagCalledEventParams {
pub distinct_id: String,
pub key: String,
pub response: Option<FlagValue>,
pub groups: HashMap<String, String>,
pub disable_geoip: Option<bool>,
pub properties: HashMap<String, Value>,
}
pub(crate) trait FeatureFlagEvaluationsHost: Send + Sync {
fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams);
fn log_warning(&self, message: &str);
}
#[derive(Default, Clone, Debug)]
pub struct EvaluateFlagsOptions {
pub groups: Option<HashMap<String, String>>,
pub person_properties: Option<HashMap<String, Value>>,
pub group_properties: Option<HashMap<String, HashMap<String, Value>>>,
pub only_evaluate_locally: bool,
pub disable_geoip: Option<bool>,
pub flag_keys: Option<Vec<String>>,
}
pub struct FeatureFlagEvaluations {
host: Arc<dyn FeatureFlagEvaluationsHost>,
distinct_id: String,
flags: HashMap<String, EvaluatedFlagRecord>,
groups: HashMap<String, String>,
disable_geoip: Option<bool>,
request_id: Option<String>,
evaluated_at: Option<i64>,
errors_while_computing: bool,
quota_limited: bool,
accessed: Mutex<HashSet<String>>,
}
impl FeatureFlagEvaluations {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
host: Arc<dyn FeatureFlagEvaluationsHost>,
distinct_id: String,
flags: HashMap<String, EvaluatedFlagRecord>,
groups: HashMap<String, String>,
disable_geoip: Option<bool>,
request_id: Option<String>,
evaluated_at: Option<i64>,
errors_while_computing: bool,
quota_limited: bool,
) -> Self {
Self {
host,
distinct_id,
flags,
groups,
disable_geoip,
request_id,
evaluated_at,
errors_while_computing,
quota_limited,
accessed: Mutex::new(HashSet::new()),
}
}
pub(crate) fn empty(host: Arc<dyn FeatureFlagEvaluationsHost>) -> Self {
Self::new(
host,
String::new(),
HashMap::new(),
HashMap::new(),
None,
None,
None,
false,
false,
)
}
#[must_use]
pub fn is_enabled(&self, key: &str) -> bool {
self.record_access(key);
self.flags.get(key).is_some_and(|f| f.enabled)
}
#[must_use]
pub fn get_flag(&self, key: &str) -> Option<FlagValue> {
self.record_access(key);
let flag = self.flags.get(key)?;
Some(flag_value_for(flag))
}
#[must_use]
pub fn get_flag_payload(&self, key: &str) -> Option<Value> {
self.flags.get(key).and_then(|f| f.payload.clone())
}
#[must_use]
pub fn keys(&self) -> Vec<String> {
self.flags.keys().cloned().collect()
}
#[must_use]
pub fn only_accessed(&self) -> Self {
let accessed = self.snapshot_accessed();
let filtered = self
.flags
.iter()
.filter(|(k, _)| accessed.contains(k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
self.clone_with(filtered)
}
#[must_use]
pub fn only(&self, keys: &[&str]) -> Self {
let mut filtered: HashMap<String, EvaluatedFlagRecord> = HashMap::new();
let mut missing: Vec<&str> = Vec::new();
for key in keys {
match self.flags.get(*key) {
Some(record) => {
filtered.insert((*key).to_string(), record.clone());
}
None => missing.push(*key),
}
}
if !missing.is_empty() {
self.host.log_warning(&format!(
"FeatureFlagEvaluations::only() was called with flag keys that are not in the \
evaluation set and will be dropped: {}",
missing.join(", ")
));
}
self.clone_with(filtered)
}
pub(crate) fn event_properties(&self) -> HashMap<String, Value> {
let mut props: HashMap<String, Value> = HashMap::with_capacity(self.flags.len() + 1);
let mut active: Vec<String> = Vec::new();
for (key, flag) in &self.flags {
let value = flag_value_json(flag);
props.insert(format!("$feature/{key}"), value);
if flag.enabled {
active.push(key.clone());
}
}
if !active.is_empty() {
active.sort();
props.insert("$active_feature_flags".into(), json!(active));
}
props
}
fn snapshot_accessed(&self) -> HashSet<String> {
match self.accessed.lock() {
Ok(g) => g.clone(),
Err(p) => p.into_inner().clone(),
}
}
fn clone_with(&self, flags: HashMap<String, EvaluatedFlagRecord>) -> Self {
Self {
host: Arc::clone(&self.host),
distinct_id: self.distinct_id.clone(),
flags,
groups: self.groups.clone(),
disable_geoip: self.disable_geoip,
request_id: self.request_id.clone(),
evaluated_at: self.evaluated_at,
errors_while_computing: self.errors_while_computing,
quota_limited: self.quota_limited,
accessed: Mutex::new(self.snapshot_accessed()),
}
}
fn record_access(&self, key: &str) {
if let Ok(mut accessed) = self.accessed.lock() {
accessed.insert(key.to_string());
}
if self.distinct_id.is_empty() {
return;
}
let flag = self.flags.get(key);
let response = flag.map(flag_value_for);
let properties = self.build_called_event_properties(key, flag, &response);
self.host
.capture_flag_called_event_if_needed(FlagCalledEventParams {
distinct_id: self.distinct_id.clone(),
key: key.to_string(),
response,
groups: self.groups.clone(),
disable_geoip: self.disable_geoip,
properties,
});
}
fn build_called_event_properties(
&self,
key: &str,
flag: Option<&EvaluatedFlagRecord>,
response: &Option<FlagValue>,
) -> HashMap<String, Value> {
let mut props: HashMap<String, Value> = HashMap::new();
props.insert("$feature_flag".into(), json!(key));
let response_json = match response {
Some(v) => flag_value_to_json(v),
None => Value::Null,
};
props.insert("$feature_flag_response".into(), response_json.clone());
props.insert(format!("$feature/{key}"), response_json);
let locally_evaluated = flag.is_some_and(|f| f.locally_evaluated);
props.insert("locally_evaluated".into(), json!(locally_evaluated));
if let Some(flag) = flag {
if let Some(payload) = &flag.payload {
props.insert("$feature_flag_payload".into(), payload.clone());
}
if let Some(id) = flag.id {
if id != 0 {
props.insert("$feature_flag_id".into(), json!(id));
}
}
if let Some(version) = flag.version {
if version != 0 {
props.insert("$feature_flag_version".into(), json!(version));
}
}
if let Some(reason) = &flag.reason {
if !reason.is_empty() {
props.insert("$feature_flag_reason".into(), json!(reason));
}
}
}
if let Some(request_id) = &self.request_id {
props.insert("$feature_flag_request_id".into(), json!(request_id));
}
if !locally_evaluated {
if let Some(evaluated_at) = self.evaluated_at {
props.insert("$feature_flag_evaluated_at".into(), json!(evaluated_at));
}
}
let mut errors: Vec<&str> = Vec::new();
if self.errors_while_computing {
errors.push("errors_while_computing_flags");
}
if self.quota_limited {
errors.push("quota_limited");
}
if flag.is_none() {
errors.push("flag_missing");
}
if !errors.is_empty() {
props.insert("$feature_flag_error".into(), json!(errors.join(",")));
}
props
}
}
impl std::fmt::Debug for FeatureFlagEvaluations {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("FeatureFlagEvaluations")
.field("distinct_id", &self.distinct_id)
.field("flags", &self.flags)
.field("groups", &self.groups)
.field("disable_geoip", &self.disable_geoip)
.field("request_id", &self.request_id)
.field("evaluated_at", &self.evaluated_at)
.field("errors_while_computing", &self.errors_while_computing)
.field("quota_limited", &self.quota_limited)
.finish_non_exhaustive()
}
}
fn flag_value_for(flag: &EvaluatedFlagRecord) -> FlagValue {
if !flag.enabled {
FlagValue::Boolean(false)
} else if let Some(variant) = &flag.variant {
FlagValue::String(variant.clone())
} else {
FlagValue::Boolean(true)
}
}
fn flag_value_to_json(value: &FlagValue) -> Value {
match value {
FlagValue::Boolean(b) => json!(b),
FlagValue::String(s) => json!(s),
}
}
fn flag_value_json(flag: &EvaluatedFlagRecord) -> Value {
flag_value_to_json(&flag_value_for(flag))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex as StdMutex;
#[derive(Default)]
struct RecordingHost {
captured: StdMutex<Vec<FlagCalledEventParams>>,
warnings: StdMutex<Vec<String>>,
}
impl FeatureFlagEvaluationsHost for RecordingHost {
fn capture_flag_called_event_if_needed(&self, params: FlagCalledEventParams) {
self.captured.lock().unwrap().push(params);
}
fn log_warning(&self, message: &str) {
self.warnings.lock().unwrap().push(message.to_string());
}
}
fn record(
_key: &str,
enabled: bool,
variant: Option<&str>,
locally_evaluated: bool,
) -> EvaluatedFlagRecord {
EvaluatedFlagRecord {
enabled,
variant: variant.map(str::to_string),
payload: None,
id: Some(42),
version: Some(7),
reason: Some("condition match".into()),
locally_evaluated,
}
}
fn build(
host: Arc<dyn FeatureFlagEvaluationsHost>,
distinct_id: &str,
) -> FeatureFlagEvaluations {
let mut flags = HashMap::new();
flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
flags.insert("beta".into(), record("beta", false, None, false));
flags.insert("gamma".into(), record("gamma", true, None, true));
FeatureFlagEvaluations::new(
host,
distinct_id.into(),
flags,
HashMap::new(),
None,
Some("req-1".into()),
Some(1700000000),
false,
false,
)
}
#[test]
fn is_enabled_records_access_and_fires_event() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
assert!(snap.is_enabled("alpha"));
let captured = host.captured.lock().unwrap();
assert_eq!(captured.len(), 1);
assert_eq!(captured[0].key, "alpha");
let props = &captured[0].properties;
assert_eq!(props.get("$feature_flag_id"), Some(&json!(42_u64)));
assert_eq!(props.get("$feature_flag_version"), Some(&json!(7_u32)));
assert_eq!(
props.get("$feature_flag_reason"),
Some(&json!("condition match"))
);
assert_eq!(props.get("$feature_flag_request_id"), Some(&json!("req-1")));
}
#[test]
fn get_flag_payload_does_not_record_access_or_fire_event() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
assert!(snap.get_flag_payload("alpha").is_none());
assert!(host.captured.lock().unwrap().is_empty());
}
#[test]
fn empty_distinct_id_does_not_fire_events() {
let host = Arc::new(RecordingHost::default());
let snap =
FeatureFlagEvaluations::empty(Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>);
assert!(!snap.is_enabled("anything"));
assert!(host.captured.lock().unwrap().is_empty());
}
#[test]
fn locally_evaluated_event_omits_evaluated_at_and_carries_locally_evaluated_flag() {
let host = Arc::new(RecordingHost::default());
let mut flags = HashMap::new();
flags.insert(
"gamma".into(),
EvaluatedFlagRecord {
reason: Some("Evaluated locally".into()),
..record("gamma", true, None, true)
},
);
let snap = FeatureFlagEvaluations::new(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1".into(),
flags,
HashMap::new(),
None,
None,
Some(1700000000),
false,
false,
);
let _ = snap.is_enabled("gamma");
let captured = host.captured.lock().unwrap();
let props = &captured[0].properties;
assert_eq!(props.get("locally_evaluated"), Some(&json!(true)));
assert_eq!(
props.get("$feature_flag_reason"),
Some(&json!("Evaluated locally"))
);
assert!(!props.contains_key("$feature_flag_evaluated_at"));
}
#[test]
fn errors_while_computing_propagates_to_event() {
let host = Arc::new(RecordingHost::default());
let mut flags = HashMap::new();
flags.insert("alpha".into(), record("alpha", true, Some("test"), false));
let snap = FeatureFlagEvaluations::new(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1".into(),
flags,
HashMap::new(),
None,
Some("req-1".into()),
Some(1700000000),
true, false, );
let _ = snap.is_enabled("alpha");
let captured = host.captured.lock().unwrap();
assert_eq!(
captured[0].properties.get("$feature_flag_error"),
Some(&json!("errors_while_computing_flags"))
);
}
#[test]
fn payload_can_be_set_directly() {
let mut flags = HashMap::new();
flags.insert(
"alpha".into(),
EvaluatedFlagRecord {
payload: Some(json!({"hello": "world"})),
..record("alpha", true, None, false)
},
);
let host = Arc::new(RecordingHost::default());
let snap = FeatureFlagEvaluations::new(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1".into(),
flags,
HashMap::new(),
None,
None,
None,
false,
false,
);
assert_eq!(
snap.get_flag_payload("alpha"),
Some(json!({"hello": "world"}))
);
}
#[test]
fn quota_limited_combines_with_flag_missing_in_error_string() {
let host = Arc::new(RecordingHost::default());
let snap = FeatureFlagEvaluations::new(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1".into(),
HashMap::new(),
HashMap::new(),
None,
None,
None,
false,
true, );
assert!(snap.get_flag("does-not-exist").is_none());
let captured = host.captured.lock().unwrap();
assert_eq!(
captured[0].properties.get("$feature_flag_error"),
Some(&json!("quota_limited,flag_missing"))
);
}
#[test]
fn missing_flag_records_flag_missing_error() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
assert!(snap.get_flag("does-not-exist").is_none());
let captured = host.captured.lock().unwrap();
assert_eq!(
captured[0].properties.get("$feature_flag_error"),
Some(&json!("flag_missing"))
);
}
#[test]
fn missing_flag_with_no_response_errors_emits_no_error_for_present_flag() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
assert!(snap.is_enabled("alpha"));
let captured = host.captured.lock().unwrap();
assert!(!captured[0].properties.contains_key("$feature_flag_error"));
}
#[test]
fn only_accessed_filters_to_accessed_keys() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
let _ = snap.is_enabled("alpha");
let filtered = snap.only_accessed();
let mut keys = filtered.keys();
keys.sort();
assert_eq!(keys, vec!["alpha".to_string()]);
}
#[test]
fn only_accessed_returns_empty_when_nothing_accessed() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
let filtered = snap.only_accessed();
assert!(filtered.keys().is_empty());
assert!(host.warnings.lock().unwrap().is_empty());
}
#[test]
fn only_drops_unknown_keys_with_warning() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
let filtered = snap.only(&["alpha", "missing"]);
assert_eq!(filtered.keys(), vec!["alpha".to_string()]);
let warnings = host.warnings.lock().unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("missing"));
}
#[test]
fn filtered_snapshots_do_not_back_propagate_access_to_parent() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
let _ = snap.is_enabled("alpha");
let child = snap.only_accessed();
let _ = child.is_enabled("alpha");
assert_eq!(snap.snapshot_accessed().len(), 1);
}
#[test]
fn event_properties_attaches_active_flags_sorted() {
let host = Arc::new(RecordingHost::default());
let snap = build(
Arc::clone(&host) as Arc<dyn FeatureFlagEvaluationsHost>,
"u1",
);
let props = snap.event_properties();
assert_eq!(props.get("$feature/alpha"), Some(&json!("test")));
assert_eq!(props.get("$feature/beta"), Some(&json!(false)));
assert_eq!(props.get("$feature/gamma"), Some(&json!(true)));
let active = props.get("$active_feature_flags").unwrap();
assert_eq!(active, &json!(["alpha", "gamma"]));
}
}