fix: remove nested .git folders, re-add as normal directories
This commit is contained in:
@@ -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