rainier_metrics/
middleware.rs1use std::sync::Arc;
4use std::time::Instant;
5
6use rainier_http::{Request, Response, StatusCode};
7use rainier_middleware::{Middleware, Next};
8use rainier_routing::{MatchedRoute, Req};
9
10use crate::registry::{labels, Labels, Metrics};
11
12pub const REQUESTS: &str = "http_requests_total";
14
15pub const DURATION: &str = "http_request_duration_seconds";
17
18pub const IN_FLIGHT: &str = "http_requests_in_flight";
20
21pub const UNMATCHED: &str = "<unmatched>";
27
28pub struct RecordMetrics {
51 metrics: Arc<Metrics>,
52}
53
54impl RecordMetrics {
55 pub fn new(metrics: Arc<Metrics>) -> Self {
60 metrics.counter(REQUESTS, "Requests handled, by method, route and status");
61 metrics.histogram(DURATION, "Request duration in seconds, by method and route");
62 metrics.gauge(IN_FLIGHT, "Requests being handled right now");
63
64 Self { metrics }
65 }
66
67 fn route_of(request: &Request) -> String {
69 request
70 .extension::<MatchedRoute>()
71 .map(|matched| matched.uri.clone())
72 .unwrap_or_else(|| UNMATCHED.to_string())
73 }
74}
75
76#[async_trait::async_trait]
77impl Middleware for RecordMetrics {
78 async fn handle(&self, request: Request, next: Next) -> Response {
79 let method = request.method().to_string();
81 let route = Self::route_of(&request);
82
83 self.metrics.add(IN_FLIGHT, Labels::new(), 1.0);
84 let started = Instant::now();
85
86 let response = next.run(request).await;
87
88 let elapsed = started.elapsed().as_secs_f64();
89 self.metrics.add(IN_FLIGHT, Labels::new(), -1.0);
90
91 self.metrics.increment(
92 REQUESTS,
93 labels([
94 ("method", method.clone()),
95 ("route", route.clone()),
96 ("status", response.status().as_u16().to_string()),
97 ]),
98 );
99
100 self.metrics.observe(DURATION, labels([("method", method), ("route", route)]), elapsed);
104
105 response
106 }
107
108 fn name(&self) -> &'static str {
109 "RecordMetrics"
110 }
111}
112
113pub async fn endpoint(request: Req) -> Response {
121 let Some(metrics) = request.extension::<Arc<Metrics>>().cloned() else {
122 return Response::new(StatusCode::SERVICE_UNAVAILABLE)
126 .with_body("metrics are not configured.");
127 };
128
129 Response::ok(metrics.render())
130 .with_header("content-type", "text/plain; version=0.0.4; charset=utf-8")
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136 use rainier_http::Method;
137 use rainier_middleware::Pipeline;
138
139 async fn through(metrics: &Arc<Metrics>, request: Request, status: StatusCode) -> Response {
140 let pipeline = Pipeline::new()
141 .through(RecordMetrics::new(Arc::clone(metrics)))
142 .then(move |_| Box::pin(async move { Response::new(status) }));
143
144 pipeline.run(request).await
145 }
146
147 fn matched(uri: &str) -> Request {
148 Request::builder().method(Method::GET).uri("/posts/1").build().with_extension(
149 MatchedRoute { name: None, uri: uri.to_string(), methods: vec![Method::GET] },
150 )
151 }
152
153 #[tokio::test]
154 async fn a_request_is_counted_and_timed_under_its_route_pattern() {
155 let metrics = Arc::new(Metrics::new());
156
157 through(&metrics, matched("/posts/{post}"), StatusCode::OK).await;
158
159 let rendered = metrics.render();
160 assert!(rendered.contains(r#"route="/posts/{post}""#), "{rendered}");
161 assert!(rendered.contains(r#"status="200""#), "{rendered}");
162 assert!(rendered.contains("http_request_duration_seconds_count"), "{rendered}");
163 }
164
165 #[tokio::test]
166 async fn two_requests_to_one_pattern_are_one_series() {
167 let metrics = Arc::new(Metrics::new());
170
171 through(&metrics, matched("/posts/{post}"), StatusCode::OK).await;
172 through(&metrics, matched("/posts/{post}"), StatusCode::OK).await;
173
174 let count = metrics.value(
175 REQUESTS,
176 &labels([("method", "GET"), ("route", "/posts/{post}"), ("status", "200")]),
177 );
178 assert_eq!(count, Some(2.0));
179 }
180
181 #[tokio::test]
182 async fn an_unmatched_request_gets_one_shared_label() {
183 let metrics = Arc::new(Metrics::new());
185
186 through(&metrics, Request::builder().uri("/nope").build(), StatusCode::NOT_FOUND).await;
187 through(&metrics, Request::builder().uri("/also-nope").build(), StatusCode::NOT_FOUND)
188 .await;
189
190 let rendered = metrics.render();
191
192 let counters =
195 rendered.lines().filter(|line| line.starts_with("http_requests_total{")).count();
196
197 assert_eq!(counters, 1, "{rendered}");
198 assert_eq!(
199 metrics.value(
200 REQUESTS,
201 &labels([("method", "GET"), ("route", UNMATCHED), ("status", "404"),])
202 ),
203 Some(2.0)
204 );
205 assert!(!rendered.contains("/nope"), "the path a client chose must not be a label");
206 }
207
208 #[tokio::test]
209 async fn the_status_is_on_the_counter_and_not_on_the_histogram() {
210 let metrics = Arc::new(Metrics::new());
211
212 through(&metrics, matched("/x"), StatusCode::INTERNAL_SERVER_ERROR).await;
213
214 let rendered = metrics.render();
215 let duration = rendered
216 .lines()
217 .find(|line| line.starts_with("http_request_duration_seconds_count"))
218 .expect("a duration line");
219
220 assert!(rendered.contains(r#"status="500""#), "{rendered}");
221 assert!(!duration.contains("status"), "{duration}");
222 }
223
224 #[tokio::test]
225 async fn the_in_flight_gauge_returns_to_zero() {
226 let metrics = Arc::new(Metrics::new());
227
228 through(&metrics, matched("/x"), StatusCode::OK).await;
229
230 assert_eq!(metrics.value(IN_FLIGHT, &Labels::new()), Some(0.0));
231 }
232
233 #[tokio::test]
234 async fn the_endpoint_says_so_when_nothing_is_bound() {
235 let response = endpoint(Arc::new(Request::builder().build())).await;
236
237 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
238 }
239
240 #[tokio::test]
241 async fn the_endpoint_answers_in_the_format_prometheus_expects() {
242 let metrics = Arc::new(Metrics::new());
243 metrics.counter("things_total", "Things");
244 metrics.increment("things_total", Labels::new());
245
246 let request = Arc::new(Request::builder().build().with_extension(Arc::clone(&metrics)));
247 let response = endpoint(request).await;
248
249 assert_eq!(response.status(), StatusCode::OK);
250 assert_eq!(
251 response.header("content-type"),
252 Some("text/plain; version=0.0.4; charset=utf-8")
253 );
254 }
255}