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}