Skip to main content

faucet_cli/schedule/
metrics.rs

1//! Scheduler metrics, emitted via the `metrics` facade. `pipeline` is the only
2//! label (low cardinality); per-row outcomes are covered by the pipeline-run
3//! metrics in `faucet-core`. No-ops when no recorder is installed.
4
5use chrono::{DateTime, Utc};
6use metrics::{counter, describe_counter, describe_gauge, describe_histogram, gauge, histogram};
7use std::time::Duration;
8
9/// Register HELP text for every scheduler metric. Idempotent — safe to call
10/// more than once. Called from the scheduler loop at startup so the series'
11/// descriptions are present in `/metrics` from t=0, even before the first tick.
12pub fn describe() {
13    describe_counter!(
14        "faucet_schedule_runs_total",
15        "Scheduled pipeline runs, by outcome (ok|err|skipped)."
16    );
17    describe_counter!(
18        "faucet_schedule_overlaps_total",
19        "Scheduler ticks that overlapped an in-flight run, by policy (skip|queue|forbid)."
20    );
21    describe_counter!(
22        "faucet_schedule_reloads_total",
23        "Hot config reloads (SIGHUP), by outcome (ok|error)."
24    );
25    describe_gauge!(
26        "faucet_schedule_next_tick_unix_seconds",
27        "Unix timestamp of the next scheduled tick."
28    );
29    describe_gauge!(
30        "faucet_schedule_runs_in_flight",
31        "Scheduled runs currently executing (0 or 1)."
32    );
33    describe_gauge!(
34        "faucet_schedule_consecutive_failures",
35        "Consecutive failed scheduled runs since the last success."
36    );
37    describe_gauge!(
38        "faucet_schedule_heartbeat_unix_seconds",
39        "Unix timestamp the scheduler loop last ran (alert if it stalls)."
40    );
41    describe_gauge!(
42        "faucet_schedule_last_run_started_unix_seconds",
43        "Unix timestamp the most recent run started."
44    );
45    describe_gauge!(
46        "faucet_schedule_last_run_completed_unix_seconds",
47        "Unix timestamp the most recent run completed."
48    );
49    describe_gauge!(
50        "faucet_schedule_last_run_duration_seconds",
51        "Wall-clock duration of the most recent run."
52    );
53    describe_histogram!(
54        "faucet_schedule_run_lateness_seconds",
55        "How late each run started relative to its scheduled tick."
56    );
57}
58
59/// `outcome ∈ {"ok", "err", "skipped"}`.
60pub fn run_outcome(pipeline: &str, outcome: &'static str) {
61    counter!("faucet_schedule_runs_total", "pipeline" => pipeline.to_string(), "outcome" => outcome)
62        .increment(1);
63}
64
65/// `policy ∈ {"skip", "queue", "forbid"}`.
66/// Count a hot config reload attempt (`outcome` = `ok` | `error`).
67pub fn reload(pipeline: &str, outcome: &'static str) {
68    counter!("faucet_schedule_reloads_total", "pipeline" => pipeline.to_string(), "outcome" => outcome)
69        .increment(1);
70}
71
72pub fn overlap(pipeline: &str, policy: &'static str) {
73    counter!("faucet_schedule_overlaps_total", "pipeline" => pipeline.to_string(), "policy" => policy)
74        .increment(1);
75}
76
77pub fn next_tick(pipeline: &str, when: DateTime<Utc>) {
78    gauge!("faucet_schedule_next_tick_unix_seconds", "pipeline" => pipeline.to_string())
79        .set(when.timestamp() as f64);
80}
81
82pub fn in_flight(pipeline: &str, n: u64) {
83    gauge!("faucet_schedule_runs_in_flight", "pipeline" => pipeline.to_string()).set(n as f64);
84}
85
86pub fn consecutive_failures(pipeline: &str, n: u64) {
87    gauge!("faucet_schedule_consecutive_failures", "pipeline" => pipeline.to_string())
88        .set(n as f64);
89}
90
91pub fn heartbeat(pipeline: &str, now: DateTime<Utc>) {
92    gauge!("faucet_schedule_heartbeat_unix_seconds", "pipeline" => pipeline.to_string())
93        .set(now.timestamp() as f64);
94}
95
96pub fn last_run_started(pipeline: &str, when: DateTime<Utc>) {
97    gauge!("faucet_schedule_last_run_started_unix_seconds", "pipeline" => pipeline.to_string())
98        .set(when.timestamp() as f64);
99}
100
101pub fn last_run_completed(pipeline: &str, when: DateTime<Utc>) {
102    gauge!("faucet_schedule_last_run_completed_unix_seconds", "pipeline" => pipeline.to_string())
103        .set(when.timestamp() as f64);
104}
105
106pub fn last_run_duration(pipeline: &str, d: Duration) {
107    gauge!("faucet_schedule_last_run_duration_seconds", "pipeline" => pipeline.to_string())
108        .set(d.as_secs_f64());
109}
110
111/// `late` may be negative if a run started slightly early; clamp to 0.
112pub fn lateness(pipeline: &str, late: chrono::Duration) {
113    let secs = (late.num_milliseconds() as f64 / 1000.0).max(0.0);
114    histogram!("faucet_schedule_run_lateness_seconds", "pipeline" => pipeline.to_string())
115        .record(secs);
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use metrics::with_local_recorder;
122    use metrics_util::debugging::{DebugValue, DebuggingRecorder};
123
124    /// Find the latest gauge value emitted for `name` with the given `pipeline`
125    /// label in a `DebuggingRecorder` snapshot.
126    fn gauge_value(
127        snapshot: metrics_util::debugging::Snapshot,
128        name: &str,
129        pipeline: &str,
130    ) -> Option<f64> {
131        snapshot
132            .into_vec()
133            .into_iter()
134            .find_map(|(key, _u, _d, v)| {
135                let k = key.key();
136                let labelled = k
137                    .labels()
138                    .any(|l| l.key() == "pipeline" && l.value() == pipeline);
139                if k.name() == name && labelled {
140                    match v {
141                        DebugValue::Gauge(g) => Some(g.into_inner()),
142                        _ => None,
143                    }
144                } else {
145                    None
146                }
147            })
148    }
149
150    #[test]
151    fn in_flight_sets_gauge() {
152        let recorder = DebuggingRecorder::new();
153        let snap = recorder.snapshotter();
154        with_local_recorder(&recorder, || {
155            in_flight("p", 1);
156            in_flight("p", 0);
157        });
158        assert_eq!(
159            gauge_value(snap.snapshot(), "faucet_schedule_runs_in_flight", "p"),
160            Some(0.0),
161            "in_flight(0) must leave the gauge at 0"
162        );
163    }
164
165    #[test]
166    fn consecutive_failures_sets_gauge() {
167        let recorder = DebuggingRecorder::new();
168        let snap = recorder.snapshotter();
169        with_local_recorder(&recorder, || {
170            consecutive_failures("p", 0);
171        });
172        assert_eq!(
173            gauge_value(snap.snapshot(), "faucet_schedule_consecutive_failures", "p"),
174            Some(0.0),
175            "consecutive_failures(0) must register the series at 0"
176        );
177    }
178
179    /// Mirrors what the scheduler does at startup (item 2): pre-emit both gauges
180    /// so the series exist in `/metrics` before the first dispatch.
181    #[test]
182    fn startup_preemit_registers_both_gauges_at_zero() {
183        let recorder = DebuggingRecorder::new();
184        let snap = recorder.snapshotter();
185        with_local_recorder(&recorder, || {
186            describe();
187            in_flight("p", 0);
188            consecutive_failures("p", 0);
189        });
190        assert_eq!(
191            gauge_value(snap.snapshot(), "faucet_schedule_runs_in_flight", "p"),
192            Some(0.0),
193            "runs_in_flight must exist at 0 from startup"
194        );
195        assert_eq!(
196            gauge_value(snap.snapshot(), "faucet_schedule_consecutive_failures", "p"),
197            Some(0.0),
198            "consecutive_failures must exist at 0 from startup"
199        );
200    }
201}