// Crash and feedback triage, as pure parts: crash signatures from TestFlight crash logs, and clusters by signature, // device and OS version for TestFlight feedback and Google Play vitals error reports. The command (cli/triage.ts) // reads (GET only), groups and prints a table or --json. Tester emails are never read into the output (the feedback // requests ask only for the fields listed below). // Sources (checked October 2026): // TestFlight feedback (App Store Connect API 4.0+, OpenAPI 4.5.1): GET /v1/apps/{id}/betaFeedbackCrashSubmissions, // GET /v1/apps/{id}/betaFeedbackScreenshotSubmissions (createdDate, comment, deviceModel, osVersion, locale, // appPlatform, deviceFamily, build relationship; filter[build], sort=-createdDate, fields[...]), // GET /v1/betaFeedbackCrashSubmissions/{id}/crashLog -> BetaCrashLog.logText. // https://developer.apple.com/documentation/appstoreconnectapi/prerelease-versions-and-beta-testers // Google Play Developer Reporting API v1beta1: errorIssues:search (issues = Play's own clusters: type, cause, // location, errorReportCount, distinctUsers) and errorReports:search (filter "errorIssueId = ", interval // hour-aligned UTC, pageSize <= 100; ErrorReport: name, type, reportText, issue, eventTime, deviceModel // {deviceId {buildBrand, buildDevice}, marketingName}, osVersion {apiLevel}, appVersion {versionCode}). // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports/search // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.reports /** Fields read for feedback (no email, no tester relationship). */ export const FEEDBACK_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build'; export const CRASH_FIELDS = 'createdDate,comment,deviceModel,osVersion,locale,appPlatform,deviceFamily,build,crashLog'; const cleanFrame = (s: string): string => s.replace(/\s+\+\s+\d+$/, '').replace(/0x[0-9a-f]+/gi, '').replace(/\s+/g, ' ').trim(); /** * One crash signature from a crash log: " @ " (else the * first frame). Handles the text crash report format and the JSON .ips format (header line + body). Pure. */ export function crashSignature(log: string): string { const text = log.trim(); // .ips: first line is a JSON header, the rest a JSON body const nl = text.indexOf('\n'); if (text.startsWith('{') && nl > 0) { try { const head = JSON.parse(text.slice(0, nl)) as { app_name?: string; name?: string }; const body = JSON.parse(text.slice(nl + 1)) as { exception?: { type?: string; signal?: string }; faultingThread?: number; threads?: { triggered?: boolean; frames?: { imageIndex?: number; symbol?: string; imageOffset?: number }[] }[]; usedImages?: { name?: string }[]; procName?: string }; const ex = [body.exception?.type, body.exception?.signal].filter(Boolean).join(' ') || 'crash'; const t = body.threads?.[body.faultingThread ?? -1] ?? body.threads?.find(x => x.triggered); const app = body.procName ?? head.app_name ?? head.name; const frames = (t?.frames ?? []).map(f => ({ image: body.usedImages?.[f.imageIndex ?? -1]?.name ?? '?', symbol: f.symbol ?? `+${f.imageOffset ?? '?'}` })); const f = frames.find(x => app && x.image === app) ?? frames[0]; return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex; } catch { /* fall through to the text format */ } } const ex = /^Exception Type:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? /^Termination Reason:\s*(.+)$/m.exec(text)?.[1]?.trim() ?? 'crash'; const proc = /^Process:\s*([^\s\[]+)/m.exec(text)?.[1]; const crashed = /^Crashed Thread:\s*(\d+)/m.exec(text)?.[1]; const lines = text.split(/\r?\n/); const at = lines.findIndex(l => (crashed !== undefined ? new RegExp(`^Thread ${crashed} Crashed`) : /^Thread \d+ Crashed/).test(l)); const block: string[] = []; if (at >= 0) for (const l of lines.slice(at + 1)) { if (!l.trim()) break; block.push(l); } const frames = block.map(l => /^\d+\s+(\S+)\s+0x[0-9a-f]+\s+(.+)$/i.exec(l.trim())).filter((m): m is RegExpExecArray => !!m).map(m => ({ image: m[1]!, symbol: m[2]! })); const f = frames.find(x => proc && x.image === proc) ?? frames[0]; return f ? `${ex} @ ${f.image} ${cleanFrame(f.symbol)}` : ex; } export interface TriageItem { signature: string; device: string; os: string; build?: string; date?: string; comment?: string } export interface Cluster { signature: string; count: number; devices: [string, number][]; os: [string, number][]; builds: [string, number][]; first?: string; last?: string; comments?: string[] } const top = (xs: string[], n = 3): [string, number][] => { const m = new Map(); for (const x of xs) m.set(x, (m.get(x) ?? 0) + 1); return [...m.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, n); }; /** Items -> clusters by signature, most frequent first, each with its top devices, OS versions and builds. Pure. */ export function cluster(items: TriageItem[], o: { comments?: number } = {}): Cluster[] { const by = new Map(); for (const i of items) by.set(i.signature, [...(by.get(i.signature) ?? []), i]); return [...by.entries()].map(([signature, xs]) => { const dates = xs.map(x => x.date ?? '').filter(Boolean).sort(); const comments = o.comments ? xs.map(x => (x.comment ?? '').replace(/\s+/g, ' ').trim()).filter(Boolean).slice(0, o.comments).map(c => c.slice(0, 120)) : undefined; return { signature, count: xs.length, devices: top(xs.map(x => x.device)), os: top(xs.map(x => x.os)), builds: top(xs.map(x => x.build ?? '?')), ...(dates.length ? { first: dates[0], last: dates.at(-1) } : {}), ...(comments?.length ? { comments } : {}) }; }).sort((a, b) => b.count - a.count || a.signature.localeCompare(b.signature)); } /** TestFlight feedback (crash or screenshot submissions) -> items. Screenshot feedback has no crash: its signature is * "feedback". Pure. */ export function feedbackItems(subs: { id: string; attributes?: Record; relationships?: { build?: { data?: { id: string } | null } } }[], builds: Map, signatures: Map = new Map()): TriageItem[] { return subs.map(s => { const a = s.attributes ?? {}; return { signature: signatures.get(s.id) ?? 'feedback', device: String(a.deviceModel ?? '?'), os: String(a.osVersion ?? '?'), build: builds.get(s.relationships?.build?.data?.id ?? '') ?? undefined, date: typeof a.createdDate === 'string' ? a.createdDate : undefined, comment: typeof a.comment === 'string' ? a.comment : undefined }; }); } export interface PlayIssueRef { id: string; type: string; cause: string; location: string; reports: number; users: number } /** errorIssues:search -> issue refs with their id (the last part of `name`). Pure. */ export function playIssues(resp: { errorIssues?: { name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string }[] }): PlayIssueRef[] { return (resp.errorIssues ?? []).map(i => ({ id: (i.name ?? '').split('/').pop() ?? '', type: i.type === 'APPLICATION_NOT_RESPONDING' ? 'ANR' : i.type ?? '?', cause: i.cause ?? '', location: i.location ?? '', reports: Number(i.errorReportCount ?? 0), users: Number(i.distinctUsers ?? 0) })).filter(i => i.id); } /** errorReports:search -> items with the signature of their issue. Pure. */ export function playReportItems(resp: { errorReports?: { issue?: string; type?: string; eventTime?: string; deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } }; osVersion?: { apiLevel?: string }; appVersion?: { versionCode?: string } }[] }, issues: PlayIssueRef[]): TriageItem[] { const sig = new Map(issues.map(i => [i.id, `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`] as const)); return (resp.errorReports ?? []).map(r => { const id = (r.issue ?? '').split('/').pop() ?? ''; const dev = r.deviceModel?.marketingName || [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/') || '?'; return { signature: sig.get(id) ?? `${r.type ?? '?'} issue ${id}`, device: dev, os: r.osVersion?.apiLevel ? `API ${r.osVersion.apiLevel}` : '?', build: r.appVersion?.versionCode, date: r.eventTime }; }); } /** errorReports:search query string for one issue (UTC, hour-aligned interval, newest window). Pure. */ export function reportsQuery(issueId: string, start: Date, end: Date, pageSize = 100): string { const p = new URLSearchParams(); for (const [k, d] of [['start', start], ['end', end]] as const) { p.set(`interval.${k}Time.year`, String(d.getUTCFullYear())); p.set(`interval.${k}Time.month`, String(d.getUTCMonth() + 1)); p.set(`interval.${k}Time.day`, String(d.getUTCDate())); p.set(`interval.${k}Time.hours`, '0'); p.set(`interval.${k}Time.timeZone.id`, 'UTC'); } p.set('filter', `errorIssueId = ${issueId}`); p.set('pageSize', String(Math.min(100, pageSize))); return p.toString(); } /** Table rows for a cluster list. Pure. */ export function clusterRows(cs: Cluster[]): (string | number)[][] { const fmt = (xs: [string, number][]) => xs.map(([k, n]) => `${k} (${n})`).join(', '); return cs.map(c => [c.count, c.signature.slice(0, 70), fmt(c.devices), fmt(c.os), fmt(c.builds)]); }