tools: ios-perf-pull.mjs โ pull Apple's aggregated perf telemetry from ASC (#1201)
The zero-client-code layer of the native perf story: one dependency-free Node script that mints the ES256 ASC JWT (raw r||s via dsaEncoding ieee-p1363, no jwt lib) and pulls the app's perf-power metrics (Organizer's launch/hang/memory percentiles) plus per-build hang/disk-write diagnostic signatures (--logs adds sample call trees), emitting one JSON document + a terse stderr headline. Auth = the existing ASC API key via --key/env; graceful "no data yet" for a small opted-in base.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+182
−0
tools/ios-perf-pull.mjs+182 −0
diff --git a/tools/ios-perf-pull.mjs b/tools/ios-perf-pull.mjs| new file mode 100644 |
| index 0000000..88999f9 |
| --- /dev/null |
| +++ b/tools/ios-perf-pull.mjs |
| @@ -0,0 +1,182 @@ |
| +#!/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 <p8> --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 <path to .p8> (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 ?? "?"}`); |
| + } |
| +} |