New posts

This commit is contained in:
2026-11-06 21:43:41 +05:30
parent 11782a7ec3
commit c92977b23d
38 changed files with 2006 additions and 0 deletions
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Turns the raw load-test artefacts into the tables and chart quoted in the post.
analyze.py gatling <report-dir> -> output/10-gatling-steps-table.txt, output/knee-gatling.json
analyze.py k6 <k6-json> -> output/21-k6-steps-table.txt, output/knee-k6.json
analyze.py chart -> output/30-knee-chart.svg
Gatling's open-source report has no stats.json, so the per-step rows are read from the
statistics table in index.html (columns: total, ok, ko, ko%, req/s, min, p50, p75, p95, p99, max, mean, stddev).
Standard library only.
"""
import html, json, math, re, sys
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "output"
KEYS = ["step", "requests", "ok", "ko", "ko_pct", "p50", "p95", "p99", "max"]
def gatling(report_dir):
page = (Path(report_dir) / "index.html").read_text(encoding="utf-8")
rows = []
for tr in re.findall(r'<tr id="req_[^"]+" data-parent="ROOT">(.*?)</tr>', page, re.S):
name = html.unescape(re.search(r'class="ellipsed-name">([^<]+)<', tr).group(1)).replace("quote ", "")
c = {int(n): v for n, v in re.findall(r'class="value [a-z ]*col-(\d+)">([^<]*)<', tr)}
rows.append(dict(step=name, requests=int(c[2]), ok=int(c[3]), ko=int(c[4]), ko_pct=float(c[5]),
p50=int(c[8]), p95=int(c[10]), p99=int(c[11]), max=int(c[12])))
return rows
def pct(values, p):
"""k6's own method: linear interpolation between closest ranks."""
if not values:
return 0
s = sorted(values)
idx = p / 100 * (len(s) - 1)
lo, hi = math.floor(idx), math.ceil(idx)
return s[lo] + (s[hi] - s[lo]) * (idx - lo)
def k6(json_file):
dur, failed = {}, {}
with open(json_file, encoding="utf-8") as f:
for line in f:
if '"http_req_duration"' not in line and '"http_req_failed"' not in line:
continue
o = json.loads(line)
if o.get("type") != "Point":
continue
tags = o["data"]["tags"]
if "step" not in tags:
continue
if o["metric"] == "http_req_duration":
dur.setdefault(tags["step"], []).append(o["data"]["value"])
elif o["metric"] == "http_req_failed":
failed.setdefault(tags["step"], []).append(o["data"]["value"])
rows = []
for step in sorted(dur, key=lambda s: (s == "ramp", s)):
n = len(dur[step]); ko = int(sum(failed.get(step, [])))
rows.append(dict(step=step, requests=n, ok=n - ko, ko=ko, ko_pct=round(100 * ko / n, 1),
p50=round(pct(dur[step], 50)), p95=round(pct(dur[step], 95)),
p99=round(pct(dur[step], 99)), max=round(max(dur[step]))))
return rows
def table(rows):
# Gatling lists the ramp row where it was first seen; show steps in rate order, ramp last.
rows = sorted(rows, key=lambda r: (r["step"] == "ramp", r["step"]))
lines = [f'{"step":<13}{"requests":>9}{"ok":>7}{"ko":>6}{"ko %":>7}{"p50":>6}{"p95":>6}{"p99":>6}{"max":>6} (times in ms)']
for r in rows:
lines.append(f'{r["step"]:<13}{r["requests"]:>9}{r["ok"]:>7}{r["ko"]:>6}{r["ko_pct"]:>7.1f}'
f'{r["p50"]:>6}{r["p95"]:>6}{r["p99"]:>6}{r["max"]:>6}')
return "\n".join(lines) + "\n"
def chart():
g = {r["step"]: r for r in json.loads((OUT / "knee-gatling.json").read_text())}
k = {}
if (OUT / "knee-k6.json").exists():
k = {r["step"]: r for r in json.loads((OUT / "knee-k6.json").read_text())}
steps = [f"rate {r:03d}/s" for r in range(30, 241, 30)]
X0, X1, Y0, Y1 = 70, 700, 210, 40 # plot box (y grows downwards)
xs = {s: X0 + (X1 - X0) * i / (len(steps) - 1) for i, s in enumerate(steps)}
ymax = 900
y = lambda ms: Y0 - (Y0 - Y1) * min(ms, ymax) / ymax
def line(series, key, cls):
pts = " ".join(f"{xs[s]:.0f},{y(series[s][key]):.0f}" for s in steps if s in series)
dots = "".join(f'<circle class="{cls}d" cx="{xs[s]:.0f}" cy="{y(series[s][key]):.0f}" r="3"/>' for s in steps if s in series)
return f'<polyline class="{cls}" points="{pts}"/>{dots}'
o = ['<svg viewBox="0 0 740 490" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Two panels over eight injected request rates from 30 to 240 per second. Top: Gatling p50 and p95 response time, flat near 50 to 60 milliseconds at low rates and climbing steeply from 150 per second, with k6 p95 shown for comparison. Bottom: percentage of requests rejected with HTTP 503, zero up to 150 per second and rising from 180 per second.">',
'<style>.t{font:600 14px sans-serif;fill:#222}.c{font:12px sans-serif;fill:#444}.m{font:11px monospace;fill:#555}.ax{stroke:#b7bec9;stroke-width:1}.gr{stroke:#e2e5ea;stroke-width:1}'
'.p95{fill:none;stroke:#5b7fc7;stroke-width:2.5}.p95d{fill:#5b7fc7}.p50{fill:none;stroke:#4a9d63;stroke-width:2.5}.p50d{fill:#4a9d63}'
'.k95{fill:none;stroke:#c9973f;stroke-width:2;stroke-dasharray:5 3}.k95d{fill:#c9973f}.kneeline{stroke:#c56a54;stroke-width:1.5;stroke-dasharray:3 3}'
'.gb{fill:#5b7fc7}.kb{fill:#c9973f}</style>',
'<text class="t" x="70" y="22">Response time (ms) vs injected rate (users/s), Gatling 3.16.0 and k6 v2.3.0</text>']
for ms in range(0, ymax + 1, 300):
o.append(f'<line class="gr" x1="{X0}" y1="{y(ms):.0f}" x2="{X1}" y2="{y(ms):.0f}"/><text class="m" x="{X0-8}" y="{y(ms)+4:.0f}" text-anchor="end">{ms}</text>')
o.append(f'<line class="ax" x1="{X0}" y1="{Y0}" x2="{X1}" y2="{Y0}"/><line class="ax" x1="{X0}" y1="{Y1}" x2="{X0}" y2="{Y0}"/>')
o.append(line(g, "p50", "p50")); o.append(line(g, "p95", "p95"))
if k: o.append(line(k, "p95", "k95"))
for s in steps:
o.append(f'<text class="m" x="{xs[s]:.0f}" y="{Y0+16}" text-anchor="middle">{int(s[5:8])}</text>')
# the capacity worked out from the app's settings: 8 workers / 0.050 s = 160 per second
kx = xs["rate 150/s"] + (xs["rate 180/s"] - xs["rate 150/s"]) / 3
o.append(f'<line class="kneeline" x1="{kx:.0f}" y1="{Y1}" x2="{kx:.0f}" y2="{Y0}"/><text class="m" x="{kx-6:.0f}" y="{Y1+110}" text-anchor="end">calculated capacity: 8 workers / 0.050 s = 160 per second</text>')
# legend
o.append('<line class="p50" x1="90" y1="58" x2="120" y2="58"/><text class="c" x="126" y="62">Gatling p50</text>'
'<line class="p95" x1="90" y1="76" x2="120" y2="76"/><text class="c" x="126" y="80">Gatling p95</text>')
if k: o.append('<line class="k95" x1="90" y1="94" x2="120" y2="94"/><text class="c" x="126" y="98">k6 p95</text>')
# bottom panel: KO %
B0, B1 = 440, 300
kmax = max([r["ko_pct"] for r in g.values()] + [r["ko_pct"] for r in k.values()] + [1])
top = math.ceil(kmax / 10) * 10
by = lambda v: B0 - (B0 - B1) * v / top
o.append(f'<text class="t" x="70" y="276">Requests rejected with HTTP 503 (%)</text>')
for v in range(0, top + 1, 10):
o.append(f'<line class="gr" x1="{X0}" y1="{by(v):.0f}" x2="{X1}" y2="{by(v):.0f}"/><text class="m" x="{X0-8}" y="{by(v)+4:.0f}" text-anchor="end">{v}</text>')
for s in steps:
for off, series, cls in ((-9, g, "gb"), (3, k, "kb")):
if s in series:
h = B0 - by(series[s]["ko_pct"])
o.append(f'<rect class="{cls}" x="{xs[s]+off:.0f}" y="{by(series[s]["ko_pct"]):.1f}" width="8" height="{h:.1f}"/>')
o.append(f'<text class="m" x="{xs[s]:.0f}" y="{B0+16}" text-anchor="middle">{int(s[5:8])}</text>')
o.append(f'<line class="ax" x1="{X0}" y1="{B0}" x2="{X1}" y2="{B0}"/>')
o.append(f'<text class="c" x="{(X0+X1)//2}" y="{B0+34}" text-anchor="middle">injected rate, users per second (blue = Gatling, amber = k6)</text>')
o.append('</svg>')
(OUT / "30-knee-chart.svg").write_text("\n".join(o) + "\n", encoding="utf-8")
if __name__ == "__main__":
OUT.mkdir(exist_ok=True)
cmd = sys.argv[1]
if cmd == "gatling":
rows = gatling(sys.argv[2])
(OUT / "knee-gatling.json").write_text(json.dumps(rows, indent=1) + "\n")
(OUT / "10-gatling-steps-table.txt").write_text(table(rows))
elif cmd == "k6":
rows = k6(sys.argv[2])
(OUT / "knee-k6.json").write_text(json.dumps(rows, indent=1) + "\n")
(OUT / "21-k6-steps-table.txt").write_text(table(rows))
elif cmd == "chart":
chart()
else:
sys.exit(__doc__)