tensor_wasm_api/http_metrics.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3
4//! HTTP request metrics middleware.
5//!
6//! Emits the three series referenced by [`docs/SLO.md`](../../../docs/SLO.md)
7//! and the reference Grafana dashboard
8//! ([`docs/dashboards/README.md`](../../../docs/dashboards/README.md)):
9//!
10//! * `tensor_wasm_http_requests_total{route, method, status}` — counter
11//! * `tensor_wasm_http_request_duration_seconds_bucket{route, method, status, le=...}` — histogram
12//! * `tensor_wasm_http_requests_in_flight{route, method}` — gauge
13//!
14//! All three families live on the shared
15//! [`tensor_wasm_core::metrics::TensorWasmMetrics`] registry so a single
16//! `GET /metrics` scrape sees the HTTP series next to the kernel-latency and
17//! tenant gauges. The middleware itself is a thin
18//! [`axum::middleware::from_fn`] body — the production stack composes it via
19//! [`http_metrics_middleware`] in [`crate::server::build_router`], placed
20//! *outside* `bearer_auth` so the counter increments for `401 Unauthorized`
21//! responses as well (per the SLO doc's `availability_http` SLI, which counts
22//! every HTTP response).
23//!
24//! ## Route-template extraction
25//!
26//! Axum exposes the matched route template via
27//! [`axum::extract::MatchedPath`], which the routing layer inserts into the
28//! request extensions before invoking any per-route layer. Our middleware is
29//! installed via `Router::layer`, which wraps each route's terminal endpoint
30//! — at the point our middleware runs, `MatchedPath` is therefore already
31//! present in the extensions whenever the request matched a route. For
32//! requests that did not match a route (axum's built-in 404 path), the
33//! extension is absent and the middleware labels the metric as `"unknown"`
34//! to avoid leaking arbitrary path strings into the label space.
35//!
36//! ## Cardinality control
37//!
38//! Label cardinality is bounded by a *runtime allow-list* of route templates
39//! initialised at startup ([`RouteAllowList`]). Templates not in the
40//! allow-list collapse to `"unknown"`. This choice over a compile-time list
41//! is deliberate: the router is built dynamically (`build_router` /
42//! `build_router_with_audit` / `build_router_with_full_config`), so a
43//! compile-time list would either duplicate the route registry (drift risk)
44//! or require a const-fn router builder (not what axum offers in 0.7). The
45//! allow-list is constructed from the same string literals the router uses
46//! — see [`DEFAULT_ROUTE_ALLOWLIST`] — and any new route added to
47//! `build_router_with_audit` MUST also be added to that constant. Failing
48//! to do so is not a security or correctness issue (the metric still
49//! emits, under `"unknown"`), but it does lose per-route resolution in
50//! dashboards. CI does not enforce parity today; the orchestrator should
51//! flag any router change that is not accompanied by an
52//! [`DEFAULT_ROUTE_ALLOWLIST`] update.
53//!
54//! ## Performance budget
55//!
56//! Target overhead is < 1 µs per request. The middleware:
57//!
58//! * reads the `MatchedPath` extension (one `HashMap::get` lookup),
59//! * one [`HashSet::contains`] probe against the allow-list,
60//! * two `Family::get_or_create` calls (one read-locked
61//! `HashMap::get` on the steady-state hot path; one write-locked insert
62//! on the cold path when a new label combination first appears),
63//! * one atomic `fetch_add` for the counter,
64//! * one atomic `fetch_add` + `fetch_sub` for the in-flight gauge,
65//! * one `Histogram::observe` which is a single bucket-array sweep.
66//!
67//! ## In-flight gauge balancing
68//!
69//! The in-flight gauge is managed by an RAII `InFlightGuard`: construction
70//! increments the gauge and the guard's `Drop` decrements it. Because the
71//! guard lives on the middleware future's stack across `next.run(req).await`,
72//! the gauge is decremented on *every* exit path — normal completion, an
73//! early return, a panic, and crucially **future cancellation** (a client
74//! disconnect or an outer `tower::timeout` layer dropping the metrics future
75//! mid-`await`). The timeout/load-shed layers in `build_router` sit *outside*
76//! this middleware, so cancellation is the realistic leak path; the guard
77//! closes it. This replaces the previous manual `inc()`/`dec()` pair, which
78//! leaked `tensor_wasm_http_requests_in_flight{route,method}` permanently
79//! whenever the future was dropped between the two calls.
80//!
81//! No heap allocations on the steady-state path beyond what
82//! `prometheus-client` itself does on a cold-cache `get_or_create`. The
83//! `String` label clones are unavoidable today — `prometheus-client 0.24`
84//! does not yet support borrowed label values in the
85//! `prometheus_client::metrics::family::Family` API; this is a known
86//! cost we accept in exchange for the type-safe label set.
87
88use std::collections::HashSet;
89use std::sync::Arc;
90use std::time::Instant;
91
92use axum::extract::{MatchedPath, Request};
93use axum::middleware::Next;
94use axum::response::Response;
95use tensor_wasm_core::metrics::{HttpInFlightLabels, HttpRequestLabels, TensorWasmMetrics};
96
97/// Label substituted when a request did not match any route template (axum's
98/// built-in 404 fallback) or when the matched template is not on the
99/// configured allow-list.
100///
101/// Kept distinct from any real route template so dashboards can flag the
102/// `route="unknown"` series as a sign that either a real route is missing
103/// from [`DEFAULT_ROUTE_ALLOWLIST`] or that scanners are probing the
104/// gateway.
105pub const UNKNOWN_ROUTE: &str = "unknown";
106
107/// Default allow-list of axum route templates emitted by
108/// [`crate::server::build_router_with_audit`]. Kept in sync by hand with the
109/// `.route(...)` calls in that function; CI does not enforce parity (see
110/// module docs for the cardinality-control rationale).
111pub const DEFAULT_ROUTE_ALLOWLIST: &[&str] = &[
112 "/healthz",
113 "/metrics",
114 "/functions",
115 "/functions/:id",
116 "/functions/:id/invoke",
117 "/functions/:id/invoke-async",
118 "/functions/:id/invoke-stream",
119 "/jobs/:id",
120 // OpenAI-compat shim (B4.9). Scaffold routes that return 501 today
121 // and wire model → function translation in v0.4. See
122 // `crates/tensor-wasm-api/src/openai.rs` for the handlers and
123 // `docs/OPENAI-COMPAT.md` for the rollout plan.
124 "/v1/completions",
125 "/v1/chat/completions",
126 // Kernel registry routes (B6.4). Only mounted under
127 // `--features kernel-registry-api`; listed unconditionally here so
128 // the route label resolves on that build axis instead of collapsing
129 // to `"unknown"`. Listing them on the default build is harmless —
130 // the allow-list is a label-cardinality guard, not a router, so a
131 // template that never matches simply never appears as a `route`
132 // value. See `crates/tensor-wasm-api/src/kernels.rs` for the
133 // handlers and `build_router_full` for the mount.
134 "/kernels",
135 "/kernels/:name/:version",
136];
137
138/// Set of route templates that the metrics middleware is willing to emit as
139/// the `route` label value. Anything not in the set collapses to
140/// [`UNKNOWN_ROUTE`].
141///
142/// Cloned into request extensions at server start so the
143/// [`http_metrics_middleware`] body does not need to capture the allow-list
144/// via a type-erased closure. The set is wrapped in [`Arc`] so the per-request
145/// clone is a single refcount bump.
146#[derive(Debug, Clone, Default)]
147pub struct RouteAllowList {
148 /// Allowed route templates. `Arc<HashSet<&'static str>>` because the
149 /// templates are compile-time string literals (see
150 /// [`DEFAULT_ROUTE_ALLOWLIST`]) — no heap allocation per probe.
151 inner: Arc<HashSet<&'static str>>,
152}
153
154impl RouteAllowList {
155 /// Construct an allow-list from [`DEFAULT_ROUTE_ALLOWLIST`].
156 pub fn new_default() -> Self {
157 Self::from_static(DEFAULT_ROUTE_ALLOWLIST)
158 }
159
160 /// Construct an allow-list from an explicit `&'static str` slice. Used by
161 /// integration tests that want to assert on a non-default route set.
162 pub fn from_static(routes: &'static [&'static str]) -> Self {
163 let set: HashSet<&'static str> = routes.iter().copied().collect();
164 Self {
165 inner: Arc::new(set),
166 }
167 }
168
169 /// `true` if `route` is on the allow-list.
170 pub fn allows(&self, route: &str) -> bool {
171 self.inner.contains(route)
172 }
173}
174
175/// Bundle passed via request extensions to [`http_metrics_middleware`].
176///
177/// Composed of the shared metrics registry plus the route allow-list. Cloned
178/// per-request — both fields are cheap [`Arc`]-backed handles.
179#[derive(Clone, Debug)]
180pub struct HttpMetricsLayerConfig {
181 /// Shared metrics registry. The middleware mutates the three HTTP-metric
182 /// families on this handle.
183 pub metrics: Arc<TensorWasmMetrics>,
184 /// Allow-list of route templates that may appear as the `route` label.
185 pub routes: RouteAllowList,
186}
187
188/// Resolve the route-template label for a request.
189///
190/// Reads the [`MatchedPath`] extension that axum's router inserts before
191/// invoking per-route layers; falls back to [`UNKNOWN_ROUTE`] if the
192/// extension is absent (no matching route) or if the matched template is
193/// not on the allow-list (cardinality guard).
194pub fn route_label(req: &Request, routes: &RouteAllowList) -> String {
195 let template = req
196 .extensions()
197 .get::<MatchedPath>()
198 .map(|m| m.as_str())
199 .unwrap_or(UNKNOWN_ROUTE);
200 if routes.allows(template) {
201 template.to_string()
202 } else {
203 UNKNOWN_ROUTE.to_string()
204 }
205}
206
207/// RAII guard that owns one balanced `inc()`/`dec()` of the in-flight gauge.
208///
209/// Mirrors the [`JobsActiveGuard`](crate::routes) idiom: construction
210/// increments the gauge for `(route, method)` and [`Drop`] decrements it, so
211/// the gauge is balanced on every exit path. Critically, because the guard is
212/// held on the middleware future's stack across `next.run(req).await`, the
213/// `Drop` also fires when that future is *cancelled* — i.e. a client
214/// disconnect, or an outer `tower::timeout` layer dropping the future
215/// mid-`await`. That cancellation path is exactly what the previous manual
216/// `inc()`/`dec()` pair leaked (`dec()` was never reached when the future was
217/// dropped between the two calls).
218///
219/// The guard owns a `dec` closure rather than the gauge directly. The
220/// closure captures the owned gauge handle returned by
221/// `Family::get_or_create_owned` (which is cheap — `prometheus-client`'s
222/// gauge wraps `Arc<AtomicI64>`, a single refcount bump — and crucially does
223/// *not* hold the `Family`'s internal `RwLock`, which must never be held
224/// across an `.await`). Capturing via a closure keeps this module free of a
225/// direct `prometheus-client` dependency: the concrete `Gauge<i64,
226/// AtomicI64>` type is inferred at the call site and never named here.
227struct InFlightGuard<F: FnMut()> {
228 dec: F,
229}
230
231impl<F: FnMut()> InFlightGuard<F> {
232 /// Build a guard from an already-incremented gauge's `dec` closure. The
233 /// caller increments before constructing so the inc/dec pair is visibly
234 /// balanced at the call site; the guard owns the matching `dec()` and
235 /// runs it in `Drop`. Keep the guard alive across `next.run(req).await`.
236 fn new(dec: F) -> Self {
237 Self { dec }
238 }
239}
240
241impl<F: FnMut()> Drop for InFlightGuard<F> {
242 /// Decrement the in-flight gauge. Runs on normal completion, early return,
243 /// panic, and future cancellation/timeout — guaranteeing the gauge is
244 /// balanced for every request that incremented it.
245 fn drop(&mut self) {
246 (self.dec)();
247 }
248}
249
250/// Tower middleware that emits the three HTTP-metric families per request.
251///
252/// Operationally:
253///
254/// 1. Resolve the route template via [`route_label`] (one extension lookup).
255/// 2. Snapshot [`Instant::now`] as the request-start time.
256/// 3. Construct an `InFlightGuard` for `(route, method)` (increments the
257/// in-flight gauge; its `Drop` decrements it on every exit path,
258/// including cancellation/timeout).
259/// 4. Run the inner service.
260/// 5. Drop the guard, decrementing the in-flight gauge.
261/// 6. Compute elapsed seconds via [`Instant::elapsed`] (saturating).
262/// 7. Increment the request counter and observe the duration histogram for
263/// `(route, method, status)`.
264///
265/// If the [`HttpMetricsLayerConfig`] extension is absent (e.g. a test
266/// driver that bypassed the production router), the middleware degrades to
267/// a no-op pass-through — no panics, no metric emission. This mirrors the
268/// audit-log middleware's behaviour.
269pub async fn http_metrics_middleware(req: Request, next: Next) -> Response {
270 // Snapshot the config; if the production router was bypassed, fall
271 // through to a plain pass-through so misconfigured test drivers do not
272 // panic.
273 let Some(cfg) = req.extensions().get::<HttpMetricsLayerConfig>().cloned() else {
274 return next.run(req).await;
275 };
276
277 let route = route_label(&req, &cfg.routes);
278 let method = req.method().as_str().to_string();
279 let in_flight_labels = HttpInFlightLabels::new(route.clone(), method.clone());
280
281 // `get_or_create_owned` returns an owned `Gauge` (cheap: `Gauge` wraps
282 // `Arc<AtomicI64>`) so we can release the family's internal RwLock
283 // before `next.run(req).await` — holding any lock across an `.await`
284 // is the canonical recipe for deadlock under Tokio.
285 let in_flight = cfg
286 .metrics
287 .http_requests_in_flight()
288 .get_or_create_owned(&in_flight_labels);
289 // Increment now, then hand the matching `dec()` to an RAII guard. Because
290 // the guard lives on this future's stack across the `.await` below, the
291 // gauge is balanced on every exit path — normal completion, early return,
292 // panic, and (the realistic leak path) cancellation when an outer timeout
293 // layer or a client disconnect drops this future mid-`await`.
294 in_flight.inc();
295 let _in_flight_guard = InFlightGuard::new(move || {
296 // `Gauge::dec` returns the previous value; discard it so the closure
297 // is `FnMut()` rather than `FnMut() -> i64`.
298 in_flight.dec();
299 });
300 let start = Instant::now();
301
302 // Run the inner service. If this future is dropped here (timeout /
303 // disconnect), `_in_flight_guard`'s `Drop` still runs and decrements the
304 // gauge — see the module-level "In-flight gauge balancing" docs.
305 let response = next.run(req).await;
306
307 let elapsed = start.elapsed();
308 // Saturating: `Duration::as_secs_f64` is already total-ordered for
309 // finite durations; `Instant::elapsed` is wall-clock-monotonic so
310 // there is no negative case. The `.max(0.0)` belt is paranoia for
311 // platforms where the monotonic guarantee leaks (none today, but
312 // cheap).
313 let elapsed_secs = elapsed.as_secs_f64().max(0.0);
314
315 let status = response.status().as_u16().to_string();
316 let labels = HttpRequestLabels::new(route, method, status);
317 cfg.metrics
318 .http_requests_total()
319 .get_or_create(&labels)
320 .inc();
321 cfg.metrics
322 .http_request_duration_seconds()
323 .get_or_create(&labels)
324 .observe(elapsed_secs);
325
326 response
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332 use axum::http::Method;
333
334 // `Arc` is only imported through the parent module; the no-extension
335 // fallback test does not need it.
336
337 #[test]
338 fn route_allowlist_default_covers_known_routes() {
339 let allow = RouteAllowList::new_default();
340 for r in [
341 "/healthz",
342 "/metrics",
343 "/functions",
344 "/functions/:id",
345 "/functions/:id/invoke",
346 "/functions/:id/invoke-async",
347 "/functions/:id/invoke-stream",
348 "/jobs/:id",
349 ] {
350 assert!(allow.allows(r), "default allow-list missing {r}");
351 }
352 }
353
354 #[test]
355 fn route_allowlist_rejects_unlisted() {
356 let allow = RouteAllowList::new_default();
357 assert!(!allow.allows("/secret"));
358 assert!(!allow.allows("/functions/abc-123/invoke"));
359 assert!(!allow.allows(""));
360 }
361
362 #[test]
363 fn route_label_falls_back_to_unknown_when_extension_missing() {
364 // `MatchedPath` is constructed only by axum's router (its tuple
365 // field is `pub(crate)`), so the unit-level test here can only
366 // cover the no-extension fallback. The end-to-end behaviour —
367 // matched template passing through, unlisted template collapsing
368 // — is exercised in `tests/http_metrics_test.rs` against a real
369 // router.
370 let allow = RouteAllowList::new_default();
371 let req = Request::builder()
372 .method(Method::GET)
373 .uri("/anything")
374 .body(axum::body::Body::empty())
375 .unwrap();
376 assert_eq!(route_label(&req, &allow), UNKNOWN_ROUTE);
377 }
378}