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}