Skip to main content

docspec_http/metrics/
middleware.rs

1//! Tower middleware for recording HTTP-level Prometheus metrics.
2//!
3//! This middleware records [`crate::metrics::METRIC_HTTP_REQUESTS_TOTAL`] and
4//! [`crate::metrics::METRIC_HTTP_REQUEST_DURATION_SECONDS`] for every request
5//! EXCEPT requests to `/metrics` itself (which would create a feedback loop).
6
7use std::time::Instant;
8
9use axum::{body::Body, extract::MatchedPath, http::Request, middleware::Next, response::Response};
10
11use crate::metrics::{
12    LABEL_METHOD, LABEL_PATH, LABEL_STATUS, METRIC_HTTP_REQUESTS_TOTAL,
13    METRIC_HTTP_REQUEST_DURATION_SECONDS, PATH_UNKNOWN,
14};
15
16/// The route path for the Prometheus metrics endpoint.
17/// Requests to this path are excluded from HTTP metrics recording
18/// to prevent a feedback loop on every scrape.
19const METRICS_ROUTE_PATH: &str = "/metrics";
20
21/// Tower middleware that records HTTP request metrics.
22///
23/// Records [`METRIC_HTTP_REQUESTS_TOTAL`] and [`METRIC_HTTP_REQUEST_DURATION_SECONDS`]
24/// for every request except those to `/metrics` (to avoid a scrape feedback loop).
25///
26/// The `path` label uses [`axum::extract::MatchedPath`] (route template, never raw URI).
27/// Fallback routes (404, 405) that have no matched path use the literal `"unknown"`.
28#[inline]
29pub async fn record_http_metrics(request: Request<Body>, next: Next) -> Response {
30    // Extract matched path BEFORE consuming the request
31    let matched_path = request
32        .extensions()
33        .get::<MatchedPath>()
34        .map(|matched: &MatchedPath| matched.as_str().to_owned());
35
36    // Skip recording for /metrics itself to prevent feedback loop
37    if matched_path.as_deref() == Some(METRICS_ROUTE_PATH) {
38        return next.run(request).await;
39    }
40
41    let method = request.method().to_string();
42    let path_label = matched_path.unwrap_or_else(|| PATH_UNKNOWN.to_owned());
43
44    let started_at = Instant::now();
45    let response = next.run(request).await;
46    let duration_seconds = started_at.elapsed().as_secs_f64();
47
48    let status_label = response.status().as_u16().to_string();
49
50    metrics::counter!(
51        METRIC_HTTP_REQUESTS_TOTAL,
52        LABEL_METHOD => method.clone(),
53        LABEL_PATH => path_label.clone(),
54        LABEL_STATUS => status_label.clone()
55    )
56    .increment(1);
57
58    metrics::histogram!(
59        METRIC_HTTP_REQUEST_DURATION_SECONDS,
60        LABEL_METHOD => method,
61        LABEL_PATH => path_label,
62        LABEL_STATUS => status_label
63    )
64    .record(duration_seconds);
65
66    response
67}
68
69#[cfg(test)]
70mod tests {
71    #![allow(
72        clippy::tests_outside_test_module,
73        clippy::unwrap_used,
74        clippy::expect_used
75    )]
76
77    use super::*;
78    use axum::{
79        body::Body,
80        http::{Request, StatusCode},
81        middleware,
82        routing::get,
83        Router,
84    };
85    use tower::ServiceExt as _;
86
87    #[test]
88    fn metrics_route_path_is_metrics() {
89        assert_eq!(METRICS_ROUTE_PATH, "/metrics");
90    }
91
92    /// Verify that the `/metrics` early-return guard (line 38) fires when the
93    /// middleware is mounted ON the `/metrics` route.
94    ///
95    /// In production, `router_with_metrics` adds `/metrics` OUTSIDE the
96    /// middleware layer stack, so the guard is never reached — it is
97    /// defense-in-depth. This test exercises the guard directly by placing
98    /// the route inside the stack, which is the only way to reach that branch.
99    #[test]
100    fn metrics_path_early_return_skips_recording() {
101        let (recorder, handle) = crate::metrics::build_recorder().expect("test recorder builds");
102
103        let runtime = tokio::runtime::Builder::new_current_thread()
104            .enable_all()
105            .build()
106            .expect("runtime builds");
107
108        // Mount /metrics INSIDE the middleware so the guard is exercised.
109        let router = Router::new()
110            .route("/metrics", get(|| async { "ok" }))
111            .layer(middleware::from_fn(record_http_metrics));
112
113        let request = Request::builder()
114            .method("GET")
115            .uri("/metrics")
116            .body(Body::empty())
117            .expect("request builds");
118
119        let response = metrics::with_local_recorder(&recorder, || {
120            runtime
121                .block_on(router.oneshot(request))
122                .expect("oneshot succeeds")
123        });
124
125        assert_eq!(response.status(), StatusCode::OK);
126
127        let rendered = handle.render();
128        assert!(
129            !rendered.contains(crate::metrics::METRIC_HTTP_REQUESTS_TOTAL),
130            "Expected no request counter for /metrics path; got:\n{rendered}"
131        );
132    }
133}