aster_forge_middleware/actix/
metrics.rs1use 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
19pub 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
40pub 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}