Skip to main content

pylon_runtime/
metrics.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Instant;
3
4/// Per-HTTP-method request counters.
5pub struct MethodCounters {
6    pub get: AtomicU64,
7    pub post: AtomicU64,
8    pub patch: AtomicU64,
9    pub delete: AtomicU64,
10    pub options: AtomicU64,
11}
12
13impl MethodCounters {
14    fn new() -> Self {
15        Self {
16            get: AtomicU64::new(0),
17            post: AtomicU64::new(0),
18            patch: AtomicU64::new(0),
19            delete: AtomicU64::new(0),
20            options: AtomicU64::new(0),
21        }
22    }
23
24    fn increment(&self, method: &str) {
25        match method {
26            "GET" => self.get.fetch_add(1, Ordering::Relaxed),
27            "POST" => self.post.fetch_add(1, Ordering::Relaxed),
28            "PATCH" => self.patch.fetch_add(1, Ordering::Relaxed),
29            "DELETE" => self.delete.fetch_add(1, Ordering::Relaxed),
30            "OPTIONS" => self.options.fetch_add(1, Ordering::Relaxed),
31            _ => 0,
32        };
33    }
34}
35
36/// Lightweight, lock-free request metrics.
37///
38/// All counters use relaxed atomic ordering — sufficient for monitoring
39/// where exact cross-thread consistency is not required.
40pub struct Metrics {
41    pub requests_total: AtomicU64,
42    pub requests_ok: AtomicU64,
43    pub requests_err: AtomicU64,
44    pub requests_by_method: MethodCounters,
45    start_time: Instant,
46}
47
48impl Metrics {
49    /// Create a new metrics instance. The uptime clock starts immediately.
50    pub fn new() -> Self {
51        Self {
52            requests_total: AtomicU64::new(0),
53            requests_ok: AtomicU64::new(0),
54            requests_err: AtomicU64::new(0),
55            requests_by_method: MethodCounters::new(),
56            start_time: Instant::now(),
57        }
58    }
59
60    /// Record a completed request. A status code in the 200-399 range is
61    /// counted as successful; everything else counts as an error.
62    pub fn record_request(&self, method: &str, status: u16) {
63        self.requests_total.fetch_add(1, Ordering::Relaxed);
64        if (200..400).contains(&status) {
65            self.requests_ok.fetch_add(1, Ordering::Relaxed);
66        } else {
67            self.requests_err.fetch_add(1, Ordering::Relaxed);
68        }
69        self.requests_by_method.increment(method);
70    }
71
72    /// Seconds elapsed since this `Metrics` instance was created.
73    pub fn uptime_secs(&self) -> u64 {
74        self.start_time.elapsed().as_secs()
75    }
76
77    /// Return a JSON snapshot of all current metrics.
78    pub fn snapshot(&self) -> serde_json::Value {
79        serde_json::json!({
80            "uptime_secs": self.uptime_secs(),
81            "requests": {
82                "total": self.requests_total.load(Ordering::Relaxed),
83                "ok": self.requests_ok.load(Ordering::Relaxed),
84                "error": self.requests_err.load(Ordering::Relaxed),
85            },
86            "methods": {
87                "GET": self.requests_by_method.get.load(Ordering::Relaxed),
88                "POST": self.requests_by_method.post.load(Ordering::Relaxed),
89                "PATCH": self.requests_by_method.patch.load(Ordering::Relaxed),
90                "DELETE": self.requests_by_method.delete.load(Ordering::Relaxed),
91            }
92        })
93    }
94
95    /// Return metrics in Prometheus text exposition format.
96    ///
97    /// Supports scraping by Prometheus, Grafana Agent, OTel collector, etc.
98    pub fn prometheus(&self) -> String {
99        let total = self.requests_total.load(Ordering::Relaxed);
100        let ok = self.requests_ok.load(Ordering::Relaxed);
101        let err = self.requests_err.load(Ordering::Relaxed);
102        let uptime = self.uptime_secs();
103        let get = self.requests_by_method.get.load(Ordering::Relaxed);
104        let post = self.requests_by_method.post.load(Ordering::Relaxed);
105        let patch = self.requests_by_method.patch.load(Ordering::Relaxed);
106        let delete = self.requests_by_method.delete.load(Ordering::Relaxed);
107        let options = self.requests_by_method.options.load(Ordering::Relaxed);
108
109        format!(
110            "# HELP pylon_uptime_seconds Server uptime in seconds.\n\
111             # TYPE pylon_uptime_seconds gauge\n\
112             pylon_uptime_seconds {uptime}\n\
113             # HELP pylon_http_requests_total HTTP requests total.\n\
114             # TYPE pylon_http_requests_total counter\n\
115             pylon_http_requests_total {total}\n\
116             # HELP pylon_http_requests_ok_total HTTP requests with 2xx/3xx status.\n\
117             # TYPE pylon_http_requests_ok_total counter\n\
118             pylon_http_requests_ok_total {ok}\n\
119             # HELP pylon_http_requests_errors_total HTTP requests with 4xx/5xx status.\n\
120             # TYPE pylon_http_requests_errors_total counter\n\
121             pylon_http_requests_errors_total {err}\n\
122             # HELP pylon_http_requests_by_method HTTP requests by method.\n\
123             # TYPE pylon_http_requests_by_method counter\n\
124             pylon_http_requests_by_method{{method=\"GET\"}} {get}\n\
125             pylon_http_requests_by_method{{method=\"POST\"}} {post}\n\
126             pylon_http_requests_by_method{{method=\"PATCH\"}} {patch}\n\
127             pylon_http_requests_by_method{{method=\"DELETE\"}} {delete}\n\
128             pylon_http_requests_by_method{{method=\"OPTIONS\"}} {options}\n"
129        )
130    }
131}
132
133impl Default for Metrics {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn new_metrics_are_zero() {
145        let m = Metrics::new();
146        assert_eq!(m.requests_total.load(Ordering::Relaxed), 0);
147        assert_eq!(m.requests_ok.load(Ordering::Relaxed), 0);
148        assert_eq!(m.requests_err.load(Ordering::Relaxed), 0);
149    }
150
151    #[test]
152    fn record_ok_request() {
153        let m = Metrics::new();
154        m.record_request("GET", 200);
155        assert_eq!(m.requests_total.load(Ordering::Relaxed), 1);
156        assert_eq!(m.requests_ok.load(Ordering::Relaxed), 1);
157        assert_eq!(m.requests_err.load(Ordering::Relaxed), 0);
158        assert_eq!(m.requests_by_method.get.load(Ordering::Relaxed), 1);
159    }
160
161    #[test]
162    fn record_error_request() {
163        let m = Metrics::new();
164        m.record_request("POST", 500);
165        assert_eq!(m.requests_total.load(Ordering::Relaxed), 1);
166        assert_eq!(m.requests_ok.load(Ordering::Relaxed), 0);
167        assert_eq!(m.requests_err.load(Ordering::Relaxed), 1);
168        assert_eq!(m.requests_by_method.post.load(Ordering::Relaxed), 1);
169    }
170
171    #[test]
172    fn method_counters_increment_independently() {
173        let m = Metrics::new();
174        m.record_request("GET", 200);
175        m.record_request("GET", 200);
176        m.record_request("POST", 201);
177        m.record_request("DELETE", 204);
178        m.record_request("PATCH", 200);
179        m.record_request("OPTIONS", 204);
180
181        assert_eq!(m.requests_by_method.get.load(Ordering::Relaxed), 2);
182        assert_eq!(m.requests_by_method.post.load(Ordering::Relaxed), 1);
183        assert_eq!(m.requests_by_method.delete.load(Ordering::Relaxed), 1);
184        assert_eq!(m.requests_by_method.patch.load(Ordering::Relaxed), 1);
185        assert_eq!(m.requests_by_method.options.load(Ordering::Relaxed), 1);
186        assert_eq!(m.requests_total.load(Ordering::Relaxed), 6);
187    }
188
189    #[test]
190    fn snapshot_returns_valid_json() {
191        let m = Metrics::new();
192        m.record_request("GET", 200);
193        m.record_request("POST", 400);
194
195        let snap = m.snapshot();
196        assert_eq!(snap["requests"]["total"], 2);
197        assert_eq!(snap["requests"]["ok"], 1);
198        assert_eq!(snap["requests"]["error"], 1);
199        assert_eq!(snap["methods"]["GET"], 1);
200        assert_eq!(snap["methods"]["POST"], 1);
201        assert_eq!(snap["methods"]["PATCH"], 0);
202        assert_eq!(snap["methods"]["DELETE"], 0);
203        assert!(snap["uptime_secs"].as_u64().is_some());
204    }
205
206    #[test]
207    fn uptime_is_non_negative() {
208        let m = Metrics::new();
209        assert!(m.uptime_secs() < 2); // should be ~0 immediately after creation
210    }
211
212    #[test]
213    fn status_boundary_classification() {
214        let m = Metrics::new();
215        // 2xx = ok
216        m.record_request("GET", 200);
217        m.record_request("GET", 204);
218        m.record_request("GET", 299);
219        // 3xx = ok (redirects)
220        m.record_request("GET", 301);
221        m.record_request("GET", 399);
222        // 4xx = error
223        m.record_request("GET", 400);
224        m.record_request("GET", 404);
225        // 5xx = error
226        m.record_request("GET", 500);
227
228        assert_eq!(m.requests_ok.load(Ordering::Relaxed), 5);
229        assert_eq!(m.requests_err.load(Ordering::Relaxed), 3);
230    }
231}