Skip to main content

iota_metrics/
quantile_gauge.rs

1// Copyright (c) 2026 IOTA Stiftung
2// SPDX-License-Identifier: Apache-2.0
3
4//! Gauges summarising a sliding window of observations.
5//!
6//! [`QuantileGauge`] exposes a small fixed set of latency quantiles as a
7//! `<name>{quantile="..."}` gauge series, computed at scrape time from a
8//! ~2-minute sliding window, instead of a full bucketed histogram. It lets a
9//! dashboard read `<name>{quantile="0.5"}` directly in place of
10//! `histogram_quantile(0.5, rate(<name>_bucket[2m]))`, collapsing the
11//! per-bucket series (and, for [`QuantileGaugeVec`], the per-label bucket
12//! expansion) down to one series per quantile.
13//!
14//! The summaries are computed on each node over its own observations, so they
15//! cannot be re-aggregated across nodes in PromQL; query them per host.
16
17use std::{
18    collections::{HashMap, VecDeque},
19    sync::Arc,
20    time::{Duration, Instant},
21};
22
23use hdrhistogram::Histogram;
24use parking_lot::Mutex;
25use prometheus_filtered::{
26    MetricLevel, Opts, Registry,
27    core::{Collector, Desc},
28    prometheus::{GaugeVec, proto::MetricFamily},
29};
30
31/// Quantiles exposed by every gauge, as `(quantile, series-label)` pairs.
32const QUANTILES: &[(f64, &str)] = &[
33    (0.05, "0.05"),
34    (0.33, "0.33"),
35    (0.5, "0.5"),
36    (0.66, "0.66"),
37    (0.95, "0.95"),
38];
39
40/// Width of one slot in the sliding window.
41pub(crate) const WINDOW_SLOT: Duration = Duration::from_secs(10);
42/// Number of slots retained; `WINDOW_SLOTS * WINDOW_SLOT` is the window length,
43/// matching the `rate(..[2m])` range the replaced dashboard panels used.
44pub(crate) const WINDOW_SLOTS: usize = 12;
45
46/// Upper bound of the histograms, 10 minutes. Observations above it are
47/// clamped.
48const MAX_TRACKED_VALUE: u64 = 600_000_000;
49
50fn new_histogram() -> Histogram<u64> {
51    // Fixed bounds (not auto-resizing) so `saturating_record` clamps outliers to
52    // `MAX_TRACKED_VALUE` instead of leaving them out of range, and so the
53    // per-authority × per-slot histograms have a bounded size. Two significant
54    // figures (~1% quantile error) keeps that size small.
55    Histogram::new_with_bounds(1, MAX_TRACKED_VALUE, 2).expect("valid histogram bounds")
56}
57
58/// A sliding window of HDR histograms. Observations land in the newest slot;
59/// slots older than the window length are dropped on the next rotation.
60struct Window {
61    slots: VecDeque<Histogram<u64>>,
62    newest_slot_start: Instant,
63}
64
65impl Window {
66    fn new(now: Instant) -> Self {
67        let mut slots = VecDeque::with_capacity(WINDOW_SLOTS);
68        slots.push_back(new_histogram());
69        Self {
70            slots,
71            newest_slot_start: now,
72        }
73    }
74
75    fn rotate(&mut self, now: Instant) {
76        // After an idle gap longer than the whole window every retained slot
77        // would be discarded anyway; reset in one step instead of looping over
78        // every elapsed slot.
79        if now.duration_since(self.newest_slot_start) >= WINDOW_SLOT * WINDOW_SLOTS as u32 {
80            self.slots.clear();
81            self.slots.push_back(new_histogram());
82            self.newest_slot_start = now;
83            return;
84        }
85        while now.duration_since(self.newest_slot_start) >= WINDOW_SLOT {
86            self.newest_slot_start += WINDOW_SLOT;
87            self.slots.push_back(new_histogram());
88            if self.slots.len() > WINDOW_SLOTS {
89                self.slots.pop_front();
90            }
91        }
92    }
93
94    fn record(&mut self, seconds: f64) {
95        self.rotate(Instant::now());
96        self.slots
97            .back_mut()
98            .expect("the window always holds at least one slot")
99            .saturating_record((seconds * 1e6).max(1.0) as u64);
100    }
101
102    /// Quantiles over the whole window, in seconds, aligned with [`QUANTILES`].
103    /// Returns `None` when no value was recorded in the window, so the caller
104    /// can drop the series (leaving a gap) rather than report a stale value.
105    fn quantiles_seconds(&mut self) -> Option<Vec<f64>> {
106        self.rotate(Instant::now());
107        let mut merged = new_histogram();
108        for slot in &self.slots {
109            merged
110                .add(slot)
111                .expect("all slot histograms share significant figures");
112        }
113        if merged.is_empty() {
114            return None;
115        }
116        Some(
117            QUANTILES
118                .iter()
119                .map(|(quantile, _)| merged.value_at_quantile(*quantile) as f64 / 1e6)
120                .collect(),
121        )
122    }
123}
124
125/// A single latency distribution exposed as `<name>{quantile="..."}`.
126///
127/// Register with [`QuantileGauge::register`], feed it with [`observe`], and the
128/// registry's scrape computes the quantiles over the current window.
129///
130/// [`observe`]: QuantileGauge::observe
131#[derive(Clone)]
132pub struct QuantileGauge {
133    gauge: GaugeVec,
134    window: Arc<Mutex<Window>>,
135}
136
137impl QuantileGauge {
138    /// # Panics
139    ///
140    /// Panics if a metric of this name is already registered.
141    pub fn register(
142        name: &str,
143        help: &str,
144        module: &str,
145        registry: &Registry,
146        level: MetricLevel,
147    ) -> Self {
148        let gauge =
149            GaugeVec::new(Opts::new(name, help), &["quantile"]).expect("valid gauge options");
150        let this = Self {
151            gauge,
152            window: Arc::new(Mutex::new(Window::new(Instant::now()))),
153        };
154        registry
155            .register_filtered(name, module, level, this)
156            .expect("quantile gauge registers without collision")
157    }
158
159    pub fn observe(&self, seconds: f64) {
160        self.window.lock().record(seconds);
161    }
162}
163
164impl Collector for QuantileGauge {
165    fn desc(&self) -> Vec<&Desc> {
166        self.gauge.desc()
167    }
168
169    fn collect(&self) -> Vec<MetricFamily> {
170        match self.window.lock().quantiles_seconds() {
171            Some(values) => {
172                for ((_, label), value) in QUANTILES.iter().zip(values) {
173                    self.gauge.with_label_values(&[label]).set(value);
174                }
175            }
176            None => {
177                for (_, label) in QUANTILES {
178                    let _ = self.gauge.remove_label_values(&[label]);
179                }
180            }
181        }
182        self.gauge.collect()
183    }
184}
185
186/// A latency distribution kept per value of one label, exposed as
187/// `<name>{<label>="...", quantile="..."}`. One window is created lazily
188/// per observed label value.
189#[derive(Clone)]
190pub struct QuantileGaugeVec {
191    gauge: GaugeVec,
192    windows: Arc<Mutex<HashMap<String, Window>>>,
193}
194
195impl QuantileGaugeVec {
196    /// # Panics
197    ///
198    /// Panics if a metric of this name is already registered.
199    pub fn register(
200        name: &str,
201        help: &str,
202        label: &str,
203        module: &str,
204        registry: &Registry,
205        level: MetricLevel,
206    ) -> Self {
207        let gauge = GaugeVec::new(Opts::new(name, help), &[label, "quantile"])
208            .expect("valid gauge options");
209        let this = Self {
210            gauge,
211            windows: Arc::new(Mutex::new(HashMap::new())),
212        };
213        registry
214            .register_filtered(name, module, level, this)
215            .expect("quantile gauge registers without collision")
216    }
217
218    pub fn observe(&self, label_value: &str, seconds: f64) {
219        let mut windows = self.windows.lock();
220        if let Some(window) = windows.get_mut(label_value) {
221            window.record(seconds);
222        } else {
223            let mut window = Window::new(Instant::now());
224            window.record(seconds);
225            windows.insert(label_value.to_owned(), window);
226        }
227    }
228}
229
230impl Collector for QuantileGaugeVec {
231    fn desc(&self) -> Vec<&Desc> {
232        self.gauge.desc()
233    }
234
235    fn collect(&self) -> Vec<MetricFamily> {
236        let mut windows = self.windows.lock();
237        windows.retain(|label_value, window| match window.quantiles_seconds() {
238            Some(values) => {
239                for ((_, quantile), value) in QUANTILES.iter().zip(values) {
240                    self.gauge
241                        .with_label_values(&[label_value.as_str(), quantile])
242                        .set(value);
243                }
244                true
245            }
246            None => {
247                // Drop both the gauge series and the window so an idle label
248                // value stops being scraped and its memory is reclaimed.
249                for (_, quantile) in QUANTILES {
250                    let _ = self
251                        .gauge
252                        .remove_label_values(&[label_value.as_str(), quantile]);
253                }
254                false
255            }
256        });
257        self.gauge.collect()
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn rotate_keeps_slot_within_window_slot() {
267        let t0 = Instant::now();
268        let mut window = Window::new(t0);
269        window.rotate(t0 + WINDOW_SLOT / 2);
270        assert_eq!(window.slots.len(), 1);
271        assert_eq!(window.newest_slot_start, t0);
272    }
273
274    #[test]
275    fn rotate_adds_one_slot_at_exactly_window_slot() {
276        let t0 = Instant::now();
277        let mut window = Window::new(t0);
278        window.rotate(t0 + WINDOW_SLOT);
279        assert_eq!(window.slots.len(), 2);
280        assert_eq!(window.newest_slot_start, t0 + WINDOW_SLOT);
281    }
282
283    #[test]
284    fn rotate_adds_multiple_slots_and_trims_to_window() {
285        let t0 = Instant::now();
286        let mut window = Window::new(t0);
287
288        window.rotate(t0 + WINDOW_SLOT * 5);
289        assert_eq!(window.slots.len(), 6);
290        assert_eq!(window.newest_slot_start, t0 + WINDOW_SLOT * 5);
291
292        // Advancing far enough to overflow the deque trims the oldest slots.
293        window.rotate(t0 + WINDOW_SLOT * 16);
294        assert_eq!(window.slots.len(), WINDOW_SLOTS);
295        assert_eq!(window.newest_slot_start, t0 + WINDOW_SLOT * 16);
296    }
297
298    #[test]
299    fn rotate_resets_after_gap_longer_than_window() {
300        let t0 = Instant::now();
301        let mut window = Window::new(t0);
302        let gap = WINDOW_SLOT * WINDOW_SLOTS as u32;
303        window.rotate(t0 + gap);
304        assert_eq!(window.slots.len(), 1);
305        assert_eq!(window.newest_slot_start, t0 + gap);
306    }
307
308    #[test]
309    fn old_observations_fall_out_of_window() {
310        let t0 = Instant::now();
311        let mut window = Window::new(t0);
312        window.slots.back_mut().unwrap().saturating_record(1_000);
313
314        // Fill the window to capacity without dropping the observed slot yet.
315        window.rotate(t0 + WINDOW_SLOT * (WINDOW_SLOTS as u32 - 1));
316        assert_eq!(window.slots.len(), WINDOW_SLOTS);
317        assert!(!window.slots.front().unwrap().is_empty());
318
319        // One more slot pushes the oldest (observed) slot out of the window.
320        window.rotate(t0 + WINDOW_SLOT * WINDOW_SLOTS as u32);
321        assert_eq!(window.slots.len(), WINDOW_SLOTS);
322        assert!(window.slots.iter().all(|slot| slot.is_empty()));
323    }
324
325    #[test]
326    fn collect_drops_idle_label_series_and_window() {
327        let registry = Registry::new();
328        let gauge = QuantileGaugeVec::register(
329            "test_quantile_gauge",
330            "help",
331            "peer",
332            module_path!(),
333            &registry,
334            MetricLevel::Warn,
335        );
336        gauge.observe("peer1", 0.01);
337
338        // Force the window idle: no observation survives in any live slot.
339        {
340            let mut windows = gauge.windows.lock();
341            let window = windows.get_mut("peer1").unwrap();
342            window.slots.clear();
343            window.slots.push_back(new_histogram());
344        }
345
346        gauge.collect();
347        assert!(gauge.windows.lock().is_empty());
348    }
349}