Skip to main content

api_gateway/middleware/
http_metrics.rs

1//! HTTP server metrics middleware (OpenTelemetry Semantic Conventions).
2//!
3//! Records two instruments per request:
4//! - `http.server.request.duration` — histogram (seconds)
5//! - `http.server.active_requests` — up-down counter
6//!
7//! Attributes follow [OpenTelemetry HTTP semantic conventions][otel]:
8//! `http.request.method`, `http.route`, `http.response.status_code`.
9//!
10//! [otel]: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/
11
12use std::sync::Arc;
13
14use axum::{
15    extract::{MatchedPath, State},
16    middleware::Next,
17    response::Response,
18};
19use opentelemetry::{
20    KeyValue,
21    metrics::{Histogram, UpDownCounter},
22};
23
24/// Holds the two OpenTelemetry instruments for HTTP server metrics.
25pub struct HttpMetrics {
26    duration: Histogram<f64>,
27    active_requests: UpDownCounter<i64>,
28}
29
30impl HttpMetrics {
31    /// Create instruments on the global meter scoped to the given module name.
32    ///
33    /// When `prefix` is non-empty the metric names become
34    /// `{prefix}.http.server.request.duration` and
35    /// `{prefix}.http.server.active_requests`.
36    #[must_use]
37    pub fn new(module_name: &str, prefix: &str) -> Self {
38        let prefix = prefix.trim().trim_end_matches('.'); // Normalize prefix.
39
40        let scope = opentelemetry::InstrumentationScope::builder(module_name.to_owned()).build();
41        let meter = opentelemetry::global::meter_with_scope(scope);
42
43        let (duration_name, active_name) = if prefix.is_empty() {
44            (
45                "http.server.request.duration".to_owned(),
46                "http.server.active_requests".to_owned(),
47            )
48        } else {
49            (
50                format!("{prefix}.http.server.request.duration"),
51                format!("{prefix}.http.server.active_requests"),
52            )
53        };
54
55        let duration = meter
56            .f64_histogram(duration_name)
57            .with_description("Duration of HTTP server requests")
58            .with_unit("s")
59            .build();
60
61        let active_requests = meter
62            .i64_up_down_counter(active_name)
63            .with_description("Number of active HTTP server requests")
64            .build();
65
66        Self {
67            duration,
68            active_requests,
69        }
70    }
71}
72
73/// Drop guard that decrements the active-requests counter, ensuring
74/// the counter is decremented even if downstream handlers panic.
75struct ActiveRequestGuard {
76    counter: UpDownCounter<i64>,
77    attrs: [KeyValue; 1],
78}
79
80impl Drop for ActiveRequestGuard {
81    fn drop(&mut self) {
82        self.counter.add(-1, &self.attrs);
83    }
84}
85
86/// Tiny `route_layer` that copies [`MatchedPath`] into **response** extensions
87/// so that outer `layer()` middleware (e.g. metrics) can read the route template.
88pub async fn propagate_matched_path(
89    matched_path: Option<MatchedPath>,
90    req: axum::extract::Request,
91    next: Next,
92) -> Response {
93    let mut response = next.run(req).await;
94    if let Some(path) = matched_path {
95        response.extensions_mut().insert(path);
96    }
97    response
98}
99
100/// Normalize HTTP method per [OTel semantic conventions][semconv].
101///
102/// Unknown methods are mapped to `_OTHER` to bound attribute cardinality
103/// and prevent metric explosion from arbitrary method strings.
104///
105/// [semconv]: https://opentelemetry.io/docs/specs/semconv/http/http-metrics/
106fn normalize_method(method: &axum::http::Method) -> &'static str {
107    match *method {
108        axum::http::Method::GET => "GET",
109        axum::http::Method::POST => "POST",
110        axum::http::Method::PUT => "PUT",
111        axum::http::Method::DELETE => "DELETE",
112        axum::http::Method::PATCH => "PATCH",
113        axum::http::Method::HEAD => "HEAD",
114        axum::http::Method::OPTIONS => "OPTIONS",
115        axum::http::Method::CONNECT => "CONNECT",
116        axum::http::Method::TRACE => "TRACE",
117        _ => "_OTHER",
118    }
119}
120
121/// Axum middleware that records HTTP server metrics.
122///
123/// Use with `axum::middleware::from_fn_with_state` and add as a **`layer`**
124/// (not `route_layer`) so it captures responses from all middleware layers.
125/// The `http.route` attribute is read from response extensions, populated
126/// by [`propagate_matched_path`] which must be added as an inner `route_layer`.
127pub async fn http_metrics_middleware(
128    State(metrics): State<Arc<HttpMetrics>>,
129    req: axum::extract::Request,
130    next: Next,
131) -> Response {
132    let method_kv = KeyValue::new("http.request.method", normalize_method(req.method()));
133
134    metrics
135        .active_requests
136        .add(1, std::slice::from_ref(&method_kv));
137    let _guard = ActiveRequestGuard {
138        counter: metrics.active_requests.clone(),
139        attrs: [method_kv.clone()],
140    };
141
142    let start = std::time::Instant::now();
143    let response = next.run(req).await;
144    let elapsed = start.elapsed().as_secs_f64();
145
146    let route = response
147        .extensions()
148        .get::<MatchedPath>()
149        .map_or("unmatched", MatchedPath::as_str)
150        .to_owned();
151    let route_kv = KeyValue::new("http.route", route);
152    let status = i64::from(response.status().as_u16());
153
154    metrics.duration.record(
155        elapsed,
156        &[
157            method_kv,
158            route_kv,
159            KeyValue::new("http.response.status_code", status),
160        ],
161    );
162
163    response
164}