#!/usr/bin/env python3 """Turns the raw load-test artefacts into the tables and chart quoted in the post. analyze.py gatling -> output/10-gatling-steps-table.txt, output/knee-gatling.json analyze.py k6 -> 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'(.*?)', 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'' for s in steps if s in series) return f'{dots}' o = ['', '', 'Response time (ms) vs injected rate (users/s), Gatling 3.16.0 and k6 v2.3.0'] for ms in range(0, ymax + 1, 300): o.append(f'{ms}') o.append(f'') 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'{int(s[5:8])}') # 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'calculated capacity: 8 workers / 0.050 s = 160 per second') # legend o.append('Gatling p50' 'Gatling p95') if k: o.append('k6 p95') # 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'Requests rejected with HTTP 503 (%)') for v in range(0, top + 1, 10): o.append(f'{v}') 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'') o.append(f'{int(s[5:8])}') o.append(f'') o.append(f'injected rate, users per second (blue = Gatling, amber = k6)') o.append('') (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__)