Skip to main content

rest/
metrics.rs

1use actix_web::{
2    dev::{Service, ServiceRequest, ServiceResponse, Transform},
3    Error,
4};
5use futures::future::{ok, LocalBoxFuture, Ready};
6use rust_zero_core::{
7    CounterVec, GaugeVec, HistogramOptions, HistogramVec, Metrics, MetricsError, VectorOptions,
8};
9use std::{
10    task::{Context, Poll},
11    time::Instant,
12};
13
14/// HTTP metrics registered in a shared [`Metrics`] registry.
15#[derive(Clone)]
16pub struct HttpMetrics {
17    requests: CounterVec,
18    duration: HistogramVec,
19    in_flight: GaugeVec,
20    protection_decisions: CounterVec,
21}
22
23impl HttpMetrics {
24    pub fn new(metrics: &Metrics, namespace: impl Into<String>) -> Result<Self, MetricsError> {
25        let namespace = namespace.into();
26        let request_options = VectorOptions::new("http_requests_total", "Completed HTTP requests")
27            .with_namespace(namespace.clone())
28            .with_labels(["method", "path", "status"]);
29        let duration_options =
30            VectorOptions::new("http_request_duration_seconds", "HTTP request duration")
31                .with_namespace(namespace.clone())
32                .with_labels(["method", "path", "status"]);
33        let in_flight_options = VectorOptions::new(
34            "http_requests_in_flight",
35            "HTTP requests currently in flight",
36        )
37        .with_namespace(namespace.clone())
38        .with_labels(["method", "path"]);
39        let protection_options = VectorOptions::new(
40            "http_protection_decisions_total",
41            "HTTP transport protection decisions",
42        )
43        .with_namespace(namespace)
44        .with_labels(["mechanism", "decision"]);
45
46        Ok(Self {
47            requests: metrics.counter_vec(request_options)?,
48            duration: metrics.histogram_vec(
49                HistogramOptions::new("", "").with_vector_options(duration_options),
50            )?,
51            in_flight: metrics.gauge_vec(in_flight_options)?,
52            protection_decisions: metrics.counter_vec(protection_options)?,
53        })
54    }
55
56    fn record(&self, method: &str, path: &str, status: u16, elapsed_seconds: f64) {
57        let status = status.to_string();
58        let labels = [method, path, status.as_str()];
59
60        self.requests
61            .inc(&labels)
62            .expect("HTTP metric labels must match the registered metric");
63        self.duration
64            .observe(elapsed_seconds, &labels)
65            .expect("HTTP metric labels and duration must be valid");
66    }
67
68    pub(crate) fn record_protection(&self, mechanism: &str, decision: &str) {
69        self.protection_decisions
70            .inc(&[mechanism, decision])
71            .expect("HTTP protection metric labels must match the registered metric");
72    }
73
74    fn track_in_flight(&self, method: String, path: String) -> HttpInFlightGuard {
75        self.in_flight
76            .inc(&[&method, &path])
77            .expect("HTTP in-flight metric labels must match the registered metric");
78        HttpInFlightGuard {
79            metrics: self.clone(),
80            method,
81            path,
82        }
83    }
84}
85
86struct HttpInFlightGuard {
87    metrics: HttpMetrics,
88    method: String,
89    path: String,
90}
91
92impl Drop for HttpInFlightGuard {
93    fn drop(&mut self) {
94        self.metrics
95            .in_flight
96            .dec(&[&self.method, &self.path])
97            .expect("HTTP in-flight metric labels must match the registered metric");
98    }
99}
100
101/// Metrics for calls made by named [`crate::HttpClient`] instances.
102#[derive(Clone)]
103pub struct HttpClientMetrics {
104    requests: CounterVec,
105    duration: HistogramVec,
106    in_flight: GaugeVec,
107}
108
109impl HttpClientMetrics {
110    pub fn new(metrics: &Metrics, namespace: impl Into<String>) -> Result<Self, MetricsError> {
111        let namespace = namespace.into();
112        let labels = ["service", "method", "result"];
113        let request_options = VectorOptions::new(
114            "http_client_requests_total",
115            "Completed HTTP client requests",
116        )
117        .with_namespace(namespace.clone())
118        .with_labels(labels);
119        let duration_options = VectorOptions::new(
120            "http_client_request_duration_seconds",
121            "HTTP client request duration",
122        )
123        .with_namespace(namespace.clone())
124        .with_labels(labels);
125        let in_flight_options = VectorOptions::new(
126            "http_client_requests_in_flight",
127            "HTTP client requests currently in flight",
128        )
129        .with_namespace(namespace)
130        .with_labels(["service", "method"]);
131
132        Ok(Self {
133            requests: metrics.counter_vec(request_options)?,
134            duration: metrics.histogram_vec(
135                HistogramOptions::new("", "").with_vector_options(duration_options),
136            )?,
137            in_flight: metrics.gauge_vec(in_flight_options)?,
138        })
139    }
140
141    pub(crate) fn record(&self, service: &str, method: &str, result: &str, elapsed_seconds: f64) {
142        let labels = [service, method, result];
143        self.requests
144            .inc(&labels)
145            .expect("HTTP client metric labels must match the registered metric");
146        self.duration
147            .observe(elapsed_seconds, &labels)
148            .expect("HTTP client metric labels and duration must be valid");
149    }
150
151    pub(crate) fn track_in_flight(
152        &self,
153        service: String,
154        method: String,
155    ) -> HttpClientInFlightGuard {
156        self.in_flight
157            .inc(&[&service, &method])
158            .expect("HTTP client in-flight labels must match the registered metric");
159        HttpClientInFlightGuard {
160            metrics: self.clone(),
161            service,
162            method,
163        }
164    }
165}
166
167pub(crate) struct HttpClientInFlightGuard {
168    metrics: HttpClientMetrics,
169    service: String,
170    method: String,
171}
172
173impl Drop for HttpClientInFlightGuard {
174    fn drop(&mut self) {
175        self.metrics
176            .in_flight
177            .dec(&[&self.service, &self.method])
178            .expect("HTTP client in-flight labels must match the registered metric");
179    }
180}
181
182/// Records request counts and execution durations in [`HttpMetrics`].
183#[derive(Clone)]
184pub struct MetricsMiddleware {
185    metrics: HttpMetrics,
186}
187
188impl MetricsMiddleware {
189    pub fn new(metrics: HttpMetrics) -> Self {
190        Self { metrics }
191    }
192}
193
194impl<S, B> Transform<S, ServiceRequest> for MetricsMiddleware
195where
196    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
197    B: 'static,
198    S::Future: 'static,
199{
200    type Response = ServiceResponse<B>;
201    type Error = Error;
202    type Transform = MetricsMiddlewareService<S>;
203    type InitError = ();
204    type Future = Ready<Result<Self::Transform, Self::InitError>>;
205
206    fn new_transform(&self, service: S) -> Self::Future {
207        ok(MetricsMiddlewareService {
208            service,
209            metrics: self.metrics.clone(),
210        })
211    }
212}
213
214pub struct MetricsMiddlewareService<S> {
215    service: S,
216    metrics: HttpMetrics,
217}
218
219impl<S, B> Service<ServiceRequest> for MetricsMiddlewareService<S>
220where
221    S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
222    B: 'static,
223{
224    type Response = ServiceResponse<B>;
225    type Error = Error;
226    type Future = LocalBoxFuture<'static, Result<Self::Response, Self::Error>>;
227
228    fn poll_ready(&self, context: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
229        self.service.poll_ready(context)
230    }
231
232    fn call(&self, request: ServiceRequest) -> Self::Future {
233        let method = request.method().to_string();
234        let path = request
235            .match_pattern()
236            .unwrap_or_else(|| "<unmatched>".to_owned());
237        let started_at = Instant::now();
238        let metrics = self.metrics.clone();
239        let in_flight = metrics.track_in_flight(method.clone(), path.clone());
240        let future = self.service.call(request);
241
242        Box::pin(async move {
243            let _in_flight = in_flight;
244            match future.await {
245                Ok(response) => {
246                    metrics.record(
247                        &method,
248                        &path,
249                        response.status().as_u16(),
250                        started_at.elapsed().as_secs_f64(),
251                    );
252                    Ok(response)
253                }
254                Err(error) => {
255                    metrics.record(&method, &path, 500, started_at.elapsed().as_secs_f64());
256                    Err(error)
257                }
258            }
259        })
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::{HttpMetrics, MetricsMiddleware};
266    use actix_web::{http::StatusCode, test, web, App, HttpResponse};
267    use rust_zero_core::Metrics;
268
269    #[actix_rt::test]
270    async fn records_request_count_and_duration() {
271        let metrics = Metrics::new();
272        let http_metrics = HttpMetrics::new(&metrics, "users").unwrap();
273        let app = test::init_service(App::new().wrap(MetricsMiddleware::new(http_metrics)).route(
274            "/users/{id}",
275            web::get().to(|| async { HttpResponse::Ok().finish() }),
276        ))
277        .await;
278
279        let response =
280            test::call_service(&app, test::TestRequest::get().uri("/users/42").to_request()).await;
281
282        assert_eq!(response.status(), StatusCode::OK);
283        let rendered = metrics.render();
284        assert!(rendered.contains(
285            "users_http_requests_total{method=\"GET\",path=\"/users/{id}\",status=\"200\"} 1"
286        ));
287        assert!(rendered.contains(
288            "users_http_request_duration_seconds_count{method=\"GET\",path=\"/users/{id}\",status=\"200\"} 1"
289        ));
290        assert!(rendered
291            .contains("users_http_requests_in_flight{method=\"GET\",path=\"/users/{id}\"} 0"));
292    }
293}