Skip to main content

nmbrs_metrics/instruments/
timer.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Timer: histogram + counter for per-operation latency recording.
5
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, OnceLock};
8
9use crate::instruments::histogram::Histogram;
10use crate::labels::Labels;
11use crate::summaries::live_window::{LiveWindowConfig, LiveWindowHistogram};
12use hdrhistogram::Histogram as HdrHistogram;
13
14pub struct Timer {
15    labels: Labels,
16    histogram: Histogram,
17    count: AtomicU64,
18    /// Opt-in sliding-window view. `None` until
19    /// [`Self::enable_live_window`] is called, at which point
20    /// [`Self::record`] writes to both the main delta reservoir
21    /// and the live-window ring. Hot path cost when unused: one
22    /// atomic load + null check (no branch misprediction in the
23    /// common case).
24    live_window: OnceLock<Arc<LiveWindowHistogram>>,
25}
26
27/// Snapshot of a timer's state for a single interval.
28pub struct TimerSnapshot {
29    pub histogram: HdrHistogram<u64>,
30    pub count: u64,
31}
32
33impl Timer {
34    pub fn new(labels: Labels) -> Self {
35        Self::with_sigdigs(labels, crate::instruments::histogram::DEFAULT_HDR_SIGDIGS)
36    }
37
38    /// Construct a Timer with explicit HDR significant-digits
39    /// precision. Use this from a call site that already knows
40    /// the desired precision.
41    pub fn with_sigdigs(labels: Labels, sigdigs: u8) -> Self {
42        Self {
43            labels: labels.clone(),
44            histogram: crate::instruments::histogram::Histogram::with_sigdigs(labels, sigdigs),
45            count: AtomicU64::new(0),
46            live_window: OnceLock::new(),
47        }
48    }
49
50    /// Construct a Timer whose underlying histogram uses the
51    /// HDR significant-digits precision configured on the
52    /// nearest ancestor of `component` that declares
53    /// [`crate::instruments::histogram::HDR_SIGDIGS_PROP`].
54    /// Falls back to the default if no ancestor declares it.
55    /// SRD 40 §"HDR significant digits — subtree-scoped setting".
56    pub fn with_sigdigs_from(labels: Labels, component: &crate::component::Component) -> Self {
57        let sigdigs = crate::instruments::histogram::resolve_hdr_sigdigs(component);
58        Self::with_sigdigs(labels, sigdigs)
59    }
60
61    /// Record a duration in nanoseconds. If the live-window ring
62    /// has been activated via [`Self::enable_live_window`] it
63    /// receives the sample too — otherwise the cold path is a
64    /// single atomic load + null check.
65    pub fn record(&self, duration_nanos: u64) {
66        self.histogram.record(duration_nanos);
67        self.count.fetch_add(1, Ordering::Relaxed);
68        if let Some(live) = self.live_window.get() {
69            live.record(duration_nanos);
70        }
71    }
72
73    /// Snapshot: returns the delta histogram and the current count.
74    ///
75    /// The count returned is the absolute total (not delta) — reporters
76    /// compute deltas by comparing with their previous snapshot.
77    pub fn snapshot(&self) -> TimerSnapshot {
78        TimerSnapshot {
79            histogram: self.histogram.snapshot(),
80            count: self.count.load(Ordering::Relaxed),
81        }
82    }
83
84    /// Non-draining snapshot — clones the histogram without resetting
85    /// it. Use for "live read-through" consumers (e.g., the `now()`
86    /// path in the windowed metrics layer) that shouldn't steal data
87    /// from the next `snapshot()`. See [`Histogram::peek_snapshot`].
88    pub fn peek_snapshot(&self) -> TimerSnapshot {
89        TimerSnapshot {
90            histogram: self.histogram.peek_snapshot(),
91            count: self.count.load(Ordering::Relaxed),
92        }
93    }
94
95    /// Explicitly activate the sliding-window live view with a
96    /// non-default config. Idempotent — second and subsequent calls
97    /// return the existing ring, ignoring the passed config.
98    ///
99    /// Callers who are happy with [`LiveWindowConfig::default`]
100    /// **do not need to call this** — [`Self::peek_live_window`]
101    /// auto-activates with defaults on first use. This method is
102    /// only for callers that want tighter bounds, a different
103    /// window size, or more slots before the first peek fires.
104    pub fn enable_live_window(&self, config: LiveWindowConfig) -> Arc<LiveWindowHistogram> {
105        self.live_window
106            .get_or_init(|| Arc::new(LiveWindowHistogram::new(config)))
107            .clone()
108    }
109
110    /// Peek the sliding-window view. Auto-activates the ring with
111    /// [`LiveWindowConfig::default`] on first call — subsequent
112    /// calls just read.
113    ///
114    /// Cost model:
115    /// - Before the first peek ever: zero hot-path overhead on
116    ///   `record` (the OnceLock is empty, the record path branches
117    ///   past it).
118    /// - Starting with the first peek: every subsequent `record`
119    ///   on this Timer writes to the ring too (+~63 ns per record
120    ///   at default config; see the `live_window/record_enabled`
121    ///   bench).
122    ///
123    /// Returns the full rolling window merged into a fresh HDR
124    /// histogram. Empty on the very first call (ring just created,
125    /// no records captured yet).
126    pub fn peek_live_window(&self) -> HdrHistogram<u64> {
127        let ring = self
128            .live_window
129            .get_or_init(|| Arc::new(LiveWindowHistogram::new(LiveWindowConfig::default())));
130        ring.peek()
131    }
132
133    /// True when the live-window ring has been activated (either
134    /// explicitly via [`Self::enable_live_window`] or lazily via a
135    /// first [`Self::peek_live_window`] call). Useful for
136    /// diagnostics / benches — does **not** trigger activation.
137    pub fn live_window_active(&self) -> bool {
138        self.live_window.get().is_some()
139    }
140
141    /// Direct access to the live-window ring if it's already active.
142    /// Unlike [`Self::peek_live_window`], does NOT lazily activate —
143    /// returns `None` if nothing has peeked yet. Useful for callers
144    /// that want to read the ring's config / `len()` without
145    /// triggering activation.
146    pub fn live_window(&self) -> Option<Arc<LiveWindowHistogram>> {
147        self.live_window.get().cloned()
148    }
149
150    /// Current total count (without snapshotting).
151    pub fn count(&self) -> u64 {
152        self.count.load(Ordering::Relaxed)
153    }
154
155    pub fn labels(&self) -> &Labels {
156        &self.labels
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn timer_record_and_snapshot() {
166        let t = Timer::new(Labels::of("name", "servicetime"));
167        t.record(1_000_000);
168        t.record(2_000_000);
169
170        let snap = t.snapshot();
171        assert_eq!(snap.histogram.len(), 2);
172        assert_eq!(snap.count, 2);
173    }
174
175    #[test]
176    fn timer_delta_histogram() {
177        let t = Timer::new(Labels::of("name", "test"));
178        t.record(1_000);
179        let snap1 = t.snapshot();
180        assert_eq!(snap1.histogram.len(), 1);
181
182        t.record(2_000);
183        t.record(3_000);
184        let snap2 = t.snapshot();
185        assert_eq!(snap2.histogram.len(), 2); // delta: only new records
186        assert_eq!(snap2.count, 3); // count is cumulative
187    }
188
189    #[test]
190    fn timer_count_monotonic() {
191        let t = Timer::new(Labels::of("name", "c"));
192        assert_eq!(t.count(), 0);
193        t.record(100);
194        assert_eq!(t.count(), 1);
195        t.record(200);
196        assert_eq!(t.count(), 2);
197    }
198
199    #[test]
200    fn live_window_inactive_until_first_peek() {
201        let t = Timer::new(Labels::of("name", "lw"));
202        t.record(100_000);
203        t.record(200_000);
204        // Records before any peek must NOT enable the ring — this
205        // is the zero-overhead default for headless runs.
206        assert!(!t.live_window_active());
207    }
208
209    #[test]
210    fn first_peek_lazily_activates_with_defaults() {
211        let t = Timer::new(Labels::of("name", "lw"));
212        // Records before any peek are NOT captured (ring doesn't
213        // exist yet).
214        t.record(50_000);
215        let first = t.peek_live_window();
216        assert!(t.live_window_active());
217        // First peek returns an empty merged result — the ring just
218        // came into existence.
219        assert_eq!(first.len(), 0);
220        // From now on, records are captured.
221        t.record(100_000);
222        t.record(200_000);
223        let snap = t.peek_live_window();
224        assert_eq!(snap.len(), 2);
225        assert!(snap.max() >= 200_000);
226    }
227
228    #[test]
229    fn explicit_enable_preempts_lazy_init() {
230        let t = Timer::new(Labels::of("name", "lw"));
231        let _ring = t.enable_live_window(Default::default());
232        t.record(100_000);
233        t.record(200_000);
234        t.record(300_000);
235        // Since enable fired before any record, all three are in.
236        let snap = t.peek_live_window();
237        assert_eq!(snap.len(), 3);
238        assert!(snap.max() >= 300_000);
239    }
240
241    #[test]
242    fn live_window_does_not_drain_main_reservoir() {
243        let t = Timer::new(Labels::of("name", "lw"));
244        let _ring = t.enable_live_window(Default::default());
245        t.record(500_000);
246        // Peek the live ring multiple times; main reservoir is
247        // untouched.
248        let _ = t.peek_live_window();
249        let _ = t.peek_live_window();
250        let main_snap = t.snapshot();
251        assert_eq!(main_snap.count, 1);
252        assert_eq!(main_snap.histogram.len(), 1);
253    }
254}