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/// Counts every HTTP request after the inner service responded: endpoint
113/// (request path) and status code are the only labels.
114pub async fn http_layer(req: Request, next: Next) -> Response {
115    let started = Instant::now();
116    let endpoint = req.uri().path().to_string();
117    let resp = next.run(req).await;
118    let m = global();
119    let status = resp.status().as_str().to_string();
120    m.http_requests
121        .with_label_values(&[endpoint.as_str(), status.as_str()])
122        .inc();
123    m.http_duration
124        .with_label_values(&[&endpoint])
125        .observe(started.elapsed().as_secs_f64());
126    resp
127}
128
129/// Core-crate observer forwarding engine outcomes into Prometheus.
130///
131/// Attach it to the search client via
132/// `client.with_observer(Arc::new(metrics::EngineMetricsObserver))`.
133#[derive(Default)]
134pub struct EngineMetricsObserver;
135
136impl EngineObserver for EngineMetricsObserver {
137    fn on_engine_done(
138        &self,
139        engine: &str,
140        status: &str,
141        scope: Option<&str>,
142        kind: Option<&str>,
143        elapsed: std::time::Duration,
144    ) {
145        let m = global();
146        m.engine_requests.with_label_values(&[engine, status]).inc();
147        if let (Some(scope), Some(kind)) = (scope, kind) {
148            m.engine_errors
149                .with_label_values(&[engine, scope, kind])
150                .inc();
151        }
152        m.engine_duration
153            .with_label_values(&[engine])
154            .observe(elapsed.as_secs_f64());
155    }
156}
157
158/// `GET /metrics` — Prometheus text exposition of every registered family.
159/// Deliberately unauthenticated so scrapers do not need to carry API keys.
160pub async fn metrics_route() -> Response {
161    let m = global();
162    let mut buf = Vec::new();
163    let encoder = TextEncoder::new();
164    let families = m.registry.gather();
165    // Gather is infallible here: every family was registered with a valid
166    // name and label set. An encoding failure still yields an empty body
167    // rather than a panic.
168    let _ = encoder.encode(&families, &mut buf);
169    (
170        [(CONTENT_TYPE, "text/plain; version=0.0.4; charset=utf-8")],
171        buf,
172    )
173        .into_response()
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // The registry is process-global, so all assertions share one test to
181    // avoid parallel-test interference.
182    #[tokio::test]
183    async fn metrics_record_and_expose_all_families() {
184        let m = global();
185        m.http_requests.with_label_values(&["/health", "200"]).inc();
186        m.http_duration
187            .with_label_values(&["/health"])
188            .observe(0.01);
189        let observer = EngineMetricsObserver;
190        observer.on_engine_done(
191            "bing",
192            "ok",
193            None,
194            None,
195            std::time::Duration::from_millis(250),
196        );
197        observer.on_engine_done(
198            "bing",
199            "empty",
200            None,
201            None,
202            std::time::Duration::from_millis(10),
203        );
204        observer.on_engine_done(
205            "google",
206            "error",
207            Some("Provider"),
208            Some("Timeout"),
209            std::time::Duration::from_secs(3),
210        );
211
212        assert_eq!(
213            m.engine_requests.with_label_values(&["bing", "ok"]).get(),
214            1
215        );
216        assert_eq!(
217            m.engine_requests
218                .with_label_values(&["bing", "empty"])
219                .get(),
220            1
221        );
222        assert_eq!(
223            m.engine_errors
224                .with_label_values(&["google", "Provider", "Timeout"])
225                .get(),
226            1
227        );
228
229        let text = scrape().await;
230        for family in [
231            "phrona_http_requests_total",
232            "phrona_http_request_duration_seconds",
233            "phrona_engine_requests_total",
234            "phrona_engine_errors_total",
235            "phrona_engine_duration_seconds",
236        ] {
237            assert!(
238                text.contains(&format!("# TYPE {family}")),
239                "missing family {family} in:\n{text}"
240            );
241        }
242        assert!(text.contains("phrona_http_requests_total{endpoint=\"/health\",status=\"200\"} 1"));
243        assert!(text.contains("phrona_engine_requests_total{engine=\"bing\",status=\"ok\"} 1"));
244        // labels are emitted alphabetically in the text format
245        assert!(text.contains(
246            "phrona_engine_errors_total{engine=\"google\",kind=\"Timeout\",scope=\"Provider\"} 1"
247        ));
248
249        // a second failure increments the counter
250        observer.on_engine_done(
251            "google",
252            "error",
253            Some("Provider"),
254            Some("Timeout"),
255            std::time::Duration::from_secs(2),
256        );
257        assert_eq!(
258            m.engine_errors
259                .with_label_values(&["google", "Provider", "Timeout"])
260                .get(),
261            2
262        );
263    }
264
265    async fn scrape() -> String {
266        let resp = metrics_route().await;
267        let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024)
268            .await
269            .unwrap();
270        String::from_utf8(body.to_vec()).unwrap()
271    }
272}