eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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
22import { readFileSync } from "node:fs";
23
24const files = process.argv.slice(2);
25if (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
34const events = [];
35const sources = [];
36for (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
54events.sort((a, b) => (a.t ?? 0) - (b.t ?? 0));
55
56const fmt = (n, digits = 0) => Number(n).toLocaleString(undefined, { maximumFractionDigits: digits });
57const plural = (n, one, many = `${one}s`) => `${fmt(n)} ${n === 1 ? one : many}`;
58const when = (t) => new Date(t).toLocaleString();
59
60// ---- digest ----------------------------------------------------------------
61
62console.log(`Sources:\n${sources.map((s) => ` ${s}`).join("\n")}`);
63if (events.length === 0) {
64 console.log("\nNo events — either nothing has been slow yet (good) or recording is off.");
65 process.exit(0);
66}
67console.log(`\n${plural(events.length, "event")} · ${when(events[0].t)} → ${when(events.at(-1).t)}\n`);
68
69const byKind = Map.groupBy(events, (e) => e.kind);
70
71// Slow spans, grouped by seam — the "little cuts" table.
72const slows = byKind.get("slow") ?? [];
73if (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
91const hangs = byKind.get("hang") ?? [];
92if (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
102const hitches = byKind.get("hitch") ?? [];
103if (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
112const boots = byKind.get("boot") ?? [];
113if (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
118const notes = byKind.get("note") ?? [];
119if (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
125for (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