Skip to main content

nmbrs_metrics/instruments/
histogram.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Delta HDR Histogram for latency/value distribution recording.
5//!
6//! Each `snapshot()` call returns the data accumulated since the last
7//! snapshot (delta semantics). The Recorder's interval is swapped
8//! atomically, so the hot path (`record()`) and the snapshot path
9//! don't contend.
10
11use crate::labels::Labels;
12use hdrhistogram::Histogram as HdrHistogram;
13use std::sync::Mutex;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16/// Default significant digits for HDR Histograms (0.1% error).
17pub const DEFAULT_HDR_SIGDIGS: u8 = 3;
18
19/// Component property name read by [`Histogram::with_sigdigs_from`]
20/// and [`crate::instruments::timer::Timer::with_sigdigs_from`] to
21/// resolve the active precision. Set on the session root by the
22/// runner; descendants pick it up via [`crate::component::Component::get_prop`]
23/// walk-up. See SRD 40 §"HDR significant digits — subtree-scoped
24/// setting".
25pub const HDR_SIGDIGS_PROP: &str = "hdr.sigdigs";
26
27/// Maximum trackable value in nanoseconds (~1 hour).
28const MAX_VALUE: u64 = 3_600_000_000_000;
29
30pub struct Histogram {
31    labels: Labels,
32    /// The accumulating histogram. Protected by mutex for the swap.
33    current: Mutex<HdrHistogram<u64>>,
34    /// Lifetime observation count — monotonic, never reset by
35    /// `snapshot()`. The drain model resets the reservoir per window,
36    /// so this is the authoritative **cumulative** count the cadence
37    /// capture stamps onto `HistogramValue::cumulative_count`
38    /// (Prometheus/VM-schematic, like `Counter`'s absolute total). See
39    /// the cumulative-counter note.
40    total: AtomicU64,
41}
42
43impl Histogram {
44    pub fn new(labels: Labels) -> Self {
45        Self::with_sigdigs(labels, DEFAULT_HDR_SIGDIGS)
46    }
47
48    /// Construct with an explicit HDR significant-digits
49    /// precision. Use this from a call site that already
50    /// knows the desired precision (e.g. the runner that has
51    /// already resolved [`HDR_SIGDIGS_PROP`] up the component
52    /// tree).
53    pub fn with_sigdigs(labels: Labels, sigdigs: u8) -> Self {
54        Self {
55            labels,
56            current: Mutex::new(
57                HdrHistogram::new_with_bounds(1, MAX_VALUE, sigdigs)
58                    .expect("failed to create HDR histogram"),
59            ),
60            total: AtomicU64::new(0),
61        }
62    }
63
64    /// Construct from a component, walking up the tree to find
65    /// the configured [`HDR_SIGDIGS_PROP`] property. Falls back
66    /// to [`DEFAULT_HDR_SIGDIGS`] if no ancestor declares it.
67    /// SRD 40 §"HDR significant digits — subtree-scoped setting".
68    pub fn with_sigdigs_from(labels: Labels, component: &crate::component::Component) -> Self {
69        let sigdigs = resolve_hdr_sigdigs(component);
70        Self::with_sigdigs(labels, sigdigs)
71    }
72}
73
74/// Resolve the configured HDR significant-digits precision
75/// from a component, walking up the tree. Returns
76/// [`DEFAULT_HDR_SIGDIGS`] if no ancestor declares
77/// [`HDR_SIGDIGS_PROP`] or the value is unparseable.
78pub fn resolve_hdr_sigdigs(component: &crate::component::Component) -> u8 {
79    component
80        .get_prop(HDR_SIGDIGS_PROP)
81        .and_then(|s| s.parse::<u8>().ok())
82        .filter(|&n| (1..=5).contains(&n))
83        .unwrap_or(DEFAULT_HDR_SIGDIGS)
84}
85
86impl Histogram {
87    /// Record a value (typically nanoseconds).
88    pub fn record(&self, value: u64) {
89        // Count the observation in the lifetime total regardless of the
90        // reservoir outcome (an out-of-range clamp is still an
91        // observation) — keeps `total()` the authoritative cumulative.
92        self.total.fetch_add(1, Ordering::Relaxed);
93        let value = value.min(MAX_VALUE);
94        let mut h = self.current.lock().unwrap_or_else(|e| e.into_inner());
95        if let Err(e) = h.record(value) {
96            crate::diag::warn(&format!(
97                "warning: histogram record failed for value {value}: {e}"
98            ));
99        }
100    }
101
102    /// Lifetime (cumulative) observation count — monotonic, unaffected
103    /// by `snapshot()` draining. The cadence capture stamps this onto
104    /// `HistogramValue::cumulative_count`.
105    pub fn total(&self) -> u64 {
106        self.total.load(Ordering::Relaxed)
107    }
108
109    /// Swap out the current histogram and return the delta.
110    ///
111    /// The returned histogram contains all data since the last
112    /// `snapshot()` call. The internal histogram is reset.
113    pub fn snapshot(&self) -> HdrHistogram<u64> {
114        let mut current = self.current.lock().unwrap_or_else(|e| e.into_inner());
115        let snapshot = current.clone();
116        current.reset();
117        snapshot
118    }
119
120    /// Produce a snapshot by CLONING the current histogram rather than
121    /// swapping it out. The instrument keeps accumulating against the
122    /// same state — no reservoir disturbance — so consumers reading
123    /// "now" values between reporter ticks don't steal samples from
124    /// the next delta snapshot.
125    ///
126    /// Cost: one HDR histogram clone (~200 KiB at 3-significant-digit
127    /// precision over a 1-hour range). Acceptable for occasional
128    /// calls — not intended for the per-sample hot path.
129    pub fn peek_snapshot(&self) -> HdrHistogram<u64> {
130        self.current
131            .lock()
132            .unwrap_or_else(|e| e.into_inner())
133            .clone()
134    }
135
136    pub fn labels(&self) -> &Labels {
137        &self.labels
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn resolve_hdr_sigdigs_uses_default_without_property() {
147        let comp = crate::component::Component::root(
148            crate::labels::Labels::empty(),
149            std::collections::HashMap::new(),
150        );
151        let guard = comp.read().unwrap();
152        assert_eq!(resolve_hdr_sigdigs(&guard), DEFAULT_HDR_SIGDIGS);
153    }
154
155    #[test]
156    fn resolve_hdr_sigdigs_reads_root_property() {
157        let mut props = std::collections::HashMap::new();
158        props.insert(HDR_SIGDIGS_PROP.to_string(), "4".to_string());
159        let comp = crate::component::Component::root(crate::labels::Labels::empty(), props);
160        let guard = comp.read().unwrap();
161        assert_eq!(resolve_hdr_sigdigs(&guard), 4);
162    }
163
164    #[test]
165    fn resolve_hdr_sigdigs_walks_up_from_descendant() {
166        use std::sync::Arc;
167        use std::sync::RwLock;
168
169        let mut root_props = std::collections::HashMap::new();
170        root_props.insert(HDR_SIGDIGS_PROP.to_string(), "5".to_string());
171        let root = crate::component::Component::root(
172            crate::labels::Labels::of("session", "hdr_test"),
173            root_props,
174        );
175        let phase = Arc::new(RwLock::new(crate::component::Component::new(
176            crate::labels::Labels::of("phase", "p"),
177            std::collections::HashMap::new(),
178        )));
179        crate::component::attach(&root, &phase);
180
181        // Descendant has no own hdr.sigdigs but walks up to find
182        // the session-scoped 5.
183        let pg = phase.read().unwrap();
184        assert_eq!(resolve_hdr_sigdigs(&pg), 5);
185    }
186
187    #[test]
188    fn resolve_hdr_sigdigs_clamps_invalid_value_to_default() {
189        let mut props = std::collections::HashMap::new();
190        props.insert(HDR_SIGDIGS_PROP.to_string(), "99".to_string());
191        let comp = crate::component::Component::root(crate::labels::Labels::empty(), props);
192        let guard = comp.read().unwrap();
193        assert_eq!(
194            resolve_hdr_sigdigs(&guard),
195            DEFAULT_HDR_SIGDIGS,
196            "invalid sigdigs values fall back to the default"
197        );
198    }
199
200    #[test]
201    fn histogram_with_sigdigs_from_uses_walk_up_value() {
202        let mut props = std::collections::HashMap::new();
203        props.insert(HDR_SIGDIGS_PROP.to_string(), "2".to_string());
204        let comp = crate::component::Component::root(crate::labels::Labels::empty(), props);
205        let guard = comp.read().unwrap();
206        // Constructs without panic at the resolved precision.
207        let _h = Histogram::with_sigdigs_from(Labels::of("name", "latency"), &guard);
208    }
209
210    #[test]
211    fn histogram_record_and_snapshot() {
212        let h = Histogram::new(Labels::of("name", "latency"));
213        h.record(1_000_000); // 1ms
214        h.record(2_000_000); // 2ms
215        h.record(3_000_000); // 3ms
216
217        let snap = h.snapshot();
218        assert_eq!(snap.len(), 3);
219        assert!(snap.min() >= 999_000); // HDR bucketing
220        assert!(snap.max() <= 3_100_000);
221    }
222
223    #[test]
224    fn histogram_delta_semantics() {
225        let h = Histogram::new(Labels::of("name", "test"));
226        h.record(1_000);
227        h.record(2_000);
228
229        let snap1 = h.snapshot();
230        assert_eq!(snap1.len(), 2);
231
232        // After snapshot, histogram is reset
233        h.record(3_000);
234        let snap2 = h.snapshot();
235        assert_eq!(snap2.len(), 1); // only the new record
236    }
237
238    #[test]
239    fn histogram_empty_snapshot() {
240        let h = Histogram::new(Labels::of("name", "empty"));
241        let snap = h.snapshot();
242        assert_eq!(snap.len(), 0);
243    }
244
245    #[test]
246    fn total_is_cumulative_across_snapshots() {
247        // The reservoir is per-window (drained by snapshot()), but total()
248        // is the monotonic lifetime count the cadence capture stamps onto
249        // `HistogramValue::cumulative_count` — so rate() over a histogram
250        // count is PromQL-correct, like a counter.
251        let h = Histogram::new(Labels::of("name", "cum"));
252        h.record(1_000);
253        h.record(2_000);
254        assert_eq!(h.total(), 2);
255
256        let s1 = h.snapshot(); // drains the reservoir
257        assert_eq!(s1.len(), 2);
258        assert_eq!(h.total(), 2, "snapshot() must NOT reset the lifetime total");
259
260        h.record(3_000);
261        let s2 = h.snapshot();
262        assert_eq!(s2.len(), 1, "reservoir is per-window (delta)");
263        assert_eq!(h.total(), 3, "total() is the monotonic cumulative count");
264    }
265
266    #[test]
267    fn peek_snapshot_does_not_drain() {
268        let h = Histogram::new(Labels::of("name", "peek"));
269        h.record(1_000_000);
270        h.record(2_000_000);
271        h.record(3_000_000);
272
273        // Peek: full data visible, instrument NOT reset.
274        let peek1 = h.peek_snapshot();
275        assert_eq!(peek1.len(), 3);
276        let peek2 = h.peek_snapshot();
277        assert_eq!(peek2.len(), 3, "peek should be idempotent");
278
279        // After a real snapshot() the instrument IS reset — peek
280        // returns empty, proving peek and snapshot target the same
281        // reservoir.
282        let _drained = h.snapshot();
283        let peek_after = h.peek_snapshot();
284        assert_eq!(peek_after.len(), 0);
285    }
286
287    #[test]
288    fn histogram_quantiles() {
289        let h = Histogram::new(Labels::of("name", "q"));
290        for i in 1..=1000 {
291            h.record(i * 1000); // 1µs to 1ms
292        }
293        let snap = h.snapshot();
294        let p50 = snap.value_at_quantile(0.5);
295        let p99 = snap.value_at_quantile(0.99);
296        assert!(p50 > 400_000 && p50 < 600_000, "p50={p50}");
297        assert!(p99 > 980_000 && p99 < 1_100_000, "p99={p99}");
298    }
299}