Skip to main content

boatramp_server/
srvmetrics.rs

1//! Process-wide HTTP/lifecycle metrics: request-level
2//! dimensions (status class + cache result + response bytes) plus deploy and
3//! certificate-renewal counters. These complement the per-`(site, trigger,
4//! route)` handler counters in [`crate::metrics`] (which exist only with the
5//! `handlers` feature) and are always-on, so the Prometheus endpoint reports
6//! serving health even on a build without handlers.
7//!
8//! Server metrics are genuinely *process*-global (one HTTP listener, one deploy
9//! store), so they live in a [`std::sync::LazyLock`] reached via
10//! [`server_metrics`] rather than threaded through every handler signature —
11//! the access-log middleware, the deploy handlers, and the certificate-renewal
12//! path (in the CLI crate) all record against the same registry.
13
14use std::sync::atomic::{AtomicU64, Ordering};
15use std::sync::{LazyLock, Mutex};
16
17/// The process-wide server-metrics registry.
18pub static SERVER_METRICS: LazyLock<ServerMetrics> = LazyLock::new(ServerMetrics::default);
19
20/// The process-wide [`ServerMetrics`]. Cheap to call (no allocation).
21pub fn server_metrics() -> &'static ServerMetrics {
22    &SERVER_METRICS
23}
24
25/// Always-on HTTP + lifecycle counters. Request cells are keyed by
26/// `(status_class, cache_result)`; the rest are scalar totals.
27#[derive(Default)]
28pub struct ServerMetrics {
29    requests: Mutex<std::collections::BTreeMap<(&'static str, &'static str), u64>>,
30    response_bytes: AtomicU64,
31    deployments: AtomicU64,
32    activations: AtomicU64,
33    cert_renewals: AtomicU64,
34}
35
36impl ServerMetrics {
37    /// Record a finished HTTP request: its status class (`2xx`…), the coarse
38    /// cache result derived from the status, and the bytes streamed back.
39    pub fn record_request(&self, status: u16, bytes: u64) {
40        self.response_bytes.fetch_add(bytes, Ordering::Relaxed);
41        let mut map = self.requests.lock().unwrap();
42        *map.entry((status_class(status), cache_result(status)))
43            .or_insert(0) += 1;
44    }
45
46    /// Record a deployment manifest having been created.
47    pub fn record_deployment(&self) {
48        self.deployments.fetch_add(1, Ordering::Relaxed);
49    }
50
51    /// Record a deployment activation (the live/alias pointer flip).
52    pub fn record_activation(&self) {
53        self.activations.fetch_add(1, Ordering::Relaxed);
54    }
55
56    /// Record a TLS certificate (re)issue (ACME issuance / renewal).
57    pub fn record_cert_renewal(&self) {
58        self.cert_renewals.fetch_add(1, Ordering::Relaxed);
59    }
60
61    /// Render the counters in Prometheus text exposition format.
62    pub fn render_prometheus(&self) -> String {
63        let mut out = String::new();
64        out.push_str(
65            "# HELP boatramp_http_requests_total HTTP requests by status class and cache result.\n\
66             # TYPE boatramp_http_requests_total counter\n",
67        );
68        for ((class, cache), value) in self.requests.lock().unwrap().iter() {
69            out.push_str(&format!(
70                "boatramp_http_requests_total{{status_class=\"{class}\",cache_result=\"{cache}\"}} {value}\n"
71            ));
72        }
73        for (name, help, value) in [
74            (
75                "boatramp_http_response_bytes_total",
76                "Total response body bytes streamed.",
77                self.response_bytes.load(Ordering::Relaxed),
78            ),
79            (
80                "boatramp_deployments_total",
81                "Deployment manifests created.",
82                self.deployments.load(Ordering::Relaxed),
83            ),
84            (
85                "boatramp_activations_total",
86                "Deployment activations (live/alias pointer flips).",
87                self.activations.load(Ordering::Relaxed),
88            ),
89            (
90                "boatramp_cert_renewals_total",
91                "TLS certificate issues/renewals.",
92                self.cert_renewals.load(Ordering::Relaxed),
93            ),
94        ] {
95            out.push_str(&format!(
96                "# HELP {name} {help}\n# TYPE {name} counter\n{name} {value}\n"
97            ));
98        }
99        out
100    }
101}
102
103/// The status class label (`2xx`, `3xx`, …) for a response code.
104fn status_class(status: u16) -> &'static str {
105    match status / 100 {
106        1 => "1xx",
107        2 => "2xx",
108        3 => "3xx",
109        4 => "4xx",
110        5 => "5xx",
111        _ => "other",
112    }
113}
114
115/// A coarse cache outcome derived from the status: a conditional/range hit vs a
116/// full body vs a redirect/error. Shared with the access-log line so the log and
117/// the metric agree on the classification.
118pub fn cache_result(status: u16) -> &'static str {
119    match status {
120        304 => "not-modified",
121        206 => "partial",
122        200 => "full",
123        s if (300..400).contains(&s) => "redirect",
124        s if s >= 400 => "error",
125        _ => "-",
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn records_and_renders_request_dimensions() {
135        let m = ServerMetrics::default();
136        m.record_request(200, 1024);
137        m.record_request(200, 512);
138        m.record_request(304, 0);
139        m.record_request(404, 48);
140        m.record_deployment();
141        m.record_activation();
142        m.record_cert_renewal();
143
144        let out = m.render_prometheus();
145        assert!(out.contains("# TYPE boatramp_http_requests_total counter"));
146        assert!(out.contains(
147            "boatramp_http_requests_total{status_class=\"2xx\",cache_result=\"full\"} 2"
148        ));
149        assert!(out.contains(
150            "boatramp_http_requests_total{status_class=\"3xx\",cache_result=\"not-modified\"} 1"
151        ));
152        assert!(out.contains(
153            "boatramp_http_requests_total{status_class=\"4xx\",cache_result=\"error\"} 1"
154        ));
155        assert!(out.contains("boatramp_http_response_bytes_total 1584"));
156        assert!(out.contains("boatramp_deployments_total 1"));
157        assert!(out.contains("boatramp_activations_total 1"));
158        assert!(out.contains("boatramp_cert_renewals_total 1"));
159    }
160
161    #[test]
162    fn cache_result_classifies_status() {
163        assert_eq!(cache_result(200), "full");
164        assert_eq!(cache_result(206), "partial");
165        assert_eq!(cache_result(304), "not-modified");
166        assert_eq!(cache_result(301), "redirect");
167        assert_eq!(cache_result(500), "error");
168    }
169}