#!/usr/bin/env node // ๐Ÿค– Pull Apple's own performance telemetry for the iOS app from the App // Store Connect API โ€” the zero-client-code layer of the native perf story // (companion to the on-device instrumentation in ios/LChatCore/Perf.swift): // // - perf-power metrics: the aggregated launch-time / hang-rate / memory / // battery percentiles Apple computes from opted-in + TestFlight devices // (what Xcode Organizer charts), fetched as one JSON document. // - diagnostic signatures: per-build hang / disk-write signatures โ€” the // symbolicated frame Apple groups field incidents by, with each // signature's share of incidents (weight). `--logs` also fetches each // signature's sample call trees (heavier; off by default). // // node tools/ios-perf-pull.mjs --key ~/Downloads/AuthKey_ZKYQ9S394B.p8 // node tools/ios-perf-pull.mjs --key --builds 3 --logs --out perf.json // // Auth is the existing ASC API key (key id ZKYQ9S394B, Admin โ€” the same one // TestFlight uploads and notarization use; the .p8 stays in the operator // vault, never in the repo). Flags/env: // --key / LCHAT_ASC_KEY_FILE path to the .p8 (required) // --key-id / LCHAT_ASC_KEY_ID default ZKYQ9S394B // --issuer / LCHAT_ASC_ISSUER_ID default 9ff11cd1-6ee9-4183-8832-839a0eb15c1a // --app / LCHAT_ASC_APP_ID default 6787060127 (Eleven Messenger) // --builds N how many recent builds' signatures (default 5) // --logs also fetch sample call-tree logs per signature // --out FILE write the JSON document there (default stdout) // // The human summary (top hang signatures by weight, headline percentiles) // prints to stderr either way, so `--out` files stay clean JSON. A small or // young install base often has NO aggregated metrics yet โ€” Apple needs // enough opted-in devices per version โ€” and that prints as "no data yet", // not an error; diagnostic signatures usually appear first. import { readFileSync, writeFileSync } from "node:fs"; import { createPrivateKey, sign } from "node:crypto"; import { fileURLToPath } from "node:url"; const API = "https://api.appstoreconnect.apple.com"; // ---- args ------------------------------------------------------------------ const args = process.argv.slice(2); function flag(name) { const i = args.indexOf(name); return i >= 0; } function opt(name, fallback) { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : fallback; } const keyFile = opt("--key", process.env.LCHAT_ASC_KEY_FILE); const keyID = opt("--key-id", process.env.LCHAT_ASC_KEY_ID ?? "ZKYQ9S394B"); const issuer = opt("--issuer", process.env.LCHAT_ASC_ISSUER_ID ?? "9ff11cd1-6ee9-4183-8832-839a0eb15c1a"); const appID = opt("--app", process.env.LCHAT_ASC_APP_ID ?? "6787060127"); const buildCount = Number(opt("--builds", "5")); const wantLogs = flag("--logs"); const outFile = opt("--out", null); if (flag("--help") || !keyFile) { // The header comment IS the usage โ€” print it rather than maintaining a copy. const self = readFileSync(fileURLToPath(import.meta.url), "utf8"); const header = []; for (const line of self.split("\n").slice(1)) { if (!line.startsWith("//")) break; // the header ends at the first import header.push(line.replace(/^\/\/ ?/, "")); } console.error(header.join("\n")); if (!flag("--help")) console.error("\nMissing the ASC API key: --key (or LCHAT_ASC_KEY_FILE)."); process.exit(flag("--help") ? 0 : 2); } // ---- ASC JWT (ES256, no dependencies) -------------------------------------- // The token Apple expects is a standard ES256 JWT scoped aud=appstoreconnect-v1, // max 20 minutes. Node signs ECDSA as DER by default; JWTs need the raw r||s // form โ€” dsaEncoding "ieee-p1363" is exactly that. const b64url = (buf) => Buffer.from(buf).toString("base64url"); function mintJWT() { const now = Math.floor(Date.now() / 1000); const header = b64url(JSON.stringify({ alg: "ES256", kid: keyID, typ: "JWT" })); const payload = b64url(JSON.stringify({ iss: issuer, iat: now, exp: now + 600, aud: "appstoreconnect-v1" })); const key = createPrivateKey(readFileSync(keyFile, "utf8")); const sig = sign("sha256", Buffer.from(`${header}.${payload}`), { key, dsaEncoding: "ieee-p1363" }); return `${header}.${payload}.${b64url(sig)}`; } const jwt = mintJWT(); async function get(path, accept = "application/json") { const res = await fetch(`${API}${path}`, { headers: { Authorization: `Bearer ${jwt}`, Accept: accept }, }); if (res.status === 404 || res.status === 409) return null; // no data (yet) for this resource if (!res.ok) { const body = (await res.text()).slice(0, 400); throw new Error(`GET ${path} โ†’ ${res.status}: ${body}`); } return res.json(); } // ---- pulls ----------------------------------------------------------------- // The Organizer-style aggregates. The xcode-metrics content type is required; // the shape (productData โ†’ metricCategories โ†’ metrics โ†’ datasets โ†’ points) is // passed through raw and only walked best-effort for the stderr headline. const metrics = await get( `/v1/apps/${appID}/perfPowerMetrics?filter[platform]=IOS`, "application/vnd.apple.xcode-metrics+json" ); const buildsResp = await get(`/v1/builds?filter[app]=${appID}&sort=-uploadedDate&limit=${buildCount}`); const builds = []; for (const b of buildsResp?.data ?? []) { const sigs = await get(`/v1/builds/${b.id}/diagnosticSignatures?limit=50`); const signatures = []; for (const s of sigs?.data ?? []) { const entry = { diagnosticType: s.attributes?.diagnosticType, signature: s.attributes?.signature, weight: s.attributes?.weight, }; if (wantLogs) { const logs = await get(`/v1/diagnosticSignatures/${s.id}/logs?limit=1`, "application/vnd.apple.diagnostic-logs+json"); if (logs) entry.sampleLogs = logs; } signatures.push(entry); } builds.push({ id: b.id, version: b.attributes?.version, uploadedDate: b.attributes?.uploadedDate, signatures, }); } const doc = { generated: new Date().toISOString(), app: appID, perfPowerMetrics: metrics, // null until Apple has enough opted-in devices builds, }; // ---- output ---------------------------------------------------------------- const json = JSON.stringify(doc, null, 2); if (outFile) { writeFileSync(outFile, json + "\n"); console.error(`wrote ${outFile} (${json.length.toLocaleString("en")} bytes)`); } else { console.log(json); } // Best-effort stderr headline โ€” the raw document is the real product. function headline() { const cats = (metrics?.productData ?? []).flatMap((p) => p.metricCategories ?? []); for (const cat of cats) { for (const m of cat.metrics ?? []) { for (const ds of m.datasets ?? []) { const pct = ds.filterCriteria?.percentile ?? ""; if (!/fifty|50/.test(pct)) continue; const last = (ds.points ?? []).at(-1); if (last?.value != null) { console.error(` ${cat.identifier ?? "?"} ยท ${m.identifier ?? "?"} p50 (${ds.filterCriteria?.device ?? "all"}): ${last.value} ${m.unit?.displayName ?? ""} @ v${last.version ?? "?"}`); } } } } } console.error(metrics ? "perf-power metrics: received" : "perf-power metrics: no data yet (needs enough opted-in devices)"); if (metrics) headline(); for (const b of builds) { const n = b.signatures.length; console.error(`build ${b.version}: ${n.toLocaleString("en")} diagnostic ${n === 1 ? "signature" : "signatures"}`); const top = [...b.signatures].sort((a, z) => (z.weight ?? 0) - (a.weight ?? 0)).slice(0, 5); for (const s of top) { const pctS = s.weight != null ? ` (${(s.weight * 100).toFixed(1)}%)` : ""; console.error(` ${s.diagnosticType ?? "?"}${pctS}: ${s.signature ?? "?"}`); } }