#!/usr/bin/env python3 """Compare two daily backend log report JSONs and produce an optimization delta report.""" from __future__ import annotations import argparse import json import math import re import sys from collections import defaultdict from pathlib import Path from typing import Any def load_report(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def pct_change(before: float, after: float) -> float | None: if before == 0: return None return round(((after - before) / before) * 100, 2) def fmt_pct(value: float | None) -> str: if value is None: return "N/A" sign = "+" if value > 0 else "" return f"{sign}{value:.2f}%" def fmt_ms(value: float) -> str: return f"{value:.2f}ms" SOBJECT_ROUTE_RE = re.compile(r"/services/data/v[0-9.]+/sobjects/(?P[^/]+)") UI_API_ROUTE_RE = re.compile(r"/services/data/v[0-9.]+/ui-api/(?P[^/]+)/(?P[^/]+)") QUERY_ROUTE_RE = re.compile(r"/services/data/v[0-9.]+/(?Pquery|search|parameterizedSearch)") ACTIONS_ROUTE_RE = re.compile(r"/services/data/v[0-9.]+/(?Pactions|composite|process)") def classify_route(route: str) -> tuple[str, str]: """Extract (object_or_domain, api_type) from a normalized route.""" m = UI_API_ROUTE_RE.search(route) if m: return m.group("object"), f"ui-api/{m.group('api_type')}" m = SOBJECT_ROUTE_RE.search(route) if m: obj = m.group("object") if "/listviews/" in route: return obj, "sobjects/listviews" return obj, "sobjects" m = QUERY_ROUTE_RE.search(route) if m: return "_query", m.group("api_type") m = ACTIONS_ROUTE_RE.search(route) if m: return "_system", m.group("api_type") if "/services/oauth2/" in route: return "_auth", "oauth2" if "/services/data/" in route: return "_platform", "data-api" return "_other", "other" def compute_percentile(values: list[float], ratio: float) -> float: if not values: return 0.0 ordered = sorted(values) index = max(0, math.ceil(ratio * len(ordered)) - 1) return float(ordered[index]) def aggregate_by_domain(profiles: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """Aggregate route profiles by (object, api_type) domain key.""" buckets: dict[str, list[dict[str, Any]]] = defaultdict(list) for p in profiles: obj, api_type = classify_route(p["route"]) key = f"{obj} [{api_type}]" buckets[key].append(p) result: dict[str, dict[str, Any]] = {} for key, routes in buckets.items(): total_count = sum(r["request_count"] for r in routes) total_slow = sum(r["slow_count"] for r in routes) all_latencies: list[float] = [] for r in routes: avg = r["avg_latency_ms"] count = r["request_count"] all_latencies.extend([avg] * count) result[key] = { "domain": key, "request_count": total_count, "slow_count": total_slow, "slow_rate": round((total_slow / total_count) * 100, 2) if total_count else 0.0, "weighted_avg_ms": round(sum(r["avg_latency_ms"] * r["request_count"] for r in routes) / total_count, 2) if total_count else 0.0, "max_p95_ms": round(max(r["p95_latency_ms"] for r in routes), 2) if routes else 0.0, "max_p99_ms": round(max(r.get("p99_latency_ms", r["p95_latency_ms"]) for r in routes), 2) if routes else 0.0, "route_count": len(routes), } return result def build_domain_comparisons(before: dict[str, dict[str, Any]], after: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: all_keys = sorted(set(before.keys()) | set(after.keys())) rows: list[dict[str, Any]] = [] for key in all_keys: bp = before.get(key) ap = after.get(key) row: dict[str, Any] = {"domain": key} if bp and ap: row["before_count"] = bp["request_count"] row["after_count"] = ap["request_count"] row["before_slow_rate"] = bp["slow_rate"] row["after_slow_rate"] = ap["slow_rate"] row["before_weighted_avg_ms"] = bp["weighted_avg_ms"] row["after_weighted_avg_ms"] = ap["weighted_avg_ms"] row["change_weighted_avg_ms"] = pct_change(bp["weighted_avg_ms"], ap["weighted_avg_ms"]) row["before_max_p95_ms"] = bp["max_p95_ms"] row["after_max_p95_ms"] = ap["max_p95_ms"] row["change_max_p95_ms"] = pct_change(bp["max_p95_ms"], ap["max_p95_ms"]) elif bp: row["status"] = "removed_after" row["before_count"] = bp["request_count"] else: row["status"] = "new_after" row["after_count"] = ap["request_count"] rows.append(row) rows.sort(key=lambda r: (-(r.get("before_count", 0) + r.get("after_count", 0)), r["domain"])) return rows def build_comparison(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]: for report in (before, after): if report["summary"].get("window_data_warning") or report["summary"].get("latency_available") is False: raise ValueError("cannot compare incomplete HTTP coverage or unavailable latency") if before.get("source_coverage", {}).get("http_source", "backend") != after.get("source_coverage", {}).get("http_source", "backend"): raise ValueError("cannot compare different HTTP counting boundaries") bs = before["summary"] as_ = after["summary"] global_metrics: list[dict[str, Any]] = [] for key, label in [ ("all_request_p50_ms", "Global P50"), ("all_request_p95_ms", "Global P95"), ("all_request_p99_ms", "Global P99"), ("all_request_avg_ms", "Global Avg"), ("slow_request_rate", "Slow Rate %"), ("interactive_slow_request_rate", "Interactive Slow Rate %"), ]: bv = bs.get(key, 0.0) av = as_.get(key, 0.0) global_metrics.append({ "metric": label, "before": bv, "after": av, "change_pct": pct_change(bv, av), }) before_profiles = {p["route"]: p for p in before.get("route_latency_profile", [])} after_profiles = {p["route"]: p for p in after.get("route_latency_profile", [])} all_routes = sorted(set(before_profiles.keys()) | set(after_profiles.keys())) route_comparisons: list[dict[str, Any]] = [] for route in all_routes: bp = before_profiles.get(route) ap = after_profiles.get(route) if not bp and not ap: continue row: dict[str, Any] = {"route": route} if bp and ap: row["before_count"] = bp["request_count"] row["after_count"] = ap["request_count"] row["before_slow_rate"] = bp["slow_rate"] row["after_slow_rate"] = ap["slow_rate"] for metric in ("p50_latency_ms", "p95_latency_ms", "p99_latency_ms", "avg_latency_ms"): row[f"before_{metric}"] = bp[metric] row[f"after_{metric}"] = ap[metric] row[f"change_{metric}"] = pct_change(bp[metric], ap[metric]) elif bp: row["status"] = "removed_after" row["before_count"] = bp["request_count"] else: row["status"] = "new_after" row["after_count"] = ap["request_count"] route_comparisons.append(row) route_comparisons.sort( key=lambda r: ( -(r.get("before_count", 0) + r.get("after_count", 0)), r["route"], ) ) before_domain = aggregate_by_domain(before.get("route_latency_profile", [])) after_domain = aggregate_by_domain(after.get("route_latency_profile", [])) domain_comparisons = build_domain_comparisons(before_domain, after_domain) return { "before_date": before.get("window_label") or before["report_date"], "after_date": after.get("window_label") or after["report_date"], "before_total_requests": bs.get("parsed_requests_in_window", 0), "after_total_requests": as_.get("parsed_requests_in_window", 0), "global_metrics": global_metrics, "domain_comparisons": domain_comparisons, "route_comparisons": route_comparisons, } def render_markdown(comparison: dict[str, Any]) -> str: lines = [ f"# Latency Comparison Report", "", f"- Before: **{comparison['before_date']}** ({comparison['before_total_requests']} requests)", f"- After: **{comparison['after_date']}** ({comparison['after_total_requests']} requests)", "", "## Global Metrics", "", "| Metric | Before | After | Change |", "| --- | ---: | ---: | ---: |", ] for m in comparison["global_metrics"]: before_val = f"{m['before']:.2f}" if isinstance(m["before"], float) else str(m["before"]) after_val = f"{m['after']:.2f}" if isinstance(m["after"], float) else str(m["after"]) lines.append(f"| {m['metric']} | {before_val} | {after_val} | {fmt_pct(m['change_pct'])} |") lines.extend(["", "## Per-Domain Comparison (by object + API type)", ""]) lines.append("| Domain | Before Count | After Count | Before Avg | After Avg | Avg Change | Before Slow% | After Slow% |") lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") for row in comparison.get("domain_comparisons", []): if row.get("status") in ("removed_after", "new_after"): continue lines.append( f"| **{row['domain']}** " f"| {row.get('before_count', '-')} " f"| {row.get('after_count', '-')} " f"| {fmt_ms(row.get('before_weighted_avg_ms', 0))} " f"| {fmt_ms(row.get('after_weighted_avg_ms', 0))} " f"| {fmt_pct(row.get('change_weighted_avg_ms'))} " f"| {row.get('before_slow_rate', '-')}% " f"| {row.get('after_slow_rate', '-')}% |" ) lines.extend(["", "## Per-Route Comparison (top 50 by volume)", ""]) lines.append("| Route | Before Count | After Count | Before P95 | After P95 | P95 Change | Before Slow% | After Slow% |") lines.append("| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |") for row in comparison["route_comparisons"][:50]: if row.get("status") in ("removed_after", "new_after"): continue lines.append( f"| `{row['route']}` " f"| {row.get('before_count', '-')} " f"| {row.get('after_count', '-')} " f"| {fmt_ms(row.get('before_p95_latency_ms', 0))} " f"| {fmt_ms(row.get('after_p95_latency_ms', 0))} " f"| {fmt_pct(row.get('change_p95_latency_ms'))} " f"| {row.get('before_slow_rate', '-')}% " f"| {row.get('after_slow_rate', '-')}% |" ) improved = [ r for r in comparison["route_comparisons"] if r.get("change_p95_latency_ms") is not None and r["change_p95_latency_ms"] < -5 ] degraded = [ r for r in comparison["route_comparisons"] if r.get("change_p95_latency_ms") is not None and r["change_p95_latency_ms"] > 5 ] if improved: lines.extend(["", "## Improved Routes (P95 decreased >5%)", ""]) lines.append("| Route | Before P95 | After P95 | Change |") lines.append("| --- | ---: | ---: | ---: |") for r in sorted(improved, key=lambda x: x["change_p95_latency_ms"])[:20]: lines.append( f"| `{r['route']}` " f"| {fmt_ms(r['before_p95_latency_ms'])} " f"| {fmt_ms(r['after_p95_latency_ms'])} " f"| {fmt_pct(r['change_p95_latency_ms'])} |" ) if degraded: lines.extend(["", "## Degraded Routes (P95 increased >5%)", ""]) lines.append("| Route | Before P95 | After P95 | Change |") lines.append("| --- | ---: | ---: | ---: |") for r in sorted(degraded, key=lambda x: -x["change_p95_latency_ms"])[:20]: lines.append( f"| `{r['route']}` " f"| {fmt_ms(r['before_p95_latency_ms'])} " f"| {fmt_ms(r['after_p95_latency_ms'])} " f"| {fmt_pct(r['change_p95_latency_ms'])} |" ) return "\n".join(lines) + "\n" def main() -> int: parser = argparse.ArgumentParser(description="Compare two daily backend latency report JSONs.") parser.add_argument("before", help="Path to the before (baseline) report JSON") parser.add_argument("after", help="Path to the after report JSON") parser.add_argument("--output-json", help="Write structured comparison JSON") parser.add_argument("--output-markdown", help="Write markdown comparison report") args = parser.parse_args() before = load_report(Path(args.before)) after = load_report(Path(args.after)) if "route_latency_profile" not in before: print("ERROR: 'before' report missing route_latency_profile. Re-run the report with the updated script.", file=sys.stderr) return 1 if "route_latency_profile" not in after: print("ERROR: 'after' report missing route_latency_profile. Re-run the report with the updated script.", file=sys.stderr) return 1 comparison = build_comparison(before, after) markdown = render_markdown(comparison) if args.output_json: Path(args.output_json).write_text(json.dumps(comparison, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") if args.output_markdown: Path(args.output_markdown).write_text(markdown, encoding="utf-8") print(markdown) return 0 if __name__ == "__main__": raise SystemExit(main())