Skip to main content

max / makenotwork

7.6 KB · 232 lines History Blame Raw
1 //! Metrics collection and reporting for load tests.
2
3 use axum::http::StatusCode;
4 use std::collections::HashMap;
5 use std::sync::{Arc, Mutex};
6 use std::time::{Duration, Instant};
7
8 /// A single request measurement.
9 pub(super) struct RequestMetric {
10 pub label: String,
11 pub latency: Duration,
12 pub status: StatusCode,
13 }
14
15 /// Thread-safe metrics collector shared across all VUs.
16 #[derive(Clone)]
17 pub(super) struct MetricsCollector {
18 metrics: Arc<Mutex<Vec<RequestMetric>>>,
19 start: Instant,
20 }
21
22 impl MetricsCollector {
23 pub(super) fn new() -> Self {
24 MetricsCollector {
25 metrics: Arc::new(Mutex::new(Vec::new())),
26 start: Instant::now(),
27 }
28 }
29
30 /// Record a completed request.
31 pub(super) fn record(&self, label: String, latency: Duration, status: StatusCode) {
32 self.metrics.lock().unwrap().push(RequestMetric {
33 label,
34 latency,
35 status,
36 });
37 }
38
39 /// Consume all metrics and produce a report.
40 pub(super) fn report(&self) -> LoadReport {
41 let metrics = self.metrics.lock().unwrap();
42 let elapsed = self.start.elapsed();
43 let total_requests = metrics.len();
44
45 let total_errors = metrics
46 .iter()
47 .filter(|m| m.status.is_server_error() || m.status == StatusCode::TOO_MANY_REQUESTS)
48 .count();
49
50 // Status code distribution
51 let mut status_counts: HashMap<u16, usize> = HashMap::new();
52 for m in metrics.iter() {
53 *status_counts.entry(m.status.as_u16()).or_default() += 1;
54 }
55
56 // Per-endpoint stats
57 let mut by_label: HashMap<String, Vec<Duration>> = HashMap::new();
58 let mut errors_by_label: HashMap<String, usize> = HashMap::new();
59 let mut rejected_by_label: HashMap<String, usize> = HashMap::new();
60 for m in metrics.iter() {
61 by_label.entry(m.label.clone()).or_default().push(m.latency);
62 // Classification, not an assertion: this counts whatever the run
63 // observed, so there is no contracted code to pin. Bound to a local
64 // for the same reason `scenarios.rs` is, so the loose-status seal in
65 // `test_hygiene.rs` reads only sites that really do assert.
66 let status = m.status;
67 if status.is_server_error() || status == StatusCode::TOO_MANY_REQUESTS {
68 *errors_by_label.entry(m.label.clone()).or_default() += 1;
69 } else if status.is_client_error() {
70 *rejected_by_label.entry(m.label.clone()).or_default() += 1;
71 }
72 }
73
74 let mut endpoint_stats: Vec<EndpointStats> = by_label
75 .into_iter()
76 .map(|(label, mut latencies)| {
77 latencies.sort();
78 let count = latencies.len();
79 let errors = errors_by_label.get(&label).copied().unwrap_or(0);
80 let rejected = rejected_by_label.get(&label).copied().unwrap_or(0);
81 let min = latencies[0];
82 let max = latencies[count - 1];
83 let mean = latencies.iter().sum::<Duration>() / count as u32;
84 let p50 = percentile(&latencies, 0.50);
85 let p95 = percentile(&latencies, 0.95);
86 let p99 = percentile(&latencies, 0.99);
87
88 EndpointStats {
89 label,
90 count,
91 errors,
92 rejected,
93 min,
94 max,
95 mean,
96 p50,
97 p95,
98 p99,
99 }
100 })
101 .collect();
102
103 endpoint_stats.sort_by(|a, b| a.label.cmp(&b.label));
104
105 let requests_per_sec = if elapsed.as_secs_f64() > 0.0 {
106 total_requests as f64 / elapsed.as_secs_f64()
107 } else {
108 0.0
109 };
110
111 let error_rate = if total_requests > 0 {
112 total_errors as f64 / total_requests as f64 * 100.0
113 } else {
114 0.0
115 };
116
117 LoadReport {
118 elapsed,
119 total_requests,
120 total_errors,
121 requests_per_sec,
122 error_rate,
123 status_counts,
124 endpoint_stats,
125 }
126 }
127 }
128
129 /// Per-endpoint statistics.
130 pub(super) struct EndpointStats {
131 pub label: String,
132 pub count: usize,
133 pub errors: usize,
134 /// 4xx other than 429, which the error count already owns.
135 ///
136 /// Its own column because a rejected request is FAST, and a comparison
137 /// between two renderings of one screen reads a route that quietly answers
138 /// 403 or 404 as the winner. That is not hypothetical here: the described
139 /// settings forums section refuses outright when Multithreaded is
140 /// unconfigured, and the creator-only dashboard tabs refuse a plain signup.
141 /// Both would have looked like sub-millisecond routes.
142 pub rejected: usize,
143 pub min: Duration,
144 pub max: Duration,
145 pub mean: Duration,
146 pub p50: Duration,
147 pub p95: Duration,
148 pub p99: Duration,
149 }
150
151 /// Summary report for the entire load test.
152 pub(super) struct LoadReport {
153 pub elapsed: Duration,
154 pub total_requests: usize,
155 pub total_errors: usize,
156 pub requests_per_sec: f64,
157 pub error_rate: f64,
158 pub status_counts: HashMap<u16, usize>,
159 pub endpoint_stats: Vec<EndpointStats>,
160 }
161
162 impl LoadReport {
163 /// Print a structured text report to stdout.
164 pub(super) fn print(&self) {
165 println!("\n{}", "=".repeat(60));
166 println!(" LOAD TEST REPORT");
167 println!("{}", "=".repeat(60));
168 println!();
169 println!(" Elapsed: {:.2?}", self.elapsed);
170 println!(" Total requests: {}", self.total_requests);
171 println!(" Total errors: {}", self.total_errors);
172 println!(" Requests/sec: {:.1}", self.requests_per_sec);
173 println!(" Error rate: {:.2}%", self.error_rate);
174 println!();
175
176 // Status code distribution
177 println!(" Status Codes:");
178 let mut codes: Vec<_> = self.status_counts.iter().collect();
179 codes.sort_by_key(|(code, _)| *code);
180 for (code, count) in &codes {
181 println!(" {code}: {count}");
182 }
183 println!();
184
185 // Per-endpoint table
186 println!(
187 " {:<34} {:>6} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}",
188 "Endpoint", "Count", "Errors", "Rej", "Min", "Max", "Mean", "p50", "p95", "p99"
189 );
190 println!(
191 " {:-<34} {:-<6} {:-<6} {:-<6} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8}",
192 "", "", "", "", "", "", "", "", "", ""
193 );
194
195 for ep in &self.endpoint_stats {
196 println!(
197 " {:<34} {:>6} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}",
198 ep.label,
199 ep.count,
200 ep.errors,
201 ep.rejected,
202 format_dur(ep.min),
203 format_dur(ep.max),
204 format_dur(ep.mean),
205 format_dur(ep.p50),
206 format_dur(ep.p95),
207 format_dur(ep.p99),
208 );
209 }
210 println!();
211 }
212 }
213
214 /// Calculate a percentile from a sorted slice of durations.
215 fn percentile(sorted: &[Duration], pct: f64) -> Duration {
216 if sorted.is_empty() {
217 return Duration::ZERO;
218 }
219 let idx = ((sorted.len() as f64 * pct) as usize).min(sorted.len() - 1);
220 sorted[idx]
221 }
222
223 /// Format a duration for display (ms or us).
224 fn format_dur(d: Duration) -> String {
225 let us = d.as_micros();
226 if us >= 1000 {
227 format!("{:.1}ms", us as f64 / 1000.0)
228 } else {
229 format!("{us}us")
230 }
231 }
232