Skip to main content

aster_forge_middleware/actix/
metrics.rs

1//! HTTP request metrics middleware for Actix Web services.
2//!
3//! The middleware reads a shared [`aster_forge_metrics::SharedMetricsRecorder`] from Actix app
4//! data, records one HTTP request metric per successful or failed downstream service call, and
5//! groups unmatched routes into stable low-cardinality labels.
6
7use std::rc::Rc;
8use std::time::Instant;
9
10use actix_web::{
11    Error,
12    dev::{Service, ServiceRequest, ServiceResponse, Transform, forward_ready},
13    web,
14};
15use futures::future::{LocalBoxFuture, Ready, ok};
16
17use aster_forge_metrics::{MetricsRecorder, SharedMetricsRecorder};
18
19/// Actix middleware that records request duration and status into the shared metrics recorder.
20pub struct MetricsMiddleware;
21
22impl<S, B> Transform<S, ServiceRequest> for MetricsMiddleware
23where
24    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
25    B: 'static,
26{
27    type Response = ServiceResponse<B>;
28    type Error = Error;
29    type InitError = ();
30    type Transform = MetricsService<S>;
31    type Future = Ready<Result<Self::Transform, Self::InitError>>;
32
33    fn new_transform(&self, service: S) -> Self::Future {
34        ok(MetricsService {
35            service: Rc::new(service),
36        })
37    }
38}
39
40/// Service wrapper installed by [`MetricsMiddleware`].
41pub struct MetricsService<S> {
42    service: Rc<S>,
43}
44
45impl<S, B> Service<ServiceRequest> for MetricsService<S>
46where
47    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
48    B: 'static,
49{
50    type Response = ServiceResponse<B>;
51    type Error = Error;
52    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
53
54    forward_ready!(service);
55
56    fn call(&self, req: ServiceRequest) -> Self::Future {
57        let svc = self.service.clone();
58        let metrics = request_metrics(&req);
59
60        if !metrics.enabled() {
61            return Box::pin(async move { svc.call(req).await });
62        }
63
64        let started_at = Instant::now();
65        let method = req.method().clone();
66        let route = route_label(&req);
67
68        Box::pin(async move {
69            match svc.call(req).await {
70                Ok(resp) => {
71                    metrics.record_http_request(
72                        method.as_str(),
73                        &route,
74                        resp.status().as_u16(),
75                        started_at.elapsed().as_secs_f64(),
76                    );
77                    Ok(resp)
78                }
79                Err(error) => {
80                    metrics.record_http_request(
81                        method.as_str(),
82                        &route,
83                        error.as_response_error().status_code().as_u16(),
84                        started_at.elapsed().as_secs_f64(),
85                    );
86                    Err(error)
87                }
88            }
89        })
90    }
91}
92
93fn request_metrics(req: &ServiceRequest) -> SharedMetricsRecorder {
94    if let Some(metrics) = req.app_data::<web::Data<dyn MetricsRecorder>>() {
95        return metrics.clone().into_inner();
96    }
97
98    req.app_data::<web::Data<SharedMetricsRecorder>>()
99        .map_or_else(aster_forge_metrics::NoopMetrics::arc, |data| {
100            data.get_ref().clone()
101        })
102}
103
104fn route_label(req: &ServiceRequest) -> String {
105    req.match_pattern().unwrap_or_else(|| unmatched_route(req))
106}
107
108fn unmatched_route(req: &ServiceRequest) -> String {
109    let path = req.path();
110    if path.starts_with("/api/") {
111        "unmatched_api".to_string()
112    } else if path.starts_with("/health") {
113        "unmatched_health".to_string()
114    } else {
115        "unmatched".to_string()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121
122    use crate::test_support::RecordingMetrics;
123    use actix_web::{App, HttpResponse, error, test as actix_test, web};
124    use aster_forge_metrics::MetricsRecorder;
125
126    use super::{MetricsMiddleware, unmatched_route};
127
128    #[test]
129    fn unmatched_route_groups_unknown_paths() {
130        let api = actix_test::TestRequest::get()
131            .uri("/api/v1/missing")
132            .to_srv_request();
133        let health = actix_test::TestRequest::get()
134            .uri("/health/full")
135            .to_srv_request();
136        let other = actix_test::TestRequest::get()
137            .uri("/missing")
138            .to_srv_request();
139
140        assert_eq!(unmatched_route(&api), "unmatched_api");
141        assert_eq!(unmatched_route(&health), "unmatched_health");
142        assert_eq!(unmatched_route(&other), "unmatched");
143    }
144
145    #[actix_web::test]
146    async fn middleware_records_successful_requests_when_metrics_are_enabled() {
147        let metrics = RecordingMetrics::enabled();
148        let app = actix_test::init_service(
149            App::new()
150                .app_data(web::Data::new(metrics.shared()))
151                .wrap(MetricsMiddleware)
152                .route(
153                    "/api/v1/profiles/{id}",
154                    web::get().to(|| async { HttpResponse::Created().finish() }),
155                ),
156        )
157        .await;
158
159        let req = actix_test::TestRequest::get()
160            .uri("/api/v1/profiles/42")
161            .to_request();
162        let resp = actix_test::call_service(&app, req).await;
163        assert_eq!(resp.status(), 201);
164
165        let records = metrics.records();
166        assert_eq!(records.len(), 1);
167        assert_eq!(records[0].method, "GET");
168        assert_eq!(records[0].route, "/api/v1/profiles/{id}");
169        assert_eq!(records[0].status, 201);
170        assert!(records[0].duration_seconds >= 0.0);
171    }
172
173    #[actix_web::test]
174    async fn middleware_accepts_single_arc_trait_object_app_data() {
175        let metrics = RecordingMetrics::enabled();
176        let shared = web::Data::<dyn MetricsRecorder>::from(metrics.shared());
177        let app =
178            actix_test::init_service(App::new().app_data(shared).wrap(MetricsMiddleware).route(
179                "/api/v1/profiles/{id}",
180                web::get().to(|| async { HttpResponse::Accepted().finish() }),
181            ))
182            .await;
183
184        let req = actix_test::TestRequest::get()
185            .uri("/api/v1/profiles/42")
186            .to_request();
187        let resp = actix_test::call_service(&app, req).await;
188        assert_eq!(resp.status(), 202);
189
190        let records = metrics.records();
191        assert_eq!(records.len(), 1);
192        assert_eq!(records[0].route, "/api/v1/profiles/{id}");
193        assert_eq!(records[0].status, 202);
194    }
195
196    #[actix_web::test]
197    async fn middleware_records_error_responses_when_metrics_are_enabled() {
198        let metrics = RecordingMetrics::enabled();
199        let app = actix_test::init_service(
200            App::new()
201                .app_data(web::Data::new(metrics.shared()))
202                .wrap(MetricsMiddleware)
203                .route(
204                    "/api/v1/fails",
205                    web::get().to(|| async {
206                        Err::<HttpResponse, _>(error::ErrorBadRequest("bad request"))
207                    }),
208                ),
209        )
210        .await;
211
212        let req = actix_test::TestRequest::get()
213            .uri("/api/v1/fails")
214            .to_request();
215        let resp = actix_test::call_service(&app, req).await;
216        assert_eq!(resp.status(), 400);
217
218        let records = metrics.records();
219        assert_eq!(records.len(), 1);
220        assert_eq!(records[0].method, "GET");
221        assert_eq!(records[0].route, "/api/v1/fails");
222        assert_eq!(records[0].status, 400);
223    }
224
225    #[actix_web::test]
226    async fn middleware_skips_recording_when_metrics_are_disabled() {
227        let metrics = RecordingMetrics::disabled();
228        let app = actix_test::init_service(
229            App::new()
230                .app_data(web::Data::new(metrics.shared()))
231                .wrap(MetricsMiddleware)
232                .route(
233                    "/health",
234                    web::get().to(|| async { HttpResponse::Ok().finish() }),
235                ),
236        )
237        .await;
238
239        let req = actix_test::TestRequest::get().uri("/health").to_request();
240        assert_eq!(actix_test::call_service(&app, req).await.status(), 200);
241        assert_eq!(
242            metrics.records(),
243            [] as [crate::test_support::HttpMetricRecord; 0]
244        );
245    }
246}