nmbrs_metrics/instruments/
histogram.rs1use crate::labels::Labels;
12use hdrhistogram::Histogram as HdrHistogram;
13use std::sync::Mutex;
14use std::sync::atomic::{AtomicU64, Ordering};
15
16pub const DEFAULT_HDR_SIGDIGS: u8 = 3;
18
19pub const HDR_SIGDIGS_PROP: &str = "hdr.sigdigs";
26
27const MAX_VALUE: u64 = 3_600_000_000_000;
29
30pub struct Histogram {
31 labels: Labels,
32 current: Mutex<HdrHistogram<u64>>,
34 total: AtomicU64,
41}
42
43impl Histogram {
44 pub fn new(labels: Labels) -> Self {
45 Self::with_sigdigs(labels, DEFAULT_HDR_SIGDIGS)
46 }
47
48 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 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
74pub 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 pub fn record(&self, value: u64) {
89 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 pub fn total(&self) -> u64 {
106 self.total.load(Ordering::Relaxed)
107 }
108
109 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 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 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 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); h.record(2_000_000); h.record(3_000_000); let snap = h.snapshot();
218 assert_eq!(snap.len(), 3);
219 assert!(snap.min() >= 999_000); 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 h.record(3_000);
234 let snap2 = h.snapshot();
235 assert_eq!(snap2.len(), 1); }
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 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(); 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 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 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); }
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}