Skip to main content

rs_zero/observability/
metrics.rs

1use std::{
2    collections::BTreeMap,
3    fmt::Write,
4    sync::{Arc, Mutex},
5    time::Duration,
6};
7
8/// Low-cardinality labels recorded for each HTTP request.
9#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct HttpMetricLabels {
11    /// HTTP method.
12    pub method: String,
13    /// Route pattern or explicit route name. Raw request paths should not be used.
14    pub route: String,
15    /// HTTP status code.
16    pub status: u16,
17}
18
19impl HttpMetricLabels {
20    /// Creates a label set for one HTTP request.
21    pub fn new(method: impl Into<String>, route: impl Into<String>, status: u16) -> Self {
22        Self {
23            method: method.into(),
24            route: route.into(),
25            status,
26        }
27    }
28}
29
30#[derive(Debug, Clone, Default)]
31struct HttpMetricValue {
32    count: u64,
33    duration_seconds_sum: f64,
34}
35
36/// In-process metrics registry with Prometheus text export.
37#[derive(Debug, Clone, Default)]
38pub struct MetricsRegistry {
39    http_requests: Arc<Mutex<BTreeMap<HttpMetricLabels, HttpMetricValue>>>,
40}
41
42impl MetricsRegistry {
43    /// Creates an empty registry.
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Records an HTTP request duration.
49    pub fn record_http_request(&self, labels: HttpMetricLabels, duration: Duration) {
50        let mut metrics = self.http_requests.lock().expect("metrics mutex");
51        let entry = metrics.entry(labels).or_default();
52        entry.count += 1;
53        entry.duration_seconds_sum += duration.as_secs_f64();
54    }
55
56    /// Renders all metrics in Prometheus text exposition format.
57    pub fn render_prometheus(&self) -> String {
58        let metrics = self.http_requests.lock().expect("metrics mutex");
59        let mut output = String::new();
60        output.push_str("# HELP rs_zero_http_requests_total Total number of HTTP requests.\n");
61        output.push_str("# TYPE rs_zero_http_requests_total counter\n");
62        for (labels, value) in metrics.iter() {
63            let _ = writeln!(
64                output,
65                "rs_zero_http_requests_total{{method=\"{}\",route=\"{}\",status=\"{}\"}} {}",
66                escape_label(&labels.method),
67                escape_label(&labels.route),
68                labels.status,
69                value.count
70            );
71        }
72
73        output.push_str(
74            "# HELP rs_zero_http_request_duration_seconds_sum Sum of HTTP request durations.\n",
75        );
76        output.push_str("# TYPE rs_zero_http_request_duration_seconds_sum counter\n");
77        for (labels, value) in metrics.iter() {
78            let _ = writeln!(
79                output,
80                "rs_zero_http_request_duration_seconds_sum{{method=\"{}\",route=\"{}\",status=\"{}\"}} {:.6}",
81                escape_label(&labels.method),
82                escape_label(&labels.route),
83                labels.status,
84                value.duration_seconds_sum
85            );
86        }
87        output
88    }
89}
90
91fn escape_label(value: &str) -> String {
92    value.replace('\\', "\\\\").replace('"', "\\\"")
93}
94
95#[cfg(test)]
96mod tests {
97    use std::time::Duration;
98
99    use super::{HttpMetricLabels, MetricsRegistry};
100
101    #[test]
102    fn metrics_render_prometheus_text() {
103        let registry = MetricsRegistry::new();
104        registry.record_http_request(
105            HttpMetricLabels::new("GET", "/users/:id", 200),
106            Duration::from_millis(20),
107        );
108        let text = registry.render_prometheus();
109        assert!(text.contains("rs_zero_http_requests_total"));
110        assert!(text.contains("route=\"/users/:id\""));
111        assert!(!text.contains("/users/42"));
112    }
113}