Skip to main content

max / makenotwork

6.3 KB · 210 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 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 struct MetricsCollector {
18 metrics: Arc<Mutex<Vec<RequestMetric>>>,
19 start: Instant,
20 }
21
22 impl MetricsCollector {
23 pub 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 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 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 for m in metrics.iter() {
60 by_label.entry(m.label.clone()).or_default().push(m.latency);
61 if m.status.is_server_error() || m.status == StatusCode::TOO_MANY_REQUESTS {
62 *errors_by_label.entry(m.label.clone()).or_default() += 1;
63 }
64 }
65
66 let mut endpoint_stats: Vec<EndpointStats> = by_label
67 .into_iter()
68 .map(|(label, mut latencies)| {
69 latencies.sort();
70 let count = latencies.len();
71 let errors = errors_by_label.get(&label).copied().unwrap_or(0);
72 let min = latencies[0];
73 let max = latencies[count - 1];
74 let mean = latencies.iter().sum::<Duration>() / count as u32;
75 let p50 = percentile(&latencies, 0.50);
76 let p95 = percentile(&latencies, 0.95);
77 let p99 = percentile(&latencies, 0.99);
78
79 EndpointStats {
80 label,
81 count,
82 errors,
83 min,
84 max,
85 mean,
86 p50,
87 p95,
88 p99,
89 }
90 })
91 .collect();
92
93 endpoint_stats.sort_by(|a, b| a.label.cmp(&b.label));
94
95 let requests_per_sec = if elapsed.as_secs_f64() > 0.0 {
96 total_requests as f64 / elapsed.as_secs_f64()
97 } else {
98 0.0
99 };
100
101 let error_rate = if total_requests > 0 {
102 total_errors as f64 / total_requests as f64 * 100.0
103 } else {
104 0.0
105 };
106
107 LoadReport {
108 elapsed,
109 total_requests,
110 total_errors,
111 requests_per_sec,
112 error_rate,
113 status_counts,
114 endpoint_stats,
115 }
116 }
117 }
118
119 /// Per-endpoint statistics.
120 pub struct EndpointStats {
121 pub label: String,
122 pub count: usize,
123 pub errors: usize,
124 pub min: Duration,
125 pub max: Duration,
126 pub mean: Duration,
127 pub p50: Duration,
128 pub p95: Duration,
129 pub p99: Duration,
130 }
131
132 /// Summary report for the entire load test.
133 pub struct LoadReport {
134 pub elapsed: Duration,
135 pub total_requests: usize,
136 pub total_errors: usize,
137 pub requests_per_sec: f64,
138 pub error_rate: f64,
139 pub status_counts: HashMap<u16, usize>,
140 pub endpoint_stats: Vec<EndpointStats>,
141 }
142
143 impl LoadReport {
144 /// Print a structured text report to stdout.
145 pub fn print(&self) {
146 println!("\n{}", "=".repeat(60));
147 println!(" LOAD TEST REPORT");
148 println!("{}", "=".repeat(60));
149 println!();
150 println!(" Elapsed: {:.2?}", self.elapsed);
151 println!(" Total requests: {}", self.total_requests);
152 println!(" Total errors: {}", self.total_errors);
153 println!(" Requests/sec: {:.1}", self.requests_per_sec);
154 println!(" Error rate: {:.2}%", self.error_rate);
155 println!();
156
157 // Status code distribution
158 println!(" Status Codes:");
159 let mut codes: Vec<_> = self.status_counts.iter().collect();
160 codes.sort_by_key(|(code, _)| *code);
161 for (code, count) in &codes {
162 println!(" {}: {}", code, count);
163 }
164 println!();
165
166 // Per-endpoint table
167 println!(
168 " {:<30} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}",
169 "Endpoint", "Count", "Errors", "Min", "Max", "Mean", "p50", "p95", "p99"
170 );
171 println!(" {:-<30} {:-<6} {:-<6} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8}",
172 "", "", "", "", "", "", "", "", "");
173
174 for ep in &self.endpoint_stats {
175 println!(
176 " {:<30} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}",
177 ep.label,
178 ep.count,
179 ep.errors,
180 format_dur(ep.min),
181 format_dur(ep.max),
182 format_dur(ep.mean),
183 format_dur(ep.p50),
184 format_dur(ep.p95),
185 format_dur(ep.p99),
186 );
187 }
188 println!();
189 }
190 }
191
192 /// Calculate a percentile from a sorted slice of durations.
193 fn percentile(sorted: &[Duration], pct: f64) -> Duration {
194 if sorted.is_empty() {
195 return Duration::ZERO;
196 }
197 let idx = ((sorted.len() as f64 * pct) as usize).min(sorted.len() - 1);
198 sorted[idx]
199 }
200
201 /// Format a duration for display (ms or us).
202 fn format_dur(d: Duration) -> String {
203 let us = d.as_micros();
204 if us >= 1000 {
205 format!("{:.1}ms", us as f64 / 1000.0)
206 } else {
207 format!("{}us", us)
208 }
209 }
210