fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyze ConnectWise API call logs.
|
||||
|
||||
Looks for the most recent log file in cw-api-logs/ by default,
|
||||
or accepts an explicit path as an argument.
|
||||
|
||||
Usage:
|
||||
python3 analyze-cw-calls.py # latest file in cw-api-logs/
|
||||
python3 analyze-cw-calls.py cw-api-logs/specific.jsonl
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import os
|
||||
import glob
|
||||
import statistics
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
CYAN = "\033[96m"
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def colour_duration(ms: float) -> str:
|
||||
if ms >= 10_000:
|
||||
return f"{RED}{ms:,.0f}ms{RESET}"
|
||||
if ms >= 5_000:
|
||||
return f"{YELLOW}{ms:,.0f}ms{RESET}"
|
||||
return f"{GREEN}{ms:,.0f}ms{RESET}"
|
||||
|
||||
def header(title: str) -> str:
|
||||
return f"\n{BOLD}{CYAN}{'─' * 60}\n {title}\n{'─' * 60}{RESET}"
|
||||
|
||||
# ── Resolve log file ────────────────────────────────────────────────────────
|
||||
|
||||
def find_latest_log() -> str:
|
||||
"""Find the most recent .jsonl file in cw-api-logs/."""
|
||||
log_dir = os.path.join(os.getcwd(), "cw-api-logs")
|
||||
files = sorted(glob.glob(os.path.join(log_dir, "*.jsonl")))
|
||||
if not files:
|
||||
print(f"{RED}No log files found in cw-api-logs/{RESET}")
|
||||
print(f"Run {BOLD}bun run dev:log{RESET} to start logging CW API calls.")
|
||||
sys.exit(1)
|
||||
return files[-1]
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
log_path = sys.argv[1]
|
||||
else:
|
||||
log_path = find_latest_log()
|
||||
|
||||
print(f"{DIM}Reading: {log_path}{RESET}")
|
||||
|
||||
entries = []
|
||||
parse_errors = 0
|
||||
with open(log_path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
parse_errors += 1
|
||||
|
||||
if not entries:
|
||||
print("No entries found. Check the log file path.")
|
||||
sys.exit(1)
|
||||
|
||||
# ── Derived fields ───────────────────────────────────────────────────────────
|
||||
|
||||
durations = [e["durationMs"] for e in entries]
|
||||
errors = [e for e in entries if e.get("error")]
|
||||
successes = [e for e in entries if not e.get("error")]
|
||||
timestamps = [datetime.fromisoformat(e["timestamp"].replace("Z", "+00:00")) for e in entries]
|
||||
|
||||
time_span = (timestamps[-1] - timestamps[0]) if len(timestamps) > 1 else timedelta(0)
|
||||
|
||||
# Normalise the URL to a route pattern for grouping
|
||||
def normalise_url(url: str) -> str:
|
||||
parts = url.split("?")[0].rstrip("/").split("/")
|
||||
normalised = []
|
||||
for p in parts:
|
||||
if p.isdigit():
|
||||
normalised.append(":id")
|
||||
else:
|
||||
normalised.append(p)
|
||||
return "/".join(normalised)
|
||||
|
||||
# ── 1. Overview ──────────────────────────────────────────────────────────────
|
||||
|
||||
print(header("OVERVIEW"))
|
||||
print(f" Log file : {log_path}")
|
||||
print(f" Total calls : {BOLD}{len(entries):,}{RESET}")
|
||||
print(f" Successes : {GREEN}{len(successes):,}{RESET}")
|
||||
print(f" Failures : {RED}{len(errors):,}{RESET} ({len(errors)/len(entries)*100:.1f}%)")
|
||||
print(f" Time span : {time_span}")
|
||||
if time_span.total_seconds() > 0:
|
||||
rps = len(entries) / time_span.total_seconds()
|
||||
print(f" Avg req/sec : {rps:.2f}")
|
||||
if parse_errors:
|
||||
print(f" Parse errors : {YELLOW}{parse_errors}{RESET}")
|
||||
|
||||
# ── 2. Duration stats ───────────────────────────────────────────────────────
|
||||
|
||||
print(header("DURATION STATS (all calls)"))
|
||||
sorted_dur = sorted(durations)
|
||||
p50 = sorted_dur[len(sorted_dur) * 50 // 100]
|
||||
p90 = sorted_dur[len(sorted_dur) * 90 // 100]
|
||||
p95 = sorted_dur[len(sorted_dur) * 95 // 100]
|
||||
p99 = sorted_dur[len(sorted_dur) * 99 // 100]
|
||||
|
||||
print(f" Min : {colour_duration(min(durations))}")
|
||||
print(f" Max : {colour_duration(max(durations))}")
|
||||
print(f" Mean : {colour_duration(statistics.mean(durations))}")
|
||||
print(f" Median (p50) : {colour_duration(p50)}")
|
||||
print(f" p90 : {colour_duration(p90)}")
|
||||
print(f" p95 : {colour_duration(p95)}")
|
||||
print(f" p99 : {colour_duration(p99)}")
|
||||
print(f" Std dev : {statistics.stdev(durations):,.0f}ms" if len(durations) > 1 else "")
|
||||
|
||||
# Duration buckets
|
||||
buckets = {"<500ms": 0, "500ms-1s": 0, "1-3s": 0, "3-5s": 0, "5-10s": 0, "10-20s": 0, "20s+": 0}
|
||||
for d in durations:
|
||||
if d < 500: buckets["<500ms"] += 1
|
||||
elif d < 1000: buckets["500ms-1s"] += 1
|
||||
elif d < 3000: buckets["1-3s"] += 1
|
||||
elif d < 5000: buckets["3-5s"] += 1
|
||||
elif d < 10000: buckets["5-10s"] += 1
|
||||
elif d < 20000: buckets["10-20s"] += 1
|
||||
else: buckets["20s+"] += 1
|
||||
|
||||
print(f"\n {BOLD}Distribution:{RESET}")
|
||||
max_bar = 40
|
||||
max_count = max(buckets.values()) if buckets else 1
|
||||
for label, count in buckets.items():
|
||||
bar_len = int(count / max_count * max_bar) if max_count else 0
|
||||
pct = count / len(durations) * 100
|
||||
bar = "█" * bar_len
|
||||
clr = GREEN if "500" in label or "<" in label else (YELLOW if "1-3" in label or "3-5" in label else RED)
|
||||
print(f" {label:>10s} {clr}{bar}{RESET} {count:>5,} ({pct:5.1f}%)")
|
||||
|
||||
# ── 3. Errors breakdown ─────────────────────────────────────────────────────
|
||||
|
||||
print(header("ERROR BREAKDOWN"))
|
||||
if not errors:
|
||||
print(f" {GREEN}No errors! 🎉{RESET}")
|
||||
else:
|
||||
error_codes = Counter()
|
||||
for e in errors:
|
||||
err_str = e.get("error", "unknown")
|
||||
code = err_str.split(":")[0] if ":" in err_str else err_str
|
||||
error_codes[code] += 1
|
||||
|
||||
for code, count in error_codes.most_common():
|
||||
print(f" {RED}{code:<30s}{RESET} {count:>5,} ({count/len(entries)*100:.1f}%)")
|
||||
|
||||
# Errored URLs
|
||||
errored_urls = Counter(normalise_url(e["url"]) for e in errors)
|
||||
print(f"\n {BOLD}Top errored endpoints:{RESET}")
|
||||
for url, count in errored_urls.most_common(10):
|
||||
print(f" {count:>5,} {url}")
|
||||
|
||||
# ── 4. Slowest individual calls ─────────────────────────────────────────────
|
||||
|
||||
print(header("TOP 20 SLOWEST CALLS"))
|
||||
slowest = sorted(entries, key=lambda e: e["durationMs"], reverse=True)[:20]
|
||||
for i, e in enumerate(slowest, 1):
|
||||
status = e.get("status") or f"{RED}ERR{RESET}"
|
||||
err_tag = f" {RED}[{e['error'].split(':')[0]}]{RESET}" if e.get("error") else ""
|
||||
print(f" {i:>2}. {colour_duration(e['durationMs']):>20s} {e['method']:>4s} {e['url'][:60]:<60s} {DIM}{status}{RESET}{err_tag}")
|
||||
|
||||
# ── 5. Per-endpoint stats ───────────────────────────────────────────────────
|
||||
|
||||
print(header("PER-ENDPOINT STATS (by route pattern)"))
|
||||
by_route = defaultdict(list)
|
||||
for e in entries:
|
||||
route = normalise_url(e["url"])
|
||||
by_route[route].append(e)
|
||||
|
||||
# Sort by total time spent descending (most impactful)
|
||||
route_stats = []
|
||||
for route, calls in by_route.items():
|
||||
durs = [c["durationMs"] for c in calls]
|
||||
errs = sum(1 for c in calls if c.get("error"))
|
||||
sorted_d = sorted(durs)
|
||||
route_stats.append({
|
||||
"route": route,
|
||||
"count": len(calls),
|
||||
"errors": errs,
|
||||
"total_ms": sum(durs),
|
||||
"mean": statistics.mean(durs),
|
||||
"p50": sorted_d[len(sorted_d) * 50 // 100],
|
||||
"p95": sorted_d[len(sorted_d) * 95 // 100],
|
||||
"max": max(durs),
|
||||
})
|
||||
|
||||
route_stats.sort(key=lambda r: r["total_ms"], reverse=True)
|
||||
|
||||
print(f" {'Route':<55s} {'Count':>6s} {'Errs':>5s} {'Mean':>8s} {'p50':>8s} {'p95':>8s} {'Max':>8s} {'Total':>10s}")
|
||||
print(f" {'─' * 55} {'─' * 6} {'─' * 5} {'─' * 8} {'─' * 8} {'─' * 8} {'─' * 8} {'─' * 10}")
|
||||
for r in route_stats[:25]:
|
||||
err_str = f"{RED}{r['errors']}{RESET}" if r['errors'] else f"{DIM}0{RESET}"
|
||||
print(
|
||||
f" {r['route']:<55s} {r['count']:>6,} {err_str:>14s} "
|
||||
f"{r['mean']:>7,.0f}ms {r['p50']:>7,.0f}ms {r['p95']:>7,.0f}ms "
|
||||
f"{r['max']:>7,.0f}ms {r['total_ms']/1000:>8,.1f}s"
|
||||
)
|
||||
|
||||
# ── 6. HTTP method breakdown ────────────────────────────────────────────────
|
||||
|
||||
print(header("BY HTTP METHOD"))
|
||||
by_method = defaultdict(list)
|
||||
for e in entries:
|
||||
by_method[e["method"]].append(e["durationMs"])
|
||||
|
||||
print(f" {'Method':<8s} {'Count':>7s} {'Mean':>9s} {'p95':>9s} {'Max':>9s}")
|
||||
print(f" {'─' * 8} {'─' * 7} {'─' * 9} {'─' * 9} {'─' * 9}")
|
||||
for method in sorted(by_method.keys()):
|
||||
durs = by_method[method]
|
||||
sd = sorted(durs)
|
||||
print(
|
||||
f" {method:<8s} {len(durs):>7,} "
|
||||
f"{statistics.mean(durs):>8,.0f}ms "
|
||||
f"{sd[len(sd)*95//100]:>8,.0f}ms "
|
||||
f"{max(durs):>8,.0f}ms"
|
||||
)
|
||||
|
||||
# ── 7. Timeline (calls per minute) ──────────────────────────────────────────
|
||||
|
||||
if time_span.total_seconds() > 60:
|
||||
print(header("TIMELINE (per-minute throughput & errors)"))
|
||||
by_minute = defaultdict(lambda: {"count": 0, "errors": 0, "dur_sum": 0})
|
||||
for e in entries:
|
||||
ts = e["timestamp"][:16] # YYYY-MM-DDTHH:MM
|
||||
by_minute[ts]["count"] += 1
|
||||
by_minute[ts]["dur_sum"] += e["durationMs"]
|
||||
if e.get("error"):
|
||||
by_minute[ts]["errors"] += 1
|
||||
|
||||
for minute in sorted(by_minute.keys()):
|
||||
m = by_minute[minute]
|
||||
avg = m["dur_sum"] / m["count"] if m["count"] else 0
|
||||
err_part = f" {RED}({m['errors']} errs){RESET}" if m["errors"] else ""
|
||||
bar = "▓" * min(m["count"] // 5, 50)
|
||||
avg_clr = colour_duration(avg)
|
||||
print(f" {minute} {m['count']:>5,} reqs avg {avg_clr:>20s} {bar}{err_part}")
|
||||
|
||||
# ── 8. Concurrency hotspots ─────────────────────────────────────────────────
|
||||
|
||||
print(header("CONCURRENCY HOTSPOTS (calls starting within 100ms of each other)"))
|
||||
ts_ms = [int(t.timestamp() * 1000) for t in timestamps]
|
||||
bursts = []
|
||||
i = 0
|
||||
while i < len(ts_ms):
|
||||
j = i
|
||||
while j < len(ts_ms) - 1 and ts_ms[j + 1] - ts_ms[j] < 100:
|
||||
j += 1
|
||||
burst_size = j - i + 1
|
||||
if burst_size >= 5:
|
||||
burst_entries = entries[i:j + 1]
|
||||
avg_dur = statistics.mean(e["durationMs"] for e in burst_entries)
|
||||
bursts.append((burst_size, entries[i]["timestamp"], avg_dur, burst_entries))
|
||||
i = j + 1
|
||||
|
||||
bursts.sort(key=lambda b: b[0], reverse=True)
|
||||
if bursts:
|
||||
print(f" Found {len(bursts)} burst(s) of ≥5 concurrent requests\n")
|
||||
for size, ts, avg, _ in bursts[:10]:
|
||||
print(f" {YELLOW}{size:>3} concurrent{RESET} at {ts} avg {colour_duration(avg)}")
|
||||
else:
|
||||
print(f" {GREEN}No major concurrency bursts detected.{RESET}")
|
||||
|
||||
# ── 9. Summary / recommendations ────────────────────────────────────────────
|
||||
|
||||
print(header("SUMMARY"))
|
||||
err_rate = len(errors) / len(entries) * 100
|
||||
slow_5s = sum(1 for d in durations if d >= 5000)
|
||||
slow_pct = slow_5s / len(entries) * 100
|
||||
|
||||
if err_rate > 5:
|
||||
print(f" {RED}⚠ Error rate is {err_rate:.1f}% — CW API is struggling{RESET}")
|
||||
elif err_rate > 1:
|
||||
print(f" {YELLOW}⚠ Error rate is {err_rate:.1f}% — some instability{RESET}")
|
||||
else:
|
||||
print(f" {GREEN}✓ Error rate is {err_rate:.1f}% — acceptable{RESET}")
|
||||
|
||||
if slow_pct > 10:
|
||||
print(f" {RED}⚠ {slow_5s:,} calls ({slow_pct:.1f}%) took >5s — CW is slow or rate-limiting{RESET}")
|
||||
elif slow_pct > 2:
|
||||
print(f" {YELLOW}⚠ {slow_5s:,} calls ({slow_pct:.1f}%) took >5s{RESET}")
|
||||
else:
|
||||
print(f" {GREEN}✓ Only {slow_5s:,} calls ({slow_pct:.1f}%) over 5s{RESET}")
|
||||
|
||||
if bursts:
|
||||
max_burst = max(b[0] for b in bursts)
|
||||
print(f" {YELLOW}⚠ Max concurrency burst: {max_burst} simultaneous requests — consider lowering CONCURRENCY{RESET}")
|
||||
|
||||
total_time_s = sum(durations) / 1000
|
||||
print(f"\n Total wall-clock time spent waiting on CW: {BOLD}{total_time_s:,.1f}s{RESET} ({total_time_s/60:,.1f} min)")
|
||||
print()
|
||||
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_iso(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
normalized = value.replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(normalized)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
if isinstance(value, str) and value.strip() == "":
|
||||
continue
|
||||
return str(value)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def top_lines(counter: Counter[str], limit: int) -> list[str]:
|
||||
return [f"{k}: {v}" for k, v in counter.most_common(limit)]
|
||||
|
||||
|
||||
def fmt_pct(part: int, total: int) -> str:
|
||||
if total == 0:
|
||||
return "0.0%"
|
||||
return f"{(part / total) * 100:.1f}%"
|
||||
|
||||
|
||||
def human_duration(start: datetime | None, end: datetime | None) -> str:
|
||||
if start is None or end is None:
|
||||
return "unknown"
|
||||
|
||||
delta = end - start
|
||||
total_seconds = int(delta.total_seconds())
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
return f"{hours}h {minutes}m {seconds}s"
|
||||
|
||||
|
||||
def truncate(value: str, max_len: int = 90) -> str:
|
||||
if len(value) <= max_len:
|
||||
return value
|
||||
return value[: max_len - 1] + "…"
|
||||
|
||||
|
||||
def add_section(lines: list[str], title: str) -> None:
|
||||
lines.append("")
|
||||
lines.append(title)
|
||||
lines.append("-" * len(title))
|
||||
|
||||
|
||||
def supports_color(enabled: bool) -> bool:
|
||||
return enabled
|
||||
|
||||
|
||||
def paint(text: str, code: str, use_color: bool) -> str:
|
||||
if not use_color:
|
||||
return text
|
||||
return f"\033[{code}m{text}\033[0m"
|
||||
|
||||
|
||||
def good_bad_neutral(value: str, state: str, use_color: bool) -> str:
|
||||
if state == "good":
|
||||
return paint(value, "32", use_color)
|
||||
if state == "bad":
|
||||
return paint(value, "31", use_color)
|
||||
return paint(value, "36", use_color)
|
||||
|
||||
|
||||
def add_ranked_counter(
|
||||
lines: list[str],
|
||||
title: str,
|
||||
counter: Counter[str],
|
||||
top_n: int,
|
||||
total: int,
|
||||
truncate_labels: bool = False,
|
||||
) -> None:
|
||||
lines.append(f"• {title}")
|
||||
items = counter.most_common(top_n)
|
||||
if not items:
|
||||
lines.append(" (no data)")
|
||||
return
|
||||
|
||||
for index, (key, count) in enumerate(items, start=1):
|
||||
label = truncate(key) if truncate_labels else key
|
||||
lines.append(f" {index:>2}. {label:<90} {count:>4} {fmt_pct(count, total):>6}")
|
||||
|
||||
|
||||
def stream_row_summary(row: dict[str, Any], use_color: bool, max_path: int) -> str:
|
||||
request = row.get("request") or {}
|
||||
response = row.get("response") or {}
|
||||
body_parsed = request.get("bodyParsed") or {}
|
||||
entity_parsed = request.get("entityParsed") or {}
|
||||
summary = request.get("summary") or {}
|
||||
|
||||
timestamp = parse_iso(row.get("timestamp"))
|
||||
time_label = timestamp.astimezone(timezone.utc).strftime("%H:%M:%S") if timestamp else "--:--:--"
|
||||
|
||||
method = first_non_empty(request.get("method"))
|
||||
path = first_non_empty(request.get("path"))
|
||||
endpoint = path.split("?", 1)[0]
|
||||
status_code = first_non_empty(response.get("status"))
|
||||
|
||||
event_type = first_non_empty(body_parsed.get("Type"), summary.get("type"))
|
||||
action = first_non_empty(
|
||||
body_parsed.get("Action"),
|
||||
summary.get("action"),
|
||||
request.get("query", {}).get("params", {}).get("action"),
|
||||
)
|
||||
item_id = first_non_empty(body_parsed.get("ID"), summary.get("id"), request.get("query", {}).get("inferredId"))
|
||||
actor = first_non_empty(
|
||||
request.get("query", {}).get("params", {}).get("memberId"),
|
||||
summary.get("entityUpdatedBy"),
|
||||
entity_parsed.get("UpdatedBy"),
|
||||
)
|
||||
entity_status = first_non_empty(entity_parsed.get("StatusName"), summary.get("entityStatus"))
|
||||
|
||||
endpoint_label = truncate(endpoint, max_path)
|
||||
status_state = "good" if status_code.startswith("2") else "bad"
|
||||
status_colored = good_bad_neutral(status_code, status_state, use_color)
|
||||
event_colored = paint(f"{event_type}.{action}", "36", use_color)
|
||||
endpoint_colored = paint(endpoint_label, "94", use_color)
|
||||
|
||||
return (
|
||||
f"[{time_label}] {method:<4} {endpoint_colored:<20} "
|
||||
f"{status_colored:>3} {event_colored:<22} "
|
||||
f"id={item_id:<7} actor={truncate(actor, 16):<16} status={truncate(entity_status, 22)}"
|
||||
)
|
||||
|
||||
|
||||
def endpoint_stream_summary(log_path: Path, use_color: bool, max_path: int) -> str:
|
||||
lines: list[str] = []
|
||||
lines.append(paint("ENDPOINT STREAM (chronological)", "1;95", use_color))
|
||||
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
|
||||
|
||||
count = 0
|
||||
invalid = 0
|
||||
with log_path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
invalid += 1
|
||||
continue
|
||||
|
||||
lines.append(stream_row_summary(row, use_color=use_color, max_path=max_path))
|
||||
count += 1
|
||||
|
||||
lines.append(paint("────────────────────────────────────────────────────────────────────────────────────────────", "90", use_color))
|
||||
lines.append(
|
||||
f"events={count} invalid={good_bad_neutral(str(invalid), 'good' if invalid == 0 else 'bad', use_color)}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogStats:
|
||||
total_rows: int = 0
|
||||
parsed_rows: int = 0
|
||||
invalid_rows: int = 0
|
||||
earliest: datetime | None = None
|
||||
latest: datetime | None = None
|
||||
|
||||
methods: Counter[str] = None # type: ignore[assignment]
|
||||
paths: Counter[str] = None # type: ignore[assignment]
|
||||
endpoint_roots: Counter[str] = None # type: ignore[assignment]
|
||||
response_statuses: Counter[str] = None # type: ignore[assignment]
|
||||
event_types: Counter[str] = None # type: ignore[assignment]
|
||||
actions: Counter[str] = None # type: ignore[assignment]
|
||||
type_action_combo: Counter[str] = None # type: ignore[assignment]
|
||||
company_ids: Counter[str] = None # type: ignore[assignment]
|
||||
|
||||
source_members: Counter[str] = None # type: ignore[assignment]
|
||||
actor_members: Counter[str] = None # type: ignore[assignment]
|
||||
entity_updated_by: Counter[str] = None # type: ignore[assignment]
|
||||
|
||||
requests_by_hour: Counter[str] = None # type: ignore[assignment]
|
||||
requests_by_minute: Counter[str] = None # type: ignore[assignment]
|
||||
endpoint_by_hour: dict[str, Counter[str]] = None # type: ignore[assignment]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.methods = Counter()
|
||||
self.paths = Counter()
|
||||
self.endpoint_roots = Counter()
|
||||
self.response_statuses = Counter()
|
||||
self.event_types = Counter()
|
||||
self.actions = Counter()
|
||||
self.type_action_combo = Counter()
|
||||
self.company_ids = Counter()
|
||||
|
||||
self.source_members = Counter()
|
||||
self.actor_members = Counter()
|
||||
self.entity_updated_by = Counter()
|
||||
|
||||
self.requests_by_hour = Counter()
|
||||
self.requests_by_minute = Counter()
|
||||
self.endpoint_by_hour = defaultdict(Counter)
|
||||
|
||||
def add_timestamp(self, timestamp: datetime | None) -> None:
|
||||
if timestamp is None:
|
||||
return
|
||||
|
||||
self.earliest = timestamp if self.earliest is None else min(self.earliest, timestamp)
|
||||
self.latest = timestamp if self.latest is None else max(self.latest, timestamp)
|
||||
|
||||
hour_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
|
||||
minute_bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
self.requests_by_hour[hour_bucket] += 1
|
||||
self.requests_by_minute[minute_bucket] += 1
|
||||
|
||||
def summarize(self, top_n: int, busiest_n: int, use_color: bool) -> str:
|
||||
duration_line = human_duration(self.earliest, self.latest)
|
||||
time_range_line = "unknown"
|
||||
if self.earliest and self.latest:
|
||||
time_range_line = f"{self.earliest.isoformat()} → {self.latest.isoformat()}"
|
||||
|
||||
total_requests = self.parsed_rows
|
||||
success_count = self.response_statuses.get("200", 0)
|
||||
success_pct = fmt_pct(success_count, sum(self.response_statuses.values()))
|
||||
invalid_state = "good" if self.invalid_rows == 0 else "bad"
|
||||
|
||||
top_endpoints = self.endpoint_roots.most_common(2)
|
||||
top_users = self.actor_members.most_common(3)
|
||||
top_minutes = self.requests_by_minute.most_common(busiest_n)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(paint("WEBHOOK SNAPSHOT", "1;95", use_color))
|
||||
lines.append(paint("────────────────────────────────────────────────────────", "90", use_color))
|
||||
lines.append(
|
||||
" "
|
||||
+ paint("Rows", "1;97", use_color)
|
||||
+ f": {self.total_rows:<4} "
|
||||
+ paint("Parsed", "1;97", use_color)
|
||||
+ f": {self.parsed_rows:<4} "
|
||||
+ paint("Invalid", "1;97", use_color)
|
||||
+ f": {good_bad_neutral(str(self.invalid_rows), invalid_state, use_color)}"
|
||||
)
|
||||
lines.append(
|
||||
" "
|
||||
+ paint("Window", "1;97", use_color)
|
||||
+ f": {duration_line:<12} "
|
||||
+ paint("Success", "1;97", use_color)
|
||||
+ f": {good_bad_neutral(success_pct, 'good' if success_count else 'neutral', use_color)}"
|
||||
)
|
||||
lines.append(" " + paint("UTC Range", "1;97", use_color) + f": {time_range_line}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Top Endpoints", "1;94", use_color))
|
||||
if top_endpoints:
|
||||
for endpoint, count in top_endpoints:
|
||||
lines.append(f" • {endpoint:<14} {count:>4} ({fmt_pct(count, total_requests)})")
|
||||
if not top_endpoints:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Most Active Users (query memberId)", "1;94", use_color))
|
||||
if top_users:
|
||||
for user, count in top_users:
|
||||
lines.append(f" • {user:<18} {count:>4} ({fmt_pct(count, total_requests)})")
|
||||
if not top_users:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Busiest Minutes", "1;94", use_color))
|
||||
if top_minutes:
|
||||
for minute, count in top_minutes:
|
||||
lines.append(f" • {minute:<22} {count:>3}")
|
||||
if not top_minutes:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Request Mix", "1;94", use_color))
|
||||
method_line = ", ".join([f"{k}:{v}" for k, v in self.methods.most_common(3)]) or "(no data)"
|
||||
event_line = ", ".join([f"{k}:{v}" for k, v in self.event_types.most_common(3)]) or "(no data)"
|
||||
action_line = ", ".join([f"{k}:{v}" for k, v in self.actions.most_common(3)]) or "(no data)"
|
||||
lines.append(f" • Methods : {method_line}")
|
||||
lines.append(f" • Types : {event_line}")
|
||||
lines.append(f" • Actions : {action_line}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(paint("Status Codes", "1;94", use_color))
|
||||
if self.response_statuses:
|
||||
status_total = sum(self.response_statuses.values())
|
||||
for status, count in self.response_statuses.most_common(5):
|
||||
state = "good" if status.startswith("2") else "bad"
|
||||
status_label = good_bad_neutral(status, state, use_color)
|
||||
lines.append(f" • {status_label}: {count} ({fmt_pct(count, status_total)})")
|
||||
if not self.response_statuses:
|
||||
lines.append(" • (no data)")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def update_stats(stats: LogStats, row: dict[str, Any]) -> None:
|
||||
timestamp = parse_iso(row.get("timestamp"))
|
||||
stats.add_timestamp(timestamp)
|
||||
|
||||
request = row.get("request") or {}
|
||||
response = row.get("response") or {}
|
||||
body_parsed = request.get("bodyParsed") or {}
|
||||
entity_parsed = request.get("entityParsed") or {}
|
||||
|
||||
method = first_non_empty(request.get("method"))
|
||||
path = first_non_empty(request.get("path"))
|
||||
endpoint_root = path.split("?", 1)[0]
|
||||
status = first_non_empty(response.get("status"))
|
||||
|
||||
event_type = first_non_empty(
|
||||
body_parsed.get("Type"),
|
||||
request.get("summary", {}).get("type"),
|
||||
)
|
||||
action = first_non_empty(
|
||||
body_parsed.get("Action"),
|
||||
request.get("summary", {}).get("action"),
|
||||
request.get("query", {}).get("params", {}).get("action"),
|
||||
)
|
||||
combo = f"{event_type}:{action}"
|
||||
|
||||
source_member = first_non_empty(
|
||||
body_parsed.get("MemberId"),
|
||||
request.get("summary", {}).get("memberId"),
|
||||
)
|
||||
actor_member = first_non_empty(
|
||||
request.get("query", {}).get("params", {}).get("memberId"),
|
||||
request.get("summary", {}).get("entityUpdatedBy"),
|
||||
)
|
||||
updated_by = first_non_empty(
|
||||
entity_parsed.get("UpdatedBy"),
|
||||
request.get("summary", {}).get("entityUpdatedBy"),
|
||||
)
|
||||
company_id = first_non_empty(body_parsed.get("CompanyId"), request.get("headers", {}).get("companyname"))
|
||||
|
||||
stats.methods[method] += 1
|
||||
stats.paths[path] += 1
|
||||
stats.endpoint_roots[endpoint_root] += 1
|
||||
stats.response_statuses[status] += 1
|
||||
stats.event_types[event_type] += 1
|
||||
stats.actions[action] += 1
|
||||
stats.type_action_combo[combo] += 1
|
||||
stats.company_ids[company_id] += 1
|
||||
|
||||
stats.source_members[source_member] += 1
|
||||
stats.actor_members[actor_member] += 1
|
||||
stats.entity_updated_by[updated_by] += 1
|
||||
|
||||
if timestamp:
|
||||
bucket = timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:00 UTC")
|
||||
stats.endpoint_by_hour[endpoint_root][bucket] += 1
|
||||
|
||||
|
||||
def analyze_file(log_path: Path) -> LogStats:
|
||||
stats = LogStats()
|
||||
|
||||
with log_path.open("r", encoding="utf-8") as handle:
|
||||
for raw_line in handle:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
stats.total_rows += 1
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
stats.invalid_rows += 1
|
||||
continue
|
||||
|
||||
stats.parsed_rows += 1
|
||||
update_stats(stats, row)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze webhook JSONL logs by users, time, and request types."
|
||||
)
|
||||
parser.add_argument("log_file", help="Path to JSONL log file")
|
||||
parser.add_argument("--top", type=int, default=10, help="Top N entries per section (default: 10)")
|
||||
parser.add_argument(
|
||||
"--busiest-minutes",
|
||||
type=int,
|
||||
default=5,
|
||||
help="How many top minute buckets to show (default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-color",
|
||||
action="store_true",
|
||||
help="Disable ANSI colors",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--endpoint-stream",
|
||||
action="store_true",
|
||||
help="Show chronological one-line summary per webhook, similar to live test webserver logs",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-path",
|
||||
type=int,
|
||||
default=18,
|
||||
help="Max endpoint width in stream mode before truncation (default: 18)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log_path = Path(args.log_file)
|
||||
if not log_path.exists() or not log_path.is_file():
|
||||
raise SystemExit(f"Log file not found: {log_path}")
|
||||
|
||||
use_color = supports_color(not args.no_color)
|
||||
|
||||
if args.endpoint_stream:
|
||||
print(endpoint_stream_summary(log_path, use_color=use_color, max_path=max(args.max_path, 10)))
|
||||
return
|
||||
|
||||
stats = analyze_file(log_path)
|
||||
print(
|
||||
stats.summarize(
|
||||
top_n=max(args.top, 1),
|
||||
busiest_n=max(args.busiest_minutes, 1),
|
||||
use_color=use_color,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,900 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate a print-friendly PDF report from the latest test-webserver log file.
|
||||
|
||||
Usage:
|
||||
python3 generate_log_report.py [optional_log_file_path]
|
||||
|
||||
If no path is given, the script finds the latest test-webserver-*.jsonl
|
||||
file in ../cw-api-logs/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
from datetime import datetime, timezone
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import LETTER
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate,
|
||||
Paragraph,
|
||||
Spacer,
|
||||
Table,
|
||||
TableStyle,
|
||||
PageBreak,
|
||||
HRFlowable,
|
||||
KeepTogether,
|
||||
)
|
||||
from reportlab.graphics.shapes import Drawing, String
|
||||
from reportlab.graphics.charts.piecharts import Pie
|
||||
from reportlab.graphics.charts.barcharts import VerticalBarChart
|
||||
|
||||
# ─── Print-friendly color palette ─────────────────────────────────────────────
|
||||
# Minimal ink: white backgrounds, thin borders, dark text, subtle accents
|
||||
HEADER_BG = colors.HexColor("#2c3e50") # Dark header (used sparingly)
|
||||
ACCENT = colors.HexColor("#2980b9") # Muted blue
|
||||
ACCENT_2 = colors.HexColor("#27ae60") # Muted green
|
||||
ACCENT_3 = colors.HexColor("#8e44ad") # Muted purple
|
||||
WHITE = colors.white
|
||||
GRAY_50 = colors.HexColor("#fafafa")
|
||||
GRAY_100 = colors.HexColor("#f5f5f5")
|
||||
GRAY_200 = colors.HexColor("#e0e0e0")
|
||||
GRAY_400 = colors.HexColor("#bdbdbd")
|
||||
GRAY_600 = colors.HexColor("#757575")
|
||||
GRAY_800 = colors.HexColor("#424242")
|
||||
GRAY_900 = colors.HexColor("#212121")
|
||||
|
||||
# Pie/chart fills — muted, distinguishable in B&W too
|
||||
PIE_COLORS = [
|
||||
colors.HexColor("#5b9bd5"), # steel blue
|
||||
colors.HexColor("#ed7d31"), # soft orange
|
||||
colors.HexColor("#a5a5a5"), # gray
|
||||
colors.HexColor("#ffc000"), # amber
|
||||
colors.HexColor("#70ad47"), # olive green
|
||||
colors.HexColor("#4472c4"), # darker blue
|
||||
colors.HexColor("#c55a11"), # brown
|
||||
colors.HexColor("#7030a0"), # purple
|
||||
]
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def find_latest_log(base_dir):
|
||||
pattern = os.path.join(base_dir, "test-webserver-*.jsonl")
|
||||
files = sorted(glob.glob(pattern))
|
||||
if not files:
|
||||
raise FileNotFoundError(f"No test-webserver log files found in {base_dir}")
|
||||
return files[-1]
|
||||
|
||||
|
||||
def parse_log(path):
|
||||
entries = []
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def fmt_ts(iso_str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
except Exception:
|
||||
return str(iso_str)
|
||||
|
||||
|
||||
def duration_str(seconds):
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
minutes = seconds / 60
|
||||
if minutes < 60:
|
||||
return f"{minutes:.1f}m"
|
||||
hours = minutes / 60
|
||||
return f"{hours:.1f}h"
|
||||
|
||||
|
||||
def truncate(s, max_len=50):
|
||||
s = str(s)
|
||||
return s if len(s) <= max_len else s[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def resolve_actor(entry):
|
||||
"""
|
||||
Derive the actor exactly like testWebserver.ts does:
|
||||
entityUpdatedBy ?? query.params.memberId ?? summary.memberId ?? "-"
|
||||
The summary is already stored in request.summary in the log.
|
||||
"""
|
||||
req = entry.get("request", {})
|
||||
summary = req.get("summary") or {}
|
||||
query = req.get("query") or {}
|
||||
params = query.get("params") or {}
|
||||
|
||||
return str(
|
||||
summary.get("entityUpdatedBy")
|
||||
or params.get("memberId")
|
||||
or summary.get("memberId")
|
||||
or "-"
|
||||
)
|
||||
|
||||
|
||||
# ─── Analysis ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def analyze(entries):
|
||||
stats = {}
|
||||
|
||||
timestamps = []
|
||||
for e in entries:
|
||||
ts = e.get("timestamp")
|
||||
if ts:
|
||||
try:
|
||||
timestamps.append(datetime.fromisoformat(ts.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
timestamps.sort()
|
||||
stats["total_entries"] = len(entries)
|
||||
stats["first_ts"] = timestamps[0] if timestamps else None
|
||||
stats["last_ts"] = timestamps[-1] if timestamps else None
|
||||
stats["duration_seconds"] = (
|
||||
(timestamps[-1] - timestamps[0]).total_seconds() if len(timestamps) >= 2 else 0
|
||||
)
|
||||
|
||||
# Global counters
|
||||
methods = Counter()
|
||||
paths = Counter()
|
||||
statuses = Counter()
|
||||
actions = Counter()
|
||||
types = Counter()
|
||||
actors = Counter()
|
||||
companies = Counter()
|
||||
entity_ids = set()
|
||||
stages = Counter()
|
||||
ratings = Counter()
|
||||
hourly_buckets = Counter()
|
||||
minute_buckets = Counter()
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
|
||||
methods[req.get("method", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
paths[raw_path] += 1
|
||||
statuses[resp.get("status", "?")] += 1
|
||||
actions[bp.get("Action", "?")] += 1
|
||||
types[bp.get("Type", "?")] += 1
|
||||
|
||||
actor = resolve_actor(e)
|
||||
actors[actor] += 1
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
companies[cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
entity_ids.add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
stages[stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ratings[rating] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
dt = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
hourly_buckets[dt.strftime("%H:00")] += 1
|
||||
minute_buckets[dt.strftime("%H:%M")] += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Per-actor deep stats ──
|
||||
actor_details = defaultdict(lambda: {
|
||||
"count": 0,
|
||||
"actions": Counter(),
|
||||
"types": Counter(),
|
||||
"companies": Counter(),
|
||||
"entity_ids": set(),
|
||||
"stages": Counter(),
|
||||
"ratings": Counter(),
|
||||
"timestamps": [],
|
||||
"statuses": Counter(),
|
||||
"paths": Counter(),
|
||||
"member_ids": Counter(),
|
||||
"sales_reps": Counter(),
|
||||
"total_estimated": 0.0,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
resp = e.get("response", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
summary = req.get("summary") or {}
|
||||
|
||||
actor = resolve_actor(e)
|
||||
ad = actor_details[actor]
|
||||
ad["count"] += 1
|
||||
ad["actions"][bp.get("Action", "?")] += 1
|
||||
ad["types"][bp.get("Type", "?")] += 1
|
||||
ad["statuses"][resp.get("status", "?")] += 1
|
||||
raw_path = req.get("path", "?").split("?")[0]
|
||||
ad["paths"][raw_path] += 1
|
||||
|
||||
# Track which MemberIds triggered callbacks for this actor
|
||||
mid = bp.get("MemberId")
|
||||
if mid:
|
||||
ad["member_ids"][mid] += 1
|
||||
|
||||
ts_str = e.get("timestamp")
|
||||
if ts_str:
|
||||
try:
|
||||
ad["timestamps"].append(datetime.fromisoformat(ts_str.replace("Z", "+00:00")))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if isinstance(entity, dict):
|
||||
cn = entity.get("CompanyName")
|
||||
if cn:
|
||||
ad["companies"][cn] += 1
|
||||
eid = entity.get("Id")
|
||||
if eid is not None:
|
||||
ad["entity_ids"].add(eid)
|
||||
stage = entity.get("StageName")
|
||||
if stage:
|
||||
ad["stages"][stage] += 1
|
||||
rating = entity.get("Rating")
|
||||
if rating:
|
||||
ad["ratings"][rating] += 1
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
ad["total_estimated"] += float(et)
|
||||
pr = entity.get("PrimarySalesRep")
|
||||
if pr:
|
||||
ad["sales_reps"][pr] += 1
|
||||
|
||||
# Compute per-actor derived stats
|
||||
for aid, ad in actor_details.items():
|
||||
ad["timestamps"].sort()
|
||||
if len(ad["timestamps"]) >= 2:
|
||||
dur = (ad["timestamps"][-1] - ad["timestamps"][0]).total_seconds()
|
||||
ad["duration_seconds"] = dur
|
||||
ad["events_per_minute"] = ad["count"] / (dur / 60) if dur > 0 else ad["count"]
|
||||
else:
|
||||
ad["duration_seconds"] = 0
|
||||
ad["events_per_minute"] = ad["count"]
|
||||
ad["first_ts"] = ad["timestamps"][0] if ad["timestamps"] else None
|
||||
ad["last_ts"] = ad["timestamps"][-1] if ad["timestamps"] else None
|
||||
|
||||
stats["actor_details"] = dict(actor_details)
|
||||
stats["methods"] = methods
|
||||
stats["paths"] = paths
|
||||
stats["statuses"] = statuses
|
||||
stats["actions"] = actions
|
||||
stats["types"] = types
|
||||
stats["actors"] = actors
|
||||
stats["companies"] = companies
|
||||
stats["entity_ids"] = entity_ids
|
||||
stats["stages"] = stages
|
||||
stats["ratings"] = ratings
|
||||
stats["hourly_buckets"] = hourly_buckets
|
||||
stats["minute_buckets"] = minute_buckets
|
||||
|
||||
if stats["duration_seconds"] > 0:
|
||||
stats["events_per_minute"] = len(entries) / (stats["duration_seconds"] / 60)
|
||||
else:
|
||||
stats["events_per_minute"] = len(entries)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ─── PDF building ─────────────────────────────────────────────────────────────
|
||||
|
||||
def build_styles():
|
||||
ss = getSampleStyleSheet()
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportTitle", parent=ss["Title"], fontSize=24, textColor=WHITE,
|
||||
spaceAfter=4, fontName="Helvetica-Bold", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"ReportSubtitle", parent=ss["Normal"], fontSize=11, textColor=GRAY_400,
|
||||
spaceAfter=2, fontName="Helvetica", alignment=TA_CENTER,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SectionHeader", parent=ss["Heading1"], fontSize=16, textColor=GRAY_900,
|
||||
spaceBefore=14, spaceAfter=6, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SubHeader", parent=ss["Heading2"], fontSize=12, textColor=GRAY_800,
|
||||
spaceBefore=10, spaceAfter=4, fontName="Helvetica-Bold",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BodyText2", parent=ss["Normal"], fontSize=9, textColor=GRAY_800,
|
||||
spaceAfter=3, fontName="Helvetica", leading=13,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"SmallGray", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
spaceAfter=2, fontName="Helvetica",
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPIValue", parent=ss["Normal"], fontSize=20, textColor=GRAY_900,
|
||||
fontName="Helvetica-Bold", alignment=TA_CENTER, leading=24,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"KPILabel", parent=ss["Normal"], fontSize=8, textColor=GRAY_600,
|
||||
fontName="Helvetica", alignment=TA_CENTER, spaceAfter=2,
|
||||
))
|
||||
ss.add(ParagraphStyle(
|
||||
"BannerText", parent=ss["Normal"], fontSize=9, textColor=WHITE,
|
||||
fontName="Helvetica-Bold", spaceAfter=1,
|
||||
))
|
||||
return ss
|
||||
|
||||
|
||||
def make_header_banner(ss, log_path, stats):
|
||||
elements = []
|
||||
fname = os.path.basename(log_path)
|
||||
banner_data = [[
|
||||
Paragraph("Webhook Log Report", ss["ReportTitle"]),
|
||||
], [
|
||||
Paragraph(fname, ss["ReportSubtitle"]),
|
||||
], [
|
||||
Paragraph(
|
||||
f"Generated {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}",
|
||||
ss["ReportSubtitle"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("TOPPADDING", (0, 0), (0, 0), 20),
|
||||
("BOTTOMPADDING", (0, -1), (-1, -1), 16),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 20),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 20),
|
||||
]))
|
||||
elements.append(banner)
|
||||
elements.append(Spacer(1, 14))
|
||||
return elements
|
||||
|
||||
|
||||
def make_kpi_card(label, value):
|
||||
ss = build_styles()
|
||||
card_data = [[
|
||||
Paragraph(str(value), ss["KPIValue"]),
|
||||
], [
|
||||
Paragraph(label, ss["KPILabel"]),
|
||||
]]
|
||||
card = Table(card_data, colWidths=[1.6 * inch], rowHeights=[28, 16])
|
||||
card.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_100),
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 6),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOX", (0, 0), (-1, -1), 0.5, GRAY_200),
|
||||
]))
|
||||
return card
|
||||
|
||||
|
||||
def make_kpi_row(cards_data):
|
||||
cards = [make_kpi_card(label, value) for label, value in cards_data]
|
||||
row = Table([cards], colWidths=[1.75 * inch] * len(cards))
|
||||
row.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
return row
|
||||
|
||||
|
||||
def make_table(title, counter, ss, max_rows=15):
|
||||
"""Generic table from a Counter — light styling for print."""
|
||||
elements = []
|
||||
elements.append(Paragraph(title, ss["SubHeader"]))
|
||||
|
||||
items = counter.most_common(max_rows)
|
||||
if not items:
|
||||
elements.append(Paragraph("<i>No data</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
total = sum(counter.values())
|
||||
header = ["Item", "Count", "%"]
|
||||
rows = [header]
|
||||
for name, count in items:
|
||||
pct = (count / total * 100) if total else 0
|
||||
rows.append([truncate(str(name), 45), f"{count:,}", f"{pct:.1f}%"])
|
||||
|
||||
t = Table(rows, colWidths=[3.6 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 9),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 9),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("BOTTOMPADDING", (0, 0), (-1, 0), 6),
|
||||
("TOPPADDING", (0, 0), (-1, 0), 6),
|
||||
("GRID", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (1, 0), (2, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 8),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 3),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 3),
|
||||
]))
|
||||
elements.append(t)
|
||||
return elements
|
||||
|
||||
|
||||
def make_pie_chart(title, counter, width=280, height=190):
|
||||
items = counter.most_common(8)
|
||||
if not items:
|
||||
return Spacer(1, 1)
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 12, title,
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
pie = Pie()
|
||||
pie.x = 50
|
||||
pie.y = 10
|
||||
pie.width = 110
|
||||
pie.height = 110
|
||||
pie.data = [v for _, v in items]
|
||||
pie.labels = [truncate(str(k), 18) for k, _ in items]
|
||||
pie.sideLabels = True
|
||||
pie.slices.strokeWidth = 0.5
|
||||
pie.slices.strokeColor = WHITE
|
||||
|
||||
for i in range(len(items)):
|
||||
pie.slices[i].fillColor = PIE_COLORS[i % len(PIE_COLORS)]
|
||||
pie.slices[i].fontName = "Helvetica"
|
||||
pie.slices[i].fontSize = 7
|
||||
pie.slices[i].labelRadius = 1.35
|
||||
|
||||
d.add(pie)
|
||||
return d
|
||||
|
||||
|
||||
def make_timeline_chart(minute_buckets, width=500, height=150):
|
||||
if not minute_buckets:
|
||||
return Spacer(1, 1)
|
||||
|
||||
sorted_keys = sorted(minute_buckets.keys())
|
||||
if len(sorted_keys) > 40:
|
||||
step = max(1, len(sorted_keys) // 40)
|
||||
sampled_keys = sorted_keys[::step]
|
||||
else:
|
||||
sampled_keys = sorted_keys
|
||||
|
||||
values = [minute_buckets[k] for k in sampled_keys]
|
||||
|
||||
d = Drawing(width, height)
|
||||
d.add(String(width / 2, height - 10, "Event Timeline (by minute)",
|
||||
fontSize=10, fontName="Helvetica-Bold",
|
||||
fillColor=GRAY_900, textAnchor="middle"))
|
||||
|
||||
chart = VerticalBarChart()
|
||||
chart.x = 50
|
||||
chart.y = 25
|
||||
chart.width = width - 80
|
||||
chart.height = height - 50
|
||||
chart.data = [values]
|
||||
chart.categoryAxis.categoryNames = sampled_keys
|
||||
chart.categoryAxis.labels.angle = 45
|
||||
chart.categoryAxis.labels.fontSize = 6
|
||||
chart.categoryAxis.labels.fontName = "Helvetica"
|
||||
chart.categoryAxis.labels.dy = -5
|
||||
chart.valueAxis.labels.fontSize = 7
|
||||
chart.valueAxis.labels.fontName = "Helvetica"
|
||||
chart.valueAxis.valueMin = 0
|
||||
chart.bars[0].fillColor = GRAY_600
|
||||
chart.bars[0].strokeColor = None
|
||||
chart.barWidth = max(2, int((width - 100) / len(values) * 0.7))
|
||||
|
||||
d.add(chart)
|
||||
return d
|
||||
|
||||
|
||||
def build_actor_activity_section(stats, ss):
|
||||
"""Per-actor deep-dive. Actor = entityUpdatedBy ?? query.memberId ?? summary.memberId."""
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Actor Activity Deep Dive", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=4))
|
||||
elements.append(Paragraph(
|
||||
'The <b>actor</b> is resolved as: <i>entityUpdatedBy → query.memberId → summary.memberId</i>. '
|
||||
'This is the person or system that caused the change in ConnectWise.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
actor_details = stats.get("actor_details", {})
|
||||
if not actor_details:
|
||||
elements.append(Paragraph("<i>No actor data available.</i>", ss["BodyText2"]))
|
||||
return elements
|
||||
|
||||
sorted_actors = sorted(actor_details.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
# Actor distribution pie chart
|
||||
actor_counter = Counter({aid: ad["count"] for aid, ad in sorted_actors})
|
||||
elements.append(make_pie_chart("Events by Actor", actor_counter, width=350, height=210))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
for idx, (aid, ad) in enumerate(sorted_actors):
|
||||
# Actor header — slim dark bar
|
||||
banner_data = [[
|
||||
Paragraph(
|
||||
f'<font size="12"><b>{aid}</b></font>'
|
||||
f' '
|
||||
f'<font size="9" color="#cccccc">{ad["count"]:,} events</font>',
|
||||
ss["BannerText"],
|
||||
),
|
||||
]]
|
||||
banner = Table(banner_data, colWidths=[7.0 * inch])
|
||||
banner.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), HEADER_BG),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 7),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 7),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 12),
|
||||
]))
|
||||
elements.append(banner)
|
||||
|
||||
# KPI row
|
||||
kpi = make_kpi_row([
|
||||
("Events", f"{ad['count']:,}"),
|
||||
("Entities", f"{len(ad['entity_ids']):,}"),
|
||||
("Companies", f"{len(ad['companies']):,}"),
|
||||
("Evts/Min", f"{ad['events_per_minute']:.1f}"),
|
||||
])
|
||||
elements.append(kpi)
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
# Info grid
|
||||
first_str = ad["first_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["first_ts"] else "—"
|
||||
last_str = ad["last_ts"].strftime("%Y-%m-%d %H:%M:%S") if ad["last_ts"] else "—"
|
||||
dur = duration_str(ad["duration_seconds"])
|
||||
est = ad["total_estimated"]
|
||||
|
||||
mid_str = ", ".join(f"{k} ({v})" for k, v in ad["member_ids"].most_common(5)) if ad["member_ids"] else "—"
|
||||
sr_str = ", ".join(f"{k} ({v})" for k, v in ad["sales_reps"].most_common(5)) if ad["sales_reps"] else "—"
|
||||
|
||||
info_rows = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{first_str}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{last_str}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Active Duration</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{dur}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Total Est. Value</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>${est:,.2f}</b>', ss["BodyText2"]),
|
||||
],
|
||||
[
|
||||
Paragraph('<font color="#757575">Callback Members</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Sales Reps</font>', ss["BodyText2"]),
|
||||
Paragraph(f'{truncate(sr_str, 40)}', ss["BodyText2"]),
|
||||
],
|
||||
]
|
||||
info_table = Table(info_rows, colWidths=[1.3 * inch, 2.1 * inch, 1.3 * inch, 2.1 * inch])
|
||||
info_table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 4),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
]))
|
||||
elements.append(info_table)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Breakdown tables
|
||||
if ad["types"]:
|
||||
elements.extend(make_table(f"{aid} — Entity Types", ad["types"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["companies"]:
|
||||
elements.extend(make_table(f"{aid} — Companies", ad["companies"], ss, max_rows=10))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["stages"]:
|
||||
elements.extend(make_table(f"{aid} — Stages", ad["stages"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if ad["ratings"]:
|
||||
elements.extend(make_table(f"{aid} — Ratings", ad["ratings"], ss, max_rows=8))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Entity IDs
|
||||
if ad["entity_ids"]:
|
||||
id_list = sorted(ad["entity_ids"])
|
||||
id_str = ", ".join(str(i) for i in id_list[:30])
|
||||
if len(id_list) > 30:
|
||||
id_str += f" ... (+{len(id_list) - 30} more)"
|
||||
elements.append(Paragraph(f"{aid} — Entity IDs Touched", ss["SubHeader"]))
|
||||
elements.append(Paragraph(f'<font size="8">{id_str}</font>', ss["BodyText2"]))
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
# Divider
|
||||
if idx < len(sorted_actors) - 1:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(HRFlowable(width="100%", thickness=0.5, color=GRAY_200, spaceAfter=4))
|
||||
elements.append(Spacer(1, 4))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def build_summary_log_table(entries, ss, max_rows=30):
|
||||
elements = []
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("Event Summary", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=6))
|
||||
elements.append(Paragraph(
|
||||
f"Aggregated view of {len(entries):,} webhook events — grouped by entity.",
|
||||
ss["SmallGray"],
|
||||
))
|
||||
elements.append(Spacer(1, 8))
|
||||
|
||||
entity_groups = defaultdict(lambda: {
|
||||
"count": 0, "name": "—", "company": "—",
|
||||
"actions": Counter(), "actors": set(),
|
||||
"est_total": None,
|
||||
})
|
||||
|
||||
for e in entries:
|
||||
req = e.get("request", {})
|
||||
bp = req.get("bodyParsed") or {}
|
||||
entity = req.get("entityParsed") or bp.get("Entity") or {}
|
||||
if not isinstance(entity, dict):
|
||||
continue
|
||||
eid = entity.get("Id")
|
||||
if eid is None:
|
||||
continue
|
||||
|
||||
eg = entity_groups[eid]
|
||||
eg["count"] += 1
|
||||
eg["name"] = entity.get("OpportunityName") or entity.get("CompanyName") or eg["name"]
|
||||
eg["company"] = entity.get("CompanyName") or eg["company"]
|
||||
eg["actions"][bp.get("Action", "?")] += 1
|
||||
actor = resolve_actor(e)
|
||||
eg["actors"].add(actor)
|
||||
et = entity.get("EstimatedTotal")
|
||||
if et is not None:
|
||||
eg["est_total"] = et
|
||||
|
||||
sorted_entities = sorted(entity_groups.items(), key=lambda x: -x[1]["count"])
|
||||
|
||||
header = ["ID", "Entity Name", "Company", "Events", "Actions", "Actors", "Est. Total"]
|
||||
rows = [header]
|
||||
|
||||
for eid, eg in sorted_entities[:max_rows]:
|
||||
actions_str = ", ".join(f"{a}({c})" for a, c in eg["actions"].most_common(3))
|
||||
actors_str = ", ".join(sorted(eg["actors"]))
|
||||
total_str = f"${eg['est_total']:,.2f}" if eg["est_total"] is not None else "—"
|
||||
rows.append([
|
||||
str(eid),
|
||||
truncate(eg["name"], 28),
|
||||
truncate(eg["company"], 18),
|
||||
f"{eg['count']:,}",
|
||||
truncate(actions_str, 24),
|
||||
truncate(actors_str, 18),
|
||||
total_str,
|
||||
])
|
||||
|
||||
t = Table(rows, colWidths=[0.45 * inch, 1.7 * inch, 1.1 * inch, 0.55 * inch, 1.2 * inch, 1.0 * inch, 0.8 * inch])
|
||||
t.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), GRAY_800),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), WHITE),
|
||||
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
|
||||
("FONTSIZE", (0, 0), (-1, 0), 8),
|
||||
("FONTSIZE", (0, 1), (-1, -1), 8),
|
||||
("FONTNAME", (0, 1), (-1, -1), "Helvetica"),
|
||||
("GRID", (0, 0), (-1, -1), 0.3, GRAY_200),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [WHITE, GRAY_50]),
|
||||
("ALIGN", (0, 0), (0, -1), "CENTER"),
|
||||
("ALIGN", (3, 0), (3, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
||||
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
||||
("TOPPADDING", (0, 1), (-1, -1), 2),
|
||||
("BOTTOMPADDING", (0, 1), (-1, -1), 2),
|
||||
]))
|
||||
elements.append(t)
|
||||
|
||||
if len(sorted_entities) > max_rows:
|
||||
elements.append(Spacer(1, 4))
|
||||
elements.append(Paragraph(
|
||||
f'Showing top {max_rows} of {len(sorted_entities)} entities.',
|
||||
ss["SmallGray"],
|
||||
))
|
||||
|
||||
return elements
|
||||
|
||||
|
||||
def add_page_number(canvas, doc):
|
||||
canvas.saveState()
|
||||
canvas.setFillColor(GRAY_800)
|
||||
canvas.rect(0, 0, LETTER[0], 22, fill=1, stroke=0)
|
||||
canvas.setFillColor(WHITE)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawCentredString(LETTER[0] / 2, 8, f"Page {doc.page}")
|
||||
canvas.setFillColor(GRAY_400)
|
||||
canvas.setFont("Helvetica", 7)
|
||||
canvas.drawString(30, 8, "Optima API · Webhook Log Report")
|
||||
canvas.drawRightString(LETTER[0] - 30, 8, datetime.now(timezone.utc).strftime("%Y-%m-%d"))
|
||||
canvas.restoreState()
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
log_dir = os.path.join(script_dir, "..", "cw-api-logs")
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
log_path = sys.argv[1]
|
||||
else:
|
||||
log_path = find_latest_log(log_dir)
|
||||
|
||||
print(f"📄 Reading log: {log_path}")
|
||||
entries = parse_log(log_path)
|
||||
print(f" → {len(entries)} entries parsed")
|
||||
|
||||
stats = analyze(entries)
|
||||
ss = build_styles()
|
||||
|
||||
log_basename = os.path.splitext(os.path.basename(log_path))[0]
|
||||
out_path = os.path.join(script_dir, "..", "cw-api-logs", f"{log_basename}-report.pdf")
|
||||
|
||||
doc = SimpleDocTemplate(
|
||||
out_path,
|
||||
pagesize=LETTER,
|
||||
leftMargin=0.6 * inch,
|
||||
rightMargin=0.6 * inch,
|
||||
topMargin=0.5 * inch,
|
||||
bottomMargin=0.5 * inch,
|
||||
)
|
||||
|
||||
elements = []
|
||||
|
||||
# ── Title Banner ──
|
||||
elements.extend(make_header_banner(ss, log_path, stats))
|
||||
|
||||
# ── Overview ──
|
||||
elements.append(Paragraph("Overview", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_kpi_row([
|
||||
("Total Events", f"{stats['total_entries']:,}"),
|
||||
("Unique Entities", f"{len(stats['entity_ids']):,}"),
|
||||
("Companies", f"{len(stats['companies']):,}"),
|
||||
("Duration", duration_str(stats["duration_seconds"])),
|
||||
]))
|
||||
elements.append(Spacer(1, 8))
|
||||
elements.append(make_kpi_row([
|
||||
("Events / Min", f"{stats['events_per_minute']:.1f}"),
|
||||
("HTTP Methods", f"{len(stats['methods']):,}"),
|
||||
("Action Types", f"{len(stats['actions']):,}"),
|
||||
("Actors", f"{len(stats['actors']):,}"),
|
||||
]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
# Time range
|
||||
if stats["first_ts"] and stats["last_ts"]:
|
||||
info = [
|
||||
[
|
||||
Paragraph('<font color="#757575">First Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
Paragraph('<font color="#757575">Last Event</font>', ss["BodyText2"]),
|
||||
Paragraph(f'<b>{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}</b>', ss["BodyText2"]),
|
||||
]
|
||||
]
|
||||
ti = Table(info, colWidths=[1.2 * inch, 2.4 * inch, 1.2 * inch, 2.4 * inch])
|
||||
ti.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, -1), GRAY_50),
|
||||
("TOPPADDING", (0, 0), (-1, -1), 5),
|
||||
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
||||
("LEFTPADDING", (0, 0), (-1, -1), 8),
|
||||
("BOX", (0, 0), (-1, -1), 0.4, GRAY_200),
|
||||
]))
|
||||
elements.append(ti)
|
||||
elements.append(Spacer(1, 14))
|
||||
|
||||
# ── Charts ──
|
||||
elements.append(Paragraph("Visual Breakdown", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.append(make_timeline_chart(stats["minute_buckets"]))
|
||||
elements.append(Spacer(1, 12))
|
||||
|
||||
pie_row = [[
|
||||
make_pie_chart("By Action", stats["actions"]),
|
||||
make_pie_chart("By Type", stats["types"]),
|
||||
]]
|
||||
pt = Table(pie_row, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt)
|
||||
elements.append(Spacer(1, 6))
|
||||
|
||||
if len(stats["stages"]) > 1 or len(stats["ratings"]) > 1:
|
||||
pie_row2 = [[
|
||||
make_pie_chart("By Stage", stats["stages"]),
|
||||
make_pie_chart("By Rating", stats["ratings"]),
|
||||
]]
|
||||
pt2 = Table(pie_row2, colWidths=[3.5 * inch, 3.5 * inch])
|
||||
pt2.setStyle(TableStyle([
|
||||
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
]))
|
||||
elements.append(pt2)
|
||||
|
||||
# Actor pie chart
|
||||
elements.append(Spacer(1, 6))
|
||||
elements.append(make_pie_chart("By Actor", stats["actors"], width=350, height=210))
|
||||
|
||||
# ── General Information ──
|
||||
elements.append(PageBreak())
|
||||
elements.append(Paragraph("General Information", ss["SectionHeader"]))
|
||||
elements.append(HRFlowable(width="100%", thickness=1, color=GRAY_400, spaceAfter=10))
|
||||
|
||||
elements.extend(make_table("Response Status Codes", stats["statuses"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("HTTP Methods", stats["methods"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Webhook Actions", stats["actions"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Entity Types", stats["types"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Request Paths", stats["paths"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
elements.extend(make_table("Actors", stats["actors"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
if stats["companies"]:
|
||||
elements.extend(make_table("Companies", stats["companies"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["stages"]:
|
||||
elements.extend(make_table("Opportunity Stages", stats["stages"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
if stats["ratings"]:
|
||||
elements.extend(make_table("Opportunity Ratings", stats["ratings"], ss))
|
||||
elements.append(Spacer(1, 10))
|
||||
|
||||
elements.extend(make_table("Hourly Distribution", stats["hourly_buckets"], ss))
|
||||
|
||||
# ── Actor Deep Dive ──
|
||||
elements.extend(build_actor_activity_section(stats, ss))
|
||||
|
||||
# ── Entity Summary ──
|
||||
elements.extend(build_summary_log_table(entries, ss, max_rows=30))
|
||||
|
||||
# Build
|
||||
doc.build(elements, onFirstPage=add_page_number, onLaterPages=add_page_number)
|
||||
print(f"✅ Report generated: {out_path}")
|
||||
print(f" File size: {os.path.getsize(out_path) / 1024:.1f} KB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user