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
69fn draw_is_sampled(draw: f64, rate: f64) -> bool {
70    draw < rate
71}
72
73pub fn should_sample(signal: Signal, key: Option<&str>) -> Result<bool, TelemetryError> {
74    let policy = get_sampling_policy(signal)?;
75    let rate = key
76        .and_then(|value| policy.overrides.get(value).copied())
77        .unwrap_or(policy.default_rate);
78
79    if rate >= 1.0 {
80        return Ok(true);
81    }
82    if rate <= 0.0 {
83        increment_dropped(signal, 1);
84        return Ok(false);
85    }
86
87    let keep = draw_is_sampled(rand::random::<f64>(), rate);
88    if !keep {
89        increment_dropped(signal, 1);
90    }
91    Ok(keep)
92}
93
94pub fn _reset_sampling_for_tests() {
95    *crate::_lock::lock(policies()) = BTreeMap::from([
96        (Signal::Logs, SamplingPolicy::default()),
97        (Signal::Traces, SamplingPolicy::default()),
98        (Signal::Metrics, SamplingPolicy::default()),
99    ]);
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::health::{_reset_health_for_tests, get_health_snapshot};
106    use crate::testing::acquire_test_state_lock;
107
108    #[test]
109    fn sampling_test_draw_boundary_is_strict() {
110        assert!(draw_is_sampled(0.49, 0.5));
111        assert!(!draw_is_sampled(0.5, 0.5));
112        assert!(!draw_is_sampled(0.51, 0.5));
113    }
114
115    #[test]
116    fn sampling_test_boundary_rates_and_reset_helper() {
117        let _guard = acquire_test_state_lock();
118        _reset_sampling_for_tests();
119        set_sampling_policy(
120            Signal::Logs,
121            SamplingPolicy {
122                default_rate: 0.0,
123                overrides: BTreeMap::new(),
124            },
125        )
126        .expect("policy should set");
127        assert!(!should_sample(Signal::Logs, None).expect("sampling should work"));
128
129        set_sampling_policy(
130            Signal::Logs,
131            SamplingPolicy {
132                default_rate: 1.0,
133                overrides: BTreeMap::new(),
134            },
135        )
136        .expect("policy should set");
137        assert!(should_sample(Signal::Logs, None).expect("sampling should work"));
138
139        set_sampling_policy(
140            Signal::Logs,
141            SamplingPolicy {
142                default_rate: 0.25,
143                overrides: BTreeMap::from([("special".to_string(), 0.75)]),
144            },
145        )
146        .expect("policy should set");
147        _reset_sampling_for_tests();
148        let reset = get_sampling_policy(Signal::Logs).expect("policy should exist");
149        assert_eq!(reset.default_rate, 1.0);
150        assert!(reset.overrides.is_empty());
151    }
152
153    #[test]
154    fn sampling_test_fractional_default_rate_rolls_per_call() {
155        let _guard = acquire_test_state_lock();
156        _reset_sampling_for_tests();
157        _reset_health_for_tests();
158        set_sampling_policy(
159            Signal::Logs,
160            SamplingPolicy {
161                default_rate: 0.5,
162                overrides: BTreeMap::new(),
163            },
164        )
165        .expect("policy should set");
166
167        let mut kept = 0;
168        let mut dropped = 0;
169        for _ in 0..256 {
170            if should_sample(Signal::Logs, None).expect("sampling should work") {
171                kept += 1;
172            } else {
173                dropped += 1;
174            }
175        }
176
177        assert!(kept > 0, "fractional sampling should keep some events");
178        assert!(dropped > 0, "fractional sampling should drop some events");
179        assert_eq!(
180            get_health_snapshot().dropped_logs,
181            dropped as u64,
182            "dropped_logs counter must match the number of sampling rejections \
183             (kills `if !keep` -> `if keep` mutation)"
184        );
185    }
186
187    #[test]
188    fn sampling_test_fractional_override_rate_rolls_per_call_for_same_key() {
189        let _guard = acquire_test_state_lock();
190        _reset_sampling_for_tests();
191        _reset_health_for_tests();
192        set_sampling_policy(
193            Signal::Logs,
194            SamplingPolicy {
195                default_rate: 1.0,
196                overrides: BTreeMap::from([("special".to_string(), 0.5)]),
197            },
198        )
199        .expect("policy should set");
200
201        let mut kept = 0;
202        let mut dropped = 0;
203        for _ in 0..256 {
204            if should_sample(Signal::Logs, Some("special")).expect("sampling should work") {
205                kept += 1;
206            } else {
207                dropped += 1;
208            }
209        }
210
211        assert!(
212            kept > 0,
213            "fractional override sampling should keep some events"
214        );
215        assert!(
216            dropped > 0,
217            "fractional override sampling should drop some events for the same key"
218        );
219    }
220
221    #[test]
222    fn sampling_test_override_boundaries_use_matching_key() {
223        let _guard = acquire_test_state_lock();
224        _reset_sampling_for_tests();
225        _reset_health_for_tests();
226        let before = get_health_snapshot().dropped_logs;
227        set_sampling_policy(
228            Signal::Logs,
229            SamplingPolicy {
230                default_rate: 0.0,
231                overrides: BTreeMap::from([("special".to_string(), 1.0)]),
232            },
233        )
234        .expect("policy should set");
235
236        assert!(should_sample(Signal::Logs, Some("special")).expect("sampling should work"));
237        assert!(!should_sample(Signal::Logs, Some("other")).expect("sampling should work"));
238        let after = get_health_snapshot().dropped_logs;
239        assert_eq!(after - before, 1);
240    }
241
242    #[test]
243    fn sampling_test_unknown_policy_errors_when_internal_state_is_missing() {
244        let _guard = acquire_test_state_lock();
245        crate::_lock::lock(policies()).clear();
246
247        let err = get_sampling_policy(Signal::Logs).expect_err("missing policy must error");
248        assert!(err.message.contains("unknown signal"));
249
250        let err = should_sample(Signal::Logs, None).expect_err("missing policy must bubble up");
251        assert!(err.message.contains("unknown signal"));
252
253        _reset_sampling_for_tests();
254    }
255}