Skip to main content

teamcraft/
metrics.rs

1//! Prometheus metrics (`GET /metrics`, text exposition format 0.0.4).
2//!
3//! Off unless `TEAMCRAFT_METRICS_TOKEN` is set; the scraper sends it as a
4//! bearer token. Counts are kept in memory since the start of the process.
5//! Labels are never taken from the request (no path or user), so the number
6//! of series is fixed.
7
8use std::{
9    fmt::Write,
10    sync::atomic::{AtomicU64, Ordering},
11    time::Duration,
12};
13
14/// Upper bounds (seconds) of the latency histogram.
15pub const BUCKETS: [f64; 9] = [0.005, 0.025, 0.1, 0.25, 0.5, 1.0, 2.5, 10.0, 60.0];
16
17const CLASSES: [&str; 5] = ["1xx", "2xx", "3xx", "4xx", "5xx"];
18
19#[derive(Default)]
20pub struct Metrics {
21    by_class: [AtomicU64; 5],
22    /// Cumulative: requests that took at most `BUCKETS[i]`.
23    buckets: [AtomicU64; 9],
24    count: AtomicU64,
25    micros: AtomicU64,
26}
27
28impl Metrics {
29    /// Records one answered request.
30    pub fn record(&self, status: u16, took: Duration) {
31        let class = usize::from(status / 100).clamp(1, 5) - 1;
32        self.by_class[class].fetch_add(1, Ordering::Relaxed);
33        let seconds = took.as_secs_f64();
34        for (i, bound) in BUCKETS.iter().enumerate() {
35            if seconds <= *bound {
36                self.buckets[i].fetch_add(1, Ordering::Relaxed);
37            }
38        }
39        self.count.fetch_add(1, Ordering::Relaxed);
40        self.micros.fetch_add(
41            u64::try_from(took.as_micros()).unwrap_or(u64::MAX),
42            Ordering::Relaxed,
43        );
44    }
45
46    /// The exposition text; `gauges` are `(name, help, value)` read by the
47    /// caller (database pool, outbox backlogs).
48    pub fn render(&self, version: &str, gauges: &[(&str, &str, f64)]) -> String {
49        let mut out = String::new();
50        let _ = writeln!(
51            out,
52            "# HELP teamcraft_build_info Version of the running server."
53        );
54        let _ = writeln!(out, "# TYPE teamcraft_build_info gauge");
55        let _ = writeln!(out, "teamcraft_build_info{{version=\"{version}\"}} 1");
56        let _ = writeln!(
57            out,
58            "# HELP teamcraft_http_requests_total Answered HTTP requests by status class."
59        );
60        let _ = writeln!(out, "# TYPE teamcraft_http_requests_total counter");
61        for (i, class) in CLASSES.iter().enumerate() {
62            let n = self.by_class[i].load(Ordering::Relaxed);
63            let _ = writeln!(
64                out,
65                "teamcraft_http_requests_total{{class=\"{class}\"}} {n}"
66            );
67        }
68        let _ = writeln!(
69            out,
70            "# HELP teamcraft_http_request_duration_seconds Time until the response headers were sent (a streamed body is not included)."
71        );
72        let _ = writeln!(
73            out,
74            "# TYPE teamcraft_http_request_duration_seconds histogram"
75        );
76        for (i, bound) in BUCKETS.iter().enumerate() {
77            let n = self.buckets[i].load(Ordering::Relaxed);
78            let _ = writeln!(
79                out,
80                "teamcraft_http_request_duration_seconds_bucket{{le=\"{bound}\"}} {n}"
81            );
82        }
83        let count = self.count.load(Ordering::Relaxed);
84        let _ = writeln!(
85            out,
86            "teamcraft_http_request_duration_seconds_bucket{{le=\"+Inf\"}} {count}"
87        );
88        let _ = writeln!(
89            out,
90            "teamcraft_http_request_duration_seconds_sum {}",
91            self.micros.load(Ordering::Relaxed) as f64 / 1e6
92        );
93        let _ = writeln!(out, "teamcraft_http_request_duration_seconds_count {count}");
94        for (name, help, value) in gauges {
95            let _ = writeln!(out, "# HELP {name} {help}");
96            let _ = writeln!(out, "# TYPE {name} gauge");
97            let _ = writeln!(out, "{name} {value}");
98        }
99        out
100    }
101}
102
103/// Compares the presented bearer token with the configured one without
104/// stopping at the first difference. The scheme name is case-insensitive
105/// (RFC 7235), so `bearer …` works as well.
106pub fn token_matches(header: Option<&str>, expected: &str) -> bool {
107    let Some(given) = header.and_then(|h| {
108        let (scheme, rest) = h.split_at_checked(7)?;
109        scheme.eq_ignore_ascii_case("Bearer ").then_some(rest)
110    }) else {
111        return false;
112    };
113    let (a, b) = (given.as_bytes(), expected.as_bytes());
114    let mut diff = a.len() ^ b.len();
115    for i in 0..a.len().max(b.len()) {
116        diff |= usize::from(a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0));
117    }
118    diff == 0
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn counts_by_class_and_fills_cumulative_buckets() {
127        let m = Metrics::default();
128        m.record(200, Duration::from_millis(3));
129        m.record(404, Duration::from_millis(200));
130        m.record(503, Duration::from_secs(90));
131        let text = m.render("0.2.0", &[("teamcraft_db_pool_size", "Connections.", 5.0)]);
132        assert!(text.contains("teamcraft_build_info{version=\"0.2.0\"} 1"));
133        assert!(text.contains("teamcraft_http_requests_total{class=\"2xx\"} 1"));
134        assert!(text.contains("teamcraft_http_requests_total{class=\"4xx\"} 1"));
135        assert!(text.contains("teamcraft_http_requests_total{class=\"5xx\"} 1"));
136        assert!(text.contains("teamcraft_http_requests_total{class=\"3xx\"} 0"));
137        assert!(text.contains("le=\"0.005\"} 1"));
138        assert!(text.contains("le=\"0.25\"} 2"));
139        assert!(text.contains("le=\"60\"} 2"));
140        assert!(text.contains("le=\"+Inf\"} 3"));
141        assert!(text.contains("teamcraft_http_request_duration_seconds_count 3"));
142        assert!(text.contains("teamcraft_db_pool_size 5"));
143        // every sample line is `name{labels} value`
144        for line in text.lines().filter(|l| !l.starts_with('#')) {
145            assert_eq!(line.split(' ').count(), 2, "{line}");
146        }
147    }
148
149    #[test]
150    fn odd_status_codes_stay_in_range() {
151        let m = Metrics::default();
152        m.record(0, Duration::ZERO);
153        m.record(999, Duration::ZERO);
154        let text = m.render("x", &[]);
155        assert!(text.contains("class=\"1xx\"} 1") && text.contains("class=\"5xx\"} 1"));
156    }
157
158    #[test]
159    fn token_comparison() {
160        assert!(token_matches(Some("Bearer s3cret"), "s3cret"));
161        assert!(token_matches(Some("bearer s3cret"), "s3cret"));
162        assert!(token_matches(Some("BEARER s3cret"), "s3cret"));
163        assert!(!token_matches(Some("Basic  s3cret"), "s3cret"));
164        assert!(!token_matches(Some("Bear"), "s3cret"));
165        // a multi-byte character across the scheme boundary is no panic
166        assert!(!token_matches(Some("Bearerä s3cret"), "s3cret"));
167        assert!(!token_matches(Some("Bearer s3cret2"), "s3cret"));
168        assert!(!token_matches(Some("Bearer s3cre"), "s3cret"));
169        assert!(!token_matches(Some("s3cret"), "s3cret"));
170        assert!(!token_matches(None, "s3cret"));
171        assert!(!token_matches(Some("Bearer "), "s3cret"));
172    }
173}