eleven / messenger Public

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>
Paul Campbell 65f19846f99cc9d0e825cb3643c16087cc7f9576 parent e070569
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 ?? "?"}`);
+ }
+}