use anyhow::Result; use plotters::prelude::*; use std::path::Path; use crate::metrics::LoadTestReport; pub fn generate_html_report>(output_path: P, report: LoadTestReport) -> Result<()> { let output_path = output_path.as_ref(); tracing::info!("Generating HTML report: {:?}", output_path); // Generate plots let rps_chart_path = output_path.with_extension("rps.svg"); let latency_chart_path = output_path.with_extension("latency.svg"); let error_chart_path = output_path.with_extension("errors.svg"); generate_rps_chart(&rps_chart_path, &report)?; generate_latency_chart(&latency_chart_path, &report)?; generate_error_rate_chart(&error_chart_path, &report)?; // Generate HTML let html = format!( r#" Load Test Report: {test_name}

{test_name}

Test Period: {start_time} to {end_time}
Duration: {duration_secs} seconds ({duration_hours:.2} hours)

Summary

Total Requests

{total_requests}

Requests/Second

{rps:.0}

Error Rate

{error_rate:.2}%

P99 Latency

{p99_latency:.2}ms

Latency Statistics

Percentile Latency (ms)
Minimum{min_latency:.3}
P50 (Median){p50_latency:.3}
P90{p90_latency:.3}
P95{p95_latency:.3}
P99{p99_latency:.3}
P99.9{p99_9_latency:.3}
Maximum{max_latency:.3}
Mean{mean_latency:.3}
Std Dev{stddev_latency:.3}

Request Breakdown

Status Count Percentage
Successful {successful_requests} {success_pct:.2}%
Failed {failed_requests} {failed_pct:.2}%
Timeout {timeout_requests} {timeout_pct:.2}%
Rate Limited {rate_limited_requests} {rate_limited_pct:.2}%
Circuit Breaker {circuit_breaker_requests} {circuit_breaker_pct:.2}%
{per_service_stats} {capacity_recommendation}

Performance Over Time

Requests Per Second

RPS Chart

P99 Latency

Latency Chart

Error Rate

Error Rate Chart
"#, test_name = report.test_name, start_time = report.start_time.format("%Y-%m-%d %H:%M:%S UTC"), end_time = report.end_time.format("%Y-%m-%d %H:%M:%S UTC"), duration_secs = report.metrics.duration.as_secs(), duration_hours = report.metrics.duration.as_secs_f64() / 3600.0, total_requests = report.metrics.total_requests, rps = report.metrics.requests_per_second, rps_class = if report.metrics.requests_per_second > 1000.0 { "success" } else { "warning" }, error_rate = report.metrics.error_rate_pct, error_class = if report.metrics.error_rate_pct < 1.0 { "success" } else if report.metrics.error_rate_pct < 5.0 { "warning" } else { "error" }, p99_latency = report.metrics.latency_stats.p99_ms, latency_class = if report.metrics.latency_stats.p99_ms < 10.0 { "success" } else if report.metrics.latency_stats.p99_ms < 50.0 { "warning" } else { "error" }, min_latency = report.metrics.latency_stats.min_ms, p50_latency = report.metrics.latency_stats.p50_ms, p90_latency = report.metrics.latency_stats.p90_ms, p95_latency = report.metrics.latency_stats.p95_ms, p99_9_latency = report.metrics.latency_stats.p99_9_ms, max_latency = report.metrics.latency_stats.max_ms, mean_latency = report.metrics.latency_stats.mean_ms, stddev_latency = report.metrics.latency_stats.stddev_ms, successful_requests = report.metrics.successful_requests, success_pct = (f64::from(u32::try_from(report.metrics.successful_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX))) * 100.0, failed_requests = report.metrics.failed_requests, failed_pct = (f64::from(u32::try_from(report.metrics.failed_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX))) * 100.0, timeout_requests = report.metrics.timeout_requests, timeout_pct = (f64::from(u32::try_from(report.metrics.timeout_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX))) * 100.0, rate_limited_requests = report.metrics.rate_limited_requests, rate_limited_pct = (f64::from(u32::try_from(report.metrics.rate_limited_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX))) * 100.0, circuit_breaker_requests = report.metrics.circuit_breaker_requests, circuit_breaker_pct = (f64::from(u32::try_from(report.metrics.circuit_breaker_requests).unwrap_or(u32::MAX)) / f64::from(u32::try_from(report.metrics.total_requests).unwrap_or(u32::MAX))) * 100.0, per_service_stats = generate_per_service_stats_html(&report), capacity_recommendation = generate_capacity_recommendation_html(&report), rps_chart_filename = rps_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"), latency_chart_filename = latency_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"), error_chart_filename = error_chart_path.file_name().unwrap().to_str().expect("INVARIANT: Path should be valid UTF-8"), ); std::fs::write(output_path, html)?; tracing::info!("HTML report generated: {:?}", output_path); Ok(()) } fn generate_per_service_stats_html(report: &LoadTestReport) -> String { if report.metrics.per_service_stats.is_empty() { return String::new(); } let mut html = String::from("

