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)]
80mod tests {
81    use super::*;
82    use metrics::SharedString;
83    use metrics_util::debugging::DebugValue;
84    use std::thread;
85    use std::time::Duration;
86
87    // Delegate to the single process-global recorder installed by
88    // `decorator::source_tests`. All observability tests must share one
89    // `OnceLock<Snapshotter>` because `metrics::set_global_recorder` can only
90    // be called once per process; whoever calls it second gets an error and
91    // their snapshotter sees no metrics.
92    use crate::observability::decorator::source_tests::{LOCK, snapshotter};
93
94    #[test]
95    fn records_sample_on_drop() {
96        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
97        let snap = snapshotter();
98        {
99            let _guard = DurationGuard::with_connector(
100                "test_duration_records_sample",
101                SharedString::const_str("p"),
102                SharedString::const_str("r"),
103                SharedString::const_str("c"),
104            );
105            thread::sleep(Duration::from_millis(2));
106        }
107        let snapshot = snap.snapshot();
108        let found = snapshot.into_vec().into_iter().any(|(key, _u, _d, value)| {
109            key.key().name() == "test_duration_records_sample"
110                && matches!(
111                    value,
112                    DebugValue::Histogram(samples)
113                        if samples.first().map(|s| s.into_inner()).unwrap_or(0.0) > 0.0
114                )
115        });
116        assert!(
117            found,
118            "expected a histogram sample > 0 on test_duration_records_sample"
119        );
120    }
121
122    #[test]
123    fn record_now_records_once_and_disarms() {
124        // #321 M10: record_now() emits exactly one sample and the later drop
125        // does not double-record.
126        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
127        let snap = snapshotter();
128        {
129            let mut guard = DurationGuard::with_connector(
130                "test_duration_record_now",
131                SharedString::const_str("p"),
132                SharedString::const_str("r"),
133                SharedString::const_str("c"),
134            );
135            thread::sleep(Duration::from_millis(2));
136            guard.record_now();
137        } // drop here must be a no-op (already disarmed)
138        let snapshot = snap.snapshot();
139        let samples = snapshot
140            .into_vec()
141            .into_iter()
142            .find_map(|(key, _u, _d, value)| {
143                (key.key().name() == "test_duration_record_now").then_some(value)
144            });
145        match samples {
146            Some(DebugValue::Histogram(s)) => {
147                assert_eq!(
148                    s.len(),
149                    1,
150                    "record_now + drop must record exactly one sample"
151                );
152                assert!(s[0].into_inner() > 0.0);
153            }
154            other => panic!("expected one histogram sample, got {other:?}"),
155        }
156    }
157
158    #[test]
159    fn records_sample_when_dropped_early() {
160        // Simulate cancellation: build the guard and drop it immediately
161        // without doing any work. A sample is still recorded.
162        let _g = LOCK.lock().unwrap_or_else(|e| e.into_inner());
163        let snap = snapshotter();
164        {
165            let _guard = DurationGuard::with_connector(
166                "test_duration_drop_early",
167                SharedString::const_str("p"),
168                SharedString::const_str("r"),
169                SharedString::const_str("c"),
170            );
171        }
172        let snapshot = snap.snapshot();
173        let found = snapshot
174            .into_vec()
175            .into_iter()
176            .any(|(key, _u, _d, _v)| key.key().name() == "test_duration_drop_early");
177        assert!(
178            found,
179            "expected a histogram entry for test_duration_drop_early"
180        );
181    }
182}