Files
foxhunt/testing/api-load/src/reporting.rs
jgrusewski d25c82f8f3 refactor: rename api_gateway → api across workspace, tests, and load crate
- Workspace Cargo.toml: remove web-gateway + api_gateway members, keep api
- trading_service: dep api-gateway → api, update test imports
- testing/api-gateway-load → testing/api-load (crate renamed)
- All test crates: get_api_gateway_addr → get_api_addr + variable renames

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:38:21 +01:00

444 lines
15 KiB
Rust

use anyhow::Result;
use plotters::prelude::*;
use std::path::Path;
use crate::metrics::LoadTestReport;
pub fn generate_html_report<P: AsRef<Path>>(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#"<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Load Test Report: {test_name}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}}
.container {{
background: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
h1 {{
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
}}
h2 {{
color: #34495e;
margin-top: 30px;
}}
.summary {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin: 20px 0;
}}
.metric-card {{
background: #ecf0f1;
padding: 20px;
border-radius: 6px;
border-left: 4px solid #3498db;
}}
.metric-card h3 {{
margin: 0 0 10px 0;
color: #2c3e50;
font-size: 14px;
text-transform: uppercase;
}}
.metric-card .value {{
font-size: 32px;
font-weight: bold;
color: #2c3e50;
}}
.metric-card .unit {{
font-size: 16px;
color: #7f8c8d;
}}
.success {{ border-left-color: #27ae60; }}
.warning {{ border-left-color: #f39c12; }}
.error {{ border-left-color: #e74c3c; }}
.chart {{
margin: 30px 0;
text-align: center;
}}
.chart img {{
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 4px;
}}
.recommendation {{
background: #e8f5e9;
border-left: 4px solid #4caf50;
padding: 20px;
margin: 20px 0;
border-radius: 4px;
}}
.recommendation h3 {{
margin-top: 0;
color: #2e7d32;
}}
table {{
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}}
th, td {{
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}}
th {{
background-color: #3498db;
color: white;
}}
tr:hover {{
background-color: #f5f5f5;
}}
.timestamp {{
color: #7f8c8d;
font-size: 14px;
}}
</style>
</head>
<body>
<div class="container">
<h1>{test_name}</h1>
<p class="timestamp">
Test Period: {start_time} to {end_time}<br>
Duration: {duration_secs} seconds ({duration_hours:.2} hours)
</p>
<h2>Summary</h2>
<div class="summary">
<div class="metric-card success">
<h3>Total Requests</h3>
<div class="value">{total_requests}</div>
</div>
<div class="metric-card {rps_class}">
<h3>Requests/Second</h3>
<div class="value">{rps:.0}</div>
</div>
<div class="metric-card {error_class}">
<h3>Error Rate</h3>
<div class="value">{error_rate:.2}<span class="unit">%</span></div>
</div>
<div class="metric-card {latency_class}">
<h3>P99 Latency</h3>
<div class="value">{p99_latency:.2}<span class="unit">ms</span></div>
</div>
</div>
<h2>Latency Statistics</h2>
<table>
<tr>
<th>Percentile</th>
<th>Latency (ms)</th>
</tr>
<tr><td>Minimum</td><td>{min_latency:.3}</td></tr>
<tr><td>P50 (Median)</td><td>{p50_latency:.3}</td></tr>
<tr><td>P90</td><td>{p90_latency:.3}</td></tr>
<tr><td>P95</td><td>{p95_latency:.3}</td></tr>
<tr><td>P99</td><td>{p99_latency:.3}</td></tr>
<tr><td>P99.9</td><td>{p99_9_latency:.3}</td></tr>
<tr><td>Maximum</td><td>{max_latency:.3}</td></tr>
<tr><td>Mean</td><td>{mean_latency:.3}</td></tr>
<tr><td>Std Dev</td><td>{stddev_latency:.3}</td></tr>
</table>
<h2>Request Breakdown</h2>
<table>
<tr>
<th>Status</th>
<th>Count</th>
<th>Percentage</th>
</tr>
<tr>
<td>Successful</td>
<td>{successful_requests}</td>
<td>{success_pct:.2}%</td>
</tr>
<tr>
<td>Failed</td>
<td>{failed_requests}</td>
<td>{failed_pct:.2}%</td>
</tr>
<tr>
<td>Timeout</td>
<td>{timeout_requests}</td>
<td>{timeout_pct:.2}%</td>
</tr>
<tr>
<td>Rate Limited</td>
<td>{rate_limited_requests}</td>
<td>{rate_limited_pct:.2}%</td>
</tr>
<tr>
<td>Circuit Breaker</td>
<td>{circuit_breaker_requests}</td>
<td>{circuit_breaker_pct:.2}%</td>
</tr>
</table>
{per_service_stats}
{capacity_recommendation}
<h2>Performance Over Time</h2>
<div class="chart">
<h3>Requests Per Second</h3>
<img src="{rps_chart_filename}" alt="RPS Chart">
</div>
<div class="chart">
<h3>P99 Latency</h3>
<img src="{latency_chart_filename}" alt="Latency Chart">
</div>
<div class="chart">
<h3>Error Rate</h3>
<img src="{error_chart_filename}" alt="Error Rate Chart">
</div>
</div>
</body>
</html>"#,
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("<h2>Per-Service Statistics</h2><table>");
html.push_str("<tr><th>Service</th><th>Requests</th><th>Error Rate</th><th>P50</th><th>P95</th><th>P99</th></tr>");
for (service, stats) in &report.metrics.per_service_stats {
html.push_str(&format!(
"<tr><td>{}</td><td>{}</td><td>{:.2}%</td><td>{:.2}ms</td><td>{:.2}ms</td><td>{:.2}ms</td></tr>",
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("</table>");
html
}
fn generate_capacity_recommendation_html(report: &LoadTestReport) -> String {
if let Some(rec) = &report.capacity_recommendation {
format!(
r#"<div class="recommendation">
<h3>Capacity Recommendation</h3>
<p><strong>Max Sustainable Clients:</strong> {}</p>
<p><strong>Max Sustainable RPS:</strong> {:.0}</p>
{}
<p>{}</p>
</div>"#,
rec.max_sustainable_clients,
rec.max_sustainable_rps,
rec.bottleneck_identified
.as_ref()
.map(|b| format!("<p><strong>Bottleneck:</strong> {}</p>", b))
.unwrap_or_default(),
rec.recommendation
)
} else {
String::new()
}
}
fn generate_rps_chart<P: AsRef<Path>>(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<P: AsRef<Path>>(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<P: AsRef<Path>>(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(())
}