Files
utilities/personal/tools/pgbench_export_report.py
2026-04-23 08:26:38 -04:00

307 lines
8.6 KiB
Python

#!/usr/bin/env python3
"""Export pgbench summary.csv into an HTML report with TPS and latency charts."""
import argparse
import csv
import json
from pathlib import Path
from typing import Dict, List, Optional
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Create an HTML chart report from pgbench summary.csv")
parser.add_argument("summary_csv", help="Path to summary.csv generated by pgbench benchmark script")
parser.add_argument(
"-o",
"--output",
default=None,
help="Output HTML path (default: <summary_dir>/pgbench_report.html)",
)
return parser.parse_args()
def load_rows(path: Path) -> List[Dict[str, str]]:
with path.open("r", encoding="utf-8-sig", newline="") as f:
reader = csv.DictReader(f)
expected = {"mode", "clients", "tps", "latency_ms"}
if not expected.issubset(set(reader.fieldnames or [])):
missing = sorted(expected - set(reader.fieldnames or []))
raise ValueError(f"summary.csv missing required columns: {', '.join(missing)}")
return list(reader)
def parse_optional_float(raw: str) -> Optional[float]:
value = (raw or "").strip()
if not value:
return None
try:
return float(value)
except ValueError:
return None
def build_series(rows: List[Dict[str, str]]) -> Dict[str, List[Dict[str, float]]]:
series: Dict[str, List[Dict[str, float]]] = {}
for row in rows:
mode = row["mode"].strip()
try:
clients = int(row["clients"])
tps = float(row["tps"])
latency = float(row["latency_ms"])
except ValueError:
continue
point: Dict[str, float] = {
"clients": clients,
"tps": tps,
"latency_ms": latency,
}
p95 = parse_optional_float(row.get("p95_ms", ""))
p99 = parse_optional_float(row.get("p99_ms", ""))
if p95 is not None:
point["p95_ms"] = p95
if p99 is not None:
point["p99_ms"] = p99
series.setdefault(mode, []).append(point)
for mode in series:
series[mode].sort(key=lambda item: item["clients"])
return series
def html_report(title: str, data: Dict[str, List[Dict[str, float]]]) -> str:
payload = json.dumps(data)
return f"""<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>{title}</title>
<style>
:root {{
--bg: #f7f7f2;
--fg: #182020;
--panel: #ffffff;
--muted: #4f5b5b;
--grid: #dde4e4;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: radial-gradient(circle at 8% 15%, #e7f6f2, var(--bg));
color: var(--fg);
}}
.container {{
max-width: 1100px;
margin: 0 auto;
padding: 24px;
}}
.card {{
background: var(--panel);
border: 1px solid #e2ebeb;
border-radius: 14px;
padding: 16px;
margin-bottom: 18px;
box-shadow: 0 10px 26px rgba(20, 30, 30, 0.06);
}}
h1 {{ margin: 0 0 8px 0; font-size: 1.6rem; }}
p {{ margin: 0; color: var(--muted); }}
canvas {{ width: 100%; height: 360px; display: block; }}
.legend {{ display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }}
.legend-item {{ display: inline-flex; align-items: center; gap: 8px; font-size: 0.9rem; color: var(--muted); }}
.swatch {{ width: 14px; height: 14px; border-radius: 3px; display: inline-block; }}
</style>
</head>
<body>
<div class=\"container\">
<div class=\"card\">
<h1>{title}</h1>
<p>Generated from summary.csv. X-axis = clients. Lines are grouped by mode.</p>
</div>
<div class=\"card\">
<h2>TPS by Clients</h2>
<canvas id=\"tpsChart\" width=\"1040\" height=\"360\"></canvas>
<div id=\"legend\" class=\"legend\"></div>
</div>
<div class=\"card\">
<h2>Latency (ms) by Clients</h2>
<canvas id=\"latencyChart\" width=\"1040\" height=\"360\"></canvas>
</div>
<div class=\"card\">
<h2>p95 Latency (ms) by Clients</h2>
<canvas id=\"p95Chart\" width=\"1040\" height=\"360\"></canvas>
</div>
<div class=\"card\">
<h2>p99 Latency (ms) by Clients</h2>
<canvas id=\"p99Chart\" width=\"1040\" height=\"360\"></canvas>
</div>
</div>
<script>
const series = {payload};
const palette = ["#0b6e4f", "#f26419", "#33658a", "#2f4858", "#5f0f40", "#9a031e", "#386641", "#6a4c93"];
function flattenValues(metric) {{
const out = [];
Object.values(series).forEach(items => items.forEach(i => {{
const v = i[metric];
if (typeof v === 'number' && Number.isFinite(v)) out.push(v);
}}));
return out;
}}
function flattenClients() {{
const out = [];
Object.values(series).forEach(items => items.forEach(i => out.push(i.clients)));
return out;
}}
function drawChart(canvasId, metric, yLabel) {{
const c = document.getElementById(canvasId);
const ctx = c.getContext('2d');
const W = c.width;
const H = c.height;
const m = {{ top: 22, right: 20, bottom: 44, left: 60 }};
const x0 = m.left, y0 = H - m.bottom, x1 = W - m.right, y1 = m.top;
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#182020';
ctx.font = '13px Segoe UI, sans-serif';
ctx.fillText(yLabel, 8, 16);
const clientsAll = flattenClients();
const valuesAll = flattenValues(metric);
if (!clientsAll.length || !valuesAll.length) {{
ctx.fillText('No data in summary.csv for this metric', x0 + 10, y0 - 10);
return;
}}
const minX = Math.min(...clientsAll);
const maxX = Math.max(...clientsAll);
const minY = 0;
const maxY = Math.max(...valuesAll) * 1.1;
const x = v => x0 + ((v - minX) / Math.max(1, maxX - minX)) * (x1 - x0);
const y = v => y0 - ((v - minY) / Math.max(1e-9, maxY - minY)) * (y0 - y1);
ctx.strokeStyle = '#dde4e4';
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {{
const yy = y0 - ((y0 - y1) * i / 5);
ctx.beginPath();
ctx.moveTo(x0, yy);
ctx.lineTo(x1, yy);
ctx.stroke();
const val = (maxY * i / 5).toFixed(1);
ctx.fillStyle = '#4f5b5b';
ctx.fillText(val, 16, yy + 4);
}}
const xTicks = [...new Set(clientsAll)].sort((a, b) => a - b);
xTicks.forEach(v => {{
const xx = x(v);
ctx.beginPath();
ctx.moveTo(xx, y0);
ctx.lineTo(xx, y0 + 5);
ctx.strokeStyle = '#9da9a9';
ctx.stroke();
ctx.fillStyle = '#4f5b5b';
ctx.fillText(String(v), xx - 6, y0 + 18);
}});
ctx.strokeStyle = '#182020';
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y0);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x0, y1);
ctx.stroke();
const legend = document.getElementById('legend');
if (metric === 'tps') legend.innerHTML = '';
Object.entries(series).forEach(([mode, items], idx) => {{
const color = palette[idx % palette.length];
const sorted = [...items]
.filter(p => typeof p[metric] === 'number' && Number.isFinite(p[metric]))
.sort((a, b) => a.clients - b.clients);
if (!sorted.length) return;
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
sorted.forEach((p, i) => {{
const px = x(p.clients);
const py = y(p[metric]);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}});
ctx.stroke();
sorted.forEach(p => {{
const px = x(p.clients);
const py = y(p[metric]);
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fill();
}});
if (metric === 'tps') {{
const item = document.createElement('div');
item.className = 'legend-item';
item.innerHTML = `<span class=\"swatch\" style=\"background:${{color}}\"></span>${{mode}}`;
legend.appendChild(item);
}}
}});
}}
drawChart('tpsChart', 'tps', 'TPS');
drawChart('latencyChart', 'latency_ms', 'Latency (ms)');
drawChart('p95Chart', 'p95_ms', 'p95 (ms)');
drawChart('p99Chart', 'p99_ms', 'p99 (ms)');
</script>
</body>
</html>
"""
def main() -> int:
args = parse_args()
summary_path = Path(args.summary_csv).resolve()
if not summary_path.is_file():
raise FileNotFoundError(f"summary.csv not found: {summary_path}")
rows = load_rows(summary_path)
if not rows:
raise ValueError("summary.csv has no data rows")
series = build_series(rows)
if not series:
raise ValueError("No valid numeric rows found in summary.csv")
output_path = (
Path(args.output).resolve()
if args.output
else summary_path.parent / "pgbench_report.html"
)
title = f"pgbench report: {summary_path.parent.name}"
output_path.write_text(html_report(title=title, data=series), encoding="utf-8")
print(f"Report written: {output_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())