//! Metrics collection and reporting for load tests. use axum::http::StatusCode; use std::collections::HashMap; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; /// A single request measurement. pub struct RequestMetric { pub label: String, pub latency: Duration, pub status: StatusCode, } /// Thread-safe metrics collector shared across all VUs. #[derive(Clone)] pub struct MetricsCollector { metrics: Arc>>, start: Instant, } impl MetricsCollector { pub fn new() -> Self { MetricsCollector { metrics: Arc::new(Mutex::new(Vec::new())), start: Instant::now(), } } /// Record a completed request. pub fn record(&self, label: String, latency: Duration, status: StatusCode) { self.metrics.lock().unwrap().push(RequestMetric { label, latency, status, }); } /// Consume all metrics and produce a report. pub fn report(&self) -> LoadReport { let metrics = self.metrics.lock().unwrap(); let elapsed = self.start.elapsed(); let total_requests = metrics.len(); let total_errors = metrics .iter() .filter(|m| m.status.is_server_error() || m.status == StatusCode::TOO_MANY_REQUESTS) .count(); // Status code distribution let mut status_counts: HashMap = HashMap::new(); for m in metrics.iter() { *status_counts.entry(m.status.as_u16()).or_default() += 1; } // Per-endpoint stats let mut by_label: HashMap> = HashMap::new(); let mut errors_by_label: HashMap = HashMap::new(); for m in metrics.iter() { by_label.entry(m.label.clone()).or_default().push(m.latency); if m.status.is_server_error() || m.status == StatusCode::TOO_MANY_REQUESTS { *errors_by_label.entry(m.label.clone()).or_default() += 1; } } let mut endpoint_stats: Vec = by_label .into_iter() .map(|(label, mut latencies)| { latencies.sort(); let count = latencies.len(); let errors = errors_by_label.get(&label).copied().unwrap_or(0); let min = latencies[0]; let max = latencies[count - 1]; let mean = latencies.iter().sum::() / count as u32; let p50 = percentile(&latencies, 0.50); let p95 = percentile(&latencies, 0.95); let p99 = percentile(&latencies, 0.99); EndpointStats { label, count, errors, min, max, mean, p50, p95, p99, } }) .collect(); endpoint_stats.sort_by(|a, b| a.label.cmp(&b.label)); let requests_per_sec = if elapsed.as_secs_f64() > 0.0 { total_requests as f64 / elapsed.as_secs_f64() } else { 0.0 }; let error_rate = if total_requests > 0 { total_errors as f64 / total_requests as f64 * 100.0 } else { 0.0 }; LoadReport { elapsed, total_requests, total_errors, requests_per_sec, error_rate, status_counts, endpoint_stats, } } } /// Per-endpoint statistics. pub struct EndpointStats { pub label: String, pub count: usize, pub errors: usize, pub min: Duration, pub max: Duration, pub mean: Duration, pub p50: Duration, pub p95: Duration, pub p99: Duration, } /// Summary report for the entire load test. pub struct LoadReport { pub elapsed: Duration, pub total_requests: usize, pub total_errors: usize, pub requests_per_sec: f64, pub error_rate: f64, pub status_counts: HashMap, pub endpoint_stats: Vec, } impl LoadReport { /// Print a structured text report to stdout. pub fn print(&self) { println!("\n{}", "=".repeat(60)); println!(" LOAD TEST REPORT"); println!("{}", "=".repeat(60)); println!(); println!(" Elapsed: {:.2?}", self.elapsed); println!(" Total requests: {}", self.total_requests); println!(" Total errors: {}", self.total_errors); println!(" Requests/sec: {:.1}", self.requests_per_sec); println!(" Error rate: {:.2}%", self.error_rate); println!(); // Status code distribution println!(" Status Codes:"); let mut codes: Vec<_> = self.status_counts.iter().collect(); codes.sort_by_key(|(code, _)| *code); for (code, count) in &codes { println!(" {}: {}", code, count); } println!(); // Per-endpoint table println!( " {:<30} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}", "Endpoint", "Count", "Errors", "Min", "Max", "Mean", "p50", "p95", "p99" ); println!(" {:-<30} {:-<6} {:-<6} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8} {:-<8}", "", "", "", "", "", "", "", "", ""); for ep in &self.endpoint_stats { println!( " {:<30} {:>6} {:>6} {:>8} {:>8} {:>8} {:>8} {:>8} {:>8}", ep.label, ep.count, ep.errors, format_dur(ep.min), format_dur(ep.max), format_dur(ep.mean), format_dur(ep.p50), format_dur(ep.p95), format_dur(ep.p99), ); } println!(); } } /// Calculate a percentile from a sorted slice of durations. fn percentile(sorted: &[Duration], pct: f64) -> Duration { if sorted.is_empty() { return Duration::ZERO; } let idx = ((sorted.len() as f64 * pct) as usize).min(sorted.len() - 1); sorted[idx] } /// Format a duration for display (ms or us). fn format_dur(d: Duration) -> String { let us = d.as_micros(); if us >= 1000 { format!("{:.1}ms", us as f64 / 1000.0) } else { format!("{}us", us) } }