1use 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
30pub 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
107fn global() -> &'static Metrics {
109 METRICS.get_or_init(Metrics::new)
110}
111
112fn 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
134pub 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#[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
181pub 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 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 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 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 #[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 assert!(text.contains(
295 "phrona_engine_errors_total{engine=\"google\",kind=\"Timeout\",scope=\"Provider\"} 1"
296 ));
297
298 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}