Skip to main content

waf_proxy/
metrics.rs

1// SPDX-FileCopyrightText: 2026 0x00spor3
2// SPDX-License-Identifier: Apache-2.0
3
4//! Prometheus metrics (B1). **OPEN baseline** (`BOUNDARY.md` §1.6).
5//!
6//! Hand-rolled on purpose: the model is **fixed-bucket histograms + `_sum`/`_count`** with
7//! NO in-process quantiles — Prometheus computes them at scrape time via `histogram_quantile()`.
8//! That keeps the whole thing ~N `AtomicU64` + a trivial text serializer, not worth a dep tree.
9//!
10//! The metric NAMES and label VALUES are **ABI** (a renamed metric breaks every downstream
11//! Grafana query). `decision` is a CLOSED, low-cardinality set; **never** put user-derived data
12//! (path, IP, rule_id) in a label — that is the classic cardinality-explosion footgun.
13//!
14//! Instrumentation is a **pure side effect**: every counter is `AtomicU64` with `Relaxed`
15//! ordering, no lock or `.await` on the hot path, so recording can never change a verdict nor
16//! perturb the latency it measures. The `Metrics` type is exporter-neutral; [`Metrics::render`]
17//! is the Prometheus exporter (the only Prometheus-specific piece). A future OTLP sink would be
18//! a second renderer, not a rewrite.
19
20use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
21use std::time::Duration;
22
23/// Final disposition of a request — the closed value set of the `decision` label.
24/// Discriminants are the array index into the counters; keep [`Outcome::ALL`] in sync.
25#[derive(Clone, Copy)]
26pub enum Outcome {
27    /// Forwarded to the backend (whatever status the app returned).
28    Allowed,
29    /// Anomaly/high-confidence block (403).
30    Blocked,
31    /// Rate limit exceeded (429).
32    RateLimited,
33    /// Rejected before forwarding for a malformed/over-limit request (400).
34    BadRequest,
35    /// The BACKEND failed/timed out/was unreachable — structured upstream error (502/503).
36    UpstreamError,
37    /// The WAF's OWN machinery errored unexpectedly — catch-all `handle` 502. Distinct from
38    /// `UpstreamError` so "backend is down" and "we have a bug" are not conflated.
39    InternalError,
40}
41
42impl Outcome {
43    const ALL: [Outcome; 6] = [
44        Outcome::Allowed,
45        Outcome::Blocked,
46        Outcome::RateLimited,
47        Outcome::BadRequest,
48        Outcome::UpstreamError,
49        Outcome::InternalError,
50    ];
51
52    fn label(self) -> &'static str {
53        match self {
54            Outcome::Allowed => "allowed",
55            Outcome::Blocked => "blocked",
56            Outcome::RateLimited => "rate_limited",
57            Outcome::BadRequest => "bad_request",
58            Outcome::UpstreamError => "upstream_error",
59            Outcome::InternalError => "internal_error",
60        }
61    }
62}
63
64/// Latency histogram upper bounds in **seconds** (Prometheus base unit). Cumulative `le`
65/// buckets are derived at render time; observations above the last bound land only in `+Inf`.
66const BUCKETS_SECONDS: [f64; 14] =
67    [0.0005, 0.001, 0.0025, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0];
68
69/// Process-lifetime metrics registry. Shared via `Arc`; lives in `StaticState` so it survives
70/// config reloads (like the rate-limit store).
71pub struct Metrics {
72    /// One counter per `Outcome`, indexed by discriminant.
73    requests: [AtomicU64; Outcome::ALL.len()],
74    /// Non-cumulative per-bucket counts; rendered cumulatively.
75    hist_buckets: [AtomicU64; BUCKETS_SECONDS.len()],
76    /// Total observations (the `+Inf` bucket and `_count`).
77    hist_count: AtomicU64,
78    /// Sum of observed durations, in nanoseconds (integer accumulation; rendered as seconds).
79    hist_sum_nanos: AtomicU64,
80}
81
82impl Metrics {
83    pub fn new() -> Self {
84        Self {
85            requests: std::array::from_fn(|_| AtomicU64::new(0)),
86            hist_buckets: std::array::from_fn(|_| AtomicU64::new(0)),
87            hist_count: AtomicU64::new(0),
88            hist_sum_nanos: AtomicU64::new(0),
89        }
90    }
91
92    /// Record one finished request: its decision + total handling latency. Pure side effect.
93    pub fn record(&self, outcome: Outcome, elapsed: Duration) {
94        self.requests[outcome as usize].fetch_add(1, Relaxed);
95
96        let secs = elapsed.as_secs_f64();
97        let mut i = 0;
98        while i < BUCKETS_SECONDS.len() && secs > BUCKETS_SECONDS[i] {
99            i += 1;
100        }
101        if i < BUCKETS_SECONDS.len() {
102            self.hist_buckets[i].fetch_add(1, Relaxed);
103        }
104        self.hist_count.fetch_add(1, Relaxed);
105        self.hist_sum_nanos.fetch_add(elapsed.as_nanos() as u64, Relaxed);
106    }
107
108    /// Serialize the current snapshot in Prometheus text exposition format. This is the
109    /// Prometheus-specific exporter; the counters above are exporter-neutral.
110    pub fn render(&self) -> String {
111        let mut out = String::with_capacity(1024);
112
113        out.push_str("# HELP waf_up 1 if the WAF process is running.\n");
114        out.push_str("# TYPE waf_up gauge\n");
115        out.push_str("waf_up 1\n");
116
117        out.push_str("# HELP waf_build_info Build information.\n");
118        out.push_str("# TYPE waf_build_info gauge\n");
119        out.push_str(&format!(
120            "waf_build_info{{version=\"{}\"}} 1\n",
121            env!("CARGO_PKG_VERSION")
122        ));
123
124        out.push_str("# HELP waf_requests_total Total requests by final decision.\n");
125        out.push_str("# TYPE waf_requests_total counter\n");
126        for outcome in Outcome::ALL {
127            let n = self.requests[outcome as usize].load(Relaxed);
128            out.push_str(&format!("waf_requests_total{{decision=\"{}\"}} {}\n", outcome.label(), n));
129        }
130
131        out.push_str("# HELP waf_request_duration_seconds Request handling latency in seconds.\n");
132        out.push_str("# TYPE waf_request_duration_seconds histogram\n");
133        let mut cumulative = 0u64;
134        for (i, bound) in BUCKETS_SECONDS.iter().enumerate() {
135            cumulative += self.hist_buckets[i].load(Relaxed);
136            out.push_str(&format!(
137                "waf_request_duration_seconds_bucket{{le=\"{}\"}} {}\n",
138                bound, cumulative
139            ));
140        }
141        let count = self.hist_count.load(Relaxed);
142        out.push_str(&format!(
143            "waf_request_duration_seconds_bucket{{le=\"+Inf\"}} {}\n",
144            count
145        ));
146        let sum_seconds = self.hist_sum_nanos.load(Relaxed) as f64 / 1e9;
147        out.push_str(&format!("waf_request_duration_seconds_sum {}\n", sum_seconds));
148        out.push_str(&format!("waf_request_duration_seconds_count {}\n", count));
149
150        out
151    }
152}
153
154impl Default for Metrics {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn render_has_all_decision_labels_and_histogram_shape() {
166        let m = Metrics::new();
167        m.record(Outcome::Allowed, Duration::from_millis(2));
168        m.record(Outcome::Blocked, Duration::from_micros(300));
169        m.record(Outcome::RateLimited, Duration::from_secs(20)); // overflow → only +Inf
170
171        let text = m.render();
172        // Every closed-set decision label is present (even zero-valued).
173        for label in ["allowed", "blocked", "rate_limited", "bad_request", "upstream_error", "internal_error"] {
174            assert!(text.contains(&format!("decision=\"{label}\"")), "missing {label}");
175        }
176        assert!(text.contains("waf_requests_total{decision=\"allowed\"} 1"));
177        assert!(text.contains("waf_requests_total{decision=\"bad_request\"} 0"));
178        // +Inf counts every observation (3); count line matches.
179        assert!(text.contains("le=\"+Inf\"} 3"));
180        assert!(text.contains("waf_request_duration_seconds_count 3"));
181        assert!(text.contains("waf_up 1"));
182    }
183
184    #[test]
185    fn buckets_are_cumulative_and_monotonic() {
186        let m = Metrics::new();
187        // Three small observations all fall under 0.01s → every le>=0.01 bucket counts them.
188        for _ in 0..3 {
189            m.record(Outcome::Allowed, Duration::from_micros(800)); // 0.0008s
190        }
191        let text = m.render();
192        // 0.0008 > 0.0005 but <= 0.001 → first counted bucket is le="0.001".
193        assert!(text.contains("le=\"0.0005\"} 0"));
194        assert!(text.contains("le=\"0.001\"} 3"));
195        assert!(text.contains("le=\"0.01\"} 3"));
196        assert!(text.contains("le=\"+Inf\"} 3"));
197    }
198}