- 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>
444 lines
15 KiB
Rust
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(())
|
|
}
|