rs_zero/observability/
metrics.rs1use std::{
2 collections::BTreeMap,
3 fmt::Write,
4 sync::{Arc, Mutex},
5 time::Duration,
6};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct HttpMetricLabels {
11 pub method: String,
13 pub route: String,
15 pub status: u16,
17}
18
19impl HttpMetricLabels {
20 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#[derive(Debug, Clone, Default)]
38pub struct MetricsRegistry {
39 http_requests: Arc<Mutex<BTreeMap<HttpMetricLabels, HttpMetricValue>>>,
40}
41
42impl MetricsRegistry {
43 pub fn new() -> Self {
45 Self::default()
46 }
47
48 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 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}