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
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use axum::extract::Request;
116
117 #[test]
118 fn unmatched_path_falls_back_to_sentinel() {
119 let req = Request::builder()
120 .uri("/whatever")
121 .body(axum::body::Body::empty())
122 .unwrap();
123 assert_eq!(matched_path_label(&req), "<unmatched>");
124 }
125
126 #[test]
127 fn run_finished_label_strings_are_stable() {
128 use crate::serve::history::RunStatus;
130 assert_eq!(RunStatus::Completed.as_str(), "completed");
131 assert_eq!(RunStatus::Cancelled.as_str(), "cancelled");
132 }
133
134 #[tokio::test]
141 async fn matched_path_captured_in_outer_layer_position() {
142 use axum::routing::get;
143 use std::sync::{Arc, Mutex};
144 use tower::util::ServiceExt;
145
146 let captured: Arc<Mutex<Option<String>>> = Arc::new(Mutex::new(None));
149 let captured2 = captured.clone();
150
151 let capture_middleware = axum::middleware::from_fn(move |req: Request, next: Next| {
152 let captured = captured2.clone();
153 async move {
154 let label = matched_path_label(&req);
155 *captured.lock().unwrap() = Some(label);
156 next.run(req).await
157 }
158 });
159
160 let router = axum::Router::new()
162 .route("/v1/runs/{id}", get(|| async { "ok" }))
163 .layer(capture_middleware);
164
165 let req = Request::builder()
166 .uri("/v1/runs/abc-123")
167 .body(axum::body::Body::empty())
168 .unwrap();
169 let _resp: axum::response::Response = router.oneshot(req).await.unwrap();
170
171 let label = captured.lock().unwrap().clone().unwrap();
172 assert_eq!(
174 label, "/v1/runs/{id}",
175 "MatchedPath must be the route template, not '<unmatched>' — \
176 axum 0.8 outer .layer() correctly sees MatchedPath"
177 );
178 }
179
180 #[test]
181 fn shard_metrics_emit_without_a_recorder() {
182 super::record_shards_claimed(3);
185 super::record_shards_reclaimed(2, 1);
186 }
187}