Skip to main content

provide_telemetry/
sampling.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;
7use std::sync::{Mutex, OnceLock};
8
9use crate::errors::TelemetryError;
10use crate::health::increment_dropped;
11
12#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
13pub enum Signal {
14    Logs,
15    Traces,
16    Metrics,
17}
18
19#[derive(Clone, Debug, PartialEq)]
20pub struct SamplingPolicy {
21    pub default_rate: f64,
22    pub overrides: BTreeMap<String, f64>,
23}
24
25impl Default for SamplingPolicy {
26    fn default() -> Self {
27        Self {
28            default_rate: 1.0,
29            overrides: BTreeMap::new(),
30        }
31    }
32}
33
34static POLICIES: OnceLock<Mutex<BTreeMap<Signal, SamplingPolicy>>> = OnceLock::new();
35
36fn policies() -> &'static Mutex<BTreeMap<Signal, SamplingPolicy>> {
37    POLICIES.get_or_init(|| {
38        Mutex::new(BTreeMap::from([
39            (Signal::Logs, SamplingPolicy::default()),
40            (Signal::Traces, SamplingPolicy::default()),
41            (Signal::Metrics, SamplingPolicy::default()),
42        ]))
43    })
44}
45
46pub fn set_sampling_policy(
47    signal: Signal,
48    policy: SamplingPolicy,
49) -> Result<SamplingPolicy, TelemetryError> {
50    let normalized = SamplingPolicy {
51        default_rate: policy.default_rate.clamp(0.0, 1.0),
52        overrides: policy
53            .overrides
54            .into_iter()
55            .map(|(key, rate)| (key, rate.clamp(0.0, 1.0)))
56            .collect(),
57    };
58    crate::_lock::lock(policies()).insert(signal, normalized.clone());
59    Ok(normalized)
60}
61
62pub fn get_sampling_policy(signal: Signal) -> Result<SamplingPolicy, TelemetryError> {
63    crate::_lock::lock(policies())
64        .get(&signal)
65        .cloned()
66        .ok_or_else(|| TelemetryError::new("unknown signal"))
67}
68
69pub fn should_sample(signal: Signal, key: Option<&str>) -> Result<bool, TelemetryError> {
70    let policy = get_sampling_policy(signal)?;
71    let rate = key
72        .and_then(|value| policy.overrides.get(value).copied())
73        .unwrap_or(policy.default_rate);
74
75    if rate >= 1.0 {
76        return Ok(true);
77    }
78    if rate <= 0.0 {
79        increment_dropped(signal, 1);
80        return Ok(false);
81    }
82
83    let keep = rand::random::<f64>() < rate;
84    if !keep {
85        increment_dropped(signal, 1);
86    }
87    Ok(keep)
88}
89
90pub fn _reset_sampling_for_tests() {
91    *crate::_lock::lock(policies()) = BTreeMap::from([
92        (Signal::Logs, SamplingPolicy::default()),
93        (Signal::Traces, SamplingPolicy::default()),
94        (Signal::Metrics, SamplingPolicy::default()),
95    ]);
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::health::{_reset_health_for_tests, get_health_snapshot};
102    use crate::testing::acquire_test_state_lock;
103
104    #[test]
105    fn sampling_test_boundary_rates_and_reset_helper() {
106        let _guard = acquire_test_state_lock();
107        _reset_sampling_for_tests();
108        set_sampling_policy(
109            Signal::Logs,
110            SamplingPolicy {
111                default_rate: 0.0,
112                overrides: BTreeMap::new(),
113            },
114        )
115        .expect("policy should set");
116        assert!(!should_sample(Signal::Logs, None).expect("sampling should work"));
117
118        set_sampling_policy(
119            Signal::Logs,
120            SamplingPolicy {
121                default_rate: 1.0,
122                overrides: BTreeMap::new(),
123            },
124        )
125        .expect("policy should set");
126        assert!(should_sample(Signal::Logs, None).expect("sampling should work"));
127
128        set_sampling_policy(
129            Signal::Logs,
130            SamplingPolicy {
131                default_rate: 0.25,
132                overrides: BTreeMap::from([("special".to_string(), 0.75)]),
133            },
134        )
135        .expect("policy should set");
136        _reset_sampling_for_tests();
137        let reset = get_sampling_policy(Signal::Logs).expect("policy should exist");
138        assert_eq!(reset.default_rate, 1.0);
139        assert!(reset.overrides.is_empty());
140    }
141
142    #[test]
143    fn sampling_test_fractional_default_rate_rolls_per_call() {
144        let _guard = acquire_test_state_lock();
145        _reset_sampling_for_tests();
146        _reset_health_for_tests();
147        set_sampling_policy(
148            Signal::Logs,
149            SamplingPolicy {
150                default_rate: 0.5,
151                overrides: BTreeMap::new(),
152            },
153        )
154        .expect("policy should set");
155
156        let mut kept = 0;
157        let mut dropped = 0;
158        for _ in 0..256 {
159            if should_sample(Signal::Logs, None).expect("sampling should work") {
160                kept += 1;
161            } else {
162                dropped += 1;
163            }
164        }
165
166        assert!(kept > 0, "fractional sampling should keep some events");
167        assert!(dropped > 0, "fractional sampling should drop some events");
168        assert_eq!(
169            get_health_snapshot().dropped_logs,
170            dropped as u64,
171            "dropped_logs counter must match the number of sampling rejections \
172             (kills `if !keep` -> `if keep` mutation)"
173        );
174    }
175
176    #[test]
177    fn sampling_test_fractional_override_rate_rolls_per_call_for_same_key() {
178        let _guard = acquire_test_state_lock();
179        _reset_sampling_for_tests();
180        _reset_health_for_tests();
181        set_sampling_policy(
182            Signal::Logs,
183            SamplingPolicy {
184                default_rate: 1.0,
185                overrides: BTreeMap::from([("special".to_string(), 0.5)]),
186            },
187        )
188        .expect("policy should set");
189
190        let mut kept = 0;
191        let mut dropped = 0;
192        for _ in 0..256 {
193            if should_sample(Signal::Logs, Some("special")).expect("sampling should work") {
194                kept += 1;
195            } else {
196                dropped += 1;
197            }
198        }
199
200        assert!(
201            kept > 0,
202            "fractional override sampling should keep some events"
203        );
204        assert!(
205            dropped > 0,
206            "fractional override sampling should drop some events for the same key"
207        );
208    }
209
210    #[test]
211    fn sampling_test_override_boundaries_use_matching_key() {
212        let _guard = acquire_test_state_lock();
213        _reset_sampling_for_tests();
214        _reset_health_for_tests();
215        let before = get_health_snapshot().dropped_logs;
216        set_sampling_policy(
217            Signal::Logs,
218            SamplingPolicy {
219                default_rate: 0.0,
220                overrides: BTreeMap::from([("special".to_string(), 1.0)]),
221            },
222        )
223        .expect("policy should set");
224
225        assert!(should_sample(Signal::Logs, Some("special")).expect("sampling should work"));
226        assert!(!should_sample(Signal::Logs, Some("other")).expect("sampling should work"));
227        let after = get_health_snapshot().dropped_logs;
228        assert_eq!(after - before, 1);
229    }
230
231    #[test]
232    fn sampling_test_unknown_policy_errors_when_internal_state_is_missing() {
233        let _guard = acquire_test_state_lock();
234        crate::_lock::lock(policies()).clear();
235
236        let err = get_sampling_policy(Signal::Logs).expect_err("missing policy must error");
237        assert!(err.message.contains("unknown signal"));
238
239        let err = should_sample(Signal::Logs, None).expect_err("missing policy must bubble up");
240        assert!(err.message.contains("unknown signal"));
241
242        _reset_sampling_for_tests();
243    }
244}