Skip to main content

fbi_proxy/
metrics.rs

1//! Simple Prometheus-text metrics for fbi-proxy.
2//!
3//! Tiny counter set, no external prom_client dep — `fmt::Write` is
4//! enough. The counters are atomic so they can be incremented from any
5//! request task without locks; the renderer reads them with `Ordering::
6//! Relaxed` (monotonic counters, dirty reads are fine).
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::sync::Arc;
10
11#[derive(Default)]
12pub struct Metrics {
13    pub requests_total: AtomicU64,
14    pub status_2xx_total: AtomicU64,
15    pub status_3xx_total: AtomicU64,
16    pub status_4xx_total: AtomicU64,
17    pub status_5xx_total: AtomicU64,
18    pub upstream_connect_failures_total: AtomicU64,
19    pub upstream_timeouts_total: AtomicU64,
20    pub websocket_upgrades_total: AtomicU64,
21    pub host_rejected_total: AtomicU64,
22}
23
24impl Metrics {
25    pub fn new() -> Arc<Self> {
26        Arc::new(Self::default())
27    }
28
29    pub fn record_status(&self, status: u16) {
30        self.requests_total.fetch_add(1, Ordering::Relaxed);
31        let bucket = match status {
32            200..=299 => &self.status_2xx_total,
33            300..=399 => &self.status_3xx_total,
34            400..=499 => &self.status_4xx_total,
35            500..=599 => &self.status_5xx_total,
36            _ => return, // other status codes (1xx, etc.) — ignore for now
37        };
38        bucket.fetch_add(1, Ordering::Relaxed);
39    }
40
41    pub fn render_prometheus(&self) -> String {
42        let mut out = String::with_capacity(1024);
43        emit_counter(&mut out, "fbi_proxy_requests_total",
44            "Total HTTP requests handled by fbi-proxy.",
45            self.requests_total.load(Ordering::Relaxed));
46        emit_counter(&mut out, "fbi_proxy_status_2xx_total",
47            "HTTP responses with status 2xx.",
48            self.status_2xx_total.load(Ordering::Relaxed));
49        emit_counter(&mut out, "fbi_proxy_status_3xx_total",
50            "HTTP responses with status 3xx.",
51            self.status_3xx_total.load(Ordering::Relaxed));
52        emit_counter(&mut out, "fbi_proxy_status_4xx_total",
53            "HTTP responses with status 4xx.",
54            self.status_4xx_total.load(Ordering::Relaxed));
55        emit_counter(&mut out, "fbi_proxy_status_5xx_total",
56            "HTTP responses with status 5xx.",
57            self.status_5xx_total.load(Ordering::Relaxed));
58        emit_counter(&mut out, "fbi_proxy_upstream_connect_failures_total",
59            "Failed TCP/TLS connects to upstream.",
60            self.upstream_connect_failures_total.load(Ordering::Relaxed));
61        emit_counter(&mut out, "fbi_proxy_upstream_timeouts_total",
62            "Upstream requests that exceeded the request timeout.",
63            self.upstream_timeouts_total.load(Ordering::Relaxed));
64        emit_counter(&mut out, "fbi_proxy_websocket_upgrades_total",
65            "WebSocket upgrade requests handled.",
66            self.websocket_upgrades_total.load(Ordering::Relaxed));
67        emit_counter(&mut out, "fbi_proxy_host_rejected_total",
68            "Requests rejected because the Host header didn't match the domain filter or any route.",
69            self.host_rejected_total.load(Ordering::Relaxed));
70        out
71    }
72}
73
74fn emit_counter(out: &mut String, name: &str, help: &str, value: u64) {
75    use std::fmt::Write;
76    let _ = writeln!(out, "# HELP {} {}", name, help);
77    let _ = writeln!(out, "# TYPE {} counter", name);
78    let _ = writeln!(out, "{} {}", name, value);
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn record_status_routes_to_correct_bucket() {
87        let m = Metrics::new();
88        m.record_status(200);
89        m.record_status(204);
90        m.record_status(302);
91        m.record_status(404);
92        m.record_status(502);
93        m.record_status(502);
94        assert_eq!(m.requests_total.load(Ordering::Relaxed), 6);
95        assert_eq!(m.status_2xx_total.load(Ordering::Relaxed), 2);
96        assert_eq!(m.status_3xx_total.load(Ordering::Relaxed), 1);
97        assert_eq!(m.status_4xx_total.load(Ordering::Relaxed), 1);
98        assert_eq!(m.status_5xx_total.load(Ordering::Relaxed), 2);
99    }
100
101    #[test]
102    fn render_includes_help_and_type_lines() {
103        let m = Metrics::new();
104        m.record_status(200);
105        let out = m.render_prometheus();
106        assert!(out.contains("# HELP fbi_proxy_requests_total"));
107        assert!(out.contains("# TYPE fbi_proxy_requests_total counter"));
108        assert!(out.contains("fbi_proxy_requests_total 1\n"));
109        assert!(out.contains("fbi_proxy_status_2xx_total 1\n"));
110    }
111}