Skip to main content

reddb_server/server/
http_request_metrics.rs

1//! HTTP request/error volume counters for operational telemetry.
2//!
3//! Issue #1239 / PRD #1237 Phase A, against the Phase-0 substrate
4//! contract (ADR 0060). Every handled HTTP request increments a single
5//! counter keyed by three **bounded** dimensions:
6//!
7//! - `method` — the request method, folded to the closed HTTP verb set
8//!   (`GET`/`POST`/…); anything outside it collapses to `OTHER`.
9//! - `route`  — the matched route *template* (`/catalog/collections/:name`),
10//!   never the raw path. Requests that match no catalog route collapse to
11//!   the reserved `__unmatched__` bucket. The normalization is the caller's
12//!   job (it needs the route catalog); this module only stores the label.
13//! - `status` — the HTTP status *class* (`2xx`/`4xx`/`5xx`/…) per ADR 0060
14//!   §4, which keeps the status dimension at ≤6 values instead of the full
15//!   code space.
16//!
17//! Because all three label components are `&'static str` (verb labels,
18//! catalog route templates, and status-class strings are all static), the
19//! key set is bounded by construction: the product of the closed verb set,
20//! the static route table, and the six status classes. No raw path, id,
21//! query string, tenant value, or authorization material is ever admitted
22//! as a label — the caller passes pre-normalized static strings.
23//!
24//! The counter is a plain `Mutex<HashMap>`; HTTP request dispatch is not a
25//! lock-contention-sensitive hot path at this granularity, and the bounded
26//! key set keeps the map small. The substrate read model and the `/metrics`
27//! exporter both read [`HttpRequestMetrics::snapshot`].
28
29use std::collections::HashMap;
30use std::fmt::Write;
31use std::sync::{Arc, Mutex};
32
33use super::handlers_admin::sanitize_label;
34
35/// One bounded label set for a recorded HTTP request. Every component is
36/// `&'static str` so the cardinality of the key space is fixed at compile
37/// time (closed verb set × static route table × status classes).
38#[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
45/// Reserved route label for any request that matched no catalog route.
46/// Folding to a single bucket is what keeps raw 404 paths from blowing up
47/// the `route` dimension (ADR 0060 §4 overflow rule).
48pub const UNMATCHED_ROUTE: &str = "__unmatched__";
49
50/// Reserved method label for any verb outside the closed HTTP set.
51pub const OTHER_METHOD: &str = "OTHER";
52
53/// Fold a request method to its bounded static label. The closed HTTP verb
54/// set maps to its canonical upper-case label; anything else collapses to
55/// [`OTHER_METHOD`] so a hostile/unknown verb cannot open a new series.
56pub 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
69/// Map an HTTP status code to its bounded status *class*. Per ADR 0060 §4
70/// the class (`2xx`/`4xx`/`5xx`/…) is preferred over the exact code so the
71/// `status` dimension stays at ≤6 values.
72pub 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    /// Record one handled HTTP request. `method` and `route` must already
96    /// be normalized to bounded static labels by the caller (the verb set
97    /// and the route catalog are the authorities); `status` is the raw
98    /// response code, folded to its class here.
99    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    /// Current value of one series, or 0 if never incremented. Visible for
110    /// tests and the read model.
111    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    /// Deterministically-ordered snapshot of every recorded series. This is
117    /// the substrate read surface: `/metrics` renders it and the
118    /// operational read model (red-ui / `/cluster/status` summaries) derives
119    /// request throughput from the same measured facts.
120    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    /// Render `reddb_http_requests_total{method,route,status}` in Prometheus
128    /// text exposition format, appending to `body`. Per ADR 0017 this is a
129    /// boundary adapter: it reads the substrate snapshot and shapes it, it
130    /// owns no storage. An empty substrate emits only HELP/TYPE (Prometheus
131    /// has no envelope concept; absent series read as "nothing happened").
132    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        // Distinct 2xx codes must not each open a new series — they fold to
207        // the single `2xx` class bucket.
208        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        // Sorted by (method, route, status) — GET sorts before POST.
240        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}