Per-Service Statistics

"); html.push_str(""); for (service, stats) in &report.metrics.per_service_stats { html.push_str(&format!( "", service, stats.total_requests, stats.error_rate_pct, stats.latency_stats.p50_ms, stats.latency_stats.p95_ms, stats.latency_stats.p99_ms )); } html.push_str("
ServiceRequestsError RateP50P95P99
{}{}{:.2}%{:.2}ms{:.2}ms{:.2}ms
"); html } fn generate_capacity_recommendation_html(report: &LoadTestReport) -> String { if let Some(rec) = &report.capacity_recommendation { format!( r#"

Capacity Recommendation

Max Sustainable Clients: {}

Max Sustainable RPS: {:.0}

{}

{}

"#, rec.max_sustainable_clients, rec.max_sustainable_rps, rec.bottleneck_identified .as_ref() .map(|b| format!("

Bottleneck: {}

", b)) .unwrap_or_default(), rec.recommendation ) } else { String::new() } } fn generate_rps_chart>(path: P, report: &LoadTestReport) -> Result<()> { let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area(); root.fill(&WHITE)?; let max_rps = report .time_series .iter() .map(|p| p.rps) .fold(0.0f64, f64::max); let mut chart = ChartBuilder::on(&root) .caption("Requests Per Second", ("sans-serif", 30)) .margin(10) .x_label_area_size(30) .y_label_area_size(50) .build_cartesian_2d(0..report.time_series.len(), 0f64..max_rps * 1.1)?; chart.configure_mesh().draw()?; chart.draw_series(LineSeries::new( report .time_series .iter() .enumerate() .map(|(i, p)| (i, p.rps)), &BLUE, ))?; root.present()?; Ok(()) } fn generate_latency_chart>(path: P, report: &LoadTestReport) -> Result<()> { let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area(); root.fill(&WHITE)?; let max_latency = report .time_series .iter() .map(|p| p.p99_latency_ms) .fold(0.0f64, f64::max); let mut chart = ChartBuilder::on(&root) .caption("P99 Latency (ms)", ("sans-serif", 30)) .margin(10) .x_label_area_size(30) .y_label_area_size(50) .build_cartesian_2d(0..report.time_series.len(), 0f64..max_latency * 1.1)?; chart.configure_mesh().draw()?; chart.draw_series(LineSeries::new( report .time_series .iter() .enumerate() .map(|(i, p)| (i, p.p99_latency_ms)), &RED, ))?; root.present()?; Ok(()) } fn generate_error_rate_chart>(path: P, report: &LoadTestReport) -> Result<()> { let root = SVGBackend::new(path.as_ref(), (800_u32, 400)).into_drawing_area(); root.fill(&WHITE)?; let max_error_rate = report .time_series .iter() .map(|p| p.error_rate_pct) .fold(0.0f64, f64::max); let mut chart = ChartBuilder::on(&root) .caption("Error Rate (%)", ("sans-serif", 30)) .margin(10) .x_label_area_size(30) .y_label_area_size(50) .build_cartesian_2d( 0..report.time_series.len(), 0f64..(max_error_rate * 1.1).max(1.0), )?; chart.configure_mesh().draw()?; chart.draw_series(LineSeries::new( report .time_series .iter() .enumerate() .map(|(i, p)| (i, p.error_rate_pct)), &RED, ))?; root.present()?; Ok(()) }