Skip to main content

rainier_metrics/
middleware.rs

1//! Instrumenting requests — [`RecordMetrics`] and the `/metrics` endpoint.
2
3use 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
12/// How many requests, by method, route and status.
13pub const REQUESTS: &str = "http_requests_total";
14
15/// How long they took, in seconds.
16pub const DURATION: &str = "http_request_duration_seconds";
17
18/// How many are being handled right now.
19pub const IN_FLIGHT: &str = "http_requests_in_flight";
20
21/// The route label for a request that matched nothing.
22///
23/// One series for every 404, rather than one per made-up URL. The path of an
24/// unmatched request is chosen by whoever sent it, so labelling by it would let
25/// anyone create unbounded series — the fastest way to fill a Prometheus.
26pub const UNMATCHED: &str = "<unmatched>";
27
28/// Times every request and counts it by method, route and status.
29///
30/// # Where to put it
31///
32/// **In a group's stack**, not the global one:
33///
34/// ```ignore
35/// pub fn api() -> MiddlewareStack {
36///     MiddlewareStack::new()
37///         .push(RecordMetrics::new(Arc::clone(&metrics)))
38///         .push(HandleCors::permissive())
39/// }
40/// ```
41///
42/// The router attaches the matched route just before a route's own pipeline
43/// runs — so a group-level middleware can read the **pattern**, and a global
44/// one cannot, because global middleware runs before anything has been matched.
45///
46/// Placed globally it still works and still times the whole request; every
47/// series is simply labelled [`UNMATCHED`]. That is the trade, and it is worth
48/// knowing rather than discovering from a dashboard where every route is the
49/// same one.
50pub struct RecordMetrics {
51    metrics: Arc<Metrics>,
52}
53
54impl RecordMetrics {
55    /// Record into `metrics`, declaring the three HTTP metrics.
56    ///
57    /// Declared here rather than on first request, so a scrape before any
58    /// traffic shows zeroes instead of nothing.
59    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    /// The route pattern this request matched, or [`UNMATCHED`].
68    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        // Both read before `next`, because it takes the request.
80        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        // No status on the histogram: it would multiply the series by every
101        // status a route can return, and a duration is worth knowing per route
102        // rather than per outcome.
103        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
113/// `GET /metrics` — the scrape endpoint.
114///
115/// # Do not expose this publicly
116///
117/// It tells anyone who reads it your traffic shape, your error rate and every
118/// route you serve. Put it behind whatever your admin routes are behind, or on
119/// an interface only the scraper can reach.
120pub async fn endpoint(request: Req) -> Response {
121    let Some(metrics) = request.extension::<Arc<Metrics>>().cloned() else {
122        // Nothing bound. A 503 rather than an empty 200: an empty scrape looks
123        // like an idle application, and a monitoring system that cannot tell
124        // those apart is worse than one that is plainly broken.
125        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        // The property that keeps a monitoring system alive: `/posts/1` and
168        // `/posts/2` must not be two series.
169        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        // Otherwise anyone could mint series by making up URLs.
184        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        // One *series*, not one line — a histogram is a dozen lines. The
193        // property is that two made-up URLs did not become two label sets.
194        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}