| 1 | #!/usr/bin/env node |
| 2 | // 🤖 Digest the native apps' perf data into something a human can act on — |
| 3 | // the analysis half of the on-device instrumentation (ios/LChatCore/Perf.swift). |
| 4 | // Feed it any mix of: |
| 5 | // |
| 6 | // - perf-events.jsonl / perf-events.prev.jsonl (the raw on-device ledger) |
| 7 | // - performance-report.json (the Settings export) |
| 8 | // |
| 9 | // and it prints one digest: slow spans grouped by seam with count/p50/p95/max |
| 10 | // (worst occurrence's detail alongside), hangs (count, total, worst), boot |
| 11 | // first-frame times, noteworthy events, and any MetricKit arrivals. Files |
| 12 | // concatenate — pass several exports from several devices for a combined view. |
| 13 | // |
| 14 | // node tools/ios-perf-report.mjs perf-events.jsonl |
| 15 | // node tools/ios-perf-report.mjs report-iphone.json report-mac.json |
| 16 | // |
| 17 | // Pull a device's ledger without the Settings share sheet: |
| 18 | // xcrun devicectl device copy from --device <id> \ |
| 19 | // --source Library/Logs/perf-events.jsonl \ |
| 20 | // --domain-type appDataContainer --domain-identifier com.elevenmessenger.messenger \ |
| 21 | // --destination perf-events.jsonl |
| 22 | import { readFileSync } from "node:fs"; |
| 23 | |
| 24 | const files = process.argv.slice(2); |
| 25 | if (files.length === 0) { |
| 26 | console.error("usage: node tools/ios-perf-report.mjs <perf-events.jsonl | performance-report.json> …"); |
| 27 | process.exit(2); |
| 28 | } |
| 29 | |
| 30 | // ---- ingest ---------------------------------------------------------------- |
| 31 | // Both shapes reduce to the same event list ({t, kind, name, ms?, detail?}); |
| 32 | // the export also carries device/OS labels worth echoing per file. |
| 33 | |
| 34 | const events = []; |
| 35 | const sources = []; |
| 36 | for (const f of files) { |
| 37 | const text = readFileSync(f, "utf8").trim(); |
| 38 | // A ledger line and an export doc both open with "{" — the export is the |
| 39 | // one that parses whole AND carries an events array. |
| 40 | let doc = null; |
| 41 | try { doc = JSON.parse(text); } catch { /* multi-line jsonl */ } |
| 42 | if (doc && Array.isArray(doc.events)) { |
| 43 | events.push(...doc.events); |
| 44 | sources.push(`${f} — ${doc.device ?? "?"} · ${doc.os ?? "?"} · app ${doc.version ?? "?"}` + |
| 45 | (doc.metrickit?.length ? ` · ${doc.metrickit.length} MetricKit payload${doc.metrickit.length === 1 ? "" : "s"}` : "")); |
| 46 | } else { |
| 47 | for (const line of text.split("\n")) { |
| 48 | try { events.push(JSON.parse(line)); } catch { /* torn line — skip, like the app's parser */ } |
| 49 | } |
| 50 | sources.push(f); |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | events.sort((a, b) => (a.t ?? 0) - (b.t ?? 0)); |
| 55 | |
| 56 | const fmt = (n, digits = 0) => Number(n).toLocaleString(undefined, { maximumFractionDigits: digits }); |
| 57 | const plural = (n, one, many = `${one}s`) => `${fmt(n)} ${n === 1 ? one : many}`; |
| 58 | const when = (t) => new Date(t).toLocaleString(); |
| 59 | |
| 60 | // ---- digest ---------------------------------------------------------------- |
| 61 | |
| 62 | console.log(`Sources:\n${sources.map((s) => ` ${s}`).join("\n")}`); |
| 63 | if (events.length === 0) { |
| 64 | console.log("\nNo events — either nothing has been slow yet (good) or recording is off."); |
| 65 | process.exit(0); |
| 66 | } |
| 67 | console.log(`\n${plural(events.length, "event")} · ${when(events[0].t)} → ${when(events.at(-1).t)}\n`); |
| 68 | |
| 69 | const byKind = Map.groupBy(events, (e) => e.kind); |
| 70 | |
| 71 | // Slow spans, grouped by seam — the "little cuts" table. |
| 72 | const slows = byKind.get("slow") ?? []; |
| 73 | if (slows.length) { |
| 74 | console.log(`Slow moments (${fmt(slows.length)}):`); |
| 75 | const bySeam = Map.groupBy(slows, (e) => e.name); |
| 76 | const rows = [...bySeam.entries()].map(([name, list]) => { |
| 77 | const ms = list.map((e) => e.ms ?? 0).sort((a, b) => a - b); |
| 78 | const q = (p) => ms[Math.min(ms.length - 1, Math.floor((p / 100) * ms.length))]; |
| 79 | const worst = list.reduce((a, b) => ((a.ms ?? 0) >= (b.ms ?? 0) ? a : b)); |
| 80 | return { name, n: list.length, p50: q(50), p95: q(95), max: worst.ms ?? 0, worst }; |
| 81 | }).sort((a, b) => b.n * b.p95 - a.n * a.p95); // impact-ish: frequency × badness |
| 82 | const w = Math.max(...rows.map((r) => r.name.length), 4); |
| 83 | console.log(` ${"seam".padEnd(w)} count p50 p95 max`); |
| 84 | for (const r of rows) { |
| 85 | 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)}` + |
| 86 | (r.worst.detail ? ` worst: ${r.worst.detail} @ ${when(r.worst.t)}` : "")); |
| 87 | } |
| 88 | console.log(" (durations in ms)\n"); |
| 89 | } |
| 90 | |
| 91 | const hangs = byKind.get("hang") ?? []; |
| 92 | if (hangs.length) { |
| 93 | const total = hangs.reduce((s, e) => s + (e.ms ?? 0), 0); |
| 94 | const worst = hangs.reduce((a, b) => ((a.ms ?? 0) >= (b.ms ?? 0) ? a : b)); |
| 95 | console.log(`Main-thread hangs: ${plural(hangs.length, "hang")} · ${fmt(total / 1000, 1)}s total · worst ${fmt(worst.ms)}ms @ ${when(worst.t)}`); |
| 96 | // A wedge storm reads differently from scattered stutters — show the hours. |
| 97 | const byHour = Map.groupBy(hangs, (e) => new Date(e.t).toISOString().slice(0, 13)); |
| 98 | const busy = [...byHour.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 3); |
| 99 | console.log(` busiest: ${busy.map(([h, l]) => `${h}:00Z ×${fmt(l.length)}`).join(" · ")}\n`); |
| 100 | } |
| 101 | |
| 102 | const hitches = byKind.get("hitch") ?? []; |
| 103 | if (hitches.length) { |
| 104 | const ms = hitches.map((e) => e.ms ?? 0).sort((a, b) => a - b); |
| 105 | const q = (p) => ms[Math.min(ms.length - 1, Math.floor((p / 100) * ms.length))]; |
| 106 | console.log(`Frame hitches (≥120ms gaps): ${plural(hitches.length, "hitch", "hitches")} · p50 ${fmt(q(50))}ms · worst ${fmt(ms.at(-1))}ms`); |
| 107 | const byHour = Map.groupBy(hitches, (e) => new Date(e.t).toISOString().slice(0, 13)); |
| 108 | const busy = [...byHour.entries()].sort((a, b) => b[1].length - a[1].length).slice(0, 3); |
| 109 | console.log(` busiest: ${busy.map(([h, l]) => `${h}:00Z ×${fmt(l.length)}`).join(" · ")}\n`); |
| 110 | } |
| 111 | |
| 112 | const boots = byKind.get("boot") ?? []; |
| 113 | if (boots.length) { |
| 114 | const ms = boots.map((e) => e.ms ?? 0).sort((a, b) => a - b); |
| 115 | console.log(`Launches: ${plural(boots.length, "first frame")} · median ${fmt(ms[Math.floor(ms.length / 2)])}ms · slowest ${fmt(ms.at(-1))}ms\n`); |
| 116 | } |
| 117 | |
| 118 | const notes = byKind.get("note") ?? []; |
| 119 | if (notes.length) { |
| 120 | const byName = Map.groupBy(notes, (e) => e.name); |
| 121 | console.log(`Events: ${[...byName.entries()].map(([n, l]) => `${n} ×${fmt(l.length)}`).join(" · ")}`); |
| 122 | console.log(" (socket.reconnect is floored at one per 30s — treat counts as 'at least')\n"); |
| 123 | } |
| 124 | |
| 125 | for (const kind of ["metric", "diag"]) { |
| 126 | for (const e of byKind.get(kind) ?? []) { |
| 127 | console.log(`MetricKit ${kind === "diag" ? "diagnostics" : "daily"}: ${e.detail ?? "payload"} @ ${when(e.t)}`); |
| 128 | } |
| 129 | } |
| 130 | |