#!/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 \ // --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 …"); 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 hitches = byKind.get("hitch") ?? []; if (hitches.length) { const ms = hitches.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))]; console.log(`Frame hitches (≥120ms gaps): ${plural(hitches.length, "hitch", "hitches")} · p50 ${fmt(q(50))}ms · worst ${fmt(ms.at(-1))}ms`); const byHour = Map.groupBy(hitches, (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)}`); } }