from __future__ import annotations import argparse import asyncio import csv import json import math import statistics import time from collections import Counter from pathlib import Path from typing import Any import httpx PROFILE_DEFAULTS = { "baseline_100_100": {"voice": 100, "digital": 100, "ramp_seconds": 15, "hold_seconds": 60}, "step_250_250": {"voice": 250, "digital": 250, "ramp_seconds": 30, "hold_seconds": 120}, "target_500_500": {"voice": 500, "digital": 500, "ramp_seconds": 60, "hold_seconds": 180}, } DIGITAL_DISTRIBUTION = (("telegram", 0.4), ("webchat", 0.3), ("email", 0.3)) READ_TRAFFIC_INTERVAL_SECONDS = 1.0 MAX_ERROR_SAMPLES = 200 WORKER_PAUSE_SECONDS = 1.0 def resolve_profile( name: str, ramp_seconds: int | None = None, hold_seconds: int | None = None, voice: int | None = None, digital: int | None = None, ) -> dict[str, int | str]: if name not in PROFILE_DEFAULTS: raise ValueError(f"Unsupported profile: {name}") profile = dict(PROFILE_DEFAULTS[name]) profile["profile"] = name if ramp_seconds is not None: profile["ramp_seconds"] = ramp_seconds if hold_seconds is not None: profile["hold_seconds"] = hold_seconds if voice is not None: profile["voice"] = voice if digital is not None: profile["digital"] = digital return profile def split_digital_mix(total: int) -> dict[str, int]: remaining = total result: dict[str, int] = {} for idx, (name, ratio) in enumerate(DIGITAL_DISTRIBUTION): if idx == len(DIGITAL_DISTRIBUTION) - 1: count = remaining else: count = int(round(total * ratio)) remaining -= count result[name] = count return result def build_mix_profile(profile: dict[str, int | str], include_read_traffic: bool) -> dict[str, Any]: voice = int(profile["voice"]) digital = int(profile["digital"]) digital_mix = split_digital_mix(digital) payload = { "profile": profile["profile"], "voice_workers": voice, "digital_workers": digital, "ramp_seconds": int(profile["ramp_seconds"]), "hold_seconds": int(profile["hold_seconds"]), "channels": {"voice": voice, **digital_mix}, "include_read_traffic": include_read_traffic, "read_endpoints": [ "/proxy/supervisor/supervisor/realtime", "/proxy/reporting/reports/kpi", ] if include_read_traffic else [], } return payload def _build_voice_payload(idx: int) -> tuple[str, str, dict[str, Any]]: return ( "POST", "/proxy/interaction/interactions", { "channel": "voice", "subject": f"Track7 voice load #{idx}", "customer_id": None, "queue_id": "q_load_voice", "priority": 3, }, ) def _build_telegram_payload(idx: int) -> tuple[str, str, dict[str, Any]]: return ( "POST", "/proxy/telegram/integrations/telegram/webhook", { "chat_id": f"load_chat_{idx}", "text": f"Track7 telegram load #{idx}", "customer_external_id": None, "payload": {"source": "track7-load"}, }, ) def _build_webchat_payload(idx: int) -> tuple[str, str, dict[str, Any]]: return ( "POST", "/proxy/webchat/integrations/webchat/messages", { "session_id": f"load_session_{idx}", "text": f"Track7 webchat load #{idx}", "visitor_name": "Load Test Visitor", "customer_external_id": None, "queue_id": "q_load_webchat", "priority": 3, "payload": {"source": "track7-load"}, }, ) def _build_email_payload(idx: int) -> tuple[str, str, dict[str, Any]]: return ( "POST", "/proxy/email/integrations/email/messages", { "from_email": f"load{idx}@example.test", "subject": f"Track7 email load #{idx}", "body": "Synthetic email for scale validation.", "customer_external_id": None, "queue_id": "q_load_email", "priority": 3, "payload": {"source": "track7-load"}, }, ) def build_request(target: str, idx: int) -> tuple[str, str, dict[str, Any]]: if target == "voice": return _build_voice_payload(idx) if target == "telegram": return _build_telegram_payload(idx) if target == "webchat": return _build_webchat_payload(idx) if target == "email": return _build_email_payload(idx) raise ValueError(f"Unsupported target: {target}") def build_worker_targets(profile: dict[str, int | str]) -> list[str]: targets = ["voice"] * int(profile["voice"]) digital_mix = split_digital_mix(int(profile["digital"])) for name, count in digital_mix.items(): targets.extend([name] * count) return targets def percentile(samples: list[float], ratio: float) -> float: if not samples: return 0.0 sorted_samples = sorted(samples) index = max(0, math.ceil(len(sorted_samples) * ratio) - 1) return sorted_samples[index] def ensure_report_dir(report_dir: str | None = None) -> Path: if report_dir: target = Path(report_dir) else: timestamp = time.strftime("%Y%m%d_%H%M%S") target = Path(".artifacts") / "track7" / timestamp target.mkdir(parents=True, exist_ok=True) return target async def build_auth_headers(client: httpx.AsyncClient, auth_mode: str) -> dict[str, str]: if auth_mode == "legacy_headers": return {"X-User": "admin", "X-Role": "admin"} if auth_mode != "bearer": raise ValueError(f"Unsupported auth mode: {auth_mode}") response = await client.post( "/proxy/auth/auth/login", json={"username": "admin", "password": "admin123"}, timeout=10, ) response.raise_for_status() token = response.json()["access_token"] return {"Authorization": f"Bearer {token}"} def _record_result( results: list[dict[str, Any]], errors: list[dict[str, Any]], target: str, started_at: float, elapsed: float, status_code: int, ok: bool, error_message: str | None = None, ) -> None: results.append( { "ts": round(started_at, 6), "target": target, "status_code": status_code, "latency_seconds": round(elapsed, 6), "ok": ok, } ) if (not ok or status_code >= 500 or error_message) and len(errors) < MAX_ERROR_SAMPLES: errors.append( { "ts": round(started_at, 6), "target": target, "status_code": status_code, "latency_seconds": round(elapsed, 6), "error": error_message, } ) async def _run_worker( client: httpx.AsyncClient, headers: dict[str, str], target: str, idx: int, stop_event: asyncio.Event, results: list[dict[str, Any]], errors: list[dict[str, Any]], ) -> None: method, path, payload = build_request(target, idx) while not stop_event.is_set(): started = time.perf_counter() try: response = await client.request(method, path, headers=headers, json=payload, timeout=20) elapsed = time.perf_counter() - started ok = response.status_code < 500 and response.status_code < 400 _record_result(results, errors, target, started, elapsed, response.status_code, ok) except Exception as exc: elapsed = time.perf_counter() - started _record_result(results, errors, target, started, elapsed, 0, False, str(exc)) await asyncio.sleep(WORKER_PAUSE_SECONDS) async def _run_read_probe( client: httpx.AsyncClient, headers: dict[str, str], path: str, stop_event: asyncio.Event, results: list[dict[str, Any]], errors: list[dict[str, Any]], ) -> None: target = f"read:{path.rsplit('/', 1)[-1]}" while not stop_event.is_set(): started = time.perf_counter() try: response = await client.get(path, headers=headers, timeout=20) elapsed = time.perf_counter() - started ok = response.status_code < 500 and response.status_code < 400 _record_result(results, errors, target, started, elapsed, response.status_code, ok) except Exception as exc: elapsed = time.perf_counter() - started _record_result(results, errors, target, started, elapsed, 0, False, str(exc)) await asyncio.sleep(READ_TRAFFIC_INTERVAL_SECONDS) def build_summary( results: list[dict[str, Any]], mix_profile: dict[str, Any], thresholds: dict[str, float | None], started_at: str, completed_at: str, ) -> dict[str, Any]: latencies = [item["latency_seconds"] for item in results] total = len(results) success = sum(1 for item in results if item["ok"]) failures = total - success five_xx = sum(1 for item in results if int(item["status_code"]) >= 500 or int(item["status_code"]) == 0) by_target = Counter(item["target"] for item in results) summary = { "profile": mix_profile["profile"], "started_at": started_at, "completed_at": completed_at, "traffic": mix_profile, "results": { "total_requests": total, "success": success, "failed": failures, "five_xx_or_transport": five_xx, "success_rate": round((success / total) * 100, 2) if total else 0.0, "five_xx_rate": round((five_xx / total) * 100, 2) if total else 0.0, "avg_seconds": round(statistics.mean(latencies), 4) if latencies else 0.0, "p95_seconds": round(percentile(latencies, 0.95), 4) if latencies else 0.0, "p99_seconds": round(percentile(latencies, 0.99), 4) if latencies else 0.0, "by_target": dict(by_target), }, "thresholds": thresholds, } summary["passed"] = evaluate_thresholds(summary) return summary def evaluate_thresholds(summary: dict[str, Any]) -> bool: results = summary["results"] thresholds = summary["thresholds"] if thresholds.get("require_success_rate") is not None and results["success_rate"] < thresholds["require_success_rate"]: return False if thresholds.get("require_p95_seconds") is not None and results["p95_seconds"] > thresholds["require_p95_seconds"]: return False if thresholds.get("require_p99_seconds") is not None and results["p99_seconds"] > thresholds["require_p99_seconds"]: return False return True def write_report(report_dir: Path, summary: dict[str, Any], results: list[dict[str, Any]], errors: list[dict[str, Any]]) -> None: (report_dir / "summary.json").write_text(json.dumps(summary, indent=2), encoding="utf-8") (report_dir / "mix_profile.json").write_text(json.dumps(summary["traffic"], indent=2), encoding="utf-8") (report_dir / "error_samples.json").write_text(json.dumps(errors, indent=2), encoding="utf-8") with (report_dir / "latency_samples.csv").open("w", encoding="utf-8", newline="") as handle: writer = csv.DictWriter(handle, fieldnames=["ts", "target", "status_code", "latency_seconds", "ok"]) writer.writeheader() writer.writerows(results) async def run_profile( *, base_url: str, profile_name: str, ramp_seconds: int | None, hold_seconds: int | None, voice: int | None, digital: int | None, include_read_traffic: bool, auth_mode: str, report_dir: str | None, require_success_rate: float | None, require_p95_seconds: float | None, require_p99_seconds: float | None, ) -> tuple[dict[str, Any], Path]: profile = resolve_profile( profile_name, ramp_seconds=ramp_seconds, hold_seconds=hold_seconds, voice=voice, digital=digital, ) mix_profile = build_mix_profile(profile, include_read_traffic) out_dir = ensure_report_dir(report_dir) started_at_iso = time.strftime("%Y-%m-%dT%H:%M:%S") results: list[dict[str, Any]] = [] errors: list[dict[str, Any]] = [] stop_event = asyncio.Event() worker_targets = build_worker_targets(profile) async with httpx.AsyncClient(base_url=base_url) as client: headers = await build_auth_headers(client, auth_mode) workers: list[asyncio.Task[None]] = [] ramp_window = max(1, int(profile["ramp_seconds"])) spacing = ramp_window / max(1, len(worker_targets)) for idx, target in enumerate(worker_targets): workers.append(asyncio.create_task(_run_worker(client, headers, target, idx, stop_event, results, errors))) if spacing > 0: await asyncio.sleep(spacing) read_tasks: list[asyncio.Task[None]] = [] if include_read_traffic: read_tasks = [ asyncio.create_task( _run_read_probe(client, headers, "/proxy/supervisor/supervisor/realtime", stop_event, results, errors) ), asyncio.create_task( _run_read_probe(client, headers, "/proxy/reporting/reports/kpi", stop_event, results, errors) ), ] await asyncio.sleep(int(profile["hold_seconds"])) stop_event.set() await asyncio.gather(*workers, *read_tasks, return_exceptions=True) completed_at_iso = time.strftime("%Y-%m-%dT%H:%M:%S") thresholds = { "require_success_rate": require_success_rate, "require_p95_seconds": require_p95_seconds, "require_p99_seconds": require_p99_seconds, } summary = build_summary(results, mix_profile, thresholds, started_at_iso, completed_at_iso) write_report(out_dir, summary, results, errors) return summary, out_dir async def main() -> None: parser = argparse.ArgumentParser(description="Track 7 mixed workload load harness through the gateway.") parser.add_argument("--base-url", default="http://localhost:8080", help="Gateway URL") parser.add_argument( "--profile", default="baseline_100_100", choices=sorted(PROFILE_DEFAULTS), help="Named load profile", ) parser.add_argument("--ramp-seconds", type=int, default=None, help="Override ramp duration") parser.add_argument("--hold-seconds", type=int, default=None, help="Override hold duration") parser.add_argument("--voice", type=int, default=None, help="Backward-compatible voice worker override") parser.add_argument("--digital", type=int, default=None, help="Backward-compatible digital worker override") parser.add_argument("--report-dir", default=None, help="Directory for JSON/CSV reports") parser.add_argument("--require-success-rate", type=float, default=None, help="Optional success-rate threshold") parser.add_argument("--require-p95-seconds", type=float, default=None, help="Optional p95 threshold") parser.add_argument("--require-p99-seconds", type=float, default=None, help="Optional p99 threshold") parser.add_argument( "--include-read-traffic", type=int, choices=[0, 1], default=1, help="Include background supervisor/reporting reads during the hold window", ) parser.add_argument( "--auth-mode", choices=["legacy_headers", "bearer"], default="legacy_headers", help="Authentication mode for load traffic", ) args = parser.parse_args() summary, out_dir = await run_profile( base_url=args.base_url, profile_name=args.profile, ramp_seconds=args.ramp_seconds, hold_seconds=args.hold_seconds, voice=args.voice, digital=args.digital, include_read_traffic=bool(args.include_read_traffic), auth_mode=args.auth_mode, report_dir=args.report_dir, require_success_rate=args.require_success_rate, require_p95_seconds=args.require_p95_seconds, require_p99_seconds=args.require_p99_seconds, ) print("Track 7 load test result:") print(json.dumps(summary, indent=2)) print(f"Report dir: {out_dir}") if not summary["passed"]: raise SystemExit(1) if __name__ == "__main__": asyncio.run(main())