Skip to main content

fast_telemetry/metric/
sampled_timer.rs

1//! Sampled elapsed-time recording for hot paths.
2//!
3//! `SampledTimer` counts every call but only records elapsed time for one call
4//! per configured stride. This keeps hot-path instrumentation cheap while still
5//! giving useful phase latency samples.
6
7use std::cell::Cell;
8use std::marker::PhantomData;
9use std::time::{Duration, Instant};
10
11use crate::{Counter, Histogram, LabelEnum};
12
13thread_local! {
14    static SAMPLE_SEQ: Cell<u64> = const { Cell::new(0) };
15}
16
17/// Counts every timed operation and samples elapsed latency into a histogram.
18///
19/// Durations are recorded in nanoseconds.
20pub struct SampledTimer {
21    inner: SampledTimerInner,
22}
23
24/// Label-indexed sampled timer with O(1) lookup.
25pub struct LabeledSampledTimer<L: LabelEnum> {
26    timers: Vec<SampledTimerInner>,
27    _phantom: PhantomData<L>,
28}
29
30/// RAII guard returned by [`SampledTimer::start`] and [`LabeledSampledTimer::start`].
31///
32/// Dropping the guard records elapsed time when the operation was selected for
33/// sampling. Call [`finish`](Self::finish) when an explicit endpoint is clearer.
34pub struct SampledTimerGuard<'a> {
35    timer: &'a SampledTimerInner,
36    start: Option<Instant>,
37    finished: bool,
38}
39
40struct SampledTimerInner {
41    calls: Counter,
42    samples: Histogram,
43    stride_mask: u64,
44}
45
46impl SampledTimer {
47    /// Create a sampled timer with custom histogram bounds in nanoseconds.
48    ///
49    /// `sample_stride` is rounded up to a power of two. A stride of `1` records
50    /// every call.
51    pub fn new(bounds_nanos: &[u64], shard_count: usize, sample_stride: u64) -> Self {
52        Self {
53            inner: SampledTimerInner::new(bounds_nanos, shard_count, sample_stride),
54        }
55    }
56
57    /// Create a sampled timer with default latency buckets in nanoseconds.
58    pub fn with_latency_buckets(shard_count: usize, sample_stride: u64) -> Self {
59        Self {
60            inner: SampledTimerInner::with_latency_buckets(shard_count, sample_stride),
61        }
62    }
63
64    /// Start timing one operation.
65    #[inline]
66    pub fn start(&self) -> SampledTimerGuard<'_> {
67        self.inner.start()
68    }
69
70    /// Record an already-measured duration if this call should be sampled.
71    #[inline]
72    pub fn record_elapsed(&self, elapsed: Duration) {
73        self.inner.record_elapsed(elapsed);
74    }
75
76    /// Total operation calls, sampled or not.
77    #[inline]
78    pub fn calls(&self) -> u64 {
79        self.inner.calls()
80    }
81
82    /// Number of latency samples recorded.
83    #[inline]
84    pub fn sample_count(&self) -> u64 {
85        self.inner.sample_count()
86    }
87
88    /// Sum of sampled latency values in nanoseconds.
89    #[inline]
90    pub fn sample_sum_nanos(&self) -> u64 {
91        self.inner.sample_sum_nanos()
92    }
93
94    /// Average sampled latency in nanoseconds.
95    pub fn avg_sample_nanos(&self) -> Option<f64> {
96        self.inner.avg_sample_nanos()
97    }
98
99    /// Access the underlying call counter for export.
100    #[inline]
101    pub fn calls_metric(&self) -> &Counter {
102        &self.inner.calls
103    }
104
105    /// Access the underlying sampled histogram for export.
106    #[inline]
107    pub fn histogram(&self) -> &Histogram {
108        &self.inner.samples
109    }
110}
111
112impl<L: LabelEnum> LabeledSampledTimer<L> {
113    /// Create a labeled sampled timer with custom histogram bounds in nanoseconds.
114    pub fn new(bounds_nanos: &[u64], shard_count: usize, sample_stride: u64) -> Self {
115        let timers = (0..L::CARDINALITY)
116            .map(|_| SampledTimerInner::new(bounds_nanos, shard_count, sample_stride))
117            .collect();
118        Self {
119            timers,
120            _phantom: PhantomData,
121        }
122    }
123
124    /// Create a labeled sampled timer with default latency buckets in nanoseconds.
125    pub fn with_latency_buckets(shard_count: usize, sample_stride: u64) -> Self {
126        let timers = (0..L::CARDINALITY)
127            .map(|_| SampledTimerInner::with_latency_buckets(shard_count, sample_stride))
128            .collect();
129        Self {
130            timers,
131            _phantom: PhantomData,
132        }
133    }
134
135    /// Start timing one labeled operation.
136    #[inline]
137    pub fn start(&self, label: L) -> SampledTimerGuard<'_> {
138        self.timer(label).start()
139    }
140
141    /// Record an already-measured duration for the given label if it should be sampled.
142    #[inline]
143    pub fn record_elapsed(&self, label: L, elapsed: Duration) {
144        self.timer(label).record_elapsed(elapsed);
145    }
146
147    /// Total calls for a label, sampled or not.
148    #[inline]
149    pub fn calls(&self, label: L) -> u64 {
150        self.timer(label).calls()
151    }
152
153    /// Number of latency samples recorded for a label.
154    #[inline]
155    pub fn sample_count(&self, label: L) -> u64 {
156        self.timer(label).sample_count()
157    }
158
159    /// Sum of sampled latency values in nanoseconds for a label.
160    #[inline]
161    pub fn sample_sum_nanos(&self, label: L) -> u64 {
162        self.timer(label).sample_sum_nanos()
163    }
164
165    /// Average sampled latency in nanoseconds for a label.
166    pub fn avg_sample_nanos(&self, label: L) -> Option<f64> {
167        self.timer(label).avg_sample_nanos()
168    }
169
170    /// Access the underlying call counter for a label.
171    #[inline]
172    pub fn calls_metric(&self, label: L) -> &Counter {
173        &self.timer(label).calls
174    }
175
176    /// Access the underlying sampled histogram for a label.
177    #[inline]
178    pub fn histogram(&self, label: L) -> &Histogram {
179        &self.timer(label).samples
180    }
181
182    /// Iterate over all label/timer pairs for export.
183    pub fn iter(&self) -> impl Iterator<Item = (L, &Counter, &Histogram)> + '_ {
184        self.timers
185            .iter()
186            .enumerate()
187            .map(|(idx, timer)| (L::from_index(idx), &timer.calls, &timer.samples))
188    }
189
190    #[inline]
191    fn timer(&self, label: L) -> &SampledTimerInner {
192        let idx = label.as_index();
193        debug_assert!(idx < self.timers.len(), "label index out of bounds");
194        if cfg!(debug_assertions) {
195            &self.timers[idx]
196        } else {
197            unsafe { self.timers.get_unchecked(idx) }
198        }
199    }
200}
201
202impl SampledTimerGuard<'_> {
203    /// Finish timing now.
204    #[inline]
205    pub fn finish(mut self) {
206        self.record();
207        self.finished = true;
208    }
209
210    #[inline]
211    fn record(&mut self) {
212        let Some(start) = self.start.take() else {
213            return;
214        };
215        self.timer.samples.record(duration_nanos(start.elapsed()));
216    }
217}
218
219impl Drop for SampledTimerGuard<'_> {
220    #[inline]
221    fn drop(&mut self) {
222        if !self.finished {
223            self.record();
224        }
225    }
226}
227
228impl SampledTimerInner {
229    fn new(bounds_nanos: &[u64], shard_count: usize, sample_stride: u64) -> Self {
230        Self {
231            calls: Counter::new(shard_count),
232            samples: Histogram::new(bounds_nanos, shard_count),
233            stride_mask: stride_mask(sample_stride),
234        }
235    }
236
237    fn with_latency_buckets(shard_count: usize, sample_stride: u64) -> Self {
238        Self::new(
239            &[
240                10_000,         // 10µs
241                50_000,         // 50µs
242                100_000,        // 100µs
243                500_000,        // 500µs
244                1_000_000,      // 1ms
245                5_000_000,      // 5ms
246                10_000_000,     // 10ms
247                50_000_000,     // 50ms
248                100_000_000,    // 100ms
249                500_000_000,    // 500ms
250                1_000_000_000,  // 1s
251                5_000_000_000,  // 5s
252                10_000_000_000, // 10s
253            ],
254            shard_count,
255            sample_stride,
256        )
257    }
258
259    #[inline]
260    fn start(&self) -> SampledTimerGuard<'_> {
261        self.calls.inc();
262        let sampled = should_sample(self.stride_mask);
263        SampledTimerGuard {
264            timer: self,
265            start: sampled.then(Instant::now),
266            finished: false,
267        }
268    }
269
270    #[inline]
271    fn record_elapsed(&self, elapsed: Duration) {
272        self.calls.inc();
273        if should_sample(self.stride_mask) {
274            self.samples.record(duration_nanos(elapsed));
275        }
276    }
277
278    #[inline]
279    fn calls(&self) -> u64 {
280        self.calls.sum() as u64
281    }
282
283    #[inline]
284    fn sample_count(&self) -> u64 {
285        self.samples.count()
286    }
287
288    #[inline]
289    fn sample_sum_nanos(&self) -> u64 {
290        self.samples.sum()
291    }
292
293    fn avg_sample_nanos(&self) -> Option<f64> {
294        let count = self.sample_count();
295        if count == 0 {
296            return None;
297        }
298        Some(self.sample_sum_nanos() as f64 / count as f64)
299    }
300}
301
302fn should_sample(stride_mask: u64) -> bool {
303    SAMPLE_SEQ.with(|seq| {
304        let next = seq.get().wrapping_add(1);
305        seq.set(next);
306        next & stride_mask == 0
307    })
308}
309
310fn stride_mask(sample_stride: u64) -> u64 {
311    sample_stride.max(1).next_power_of_two() - 1
312}
313
314fn duration_nanos(elapsed: Duration) -> u64 {
315    elapsed.as_nanos().min(u64::MAX as u128) as u64
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[derive(Copy, Clone, Debug, PartialEq)]
323    enum TestLabel {
324        A,
325        B,
326    }
327
328    impl LabelEnum for TestLabel {
329        const CARDINALITY: usize = 2;
330        const LABEL_NAME: &'static str = "label";
331
332        fn as_index(self) -> usize {
333            self as usize
334        }
335
336        fn from_index(index: usize) -> Self {
337            match index {
338                0 => Self::A,
339                _ => Self::B,
340            }
341        }
342
343        fn variant_name(self) -> &'static str {
344            match self {
345                Self::A => "a",
346                Self::B => "b",
347            }
348        }
349    }
350
351    #[test]
352    fn stride_one_records_every_call() {
353        let timer = SampledTimer::with_latency_buckets(4, 1);
354
355        timer.record_elapsed(Duration::from_nanos(10));
356        timer.record_elapsed(Duration::from_nanos(20));
357
358        assert_eq!(timer.calls(), 2);
359        assert_eq!(timer.sample_count(), 2);
360        assert_eq!(timer.sample_sum_nanos(), 30);
361        assert_eq!(timer.avg_sample_nanos(), Some(15.0));
362    }
363
364    #[test]
365    fn stride_samples_subset() {
366        let timer = SampledTimer::with_latency_buckets(4, 4);
367
368        for _ in 0..8 {
369            timer.record_elapsed(Duration::from_nanos(10));
370        }
371
372        assert_eq!(timer.calls(), 8);
373        assert_eq!(timer.sample_count(), 2);
374    }
375
376    #[test]
377    fn guard_records_on_drop() {
378        let timer = SampledTimer::with_latency_buckets(4, 1);
379
380        {
381            let _guard = timer.start();
382        }
383
384        assert_eq!(timer.calls(), 1);
385        assert_eq!(timer.sample_count(), 1);
386    }
387
388    #[test]
389    fn explicit_finish_records_once() {
390        let timer = SampledTimer::with_latency_buckets(4, 1);
391
392        timer.start().finish();
393
394        assert_eq!(timer.calls(), 1);
395        assert_eq!(timer.sample_count(), 1);
396    }
397
398    #[test]
399    fn labeled_timer_tracks_labels_independently() {
400        let timer: LabeledSampledTimer<TestLabel> = LabeledSampledTimer::with_latency_buckets(4, 1);
401
402        timer.record_elapsed(TestLabel::A, Duration::from_nanos(15));
403        timer.record_elapsed(TestLabel::B, Duration::from_nanos(25));
404        timer.record_elapsed(TestLabel::A, Duration::from_nanos(35));
405
406        assert_eq!(timer.calls(TestLabel::A), 2);
407        assert_eq!(timer.calls(TestLabel::B), 1);
408        assert_eq!(timer.sample_count(TestLabel::A), 2);
409        assert_eq!(timer.sample_sum_nanos(TestLabel::A), 50);
410        assert_eq!(timer.sample_sum_nanos(TestLabel::B), 25);
411    }
412}