Skip to main content

aster_forge_middleware/axum/
metrics.rs

1//! Axum request-metrics adapter.
2use axum::{
3    extract::{MatchedPath, Request},
4    middleware::Next,
5    response::Response,
6};
7use std::time::Instant;
8
9/// Records responses using an optional recorder in request extensions and low-cardinality routes.
10pub async fn metrics(request: Request, next: Next) -> Response {
11    use aster_forge_metrics::{NoopMetrics, SharedMetricsRecorder};
12    let recorder = request
13        .extensions()
14        .get::<SharedMetricsRecorder>()
15        .cloned()
16        .unwrap_or_else(NoopMetrics::arc);
17    if !recorder.enabled() {
18        return next.run(request).await;
19    }
20    let started = Instant::now();
21    let method = request.method().to_string();
22    let route = request.extensions().get::<MatchedPath>().map_or_else(
23        || unmatched_route(request.uri().path()).to_string(),
24        |path| path.as_str().to_string(),
25    );
26    let response = next.run(request).await;
27    recorder.record_http_request(
28        &method,
29        &route,
30        response.status().as_u16(),
31        started.elapsed().as_secs_f64(),
32    );
33    response
34}
35
36fn unmatched_route(path: &str) -> &'static str {
37    if path.starts_with("/api/") {
38        "unmatched_api"
39    } else if path.starts_with("/health") {
40        "unmatched_health"
41    } else {
42        "unmatched"
43    }
44}
45
46#[cfg(test)]
47mod tests {
48    use super::*;
49    use crate::test_support::RecordingMetrics;
50    use axum::{Extension, Router, body::Body, middleware, routing::get};
51    use http::{Request as HttpRequest, StatusCode};
52    use tower::ServiceExt;
53
54    #[tokio::test]
55    async fn records_success_failure_and_unmatched_paths_with_bounded_labels() {
56        let recorder = RecordingMetrics::enabled();
57        let router = Router::new()
58            .route("/api/profiles/{id}", get(|| async { StatusCode::CREATED }))
59            .route(
60                "/api/fails",
61                get(|| async { Err::<(), _>(StatusCode::BAD_REQUEST) }),
62            )
63            .layer(middleware::from_fn(metrics))
64            .layer(Extension(recorder.shared()));
65        for (path, status, route) in [
66            ("/api/profiles/42", 201, "/api/profiles/{id}"),
67            ("/api/profiles/99", 201, "/api/profiles/{id}"),
68            ("/api/fails", 400, "/api/fails"),
69            ("/api/missing-secret-id", 404, "unmatched_api"),
70            ("/health/secret-id", 404, "unmatched_health"),
71            ("/missing-secret-id", 404, "unmatched"),
72        ] {
73            let response = router
74                .clone()
75                .oneshot(
76                    HttpRequest::builder()
77                        .uri(path)
78                        .body(Body::empty())
79                        .unwrap(),
80                )
81                .await
82                .unwrap();
83            assert_eq!(response.status().as_u16(), status);
84            let record = recorder.records().pop().unwrap();
85            assert_eq!(
86                (record.method.as_str(), record.route.as_str(), record.status),
87                ("GET", route, status)
88            );
89            assert!(record.duration_seconds >= 0.0);
90        }
91        assert_eq!(recorder.records().len(), 6);
92    }
93
94    #[tokio::test]
95    async fn disabled_and_missing_recorders_leave_responses_unchanged() {
96        let recorder = RecordingMetrics::disabled();
97        let router = Router::new()
98            .route("/", get(|| async { StatusCode::ACCEPTED }))
99            .layer(middleware::from_fn(metrics));
100        for router in [router.clone(), router.layer(Extension(recorder.shared()))] {
101            assert_eq!(
102                router
103                    .oneshot(HttpRequest::new(Body::empty()))
104                    .await
105                    .unwrap()
106                    .status(),
107                StatusCode::ACCEPTED
108            );
109        }
110        assert_eq!(
111            recorder.records(),
112            [] as [crate::test_support::HttpMetricRecord; 0]
113        );
114    }
115}