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#[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#[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#[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}