Skip to main content

provide_telemetry/
cardinality.rs

1// SPDX-FileCopyrightText: Copyright (C) 2026 provide.io llc
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-Comment: Part of provide-telemetry.
4//
5
6use 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)] // Equivalent mutants only swap in Mutex::default().
30fn 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
73/// Enforce cardinality limits on an attribute map.
74/// Values that exceed the registered per-key limit are replaced with `OVERFLOW_VALUE`.
75pub 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}