Skip to main content

phrona_api/
metrics.rs

1//! Prometheus metrics for the REST API.
2//!
3//! Exposes `GET /metrics` in the Prometheus text exposition format with
4//! strictly bounded cardinality: labels are drawn only from the fixed set of
5//! endpoints and engine names — never from search queries or target URLs.
6//!
7//! Metric families:
8//! - `phrona_http_requests_total{endpoint,status}` (counter)
9//! - `phrona_http_request_duration_seconds{endpoint}` (histogram)
10//! - `phrona_engine_requests_total{engine,status}` (counter)
11//! - `phrona_engine_errors_total{engine,scope,kind}` (counter)
12//! - `phrona_engine_duration_seconds{engine}` (histogram)
13
14use std::sync::OnceLock;
15use std::time::Instant;
16
17use axum::extract::Request;
18use axum::http::header::CONTENT_TYPE;
19use axum::middleware::Next;
20use axum::response::{IntoResponse, Response};
21use prometheus::{
22    Encoder, HistogramOpts, HistogramVec, IntCounterVec, Opts, Registry, TextEncoder,
23};
24
25use phrona::EngineObserver;
26
27const HTTP_BUCKETS: [f64; 10] = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0];
28const ENGINE_BUCKETS: [f64; 11] = [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0];
29
30/// Registered metric families and the registry they are gathered from.
31pub struct Metrics {
32    registry: Registry,
33    http_requests: IntCounterVec,
34    http_duration: HistogramVec,
35    engine_requests: IntCounterVec,
36    engine_errors: IntCounterVec,
37    engine_duration: HistogramVec,
38}
39
40impl Metrics {
41    fn new() -> Self {
42        let registry = Registry::new();
43        let http_requests = IntCounterVec::new(
44            Opts::new(
45                "phrona_http_requests_total",
46                "Total HTTP requests handled by the API, by endpoint and response status",
47            ),
48            &["endpoint", "status"],
49        )
50        .expect("static metric");
51        let http_duration = HistogramVec::new(
52            HistogramOpts::new(
53                "phrona_http_request_duration_seconds",
54                "HTTP request handling duration, by endpoint",
55            )
56            .buckets(HTTP_BUCKETS.to_vec()),
57            &["endpoint"],
58        )
59        .expect("static metric");
60        let engine_requests = IntCounterVec::new(
61            Opts::new(
62                "phrona_engine_requests_total",
63                "Engine requests by outcome (ok|empty|error) and engine",
64            ),
65            &["engine", "status"],
66        )
67        .expect("static metric");
68        let engine_errors = IntCounterVec::new(
69            Opts::new(
70                "phrona_engine_errors_total",
71                "Engine failures by error scope and kind",
72            ),
73            &["engine", "scope", "kind"],
74        )
75        .expect("static metric");
76        let engine_duration = HistogramVec::new(
77            HistogramOpts::new(
78                "phrona_engine_duration_seconds",
79                "Engine request duration, by engine",
80            )
81            .buckets(ENGINE_BUCKETS.to_vec()),
82            &["engine"],
83        )
84        .expect("static metric");
85        for m in [
86            Box::new(http_requests.clone()) as Box<dyn prometheus::core::Collector>,
87            Box::new(http_duration.clone()),
88            Box::new(engine_requests.clone()),
89            Box::new(engine_errors.clone()),
90            Box::new(engine_duration.clone()),
91        ] {
92            registry.register(m).expect("unique metric families");
93        }
94        Self {
95            registry,
96            http_requests,
97            http_duration,
98            engine_requests,
99            engine_errors,
100            engine_duration,
101        }
102    }
103}
104
105static METRICS: OnceLock<Metrics> = OnceLock::new();
106
107/// The process-wide metrics registry (lazily initialized on first use).
108fn global() -> &'static Metrics {
109    METRICS.get_or_init(Metrics::new)
110}
111
112/// Normalizes a request path into a bounded-cardinality endpoint label.
113/// Known API routes keep their path; anything else (SPA fallbacks,
114/// arbitrary deep links, scanners) collapses into `"other"` so the
115/// unauthenticated `/metrics` output can never grow unbounded label sets.
116fn endpoint_label(path: &str) -> &'static str {
117    match path {
118        "/" => "/",
119        "/health" => "/health",
120        "/metrics" => "/metrics",
121        "/search" | "/v1/tavily" => "/search",
122        "/v1/search" => "/v1/search",
123        "/v1/suggest" => "/v1/suggest",
124        "/v1/extract" => "/v1/extract",
125        "/v1/test" => "/v1/test",
126        "/v1/grounding" => "/v1/grounding",
127        "/v1/engines" => "/v1/engines",
128        "/style.css" | "/app.js" | "/favicon.ico" | "/favicon.svg" => "static",
129        p if p.starts_with("/static/") => "static",
130        _ => "other",
131    }
132}
133
134/// Counts every HTTP request after the inner service responded: endpoint
135/// (bounded label, see `endpoint_label`) and status code are the only
136/// labels.
137pub async fn http_layer(req: Request, next: Next) -> Response {
138    let started = Instant::now();
139    let endpoint = endpoint_label(req.uri().path());
140    let resp = next.run(req).await;
141    let m = global();
142    let status = resp.status().as_str().to_string();
143    m.http_requests
144        .with_label_values(&[endpoint, status.as_str()])
145        .inc();
146    m.http_duration
147        .with_label_values(&[endpoint])
148        .observe(started.elapsed().as_secs_f64());
149    resp
150}
151
152/// Core-crate observer forwarding engine outcomes into Prometheus.
153///
154/// Attach it to the search client via
155/// `client.with_observer(Arc::new(metrics::EngineMetricsObserver))`.
156#[derive(Default)]
157pub struct EngineMetricsObserver;
158
159impl EngineObserver for EngineMetricsObserver {
160    fn on_engine_done(
161        &self,
162        engine: &str,
163        status: &str,
164        scope: Option<&str>,
165        kind: Option<&str>,
166        elapsed: std::time::Duration,
167    ) {
168        let m = global();
169        m.engine_requests.with_label_values(&[engine, status]).inc();
170        if let (Some(scope), Some(kind)) = (scope, kind) {
171            m.engine_errors
172                .with_label_values(&[engine, scope, kind])
173                .inc();
174        }
175        m.engine_duration
176            .with_label_values(&[engine])
177            .observe(elapsed.as_secs_f64());
178    }
179}
180
181/// `GET /metrics` — Prometheus text exposition of every registered family.
182/// Deliberately unauthenticated so scrapers do not need to carry API keys.
183pub async fn metrics_route() -> Response {
184    let m = global();
185    let mut buf = Vec::new();
186    let encoder = TextEncoder::new();
187    let families = m.registry.gather();
188    // Gather is infallible here: every family was registered with a valid
189    // name and label set. An encoding failure still yields an empty body
190    // rather than a panic.
191    let _ = encoder.encode(&families, &mut buf);
192    (
193        [(CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")],
194        buf,
195    )
196        .into_response()
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn endpoint_labels_are_bounded() {
205        for known in [
206            "/",
207            "/health",
208            "/metrics",
209            "/v1/search",
210            "/v1/suggest",
211            "/v1/extract",
212            "/v1/test",
213            "/v1/grounding",
214            "/v1/engines",
215        ] {
216            assert_eq!(endpoint_label(known), known);
217        }
218        // /search and /v1/tavily are aliases; they share one label
219        assert_eq!(endpoint_label("/search"), "/search");
220        assert_eq!(endpoint_label("/v1/tavily"), "/search");
221        assert_eq!(endpoint_label("/style.css"), "static");
222        assert_eq!(endpoint_label("/static/app.js"), "static");
223        // arbitrary paths must collapse - never become their own label
224        assert_eq!(endpoint_label("/favicon.ico"), "static");
225        assert_eq!(endpoint_label("/some/deep/spa/route"), "other");
226        assert_eq!(endpoint_label("/v1/search?q=x"), "other");
227    }
228
229    // The registry is process-global, so all assertions share one test to
230    // avoid parallel-test interference.
231    #[tokio::test]
232    async fn metrics_record_and_expose_all_families() {
233        let m = global();
234        m.http_requests.with_label_values(&["/health", "200"]).inc();
235        m.http_duration
236            .with_label_values(&["/health"])
237            .observe(0.01);
238        let observer = EngineMetricsObserver;
239        observer.on_engine_done(
240            "bing",
241            "ok",
242            None,
243            None,
244            std::time::Duration::from_millis(250),
245        );
246        observer.on_engine_done(
247            "bing",
248            "empty",
249            None,
250            None,
251            std::time::Duration::from_millis(10),
252        );
253        observer.on_engine_done(
254            "google",
255            "error",
256            Some("Provider"),
257            Some("Timeout"),
258            std::time::Duration::from_secs(3),
259        );
260
261        assert_eq!(
262            m.engine_requests.with_label_values(&["bing", "ok"]).get(),
263            1
264        );
265        assert_eq!(
266            m.engine_requests
267                .with_label_values(&["bing", "empty"])
268                .get(),
269            1
270        );
271        assert_eq!(
272            m.engine_errors
273                .with_label_values(&["google", "Provider", "Timeout"])
274                .get(),
275            1
276        );
277
278        let text = scrape().await;
279        for family in [
280            "phrona_http_requests_total",
281            "phrona_http_request_duration_seconds",
282            "phrona_engine_requests_total",
283            "phrona_engine_errors_total",
284            "phrona_engine_duration_seconds",
285        ] {
286            assert!(
287                text.contains(&format!("# TYPE {family}")),
288                "missing family {family} in:\n{text}"
289            );
290        }
291        assert!(text.contains("phrona_http_requests_total{endpoint=\"/health\",status=\"200\"} 1"));
292        assert!(text.contains("phrona_engine_requests_total{engine=\"bing\",status=\"ok\"} 1"));
293        // labels are emitted alphabetically in the text format
294        assert!(text.contains(
295            "phrona_engine_errors_total{engine=\"google\",kind=\"Timeout\",scope=\"Provider\"} 1"
296        ));
297
298        // a second failure increments the counter
299        observer.on_engine_done(
300            "google",
301            "error",
302            Some("Provider"),
303            Some("Timeout"),
304            std::time::Duration::from_secs(2),
305        );
306        assert_eq!(
307            m.engine_errors
308                .with_label_values(&["google", "Provider", "Timeout"])
309                .get(),
310            2
311        );
312    }
313
314    async fn scrape() -> String {
315        let resp = metrics_route().await;
316        let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
317            .await
318            .unwrap();
319        String::from_utf8(body.to_vec()).unwrap()
320    }
321}