use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use crate::feature_flag_evaluations::{EvaluatedFlagRecord, FlagCalledEventParams};
use crate::feature_flags::{FeatureFlagsResponse, FlagDetail, FlagMetadata, FlagValue};
use crate::Event;
pub(super) const MAX_FLAG_CALLED_CACHE_SIZE: usize = 50_000;
pub(super) type FlagEventDedupCache = Mutex<HashMap<String, HashSet<String>>>;
pub(super) fn flag_event_dedup_cache() -> FlagEventDedupCache {
Mutex::new(HashMap::new())
}
pub(super) fn already_reported(
cache: &FlagEventDedupCache,
distinct_id: &str,
dedup_key: &str,
) -> bool {
let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner());
if let Some(seen) = cache.get(distinct_id) {
if seen.contains(dedup_key) {
return true;
}
}
if cache.len() >= MAX_FLAG_CALLED_CACHE_SIZE {
cache.clear();
}
cache
.entry(distinct_id.to_string())
.or_default()
.insert(dedup_key.to_string());
false
}
pub(super) fn build_dedup_key(
flag_key: &str,
response: Option<&FlagValue>,
groups: &HashMap<String, String>,
) -> String {
let response_repr = match response {
Some(FlagValue::Boolean(true)) => "true".to_string(),
Some(FlagValue::Boolean(false)) => "false".to_string(),
Some(FlagValue::String(s)) => s.clone(),
None => "::null::".to_string(),
};
if groups.is_empty() {
format!("{flag_key}_{response_repr}")
} else {
let mut sorted: Vec<(&String, &String)> = groups.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(b.0));
let groups_repr: String = sorted
.iter()
.map(|(k, v)| format!("{}={}", pct(k), pct(v)))
.collect::<Vec<_>>()
.join(";");
format!("{flag_key}_{response_repr}_{groups_repr}")
}
}
fn pct(s: &str) -> String {
s.replace('%', "%25")
.replace('=', "%3D")
.replace(';', "%3B")
}
pub(super) fn flag_called_event(
params: FlagCalledEventParams,
client_disable_geoip: bool,
is_server: bool,
) -> Option<Event> {
let mut event = Event::new("$feature_flag_called".to_string(), params.distinct_id);
for (k, v) in params.properties {
if event.insert_prop(k, v).is_err() {
return None;
}
}
for (group_name, group_id) in ¶ms.groups {
event.add_group(group_name, group_id);
}
if params.disable_geoip.unwrap_or(client_disable_geoip) {
event.insert_prop_default("$geoip_disable", serde_json::Value::Bool(true));
}
if is_server {
event.insert_prop_default("$is_server", serde_json::Value::Bool(true));
}
Some(event)
}
pub(super) struct DetailedFlagsResponse {
pub(super) flags: HashMap<String, FlagDetail>,
pub(super) request_id: Option<String>,
pub(super) errors_while_computing_flags: bool,
pub(super) quota_limited: bool,
}
pub(super) fn extract_flag_details(response: FeatureFlagsResponse) -> DetailedFlagsResponse {
match response {
FeatureFlagsResponse::V2 {
flags,
request_id,
errors_while_computing_flags,
quota_limited,
} => DetailedFlagsResponse {
flags,
request_id,
errors_while_computing_flags,
quota_limited,
},
FeatureFlagsResponse::Legacy {
feature_flags,
feature_flag_payloads,
errors,
} => {
let mut flags = HashMap::new();
for (key, value) in feature_flags {
let (enabled, variant) = match value {
FlagValue::Boolean(b) => (b, None),
FlagValue::String(s) => (true, Some(s)),
};
let payload = feature_flag_payloads.get(&key).cloned();
flags.insert(
key.clone(),
FlagDetail {
key,
enabled,
variant,
reason: None,
metadata: payload.map(|payload| FlagMetadata {
id: 0,
version: 0,
description: None,
payload: Some(payload),
}),
},
);
}
DetailedFlagsResponse {
flags,
request_id: None,
errors_while_computing_flags: errors.is_some_and(|e| !e.is_empty()),
quota_limited: false,
}
}
}
}
pub(super) fn local_record(value: FlagValue) -> EvaluatedFlagRecord {
let (enabled, variant) = match value {
FlagValue::Boolean(b) => (b, None),
FlagValue::String(s) => (true, Some(s)),
};
EvaluatedFlagRecord {
enabled,
variant,
payload: None,
id: None,
version: None,
reason: Some("Evaluated locally".to_string()),
locally_evaluated: true,
}
}
pub(super) fn remote_record_from_detail(detail: FlagDetail) -> EvaluatedFlagRecord {
let metadata = detail.metadata;
let reason = detail
.reason
.and_then(|r| r.description.or(Some(r.code)))
.filter(|s| !s.is_empty());
let id = metadata.as_ref().map(|m| m.id);
let version = metadata.as_ref().map(|m| m.version);
let payload = metadata.and_then(|m| m.payload).map(normalize_payload);
EvaluatedFlagRecord {
enabled: detail.enabled,
variant: detail.variant,
payload,
id,
version,
reason,
locally_evaluated: false,
}
}
fn normalize_payload(payload: serde_json::Value) -> serde_json::Value {
match payload {
serde_json::Value::String(raw) => {
serde_json::from_str(&raw).unwrap_or(serde_json::Value::String(raw))
}
other => other,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn groups(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn flag_params(
properties: HashMap<String, serde_json::Value>,
groups: HashMap<String, String>,
disable_geoip: Option<bool>,
) -> FlagCalledEventParams {
FlagCalledEventParams {
distinct_id: "user-1".to_string(),
key: "alpha".to_string(),
response: Some(FlagValue::Boolean(true)),
groups,
disable_geoip,
properties,
}
}
#[test]
fn dedup_key_canonicalizes_group_order_and_escapes_separators() {
let first = build_dedup_key(
"alpha",
Some(&FlagValue::Boolean(true)),
&groups(&[("organization", "org-a"), ("team", "red")]),
);
let second = build_dedup_key(
"alpha",
Some(&FlagValue::Boolean(true)),
&groups(&[("team", "red"), ("organization", "org-a")]),
);
assert_eq!(first, second);
let with_separator_in_key = build_dedup_key(
"alpha",
Some(&FlagValue::Boolean(true)),
&groups(&[("a=b", "c")]),
);
let with_separator_in_value = build_dedup_key(
"alpha",
Some(&FlagValue::Boolean(true)),
&groups(&[("a", "b=c")]),
);
assert_ne!(with_separator_in_key, with_separator_in_value);
}
#[test]
fn flag_called_event_applies_defaults_groups_and_preserves_caller_properties() {
let mut properties = HashMap::new();
properties.insert("$is_server".to_string(), json!(false));
properties.insert("$geoip_disable".to_string(), json!(false));
let event = flag_called_event(
flag_params(properties, groups(&[("organization", "org-a")]), Some(true)),
true,
true,
)
.expect("valid flag-called event");
assert_eq!(
event.groups().get("organization"),
Some(&"org-a".to_string())
);
assert_eq!(event.properties().get("$is_server"), Some(&json!(false)));
assert_eq!(
event.properties().get("$geoip_disable"),
Some(&json!(false))
);
}
#[test]
fn flag_called_event_adds_defaults_when_missing() {
let event = flag_called_event(
flag_params(HashMap::new(), HashMap::new(), None),
true,
true,
)
.expect("valid flag-called event");
assert_eq!(event.properties().get("$is_server"), Some(&json!(true)));
assert_eq!(event.properties().get("$geoip_disable"), Some(&json!(true)));
}
}