"""Turn k6 CSV, browser JSONL and pod-usage CSV into charts and a summary JSON. Usage: analyze.py RESULTS_DIR OUT_DIR """ import csv import json import os import statistics import sys from collections import defaultdict import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt # noqa: E402 results, out = sys.argv[1], sys.argv[2] os.makedirs(out, exist_ok=True) BUCKET = 15 TEAL, AMBER, RED, GREY, BLUE = "#0b6e7f", "#e8911f", "#c0392b", "#7f8c8d", "#2c6fbb" plt.rcParams.update({"figure.dpi": 110, "axes.grid": True, "grid.alpha": 0.3, "axes.spines.top": False, "axes.spines.right": False, "font.size": 10}) def pct(values, p): values = sorted(values) return values[min(len(values) - 1, int(round(p / 100 * (len(values) - 1))))] if values else 0 # k6 samples durations = defaultdict(list) by_name = defaultdict(list) reqs, fails, vus = defaultdict(int), defaultdict(int), {} status_counts = defaultdict(int) t0 = None with open(os.path.join(results, "k6.csv")) as handle: for row in csv.DictReader(handle): ts = int(float(row["timestamp"])) t0 = ts if t0 is None else min(t0, ts) metric = row["metric_name"] if metric == "vus": vus[ts] = float(row["metric_value"]) elif metric == "http_req_duration": if (row.get("status") or "0") == "0": continue # refused/failed connections are counted in the error rate, not as 0 ms latency value = float(row["metric_value"]) durations[ts // BUCKET].append(value) name = row.get("name") or "?" if name != "static asset": by_name[name].append(value) else: by_name["static assets (first visit)"].append(value) elif metric == "http_reqs": reqs[ts] += 1 status = row.get("status") or "0" status_counts[status] += 1 if status == "0" or int(status) >= 400: fails[ts // BUCKET] += 1 t0 = (t0 // BUCKET) * BUCKET buckets = sorted(durations) events = [] if os.path.exists(os.path.join(results, "events.json")): events = json.load(open(os.path.join(results, "events.json"))) def mark_events(*axes): for event in events: minute = (event["t"] - t0) / 60 for axis in axes: axis.axvline(minute, color=GREY, ls=":", lw=1) axes[0].annotate(event["label"], (minute, 1), xycoords=("data", "axes fraction"), rotation=90, va="top", ha="right", fontsize=8, color=GREY) x = [(b * BUCKET - t0) / 60 for b in buckets] vu_t = sorted(vus) req_buckets = defaultdict(int) for ts, count in reqs.items(): req_buckets[ts // BUCKET] += count fig, ax = plt.subplots(figsize=(10, 4.2)) ax.plot([(t - t0) / 60 for t in vu_t], [vus[t] for t in vu_t], color=TEAL, lw=2, label="Concurrent users") ax.set_xlabel("Minutes into test"), ax.set_ylabel("Concurrent users", color=TEAL) ax2 = ax.twinx() ax2.plot([(b * BUCKET - t0) / 60 for b in sorted(req_buckets)], [req_buckets[b] / BUCKET for b in sorted(req_buckets)], color=AMBER, lw=1.6, label="Requests per second") ax2.set_ylabel("Requests per second", color=AMBER), ax2.grid(False) ax.set_title("Load profile: concurrent users and throughput"); mark_events(ax) fig.tight_layout(), fig.savefig(os.path.join(out, "load-profile.png")), plt.close(fig) fig, ax = plt.subplots(figsize=(10, 4.2)) for p, colour, label in ((50, TEAL, "median"), (95, AMBER, "p95"), (99, RED, "p99")): ax.plot(x, [pct(durations[b], p) for b in buckets], color=colour, lw=1.8, label=label) ax.set_xlabel("Minutes into test"), ax.set_ylabel("Response time (ms)"), ax.legend(loc="upper left") ax.set_title("Server response time over the run (all HTTP requests)"); mark_events(ax) fig.tight_layout(), fig.savefig(os.path.join(out, "response-time.png")), plt.close(fig) names = sorted(by_name, key=lambda n: pct(by_name[n], 95)) fig, ax = plt.subplots(figsize=(10, 0.42 * len(names) + 1.2)) ax.barh(names, [pct(by_name[n], 95) for n in names], color=AMBER, label="p95") ax.barh(names, [pct(by_name[n], 50) for n in names], color=TEAL, label="median") ax.set_xlabel("Response time (ms)"), ax.legend(loc="lower right") ax.set_title("Response time by user action") fig.tight_layout(), fig.savefig(os.path.join(out, "by-action.png")), plt.close(fig) fig, ax = plt.subplots(figsize=(10, 3.4)) error_buckets = sorted(req_buckets) ax.plot([(b * BUCKET - t0) / 60 for b in error_buckets], [100 * fails[b] / max(1, req_buckets[b]) for b in error_buckets], color=RED, lw=1.8) ax.set_xlabel("Minutes into test"), ax.set_ylabel("Failed requests (%)"), ax.set_ylim(bottom=0) ax.set_title("Error rate (HTTP 4xx/5xx and connection failures)"); mark_events(ax) fig.tight_layout(), fig.savefig(os.path.join(out, "errors.png")), plt.close(fig) # real browsers browser_rows = [] path = os.path.join(results, "browser.jsonl") if os.path.exists(path): with open(path) as handle: browser_rows = [json.loads(line) for line in handle if line.strip()] ok_rows = [r for r in browser_rows if r.get("status") == 200 and r.get("load")] if ok_rows: pages = sorted({r["page"] for r in ok_rows}) fig, ax = plt.subplots(figsize=(10, 4.2)) data = [[r["load"] for r in ok_rows if r["page"] == p] for p in pages] ax.boxplot(data, tick_labels=pages, showfliers=False, patch_artist=True, boxprops={"facecolor": "#cfe6ea"}, medianprops={"color": TEAL, "lw": 2}) ax.set_ylabel("Full page load in a real browser (ms)") ax.set_title(f"Real Chromium page loads during the test ({len(ok_rows)} page views)") fig.tight_layout(), fig.savefig(os.path.join(out, "browser-pages.png")), plt.close(fig) bt0 = min(r["t"] for r in ok_rows) fig, ax = plt.subplots(figsize=(10, 3.8)) ax.scatter([(r["t"] - t0) / 60 for r in ok_rows], [r["lcp"] or r["load"] for r in ok_rows], s=8, color=BLUE, alpha=0.5) ax.set_xlabel("Minutes into test"), ax.set_ylabel("Largest Contentful Paint (ms)") ax.set_title("Real-browser Largest Contentful Paint as load rises") fig.tight_layout(), fig.savefig(os.path.join(out, "browser-lcp.png")), plt.close(fig) # pod resources usage = defaultdict(list) path = os.path.join(results, "pod-usage.csv") if os.path.exists(path): with open(path) as handle: for row in csv.DictReader(handle): if float(row["cpu_m"]) >= 0: # a container restart resets the cgroup counter usage[row["pod"]].append((float(row["t"]), float(row["cpu_m"]), float(row["mem_mi"]))) if usage: fig, (ax_cpu, ax_mem) = plt.subplots(2, 1, figsize=(10, 6), sharex=True) colours = {"web": TEAL, "worker": GREY, "postgresql": AMBER, "ai-worker": BLUE} for pod, samples in sorted(usage.items()): kind = next((k for k in ("postgresql", "ai-worker", "worker", "web") if k in pod), "web") ts = [(s[0] - t0) / 60 for s in samples] ax_cpu.plot(ts, [s[1] for s in samples], label=kind, color=colours[kind], lw=1.6) ax_mem.plot(ts, [s[2] for s in samples], label=kind, color=colours[kind], lw=1.6) ax_cpu.axhline(1000, color=RED, ls="--", lw=1, label="web CPU limit") ax_cpu.set_ylabel("CPU (millicores)"), ax_cpu.legend(loc="upper left", ncol=5) ax_mem.axhline(1024, color=RED, ls="--", lw=1, label="web memory limit") ax_mem.set_ylabel("Memory (MiB)"), ax_mem.set_xlabel("Minutes into test") ax_cpu.set_title("Production pod resource use during the test"); mark_events(ax_cpu, ax_mem) fig.tight_layout(), fig.savefig(os.path.join(out, "pod-resources.png")), plt.close(fig) all_values = [v for vals in durations.values() for v in vals] total = sum(status_counts.values()) failed = sum(c for s, c in status_counts.items() if s == "0" or int(s) >= 400) summary = { "requests": total, "failed": failed, "error_pct": round(100 * failed / max(1, total), 2), "peak_users": max(vus.values()) if vus else 0, "peak_rps": round(max(req_buckets.values()) / BUCKET, 1) if req_buckets else 0, "median_ms": round(pct(all_values, 50), 1), "p95_ms": round(pct(all_values, 95), 1), "p99_ms": round(pct(all_values, 99), 1), "status_counts": dict(status_counts), "by_action": {n: {"count": len(v), "median_ms": round(pct(v, 50), 1), "p95_ms": round(pct(v, 95), 1)} for n, v in sorted(by_name.items())}, "browser": {"page_views": len(browser_rows), "ok": len(ok_rows), "failed": len(browser_rows) - len(ok_rows), "load_median_ms": round(statistics.median([r["load"] for r in ok_rows]), 1) if ok_rows else None, "load_p95_ms": round(pct([r["load"] for r in ok_rows], 95), 1) if ok_rows else None, "lcp_median_ms": round(statistics.median([r["lcp"] for r in ok_rows if r.get("lcp")]), 1) if any(r.get("lcp") for r in ok_rows) else None}, "pods": {pod: {"cpu_peak_m": max(s[1] for s in samples), "mem_peak_mi": max(s[2] for s in samples)} for pod, samples in usage.items()}, } with open(os.path.join(out, "summary.json"), "w") as handle: json.dump(summary, handle, indent=1) print(json.dumps({k: v for k, v in summary.items() if k != "by_action"}, indent=1))