1use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
21use std::time::Duration;
22
23#[derive(Clone, Copy)]
26pub enum Outcome {
27 Allowed,
29 Blocked,
31 RateLimited,
33 BadRequest,
35 UpstreamError,
37 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
64const 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
69pub struct Metrics {
72 requests: [AtomicU64; Outcome::ALL.len()],
74 hist_buckets: [AtomicU64; BUCKETS_SECONDS.len()],
76 hist_count: AtomicU64,
78 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 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 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)); let text = m.render();
172 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 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 for _ in 0..3 {
189 m.record(Outcome::Allowed, Duration::from_micros(800)); }
191 let text = m.render();
192 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}