eleven / messenger Public

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>
Paul Campbell 01166e1e16e928b6c96e6206a8d3d8d2f172fe77 parent 65f1984
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)}`);
+ }
+}