Skip to main content

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}