use std::collections::HashMap;
use std::fmt::Write;
use std::sync::{Arc, Mutex};
use super::handlers_admin::sanitize_label;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct HttpRequestLabels {
pub method: &'static str,
pub route: &'static str,
pub status: &'static str,
}
pub const UNMATCHED_ROUTE: &str = "__unmatched__";
pub const OTHER_METHOD: &str = "OTHER";
pub fn method_label(method: &str) -> &'static str {
match method {
"GET" => "GET",
"POST" => "POST",
"PUT" => "PUT",
"PATCH" => "PATCH",
"DELETE" => "DELETE",
"OPTIONS" => "OPTIONS",
"HEAD" => "HEAD",
_ => OTHER_METHOD,
}
}
pub fn status_class(status: u16) -> &'static str {
match status {
100..=199 => "1xx",
200..=299 => "2xx",
300..=399 => "3xx",
400..=499 => "4xx",
500..=599 => "5xx",
_ => "other",
}
}
#[derive(Debug, Clone)]
pub struct HttpRequestMetrics {
counters: Arc<Mutex<HashMap<HttpRequestLabels, u64>>>,
}
impl HttpRequestMetrics {
pub fn new() -> Self {
Self {
counters: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn record(&self, method: &'static str, route: &'static str, status: u16) {
let key = HttpRequestLabels {
method,
route,
status: status_class(status),
};
let mut guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
*guard.entry(key).or_insert(0) += 1;
}
pub fn count(&self, labels: HttpRequestLabels) -> u64 {
let guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
guard.get(&labels).copied().unwrap_or(0)
}
pub fn snapshot(&self) -> Vec<(HttpRequestLabels, u64)> {
let guard = self.counters.lock().unwrap_or_else(|e| e.into_inner());
let mut rows: Vec<(HttpRequestLabels, u64)> = guard.iter().map(|(k, v)| (*k, *v)).collect();
rows.sort_by_key(|a| a.0);
rows
}
pub fn render(&self, body: &mut String) {
let _ = writeln!(
body,
"# HELP reddb_http_requests_total HTTP requests handled since process start, by method, route template, and status class."
);
let _ = writeln!(body, "# TYPE reddb_http_requests_total counter");
for (labels, count) in self.snapshot() {
let _ = writeln!(
body,
"reddb_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\"}} {}",
sanitize_label(labels.method),
sanitize_label(labels.route),
sanitize_label(labels.status),
count
);
}
}
}
impl Default for HttpRequestMetrics {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn labels(
method: &'static str,
route: &'static str,
status: &'static str,
) -> HttpRequestLabels {
HttpRequestLabels {
method,
route,
status,
}
}
#[test]
fn status_codes_fold_to_classes() {
assert_eq!(status_class(200), "2xx");
assert_eq!(status_class(204), "2xx");
assert_eq!(status_class(301), "3xx");
assert_eq!(status_class(404), "4xx");
assert_eq!(status_class(503), "5xx");
assert_eq!(status_class(700), "other");
}
#[test]
fn record_increments_per_labelset() {
let m = HttpRequestMetrics::new();
m.record("GET", "/catalog/collections/:name", 200);
m.record("GET", "/catalog/collections/:name", 200);
m.record("GET", "/catalog/collections/:name", 404);
assert_eq!(
m.count(labels("GET", "/catalog/collections/:name", "2xx")),
2
);
assert_eq!(
m.count(labels("GET", "/catalog/collections/:name", "4xx")),
1
);
assert_eq!(
m.count(labels("POST", "/catalog/collections/:name", "2xx")),
0
);
}
#[test]
fn high_cardinality_status_codes_collapse_to_one_class_series() {
let m = HttpRequestMetrics::new();
for status in [200u16, 201, 202, 204, 206] {
m.record("POST", "/query", status);
}
let snapshot = m.snapshot();
let query_2xx: Vec<_> = snapshot
.iter()
.filter(|(l, _)| l.route == "/query" && l.status == "2xx")
.collect();
assert_eq!(query_2xx.len(), 1, "all 2xx codes must share one series");
assert_eq!(query_2xx[0].1, 5);
}
#[test]
fn render_emits_prometheus_counter() {
let m = HttpRequestMetrics::new();
m.record("GET", "/health", 200);
let mut body = String::new();
m.render(&mut body);
assert!(body.contains("# TYPE reddb_http_requests_total counter"));
assert!(body.contains(
"reddb_http_requests_total{method=\"GET\",route=\"/health\",status=\"2xx\"} 1"
));
}
#[test]
fn snapshot_is_deterministically_ordered() {
let m = HttpRequestMetrics::new();
m.record("POST", "/query", 500);
m.record("GET", "/health", 200);
m.record("GET", "/health", 200);
let snap = m.snapshot();
assert_eq!(snap[0].0.method, "GET");
assert_eq!(snap[0].0.route, "/health");
assert_eq!(snap[0].1, 2);
assert_eq!(snap[1].0.method, "POST");
}
}