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    /// Record the elapsed sample now and disarm (so a later `Drop` does not
37    /// double-record). Used to close the timing window at a generator `yield`
38    /// point: locals persist across a `yield`, so a drop-on-scope-exit timer
39    /// would wrongly include the time the downstream consumer spends before
40    /// polling the next item. Recording before the yield measures only the
41    /// producer's own work (audit #321 M10).
42    pub fn record_now(&mut self) {
43        if !self.armed {
44            return;
45        }
46        let elapsed = self.started_at.elapsed().as_secs_f64();
47        histogram!(self.name.clone(), self.labels.clone()).record(elapsed);
48        self.armed = false;
49    }
50
51    /// Build the canonical (name, pipeline, row, connector) label trio.
52    pub fn with_connector(
53        name: impl Into<KeyName>,
54        pipeline: SharedString,
55        row: SharedString,
56        connector: SharedString,
57    ) -> Self {
58        Self::new(
59            name,
60            vec![
61                Label::new("pipeline", pipeline),
62                Label::new("row", row),
63                Label::new("connector", connector),
64            ],
65        )
66    }
67}
68
69impl Drop for DurationGuard {
70    fn drop(&mut self) {
71        if !self.armed {
72            return;
73        }
74        let elapsed = self.started_at.elapsed().as_secs_f64();
75        histogram!(self.name.clone(), self.labels.clone()).record(elapsed);
76    }
77}
78
79#[cfg(test)]
80#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
81mod tests {
82    use super::*;
83    use metrics::SharedString;
84    use metrics_util::debugging::DebugValue;
85    use std::thread;
86    use std::time::Duration;
87
88    // Delegate to the single process-global recorder installed by
89    // `decorator::source_tests`. All observability tests must share one
90    // `OnceLock<Snapshotter>` because `metrics::set_global_recorder` can only
91    // be called once per process; whoever calls it second gets an error and
92    // their snapshotter sees no metrics.
93    use crate::observability::decorator::source_tests::{LOCK, snapshotter};
94
95    #[test]
96    fn records_sample_on_drop() {
97        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
98        let snap = snapshotter();
99        {
100            let _guard = DurationGuard::with_connector(
101                "test_duration_records_sample",
102                SharedString::const_str("p"),
103                SharedString::const_str("r"),
104                SharedString::const_str("c"),
105            );
106            thread::sleep(Duration::from_millis(2));
107        }
108        let snapshot = snap.snapshot();
109        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, value)| {
110            key.key().name() == "test_duration_records_sample"
111                && matches!(
112                    value,
113                    DebugValue::Histogram(samples)
114                        if samples.first().map(|s| s.into_inner()).unwrap_or(0.0) > 0.0
115                )
116        });
117        assert!(
118            found,
119            "expected a histogram sample > 0 on test_duration_records_sample"
120        );
121    }
122
123    #[test]
124    fn record_now_records_once_and_disarms() {
125        // #321 M10: record_now() emits exactly one sample and the later drop
126        // does not double-record.
127        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
128        let snap = snapshotter();
129        {
130            let mut guard = DurationGuard::with_connector(
131                "test_duration_record_now",
132                SharedString::const_str("p"),
133                SharedString::const_str("r"),
134                SharedString::const_str("c"),
135            );
136            thread::sleep(Duration::from_millis(2));
137            guard.record_now();
138        } // drop here must be a no-op (already disarmed)
139        let snapshot = snap.snapshot();
140        let samples = snapshot
141            .into_vec()
142            .into_iter()
143            .find_map(|(key, _u, _d, value)| {
144                (key.key().name() == "test_duration_record_now").then_some(value)
145            });
146        match samples {
147            Some(DebugValue::Histogram(s)) => {
148                assert_eq!(
149                    s.len(),
150                    1,
151                    "record_now + drop must record exactly one sample"
152                );
153                assert!(s[0].into_inner() > 0.0);
154            }
155            other => panic!("expected one histogram sample, got {other:?}"),
156        }
157    }
158
159    #[test]
160    fn records_sample_when_dropped_early() {
161        // Simulate cancellation: build the guard and drop it immediately
162        // without doing any work. A sample is still recorded.
163        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
164        let snap = snapshotter();
165        {
166            let _guard = DurationGuard::with_connector(
167                "test_duration_drop_early",
168                SharedString::const_str("p"),
169                SharedString::const_str("r"),
170                SharedString::const_str("c"),
171            );
172        }
173        let snapshot = snap.snapshot();
174        let found = snapshot
175            .into_vec()
176            .into_iter()
177            .any(|(key, _u, _d, _v)| key.key().name() == "test_duration_drop_early");
178        assert!(
179            found,
180            "expected a histogram entry for test_duration_drop_early"
181        );
182    }
183}