Skip to main content

faucet_core/observability/
timer.rs

1//! RAII timer that records a histogram sample on `Drop`. Ensures duration
2//! samples are recorded even on future cancellation or panic unwind.
3
4use metrics::{KeyName, Label, SharedString, histogram};
5use std::time::Instant;
6
7/// On `Drop`, records the elapsed time since construction into the named
8/// histogram with the supplied labels. Recording on drop guarantees a sample
9/// even if the surrounding future is cancelled or panics.
10#[must_use = "DurationGuard must be bound to a variable; otherwise it records elapsed=0"]
11pub struct DurationGuard {
12    name: KeyName,
13    labels: Vec<Label>,
14    started_at: Instant,
15    armed: bool,
16}
17
18impl DurationGuard {
19    pub fn new(name: impl Into<KeyName>, labels: Vec<Label>) -> Self {
20        Self {
21            name: name.into(),
22            labels,
23            started_at: Instant::now(),
24            armed: true,
25        }
26    }
27
28    /// Disarm the guard so dropping it records nothing. Used when the timed
29    /// span turns out not to represent real work — e.g. the terminal empty
30    /// poll at the end of a source page stream, which would otherwise record a
31    /// spurious ~0 sample into the page-duration histogram.
32    pub fn disarm(&mut self) {
33        self.armed = false;
34    }
35
36    /// Build the canonical (name, pipeline, row, connector) label trio.
37    pub fn with_connector(
38        name: impl Into<KeyName>,
39        pipeline: SharedString,
40        row: SharedString,
41        connector: SharedString,
42    ) -> Self {
43        Self::new(
44            name,
45            vec![
46                Label::new("pipeline", pipeline),
47                Label::new("row", row),
48                Label::new("connector", connector),
49            ],
50        )
51    }
52}
53
54impl Drop for DurationGuard {
55    fn drop(&mut self) {
56        if !self.armed {
57            return;
58        }
59        let elapsed = self.started_at.elapsed().as_secs_f64();
60        histogram!(self.name.clone(), self.labels.clone()).record(elapsed);
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use metrics::SharedString;
68    use metrics_util::debugging::DebugValue;
69    use std::thread;
70    use std::time::Duration;
71
72    // Delegate to the single process-global recorder installed by
73    // `decorator::source_tests`. All observability tests must share one
74    // `OnceLock<Snapshotter>` because `metrics::set_global_recorder` can only
75    // be called once per process; whoever calls it second gets an error and
76    // their snapshotter sees no metrics.
77    use crate::observability::decorator::source_tests::{LOCK, snapshotter};
78
79    #[test]
80    fn records_sample_on_drop() {
81        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
82        let snap = snapshotter();
83        {
84            let _guard = DurationGuard::with_connector(
85                "test_duration_records_sample",
86                SharedString::const_str("p"),
87                SharedString::const_str("r"),
88                SharedString::const_str("c"),
89            );
90            thread::sleep(Duration::from_millis(2));
91        }
92        let snapshot = snap.snapshot();
93        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, value)| {
94            key.key().name() == "test_duration_records_sample"
95                && matches!(
96                    value,
97                    DebugValue::Histogram(samples)
98                        if samples.first().map(|s| s.into_inner()).unwrap_or(0.0) > 0.0
99                )
100        });
101        assert!(
102            found,
103            "expected a histogram sample > 0 on test_duration_records_sample"
104        );
105    }
106
107    #[test]
108    fn records_sample_when_dropped_early() {
109        // Simulate cancellation: build the guard and drop it immediately
110        // without doing any work. A sample is still recorded.
111        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
112        let snap = snapshotter();
113        {
114            let _guard = DurationGuard::with_connector(
115                "test_duration_drop_early",
116                SharedString::const_str("p"),
117                SharedString::const_str("r"),
118                SharedString::const_str("c"),
119            );
120        }
121        let snapshot = snap.snapshot();
122        let found = snapshot
123            .into_vec()
124            .into_iter()
125            .any(|(key, _u, _d, _v)| key.key().name() == "test_duration_drop_early");
126        assert!(
127            found,
128            "expected a histogram entry for test_duration_drop_early"
129        );
130    }
131}