provide_telemetry/
cardinality.rs1use std::collections::{BTreeMap, HashMap};
7use std::sync::{Mutex, OnceLock};
8use std::time::{Duration, Instant};
9
10pub const OVERFLOW_VALUE: &str = "__overflow__";
11
12const PRUNE_INTERVAL: Duration = Duration::from_secs(5);
13
14#[derive(Clone, Debug, Default, PartialEq)]
15pub struct CardinalityLimit {
16 pub max_values: usize,
17 pub ttl_seconds: f64,
18}
19
20#[derive(Default)]
21struct CardinalityState {
22 limits: BTreeMap<String, CardinalityLimit>,
23 seen: HashMap<String, HashMap<String, Instant>>,
24 last_prune: HashMap<String, Instant>,
25}
26
27static STATE: OnceLock<Mutex<CardinalityState>> = OnceLock::new();
28
29#[cfg_attr(test, mutants::skip)] fn default_cardinality_state_mutex() -> Mutex<CardinalityState> {
31 Mutex::new(CardinalityState::default())
32}
33
34fn state() -> &'static Mutex<CardinalityState> {
35 STATE.get_or_init(default_cardinality_state_mutex)
36}
37
38pub fn register_cardinality_limit(key: impl Into<String>, limit: CardinalityLimit) {
39 let key = key.into();
40 let mut guard = crate::_lock::lock(state());
41 guard.limits.insert(
42 key.clone(),
43 CardinalityLimit {
44 max_values: limit.max_values.max(1),
45 ttl_seconds: limit.ttl_seconds.max(1.0),
46 },
47 );
48 guard.seen.entry(key).or_default();
49}
50
51pub fn get_cardinality_limits() -> BTreeMap<String, CardinalityLimit> {
52 crate::_lock::lock(state()).limits.clone()
53}
54
55pub fn clear_cardinality_limits() {
56 let mut guard = crate::_lock::lock(state());
57 guard.limits.clear();
58 guard.seen.clear();
59 guard.last_prune.clear();
60}
61
62fn should_prune(last_prune: Option<Instant>, now: Instant) -> bool {
63 last_prune
64 .map(|last| now.duration_since(last) >= PRUNE_INTERVAL)
65 .unwrap_or(true)
66}
67
68fn prune_expired_values(seen: &mut HashMap<String, Instant>, ttl_seconds: f64, now: Instant) {
69 let ttl = Duration::from_secs_f64(ttl_seconds.max(1.0));
70 seen.retain(|_, seen_at| now.duration_since(*seen_at) < ttl);
71}
72
73pub fn guard_attributes(attributes: HashMap<String, String>) -> HashMap<String, String> {
76 let now = Instant::now();
77 let mut out = HashMap::with_capacity(attributes.len());
78
79 for (key, value) in attributes {
80 let mut guard = crate::_lock::lock(state());
81 let Some(limit) = guard.limits.get(&key).cloned() else {
82 out.insert(key, value);
83 continue;
84 };
85
86 let last_prune = guard.last_prune.get(&key).copied();
87 let prune_now = should_prune(last_prune, now);
88
89 if prune_now {
90 let seen = guard.seen.entry(key.clone()).or_default();
91 prune_expired_values(seen, limit.ttl_seconds, now);
92 guard.last_prune.insert(key.clone(), now);
93 }
94
95 let seen = guard.seen.entry(key.clone()).or_default();
96
97 if seen.contains_key(&value) {
98 seen.insert(value.clone(), now);
99 out.insert(key, value);
100 continue;
101 }
102
103 if seen.len() >= limit.max_values {
104 out.insert(key, OVERFLOW_VALUE.to_string());
105 continue;
106 }
107
108 seen.insert(value.clone(), now);
109 out.insert(key, value);
110 }
111
112 out
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::testing::acquire_test_state_lock;
119
120 #[test]
121 fn cardinality_test_clear_limits_removes_registered_entries() {
122 let _guard = acquire_test_state_lock();
123 clear_cardinality_limits();
124 register_cardinality_limit(
125 "user.id",
126 CardinalityLimit {
127 max_values: 5,
128 ttl_seconds: 60.0,
129 },
130 );
131 assert!(get_cardinality_limits().contains_key("user.id"));
132
133 clear_cardinality_limits();
134
135 assert!(get_cardinality_limits().is_empty());
136 }
137
138 #[test]
139 fn cardinality_test_a_get_limits_returns_registered_clamped_values() {
140 let _guard = acquire_test_state_lock();
141 clear_cardinality_limits();
142 register_cardinality_limit(
143 "tenant.id",
144 CardinalityLimit {
145 max_values: 0,
146 ttl_seconds: 0.0,
147 },
148 );
149
150 let limits = get_cardinality_limits();
151 let limit = limits.get("tenant.id").expect("limit should exist");
152 assert_eq!(limit.max_values, 1);
153 assert_eq!(limit.ttl_seconds, 1.0);
154 assert_eq!(limits.len(), 1);
155 }
156
157 #[test]
158 fn cardinality_test_should_prune_false_before_interval() {
159 let now = Instant::now();
160 assert!(!should_prune(Some(now - Duration::from_secs(1)), now));
161 }
162
163 #[test]
164 fn cardinality_test_should_prune_true_without_previous_prune() {
165 assert!(should_prune(None, Instant::now()));
166 }
167
168 #[test]
169 fn cardinality_test_prune_expired_values_drops_exact_ttl_boundary() {
170 let now = Instant::now();
171 let mut seen = HashMap::from([
172 ("fresh".to_string(), now - Duration::from_millis(500)),
173 ("boundary".to_string(), now - Duration::from_secs(1)),
174 ("expired".to_string(), now - Duration::from_secs(2)),
175 ]);
176
177 prune_expired_values(&mut seen, 1.0, now);
178
179 assert!(seen.contains_key("fresh"));
180 assert!(!seen.contains_key("boundary"));
181 assert!(!seen.contains_key("expired"));
182 }
183
184 #[test]
185 fn cardinality_test_guard_attributes_prunes_before_capacity_check() {
186 let _guard = acquire_test_state_lock();
187 clear_cardinality_limits();
188 register_cardinality_limit(
189 "user.id",
190 CardinalityLimit {
191 max_values: 1,
192 ttl_seconds: 1.0,
193 },
194 );
195
196 let stale_seen_at = Instant::now() - Duration::from_secs(2);
197 let stale_last_prune = Instant::now() - PRUNE_INTERVAL - Duration::from_millis(1);
198 {
199 let mut state = crate::_lock::lock(state());
200 state.seen.insert(
201 "user.id".to_string(),
202 HashMap::from([("stale".to_string(), stale_seen_at)]),
203 );
204 state
205 .last_prune
206 .insert("user.id".to_string(), stale_last_prune);
207 }
208
209 let result = guard_attributes(HashMap::from([(
210 "user.id".to_string(),
211 "fresh".to_string(),
212 )]));
213
214 assert_eq!(result.get("user.id").map(String::as_str), Some("fresh"));
215 let state = crate::_lock::lock(state());
216 let seen = state.seen.get("user.id").expect("seen values should exist");
217 assert_eq!(seen.len(), 1);
218 assert!(seen.contains_key("fresh"));
219 assert!(!seen.contains_key("stale"));
220 }
221}