1use std::collections::HashMap;
30use std::fmt::Write;
31use std::sync::{Arc, Mutex};
32
33use super::handlers_admin::sanitize_label;
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
39pub struct HttpRequestLabels {
40 pub method: &'static str,
41 pub route: &'static str,
42 pub status: &'static str,
43}
44
45pub const UNMATCHED_ROUTE: &str = "__unmatched__";
49
50pub const OTHER_METHOD: &str = "OTHER";
52
53pub fn method_label(method: &str) -> &'static str {
57 match method {
58 "GET" => "GET",
59 "POST" => "POST",
60 "PUT" => "PUT",
61 "PATCH" => "PATCH",
62 "DELETE" => "DELETE",
63 "OPTIONS" => "OPTIONS",
64 "HEAD" => "HEAD",
65 _ => OTHER_METHOD,
66 }
67}
68
69pub fn status_class(status: u16) -> &'static str {
73 match status {
74 100..=199 => "1xx",
75 200..=299 => "2xx",
76 300..=399 => "3xx",
77 400..=499 => "4xx",
78 500..=599 => "5xx",
79 _ => "other",
80 }
81}
82
83#[derive(Debug, Clone)]
84pub struct HttpRequestMetrics {
85 counters: Arc<Mutex<HashMap<HttpRequestLabels, u64>>>,
86}
87
88impl HttpRequestMetrics {
89 pub fn new() -> Self {
90 Self {
91 counters: Arc::new(Mutex::new(HashMap::new())),
92 }
93 }
94
95 pub fn record(&self, method: &'static str, route: &'static str, status: u16) {
100 let key = HttpRequestLabels {
101 method,
102 route,
103 status: status_class(status),
104 };
105 let mut guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
106 *guard.entry(key).or_insert(0) += 1;
107 }
108
109 pub fn count(&self, labels: HttpRequestLabels) -> u64 {
112 let guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
113 guard.get(&labels).copied().unwrap_or(0)
114 }
115
116 pub fn snapshot(&self) -> Vec<(HttpRequestLabels, u64)> {
121 let guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
122 let mut rows: Vec<(HttpRequestLabels, u64)> = guard.iter().map(|(k, v)| (*k, *v)).collect();
123 rows.sort_by_key(|a| a.0);
124 rows
125 }
126
127 pub fn render(&self, body: &mut String) {
133 let _ = writeln!(
134 body,
135 "# HELP reddb_http_requests_total HTTP requests handled since process start, by method, route template, and status class."
136 );
137 let _ = writeln!(body, "# TYPE reddb_http_requests_total counter");
138 for (labels, count) in self.snapshot() {
139 let _ = writeln!(
140 body,
141 "reddb_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\"}} {}",
142 sanitize_label(labels.method),
143 sanitize_label(labels.route),
144 sanitize_label(labels.status),
145 count
146 );
147 }
148 }
149}
150
151impl Default for HttpRequestMetrics {
152 fn default() -> Self {
153 Self::new()
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn labels(
162 method: &'static str,
163 route: &'static str,
164 status: &'static str,
165 ) -> HttpRequestLabels {
166 HttpRequestLabels {
167 method,
168 route,
169 status,
170 }
171 }
172
173 #[test]
174 fn status_codes_fold_to_classes() {
175 assert_eq!(status_class(200), "2xx");
176 assert_eq!(status_class(204), "2xx");
177 assert_eq!(status_class(301), "3xx");
178 assert_eq!(status_class(404), "4xx");
179 assert_eq!(status_class(503), "5xx");
180 assert_eq!(status_class(700), "other");
181 }
182
183 #[test]
184 fn record_increments_per_labelset() {
185 let m = HttpRequestMetrics::new();
186 m.record("GET", "/catalog/collections/:name", 200);
187 m.record("GET", "/catalog/collections/:name", 200);
188 m.record("GET", "/catalog/collections/:name", 404);
189 assert_eq!(
190 m.count(labels("GET", "/catalog/collections/:name", "2xx")),
191 2
192 );
193 assert_eq!(
194 m.count(labels("GET", "/catalog/collections/:name", "4xx")),
195 1
196 );
197 assert_eq!(
198 m.count(labels("POST", "/catalog/collections/:name", "2xx")),
199 0
200 );
201 }
202
203 #[test]
204 fn high_cardinality_status_codes_collapse_to_one_class_series() {
205 let m = HttpRequestMetrics::new();
206 for status in [200u16, 201, 202, 204, 206] {
209 m.record("POST", "/query", status);
210 }
211 let snapshot = m.snapshot();
212 let query_2xx: Vec<_> = snapshot
213 .iter()
214 .filter(|(l, _)| l.route == "/query" && l.status == "2xx")
215 .collect();
216 assert_eq!(query_2xx.len(), 1, "all 2xx codes must share one series");
217 assert_eq!(query_2xx[0].1, 5);
218 }
219
220 #[test]
221 fn render_emits_prometheus_counter() {
222 let m = HttpRequestMetrics::new();
223 m.record("GET", "/health", 200);
224 let mut body = String::new();
225 m.render(&mut body);
226 assert!(body.contains("# TYPE reddb_http_requests_total counter"));
227 assert!(body.contains(
228 "reddb_http_requests_total{method=\"GET\",route=\"/health\",status=\"2xx\"} 1"
229 ));
230 }
231
232 #[test]
233 fn snapshot_is_deterministically_ordered() {
234 let m = HttpRequestMetrics::new();
235 m.record("POST", "/query", 500);
236 m.record("GET", "/health", 200);
237 m.record("GET", "/health", 200);
238 let snap = m.snapshot();
239 assert_eq!(snap[0].0.method, "GET");
241 assert_eq!(snap[0].0.route, "/health");
242 assert_eq!(snap[0].1, 2);
243 assert_eq!(snap[1].0.method, "POST");
244 }
245}