| 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. |
| 33 | import { readFileSync, writeFileSync } from "node:fs"; |
| 34 | import { createPrivateKey, sign } from "node:crypto"; |
| 35 | import { fileURLToPath } from "node:url"; |
| 36 | |
| 37 | const API = "https://api.appstoreconnect.apple.com"; |
| 38 | |
| 39 | // ---- args ------------------------------------------------------------------ |
| 40 | |
| 41 | const args = process.argv.slice(2); |
| 42 | function flag(name) { |
| 43 | const i = args.indexOf(name); |
| 44 | return i >= 0; |
| 45 | } |
| 46 | function opt(name, fallback) { |
| 47 | const i = args.indexOf(name); |
| 48 | return i >= 0 && args[i + 1] ? args[i + 1] : fallback; |
| 49 | } |
| 50 | |
| 51 | const keyFile = opt("--key", process.env.LCHAT_ASC_KEY_FILE); |
| 52 | const keyID = opt("--key-id", process.env.LCHAT_ASC_KEY_ID ?? "ZKYQ9S394B"); |
| 53 | const issuer = opt("--issuer", process.env.LCHAT_ASC_ISSUER_ID ?? "9ff11cd1-6ee9-4183-8832-839a0eb15c1a"); |
| 54 | const appID = opt("--app", process.env.LCHAT_ASC_APP_ID ?? "6787060127"); |
| 55 | const buildCount = Number(opt("--builds", "5")); |
| 56 | const wantLogs = flag("--logs"); |
| 57 | const outFile = opt("--out", null); |
| 58 | |
| 59 | if (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 | |
| 77 | const b64url = (buf) => Buffer.from(buf).toString("base64url"); |
| 78 | |
| 79 | function 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 | |
| 88 | const jwt = mintJWT(); |
| 89 | |
| 90 | async 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. |
| 107 | const metrics = await get( |
| 108 | `/v1/apps/${appID}/perfPowerMetrics?filter[platform]=IOS`, |
| 109 | "application/vnd.apple.xcode-metrics+json" |
| 110 | ); |
| 111 | |
| 112 | const buildsResp = await get(`/v1/builds?filter[app]=${appID}&sort=-uploadedDate&limit=${buildCount}`); |
| 113 | const builds = []; |
| 114 | for (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 | |
| 138 | const 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 | |
| 147 | const json = JSON.stringify(doc, null, 2); |
| 148 | if (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. |
| 156 | function 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 | |
| 172 | console.error(metrics ? "perf-power metrics: received" : "perf-power metrics: no data yet (needs enough opted-in devices)"); |
| 173 | if (metrics) headline(); |
| 174 | for (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 | |