aster_forge_middleware/axum/
metrics.rs1use axum::{
3 extract::{MatchedPath, Request},
4 middleware::Next,
5 response::Response,
6};
7use std::time::Instant;
8
9pub 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}