faucet_cli/serve/
metrics.rs1use crate::serve::history::RunStatus;
5use crate::serve::state::ServerState;
6use axum::extract::{MatchedPath, Request};
7use axum::middleware::Next;
8use axum::response::Response;
9use std::time::Instant;
10
11pub fn matched_path_label(req: &Request) -> String {
13 req.extensions()
14 .get::<MatchedPath>()
15 .map(|m| m.as_str().to_owned())
16 .unwrap_or_else(|| "<unmatched>".to_string())
17}
18
19pub async fn track_metrics(req: Request, next: Next) -> Response {
22 let method = req.method().as_str().to_owned();
23 let path = matched_path_label(&req);
24 let start = Instant::now();
25 let resp = next.run(req).await;
26 let status = resp.status().as_u16().to_string();
27
28 metrics::counter!(
29 "faucet_serve_requests_total",
30 "method" => method.clone(), "path" => path.clone(), "status" => status
31 )
32 .increment(1);
33 metrics::histogram!(
37 "faucet_serve_request_duration_seconds",
38 "method" => method, "path" => path
39 )
40 .record(start.elapsed().as_secs_f64());
41
42 resp
43}
44
45pub fn set_run_gauges(state: &ServerState) {
48 metrics::gauge!("faucet_serve_runs_queued").set(state.registry().queued() as f64);
49 metrics::gauge!("faucet_serve_runs_in_flight").set(state.registry().in_flight() as f64);
50}
51
52pub fn record_run_finished(status: RunStatus, reason: &'static str) {
54 metrics::counter!(
55 "faucet_serve_runs_total",
56 "status" => status.as_str(), "reason" => reason
57 )
58 .increment(1);
59}
60
61pub fn record_idempotency_hit() {
63 metrics::counter!("faucet_serve_idempotency_hits_total").increment(1);
64}
65
66pub fn set_history_degraded(degraded: bool) {
69 metrics::gauge!("faucet_serve_history_degraded").set(if degraded { 1.0 } else { 0.0 });
70}
71
72pub fn record_runs_claimed(n: usize) {
74 metrics::counter!("faucet_serve_runs_claimed_total").increment(n as u64);
75}
76
77pub fn record_shards_claimed(n: usize) {
79 metrics::counter!("faucet_serve_shards_claimed_total").increment(n as u64);
80}
81
82pub fn record_shards_reclaimed(requeued: usize, failed: usize) {
84 metrics::counter!("faucet_serve_shards_reclaimed_total", "outcome" => "requeued")
85 .increment(requeued as u64);
86 metrics::counter!("faucet_serve_shards_reclaimed_total", "outcome" => "failed")
87 .increment(failed as u64);
88}
89
90pub fn set_cluster_enabled(on: bool) {
92 metrics::gauge!("faucet_serve_cluster_enabled").set(if on { 1.0 } else { 0.0 });
93}
94
95pub fn set_cluster_instances(n: usize) {
97 metrics::gauge!("faucet_serve_cluster_instances").set(n as f64);
98}
99
100pub fn record_runs_reclaimed(requeued: usize, failed: usize) {
102 metrics::counter!("faucet_serve_runs_reclaimed_total", "outcome" => "requeued")
107 .increment(requeued as u64);
108 metrics::counter!("faucet_serve_runs_reclaimed_total", "outcome" => "failed")
109 .increment(failed as u64);
110}
111
112pub fn inc_run_logs_purged(n: usize) {
114 metrics::counter!("faucet_serve_run_logs_purged_total").increment(n as u64);
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use axum::extract::Request;
121
122 #[test]
123 fn unmatched_path_falls_back_to_sentinel() {
124 let req = Request::builder()
125 .uri("/whatever")
126 .body(axum::body::Body::empty())
127 .unwrap();
128 assert_eq!(matched_path_label(&req), "<unmatched>");
129 }
130
131 #[test]
132 fn run_finished_label_strings_are_stable() {
133 use crate::serve::history::RunStatus;
135 assert_eq!(RunStatus::Completed.as_str(), "completed");
136 assert_eq!(RunStatus::Cancelled.as_str(), "cancelled");
137 }
138
139 #[tokio::test]
146 async fn matched_path_captured_in_outer_layer_position() {
147 use axum::routing::get;
148 use std::sync::{Arc, Mutex};
149 use tower::util::ServiceExt;
150
151 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
154 let captured2 = captured.clone();
155
156 let capture_middleware = axum::middleware::from_fn(move |req: Request, next: Next| {
157 let captured = captured2.clone();
158 async move {
159 let label = matched_path_label(&req);
160 *captured.lock().unwrap() = Some(label);
161 next.run(req).await
162 }
163 });
164
165 let router = axum::Router::new()
167 .route("/v1/runs/{id}", get(|| async { "ok" }))
168 .layer(capture_middleware);
169
170 let req = Request::builder()
171 .uri("/v1/runs/abc-123")
172 .body(axum::body::Body::empty())
173 .unwrap();
174 let _resp: axum::response::Response = router.oneshot(req).await.unwrap();
175
176 let label = captured.lock().unwrap().clone().unwrap();
177 assert_eq!(
179 label, "/v1/runs/{id}",
180 "MatchedPath must be the route template, not '<unmatched>' — \
181 axum 0.8 outer .layer() correctly sees MatchedPath"
182 );
183 }
184
185 #[test]
186 fn shard_metrics_emit_without_a_recorder() {
187 super::record_shards_claimed(3);
190 super::record_shards_reclaimed(2, 1);
191 }
192}