Skip to main content

ff_server/
metrics.rs

1//! PR-94: Prometheus /metrics endpoint + HTTP metrics middleware.
2//!
3//! This module is a thin wrapper around [`ff_observability::Metrics`].
4//! It compiles in both feature configurations:
5//!
6//! * `observability` **off** — [`Metrics`] re-exports the no-op shim
7//!   so call sites in `api::router` use an identical signature. The
8//!   `/metrics` route is not mounted (see `api::router`) so serve is
9//!   404; the HTTP middleware is not installed.
10//! * `observability` **on** — real OTEL-backed registry, `/metrics`
11//!   mounted, HTTP middleware installed that records
12//!   `ff_http_requests_total` + `ff_http_request_duration_seconds`
13//!   labelled by `method` + `path` (`MatchedPath`, so parameterized
14//!   paths like `/v1/executions/{id}` collapse to one series) +
15//!   `status`.
16
17use std::sync::Arc;
18use std::time::Instant;
19
20use axum::{
21    extract::{MatchedPath, Request, State},
22    http::{HeaderValue, StatusCode, header},
23    middleware,
24    response::{IntoResponse, Response},
25};
26
27pub use ff_observability::Metrics;
28
29/// GET /metrics — Prometheus text exposition (`text/plain; version=0.0.4`).
30///
31/// # Authentication
32///
33/// Intentionally unauthenticated. Matches Prometheus operational
34/// convention: network-layer (ingress ACL, service-mesh policy, or
35/// cluster-internal-only listen) gates scrape access. FlowFabric does
36/// not own auth for scrape endpoints.
37///
38/// If you need to restrict scrapers, constrain the listen address
39/// (bind to the metrics-only interface) or set ingress rules.
40#[cfg_attr(not(feature = "observability"), allow(dead_code))]
41pub async fn metrics_handler(State(metrics): State<Arc<Metrics>>) -> Response {
42    let body = metrics.render();
43    (
44        StatusCode::OK,
45        [(
46            header::CONTENT_TYPE,
47            HeaderValue::from_static("text/plain; version=0.0.4; charset=utf-8"),
48        )],
49        body,
50    )
51        .into_response()
52}
53
54/// Axum middleware: record HTTP method + `MatchedPath` + status + duration.
55///
56/// Runs after the handler finishes so we see the final status. Missing
57/// `MatchedPath` (404 for unrouted paths) is labelled `"unknown"` to
58/// cap cardinality — a flood of distinct 404 paths would otherwise
59/// explode the `path` label space.
60#[cfg_attr(not(feature = "observability"), allow(dead_code))]
61pub async fn http_middleware(
62    State(metrics): State<Arc<Metrics>>,
63    req: Request,
64    next: middleware::Next,
65) -> Response {
66    // `Method::as_str` returns `&'static str` for standard HTTP
67    // verbs; no allocation on the hot path here. Snapshot before
68    // `req` moves into `next.run`.
69    let method: &'static str = method_as_static(req.method());
70    // `MatchedPath` internally holds an `Arc<str>`; `clone` is a
71    // refcount bump, not a heap copy.
72    let matched = req.extensions().get::<MatchedPath>().cloned();
73
74    let start = Instant::now();
75    let resp = next.run(req).await;
76    let elapsed = start.elapsed();
77    let status = resp.status().as_u16();
78
79    let path: &str = matched.as_ref().map(|m| m.as_str()).unwrap_or("unknown");
80    // OTEL KeyValue construction inside `record_http_request` is
81    // the only remaining allocation (method / path become owned
82    // strings there — unavoidable without a global interning
83    // layer).
84    metrics.record_http_request(method, path, status, elapsed);
85    resp
86}
87
88/// Map a `http::Method` to a `&'static str` without allocating.
89///
90/// Standard HTTP verbs live as `const` on `Method`, so the match
91/// resolves to static strings at compile time. Anything else is
92/// bucketed under `"OTHER"` to keep the label-set cardinality
93/// bounded (a malicious client can otherwise spam arbitrary
94/// method names and blow up the `method` label space).
95fn method_as_static(m: &axum::http::Method) -> &'static str {
96    match *m {
97        axum::http::Method::GET => "GET",
98        axum::http::Method::POST => "POST",
99        axum::http::Method::PUT => "PUT",
100        axum::http::Method::DELETE => "DELETE",
101        axum::http::Method::HEAD => "HEAD",
102        axum::http::Method::OPTIONS => "OPTIONS",
103        axum::http::Method::PATCH => "PATCH",
104        axum::http::Method::CONNECT => "CONNECT",
105        axum::http::Method::TRACE => "TRACE",
106        _ => "OTHER",
107    }
108}