#!/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("No data", 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 actor is resolved as: entityUpdatedBy → query.memberId → summary.memberId. ' '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("No actor data available.", 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'{aid}' f'   ' f'{ad["count"]:,} events', 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('First Event', ss["BodyText2"]), Paragraph(f'{first_str}', ss["BodyText2"]), Paragraph('Last Event', ss["BodyText2"]), Paragraph(f'{last_str}', ss["BodyText2"]), ], [ Paragraph('Active Duration', ss["BodyText2"]), Paragraph(f'{dur}', ss["BodyText2"]), Paragraph('Total Est. Value', ss["BodyText2"]), Paragraph(f'${est:,.2f}', ss["BodyText2"]), ], [ Paragraph('Callback Members', ss["BodyText2"]), Paragraph(f'{truncate(mid_str, 40)}', ss["BodyText2"]), Paragraph('Sales Reps', 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'{id_str}', 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('First Event', ss["BodyText2"]), Paragraph(f'{stats["first_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}', ss["BodyText2"]), Paragraph('Last Event', ss["BodyText2"]), Paragraph(f'{stats["last_ts"].strftime("%Y-%m-%d %H:%M:%S UTC")}', 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()