1use std::{
9 fmt::Write,
10 sync::atomic::{AtomicU64, Ordering},
11 time::Duration,
12};
13
14pub 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 buckets: [AtomicU64; 9],
24 count: AtomicU64,
25 micros: AtomicU64,
26}
27
28impl Metrics {
29 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 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
103pub 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 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 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}