eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1#!/usr/bin/env node
2// πŸ€– Pull Apple's own performance telemetry for the iOS app from the App
3// Store Connect API β€” the zero-client-code layer of the native perf story
4// (companion to the on-device instrumentation in ios/LChatCore/Perf.swift):
5//
6// - perf-power metrics: the aggregated launch-time / hang-rate / memory /
7// battery percentiles Apple computes from opted-in + TestFlight devices
8// (what Xcode Organizer charts), fetched as one JSON document.
9// - diagnostic signatures: per-build hang / disk-write signatures β€” the
10// symbolicated frame Apple groups field incidents by, with each
11// signature's share of incidents (weight). `--logs` also fetches each
12// signature's sample call trees (heavier; off by default).
13//
14// node tools/ios-perf-pull.mjs --key ~/Downloads/AuthKey_ZKYQ9S394B.p8
15// node tools/ios-perf-pull.mjs --key <p8> --builds 3 --logs --out perf.json
16//
17// Auth is the existing ASC API key (key id ZKYQ9S394B, Admin β€” the same one
18// TestFlight uploads and notarization use; the .p8 stays in the operator
19// vault, never in the repo). Flags/env:
20// --key / LCHAT_ASC_KEY_FILE path to the .p8 (required)
21// --key-id / LCHAT_ASC_KEY_ID default ZKYQ9S394B
22// --issuer / LCHAT_ASC_ISSUER_ID default 9ff11cd1-6ee9-4183-8832-839a0eb15c1a
23// --app / LCHAT_ASC_APP_ID default 6787060127 (Eleven Messenger)
24// --builds N how many recent builds' signatures (default 5)
25// --logs also fetch sample call-tree logs per signature
26// --out FILE write the JSON document there (default stdout)
27//
28// The human summary (top hang signatures by weight, headline percentiles)
29// prints to stderr either way, so `--out` files stay clean JSON. A small or
30// young install base often has NO aggregated metrics yet β€” Apple needs
31// enough opted-in devices per version β€” and that prints as "no data yet",
32// not an error; diagnostic signatures usually appear first.
33import { readFileSync, writeFileSync } from "node:fs";
34import { createPrivateKey, sign } from "node:crypto";
35import { fileURLToPath } from "node:url";
36
37const API = "https://api.appstoreconnect.apple.com";
38
39// ---- args ------------------------------------------------------------------
40
41const args = process.argv.slice(2);
42function flag(name) {
43 const i = args.indexOf(name);
44 return i >= 0;
45}
46function opt(name, fallback) {
47 const i = args.indexOf(name);
48 return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
49}
50
51const keyFile = opt("--key", process.env.LCHAT_ASC_KEY_FILE);
52const keyID = opt("--key-id", process.env.LCHAT_ASC_KEY_ID ?? "ZKYQ9S394B");
53const issuer = opt("--issuer", process.env.LCHAT_ASC_ISSUER_ID ?? "9ff11cd1-6ee9-4183-8832-839a0eb15c1a");
54const appID = opt("--app", process.env.LCHAT_ASC_APP_ID ?? "6787060127");
55const buildCount = Number(opt("--builds", "5"));
56const wantLogs = flag("--logs");
57const outFile = opt("--out", null);
58
59if (flag("--help") || !keyFile) {
60 // The header comment IS the usage β€” print it rather than maintaining a copy.
61 const self = readFileSync(fileURLToPath(import.meta.url), "utf8");
62 const header = [];
63 for (const line of self.split("\n").slice(1)) {
64 if (!line.startsWith("//")) break; // the header ends at the first import
65 header.push(line.replace(/^\/\/ ?/, ""));
66 }
67 console.error(header.join("\n"));
68 if (!flag("--help")) console.error("\nMissing the ASC API key: --key <path to .p8> (or LCHAT_ASC_KEY_FILE).");
69 process.exit(flag("--help") ? 0 : 2);
70}
71
72// ---- ASC JWT (ES256, no dependencies) --------------------------------------
73// The token Apple expects is a standard ES256 JWT scoped aud=appstoreconnect-v1,
74// max 20 minutes. Node signs ECDSA as DER by default; JWTs need the raw r||s
75// form β€” dsaEncoding "ieee-p1363" is exactly that.
76
77const b64url = (buf) => Buffer.from(buf).toString("base64url");
78
79function mintJWT() {
80 const now = Math.floor(Date.now() / 1000);
81 const header = b64url(JSON.stringify({ alg: "ES256", kid: keyID, typ: "JWT" }));
82 const payload = b64url(JSON.stringify({ iss: issuer, iat: now, exp: now + 600, aud: "appstoreconnect-v1" }));
83 const key = createPrivateKey(readFileSync(keyFile, "utf8"));
84 const sig = sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" });
85 return `${header}.${payload}.${b64url(sig)}`;
86}
87
88const jwt = mintJWT();
89
90async function get(path, accept = "application/json") {
91 const res = await fetch(`${API}${path}`, {
92 headers: { Authorization: `Bearer ${jwt}`, Accept: accept },
93 });
94 if (res.status === 404 || res.status === 409) return null; // no data (yet) for this resource
95 if (!res.ok) {
96 const body = (await res.text()).slice(0, 400);
97 throw new Error(`GET ${path} β†’ ${res.status}: ${body}`);
98 }
99 return res.json();
100}
101
102// ---- pulls -----------------------------------------------------------------
103
104// The Organizer-style aggregates. The xcode-metrics content type is required;
105// the shape (productData β†’ metricCategories β†’ metrics β†’ datasets β†’ points) is
106// passed through raw and only walked best-effort for the stderr headline.
107const metrics = await get(
108 `/v1/apps/${appID}/perfPowerMetrics?filter[platform]=IOS`,
109 "application/vnd.apple.xcode-metrics+json"
110);
111
112const buildsResp = await get(`/v1/builds?filter[app]=${appID}&sort=-uploadedDate&limit=${buildCount}`);
113const builds = [];
114for (const b of buildsResp?.data ?? []) {
115 const sigs = await get(`/v1/builds/${b.id}/diagnosticSignatures?limit=50`);
116 const signatures = [];
117 for (const s of sigs?.data ?? []) {
118 const entry = {
119 diagnosticType: s.attributes?.diagnosticType,
120 signature: s.attributes?.signature,
121 weight: s.attributes?.weight,
122 };
123 if (wantLogs) {
124 const logs = await get(`/v1/diagnosticSignatures/${s.id}/logs?limit=1`,
125 "application/vnd.apple.diagnostic-logs+json");
126 if (logs) entry.sampleLogs = logs;
127 }
128 signatures.push(entry);
129 }
130 builds.push({
131 id: b.id,
132 version: b.attributes?.version,
133 uploadedDate: b.attributes?.uploadedDate,
134 signatures,
135 });
136}
137
138const doc = {
139 generated: new Date().toISOString(),
140 app: appID,
141 perfPowerMetrics: metrics, // null until Apple has enough opted-in devices
142 builds,
143};
144
145// ---- output ----------------------------------------------------------------
146
147const json = JSON.stringify(doc, null, 2);
148if (outFile) {
149 writeFileSync(outFile, json + "\n");
150 console.error(`wrote ${outFile} (${json.length.toLocaleString("en")} bytes)`);
151} else {
152 console.log(json);
153}
154
155// Best-effort stderr headline β€” the raw document is the real product.
156function headline() {
157 const cats = (metrics?.productData ?? []).flatMap((p) => p.metricCategories ?? []);
158 for (const cat of cats) {
159 for (const m of cat.metrics ?? []) {
160 for (const ds of m.datasets ?? []) {
161 const pct = ds.filterCriteria?.percentile ?? "";
162 if (!/fifty|50/.test(pct)) continue;
163 const last = (ds.points ?? []).at(-1);
164 if (last?.value != null) {
165 console.error(` ${cat.identifier ?? "?"} Β· ${m.identifier ?? "?"} p50 (${ds.filterCriteria?.device ?? "all"}): ${last.value} ${m.unit?.displayName ?? ""} @ v${last.version ?? "?"}`);
166 }
167 }
168 }
169 }
170}
171
172console.error(metrics ? "perf-power metrics: received" : "perf-power metrics: no data yet (needs enough opted-in devices)");
173if (metrics) headline();
174for (const b of builds) {
175 const n = b.signatures.length;
176 console.error(`build ${b.version}: ${n.toLocaleString("en")} diagnostic ${n === 1 ? "signature" : "signatures"}`);
177 const top = [...b.signatures].sort((a, z) => (z.weight ?? 0) - (a.weight ?? 0)).slice(0, 5);
178 for (const s of top) {
179 const pctS = s.weight != null ? ` (${(s.weight * 100).toFixed(1)}%)` : "";
180 console.error(` ${s.diagnosticType ?? "?"}${pctS}: ${s.signature ?? "?"}`);
181 }
182}
183