| 1 | #!/usr/bin/env python3 |
| 2 | """Run on the VM to measure the dashboard through its trusted loopback listener.""" |
| 3 | import argparse |
| 4 | import concurrent.futures |
| 5 | import json |
| 6 | from pathlib import Path |
| 7 | import time |
| 8 | import urllib.request |
| 9 | |
| 10 | |
| 11 | parser = argparse.ArgumentParser(description=__doc__) |
| 12 | parser.add_argument("--clients", type=int, default=24) |
| 13 | parser.add_argument("--rounds", type=int, default=20) |
| 14 | parser.add_argument("--port", type=int, default=7072) |
| 15 | parser.add_argument("--unit", default="studio-dashboard.service") |
| 16 | parser.add_argument("--include-files", action="store_true") |
| 17 | args = parser.parse_args() |
| 18 | if args.clients < 1 or args.rounds < 1: |
| 19 | parser.error("clients and rounds must be positive") |
| 20 | |
| 21 | paths = ["/", "/api/me", "/api/host", "/api/live", "/api/services", "/api/launcher", "/api/status", "/api/storage"] |
| 22 | paths += [f"/api/metrics/{metric}?range=3600" for metric in ["host.cpu", "host.memory", "service.cpu", "service.memory"]] |
| 23 | if args.include_files: |
| 24 | paths.append("/api/media/list") |
| 25 | |
| 26 | |
| 27 | def request(path): |
| 28 | started = time.monotonic() |
| 29 | try: |
| 30 | request = urllib.request.Request(f"http://127.0.0.1:{args.port}" + path, |
| 31 | headers={"User-Name": "benchmark", "User-Groups": "infra-admin"}) |
| 32 | with urllib.request.urlopen(request, timeout=15) as response: |
| 33 | if path == "/api/live": |
| 34 | while response.readline().strip(): |
| 35 | pass |
| 36 | else: |
| 37 | response.read() |
| 38 | error = None |
| 39 | except Exception as failure: |
| 40 | error = str(failure) |
| 41 | return path, time.monotonic() - started, error |
| 42 | |
| 43 | |
| 44 | def cpu(): |
| 45 | values = dict(line.split() for line in Path(f"/sys/fs/cgroup/system.slice/{args.unit}/cpu.stat").read_text().splitlines()) |
| 46 | return int(values["usage_usec"]) / 1_000_000 |
| 47 | |
| 48 | |
| 49 | for path in paths: |
| 50 | request(path) |
| 51 | started = time.monotonic() |
| 52 | before = cpu() |
| 53 | with concurrent.futures.ThreadPoolExecutor(max_workers=args.clients) as pool: |
| 54 | results = list(pool.map(request, paths * args.rounds)) |
| 55 | elapsed = time.monotonic() - started |
| 56 | report = {} |
| 57 | for path in paths: |
| 58 | samples = sorted(duration for route, duration, _ in results if route == path) |
| 59 | errors = [error for route, _, error in results if route == path and error] |
| 60 | report[path] = { |
| 61 | "requests": len(samples), |
| 62 | "p50_ms": round(samples[len(samples) // 2] * 1000, 1), |
| 63 | "p95_ms": round(samples[min(len(samples) - 1, int(len(samples) * .95))] * 1000, 1), |
| 64 | "errors": errors, |
| 65 | } |
| 66 | print(json.dumps({"clients": args.clients, "requests": len(results), "seconds": round(elapsed, 2), |
| 67 | "dashboard_cpu_cores": round((cpu() - before) / elapsed, 3), "routes": report}, indent=2)) |