#!/usr/bin/env python3 """Summarize Tsung log against stress boundary 2A. Boundary hit if: - error rate > 1% (4xx+5xx+nomatch+match_stop relative to requests), OR - cumulative mean request > 500 ms Usage: summarize-tsung-stress.py [path/to/tsung.log] Default: newest ~/.tsung/log/*/tsung.log """ from __future__ import annotations import re import sys from pathlib import Path def latest_log() -> Path: root = Path.home() / ".tsung" / "log" dirs = sorted([p for p in root.iterdir() if p.is_dir()], reverse=True) for d in dirs: cand = d / "tsung.log" if cand.is_file(): return cand raise SystemExit("No tsung.log under ~/.tsung/log") def last_stats(text: str, key: str) -> list[str] | None: ms = list(re.finditer(rf"^stats: {re.escape(key)} (.+)$", text, re.M)) if not ms: return None return ms[-1].group(1).split() def cumul_count(fields: list[str] | None) -> int: """Tsung dump: for counters usually last field is cumulative total.""" if not fields: return 0 return int(float(fields[-1])) def mean_request_ms(fields: list[str] | None) -> float | None: """request line: count mean stddev max min mean_cumul count_cumul (approx).""" if not fields or len(fields) < 6: return None # Prefer overall mean (field index 5 in dump = running mean of means in later dumps) try: return float(fields[5]) except (IndexError, ValueError): return float(fields[1]) def phase_means(text: str) -> list[tuple[int, float, int]]: """Per-dump mean from request lines: (dump_idx, mean_interval, cumul_count).""" out = [] dumps = text.split("# stats: dump") for i, block in enumerate(dumps[1:], start=1): m = re.search(r"^stats: request (.+)$", block, re.M) if not m: continue f = m.group(1).split() if len(f) >= 2: try: out.append((i, float(f[1]), cumul_count(f))) except ValueError: pass return out def main() -> int: log = Path(sys.argv[1]) if len(sys.argv) > 1 else latest_log() text = log.read_text(encoding="utf-8", errors="replace") print(f"log={log}") req = last_stats(text, "request") users = last_stats(text, "users_count") finish = last_stats(text, "finish_users_count") match = last_stats(text, "match") nomatch = last_stats(text, "nomatch") match_stop = last_stats(text, "match_stop") codes = {} for code in ("200", "201", "400", "401", "403", "404", "429", "500", "502", "503"): st = last_stats(text, code) if st: codes[code] = cumul_count(st) transport_errs = {} for key in ( "error_connect_closed", "error_timeout", "error_abort_max_conn_retries", "error_connect_econnrefused", "error_connect_etimedout", "error_unknown", ): st = last_stats(text, key) if st: transport_errs[key] = cumul_count(st) n_req = cumul_count(req) n_nomatch = cumul_count(nomatch) n_stop = cumul_count(match_stop) n_4xx = sum(v for k, v in codes.items() if k.startswith("4")) n_5xx = sum(v for k, v in codes.items() if k.startswith("5")) n_2xx = sum(v for k, v in codes.items() if k.startswith("2")) n_transport = sum(transport_errs.values()) # Denominator: completed HTTP + transport failures (attempts that never got status) attempts = max(n_req + n_transport, 1) err = n_4xx + n_5xx + n_nomatch + n_stop + n_transport err_rate = err / attempts * 100.0 mean_ms = mean_request_ms(req) print(f"users_started={cumul_count(users)} finished={cumul_count(finish)}") print(f"requests={n_req} match={cumul_count(match)} nomatch={n_nomatch} match_stop={n_stop}") print(f"http_2xx={n_2xx} http_4xx={n_4xx} http_5xx={n_5xx} codes={codes}") print(f"transport_errors={transport_errs} transport_total={n_transport}") print(f"attemptsā‰ˆ{n_req + n_transport} error_units={err} error_rate_pct={err_rate:.3f}") print(f"mean_request_ms_cumul={mean_ms}") # Hold-ish: last third of dumps (late phases) means = phase_means(text) if means: tail = means[max(0, len(means) * 2 // 3) :] hold_means = [m for _, m, _ in tail if m > 0] hold_mean = sum(hold_means) / len(hold_means) if hold_means else None print(f"dumps={len(means)} late_interval_mean_ms={hold_mean}") peak_interval = max(means, key=lambda t: t[1]) print(f"peak_interval_mean_ms={peak_interval[1]} at_dump={peak_interval[0]}") else: hold_mean = None boundary = False reasons = [] if err_rate > 1.0: boundary = True reasons.append(f"error_rate {err_rate:.3f}% > 1%") check_mean = mean_ms if mean_ms is not None else hold_mean if check_mean is not None and check_mean > 500: boundary = True reasons.append(f"mean_request {check_mean:.1f} ms > 500 ms") if hold_mean is not None and hold_mean > 500: boundary = True if f"mean_request {hold_mean:.1f} ms > 500 ms" not in reasons: reasons.append(f"late_phase_mean {hold_mean:.1f} ms > 500 ms") verdict = "BOUNDARY_HIT" if boundary else "UNDER_THRESHOLD" print(f"verdict={verdict}") if reasons: print("reasons=" + "; ".join(reasons)) return 0 if not boundary else 2 if __name__ == "__main__": raise SystemExit(main())