tools: ios-perf-report.mjs — digest the native perf ledger/exports (#1202)
The analysis half of the on-device instrumentation: takes any mix of perf-events.jsonl ledgers and Settings performance-report.json exports (several devices concatenate) and prints one digest — slow spans per seam with count/p50/p95/max and the worst occurrence's detail, hangs with busiest hours, launch first-frame times, event counts, MetricKit arrivals. Verified against the first real ledger pulled off Paul's iPhone and a synthetic file exercising every section.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+119
−0
tools/ios-perf-report.mjs+119 −0
diff --git a/tools/ios-perf-report.mjs b/tools/ios-perf-report.mjs| new file mode 100644 |
| index 0000000..059357e |
| --- /dev/null |
| +++ b/tools/ios-perf-report.mjs |
| @@ -0,0 +1,119 @@ |
| +#!/usr/bin/env node |
| +// 🤖 Digest the native apps' perf data into something a human can act on — |
| +// the analysis half of the on-device instrumentation (ios/LChatCore/Perf.swift). |
| +// Feed it any mix of: |
| +// |
| +// - perf-events.jsonl / perf-events.prev.jsonl (the raw on-device ledger) |
| +// - performance-report.json (the Settings export) |
| +// |
| +// and it prints one digest: slow spans grouped by seam with count/p50/p95/max |
| +// (worst occurrence's detail alongside), hangs (count, total, worst), boot |
| +// first-frame times, noteworthy events, and any MetricKit arrivals. Files |
| +// concatenate — pass several exports from several devices for a combined view. |
| +// |
| +// node tools/ios-perf-report.mjs perf-events.jsonl |
| +// node tools/ios-perf-report.mjs report-iphone.json report-mac.json |
| +// |
| +// Pull a device's ledger without the Settings share sheet: |
| +// xcrun devicectl device copy from --device <id> \ |
| +// --source Library/Logs/perf-events.jsonl \ |
| +// --domain-type appDataContainer --domain-identifier com.elevenmessenger.messenger \ |
| +// --destination perf-events.jsonl |
| +import { readFileSync } from "node:fs"; |
| + |
| +const files = process.argv.slice(2); |
| +if (files.length === 0) { |
| + console.error("usage: node tools/ios-perf-report.mjs <perf-events.jsonl | performance-report.json> …"); |
| + process.exit(2); |
| +} |
| + |
| +// ---- ingest ---------------------------------------------------------------- |
| +// Both shapes reduce to the same event list ({t, kind, name, ms?, detail?}); |
| +// the export also carries device/OS labels worth echoing per file. |
| + |
| +const events = []; |
| +const sources = []; |
| +for (const f of files) { |
| + const text = readFileSync(f, "utf8").trim(); |
| + // A ledger line and an export doc both open with "{" — the export is the |
| + // one that parses whole AND carries an events array. |
| + let doc = null; |
| + try { doc = JSON.parse(text); } catch { /* multi-line jsonl */ } |
| + if (doc && Array.isArray(doc.events)) { |
| + events.push(...doc.events); |
| + sources.push(`${f} — ${doc.device ?? "?"} · ${doc.os ?? "?"} · app ${doc.version ?? "?"}` + |
| + (doc.metrickit?.length ? ` · ${doc.metrickit.length} MetricKit payload${doc.metrickit.length === 1 ? "" : "s"}` : "")); |
| + } else { |
| + for (const line of text.split("\n")) { |
| + try { events.push(JSON.parse(line)); } catch { /* torn line — skip, like the app's parser */ } |
| + } |
| + sources.push(f); |
| + } |
| +} |
| + |
| +events.sort((a, b) => (a.t ?? 0) - (b.t ?? 0)); |
| + |
| +const fmt = (n, digits = 0) => Number(n).toLocaleString(undefined, { maximumFractionDigits: digits }); |
| +const plural = (n, one, many = `${one}s`) => `${fmt(n)} ${n === 1 ? one : many}`; |
| +const when = (t) => new Date(t).toLocaleString(); |
| + |
| +// ---- digest ---------------------------------------------------------------- |
| + |
| +console.log(`Sources:\n${sources.map((s) => ` ${s}`).join("\n")}`); |
| +if (events.length === 0) { |
| + console.log("\nNo events — either nothing has been slow yet (good) or recording is off."); |
| + process.exit(0); |
| +} |
| +console.log(`\n${plural(events.length, "event")} · ${when(events[0].t)} → ${when(events.at(-1).t)}\n`); |
| + |
| +const byKind = Map.groupBy(events, (e) => e.kind); |
| + |
| +// Slow spans, grouped by seam — the "little cuts" table. |
| +const slows = byKind.get("slow") ?? []; |
| +if (slows.length) { |
| + console.log(`Slow moments (${fmt(slows.length)}):`); |
| + const bySeam = Map.groupBy(slows, (e) => e.name); |
| + const rows = [...bySeam.entries()].map(([name, list]) => { |
| + const ms = list.map((e) => e.ms ?? 0).sort((a, b) => a - b); |
| + const q = (p) => ms[Math.min(ms.length - 1, Math.floor((p / 100) * ms.length))]; |
| + const worst = list.reduce((a, b) => ((a.ms ?? 0) >= (b.ms ?? 0) ? a : b)); |
| + return { name, n: list.length, p50: q(50), p95: q(95), max: worst.ms ?? 0, worst }; |
| + }).sort((a, b) => b.n * b.p95 - a.n * a.p95); // impact-ish: frequency × badness |
| + const w = Math.max(...rows.map((r) => r.name.length), 4); |
| + console.log(` ${"seam".padEnd(w)} count p50 p95 max`); |
| + for (const r of rows) { |
| + console.log(` ${r.name.padEnd(w)} ${fmt(r.n).padStart(5)} ${fmt(r.p50).padStart(5)} ${fmt(r.p95).padStart(5)} ${fmt(r.max).padStart(5)}` + |
| + (r.worst.detail ? ` worst: ${r.worst.detail} @ ${when(r.worst.t)}` : "")); |
| + } |
| + console.log(" (durations in ms)\n"); |
| +} |
| + |
| +const hangs = byKind.get("hang") ?? []; |
| +if (hangs.length) { |
| + const total = hangs.reduce((s, e) => s + (e.ms ?? 0), 0); |
| + const worst = hangs.reduce((a, b) => ((a.ms ?? 0) >= (b.ms ?? 0) ? a : b)); |
| + console.log(`Main-thread hangs: ${plural(hangs.length, "hang")} · ${fmt(total / 1000, 1)}s total · worst ${fmt(worst.ms)}ms @ ${when(worst.t)}`); |
| + // A wedge storm reads differently from scattered stutters — show the hours. |
| + const byHour = Map.groupBy(hangs, (e) => new Date(e.t).toISOString().slice(0, 13)); |
| + const busy = [...byHour.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 3); |
| + console.log(` busiest: ${busy.map(([h, l]) => `${h}:00Z ×${fmt(l.length)}`).join(" · ")}\n`); |
| +} |
| + |
| +const boots = byKind.get("boot") ?? []; |
| +if (boots.length) { |
| + const ms = boots.map((e) => e.ms ?? 0).sort((a, b) => a - b); |
| + console.log(`Launches: ${plural(boots.length, "first frame")} · median ${fmt(ms[Math.floor(ms.length / 2)])}ms · slowest ${fmt(ms.at(-1))}ms\n`); |
| +} |
| + |
| +const notes = byKind.get("note") ?? []; |
| +if (notes.length) { |
| + const byName = Map.groupBy(notes, (e) => e.name); |
| + console.log(`Events: ${[...byName.entries()].map(([n, l]) => `${n} ×${fmt(l.length)}`).join(" · ")}`); |
| + console.log(" (socket.reconnect is floored at one per 30s — treat counts as 'at least')\n"); |
| +} |
| + |
| +for (const kind of ["metric", "diag"]) { |
| + for (const e of byKind.get(kind) ?? []) { |
| + console.log(`MetricKit ${kind === "diag" ? "diagnostics" : "daily"}: ${e.detail ?? "payload"} @ ${when(e.t)}`); |
| + } |
| +} |