grafos_observe/
sampling.rs

1//! Sampling strategies for distributed tracing.
2//!
3//! Sampling controls which traces are recorded and exported. The decision is
4//! made at the trace root (head-based sampling) and propagated to all child
5//! spans via the [`FLAG_SAMPLED`](crate::trace::FLAG_SAMPLED) bit in
6//! [`TraceContext::flags`](crate::trace::TraceContext).
7
8use crate::trace::TraceContext;
9
10/// Sampling strategy enumeration.
11#[derive(Debug, Clone, PartialEq)]
12pub enum SamplingStrategy {
13    /// Sample all traces (default for development).
14    AlwaysOn,
15    /// Sample no traces (useful for benchmarks).
16    AlwaysOff,
17    /// Probabilistic head-based sampling. The `rate` field in
18    /// [`SamplingConfig`] controls the probability (0.0 to 1.0).
19    HeadBased,
20    /// Rate-limited sampling: at most N traces per second.
21    RateBased {
22        /// Maximum traces per second.
23        traces_per_sec: u32,
24    },
25}
26
27/// Sampling configuration.
28#[derive(Debug, Clone)]
29pub struct SamplingConfig {
30    /// Sample rate (0.0 to 1.0). Only used with [`SamplingStrategy::HeadBased`].
31    pub rate: f64,
32    /// Strategy to use.
33    pub strategy: SamplingStrategy,
34}
35
36impl Default for SamplingConfig {
37    /// Default: AlwaysOn (sample everything, suitable for dev).
38    fn default() -> Self {
39        SamplingConfig {
40            rate: 1.0,
41            strategy: SamplingStrategy::AlwaysOn,
42        }
43    }
44}
45
46/// A sampler that decides whether a trace should be recorded.
47pub struct Sampler {
48    config: SamplingConfig,
49    /// For RateBased: count of traces started in the current second window.
50    rate_count: u32,
51    /// For RateBased: the second (Unix timestamp) of the current window.
52    rate_window_sec: u64,
53}
54
55impl Sampler {
56    /// Create a new sampler with the given configuration.
57    pub fn new(config: SamplingConfig) -> Self {
58        Sampler {
59            config,
60            rate_count: 0,
61            rate_window_sec: 0,
62        }
63    }
64
65    /// Decide whether a new root trace should be sampled.
66    ///
67    /// For child spans, the decision is inherited from the parent trace
68    /// context (if the parent is sampled, the child is sampled).
69    ///
70    /// `trace_id_low` is the low 64 bits of the trace ID, used as a
71    /// deterministic hash for HeadBased sampling. `now_unix_sec` is the
72    /// current Unix timestamp in seconds, used for RateBased windows.
73    pub fn should_sample(&mut self, trace_id_low: u64, now_unix_sec: u64) -> bool {
74        match &self.config.strategy {
75            SamplingStrategy::AlwaysOn => true,
76            SamplingStrategy::AlwaysOff => false,
77            SamplingStrategy::HeadBased => {
78                // Use the low bits of trace_id as a deterministic hash.
79                // Scale to 0.0..1.0 range and compare with rate.
80                let hash_f = (trace_id_low as f64) / (u64::MAX as f64);
81                hash_f < self.config.rate
82            }
83            SamplingStrategy::RateBased { traces_per_sec } => {
84                if now_unix_sec != self.rate_window_sec {
85                    self.rate_window_sec = now_unix_sec;
86                    self.rate_count = 0;
87                }
88                if self.rate_count < *traces_per_sec {
89                    self.rate_count += 1;
90                    true
91                } else {
92                    false
93                }
94            }
95        }
96    }
97
98    /// Check if a child span should be sampled, given its parent context.
99    ///
100    /// If the parent is sampled, the child is always sampled (propagation).
101    pub fn should_sample_child(parent: &TraceContext) -> bool {
102        parent.is_sampled()
103    }
104
105    /// Get the current configuration.
106    pub fn config(&self) -> &SamplingConfig {
107        &self.config
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn always_on_samples_everything() {
117        let mut sampler = Sampler::new(SamplingConfig {
118            rate: 1.0,
119            strategy: SamplingStrategy::AlwaysOn,
120        });
121        for i in 0..100 {
122            assert!(sampler.should_sample(i, 1000));
123        }
124    }
125
126    #[test]
127    fn always_off_samples_nothing() {
128        let mut sampler = Sampler::new(SamplingConfig {
129            rate: 0.0,
130            strategy: SamplingStrategy::AlwaysOff,
131        });
132        for i in 0..100 {
133            assert!(!sampler.should_sample(i, 1000));
134        }
135    }
136
137    #[test]
138    fn head_based_rate_zero_samples_none() {
139        let mut sampler = Sampler::new(SamplingConfig {
140            rate: 0.0,
141            strategy: SamplingStrategy::HeadBased,
142        });
143        // rate=0.0 means nothing should be sampled
144        let mut sampled = 0;
145        for i in 0..1000u64 {
146            if sampler.should_sample(i, 1000) {
147                sampled += 1;
148            }
149        }
150        assert_eq!(sampled, 0);
151    }
152
153    #[test]
154    fn head_based_rate_one_samples_all() {
155        let mut sampler = Sampler::new(SamplingConfig {
156            rate: 1.0,
157            strategy: SamplingStrategy::HeadBased,
158        });
159        let mut sampled = 0;
160        for i in 0..1000u64 {
161            if sampler.should_sample(i, 1000) {
162                sampled += 1;
163            }
164        }
165        assert_eq!(sampled, 1000);
166    }
167
168    #[test]
169    fn head_based_approximately_half() {
170        let mut sampler = Sampler::new(SamplingConfig {
171            rate: 0.5,
172            strategy: SamplingStrategy::HeadBased,
173        });
174        let mut sampled = 0u64;
175        // Use well-distributed trace IDs
176        for i in 0..10_000u64 {
177            let trace_id_low = i
178                .wrapping_mul(6364136223846793005)
179                .wrapping_add(1442695040888963407);
180            if sampler.should_sample(trace_id_low, 1000) {
181                sampled += 1;
182            }
183        }
184        // Should be approximately 5000, allow 15% tolerance
185        assert!(
186            sampled > 4000 && sampled < 6000,
187            "expected ~5000, got {sampled}"
188        );
189    }
190
191    #[test]
192    fn rate_based_caps_per_second() {
193        let mut sampler = Sampler::new(SamplingConfig {
194            rate: 1.0,
195            strategy: SamplingStrategy::RateBased { traces_per_sec: 5 },
196        });
197
198        // First 5 in second 100 should be sampled
199        for i in 0..5 {
200            assert!(sampler.should_sample(i, 100), "trace {i} should be sampled");
201        }
202        // 6th should be rejected
203        assert!(!sampler.should_sample(5, 100));
204
205        // New second resets the window
206        assert!(sampler.should_sample(6, 101));
207    }
208
209    #[test]
210    fn child_inherits_parent_sampling() {
211        let mut ctx = TraceContext::default();
212        ctx.set_sampled(true);
213        assert!(Sampler::should_sample_child(&ctx));
214
215        ctx.set_sampled(false);
216        assert!(!Sampler::should_sample_child(&ctx));
217    }
218
219    #[test]
220    fn default_config_is_always_on() {
221        let config = SamplingConfig::default();
222        assert_eq!(config.strategy, SamplingStrategy::AlwaysOn);
223        assert!((config.rate - 1.0).abs() < f64::EPSILON);
224    }
225}