#!/usr/bin/env python3 import argparse import os import re import subprocess import sys from pathlib import Path import matplotlib.pyplot as plt import pandas as pd plt.style.use("ggplot") def run_locust( locust_file: str, host: str, users: int, hatch_rate: int, run_time: str, csv_prefix: Path, ) -> Path: artifacts_dir = Path("artifacts") artifacts_dir.mkdir(parents=True, exist_ok=True) cmd = [ "locust", "-f", f"scenarios/{locust_file}", "--headless", "-u", str(users), "-r", str(hatch_rate), "-t", run_time, "--host", host, "--csv", str(artifacts_dir / csv_prefix.name), ] print(f"Running Locust: {' '.join(cmd)}") process = subprocess.run(cmd) if process.returncode: sys.exit("Locust execution failed") stats_file = artifacts_dir / f"{csv_prefix.stem}_stats.csv" if not stats_file.exists(): sys.exit(f"Stats CSV not found: {stats_file}") return stats_file def load_percentiles(csv_path: Path) -> pd.DataFrame: df = pd.read_csv(csv_path) mapping = {"50%": "p50", "75%": "p75", "90%": "p90", "95%": "p95"} available = [col for col in mapping if col in df.columns] renamed = {col: mapping[col] for col in available} df = df.rename(columns=renamed).set_index("Name")[renamed.values()] return df.drop(index=["Aggregated"], errors="ignore") def sanitize_label(label: str) -> str: text = re.sub(r"[^\w]+", "_", label.strip().lower()) return text.strip("_") def plot_multi_comparison(metrics: dict[str, pd.DataFrame]) -> None: common = sorted(set.intersection(*(set(df.index) for df in metrics.values()))) percentiles = list(next(iter(metrics.values())).columns) groups = len(metrics) width = 0.8 / groups for endpoint in common: fig, ax = plt.subplots(figsize=(10, 5), dpi=100) for idx, (label, df) in enumerate(metrics.items()): series = df.loc[endpoint] positions = [ i + (idx - groups / 2) * width + width / 2 for i in range(len(percentiles)) ] bars = ax.bar(positions, series.values, width, label=label) for bar in bars: height = bar.get_height() ax.annotate( f"{int(height)}", xy=(bar.get_x() + bar.get_width() / 2, height), xytext=(0, 3), textcoords="offset points", ha="center", va="bottom", fontsize=8, ) ax.set_xticks(range(len(percentiles))) ax.set_xticklabels(percentiles) ax.set_ylabel("Latency (ms)") ax.set_title(endpoint, fontsize=12) ax.grid(True, axis="y", linestyle="--", alpha=0.7) fig.tight_layout() fig.subplots_adjust(right=0.75) ax.legend(loc="center left", bbox_to_anchor=(1, 0.5), framealpha=0.9) output = Path("artifacts") / f"comparison_{sanitize_label(endpoint)}.png" plt.savefig(output) plt.close(fig) print(f"Saved chart: {output}") def main() -> None: parser = argparse.ArgumentParser(description="Run Locust and compare metrics") parser.add_argument("--locustfile", required=True, help="Locust file in scenarios/") parser.add_argument("--host", required=True, help="Target host URL") parser.add_argument( "--users", type=int, default=10, help="Number of simulated users" ) parser.add_argument("--rate", type=int, default=1, help="Hatch rate per second") parser.add_argument("--time", default="1m", help="Test duration (e.g. 30s, 1m)") parser.add_argument( "--metrics-dir", default="baselines", help="Directory with CSV baselines" ) parser.add_argument("--version", default="current", help="Test version") args = parser.parse_args() metrics_dir = Path(args.metrics_dir) os.makedirs(metrics_dir, exist_ok=True) metrics_data: dict[str, pd.DataFrame] = {} for csv_file in sorted(metrics_dir.glob("*.csv")): metrics_data[csv_file.stem] = load_percentiles(csv_file) current_prefix = Path(args.version) current_csv = run_locust( locust_file=args.locustfile, host=args.host, users=args.users, hatch_rate=args.rate, run_time=args.time, csv_prefix=current_prefix, ) metrics_data[args.version] = load_percentiles(current_csv) for endpoint in sorted( set.intersection(*(set(df.index) for df in metrics_data.values())) ): parts = [endpoint] for label, df in metrics_data.items(): s = df.loc[endpoint] parts.append(f"{label}: p50 {s.p50}, p75 {s.p75}, p90 {s.p90}, p95 {s.p95}") print(" | ".join(parts)) plot_multi_comparison(metrics_data) if __name__ == "__main__": main()