// Crash and feedback triage: TestFlight crash and screenshot feedback, and Google Play vitals error reports, grouped // by signature with their top devices, OS versions and builds. Read-only on both stores. // storebound triage [--apple] [--play] [--days 14] [--build N] [--issues 10] [--comments 3] [--dry] [--json] // Apple: crash feedback -> the crash log of each (signature = exception + first app frame of the crashed thread); // screenshot feedback grouped by device / OS / build (--comments N shows up to N tester comments per group, cut to // 120 characters; emails are never read). Play: the top --issues issues of the window (Play's own clusters), then // their error reports for the device / API level / versionCode spread. --dry lists the requests without sending. // Pure parts and sources: cli/lib/triage.ts. import { ascClient, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { REPORTING_SCOPE } from './lib/google-auth'; import { issuesQuery, ymd } from './lib/health'; import { done, log, printJson, warn } from './lib/log'; import { textTable } from './lib/table'; import { CRASH_FIELDS, cluster, type Cluster, clusterRows, crashSignature, FEEDBACK_FIELDS, feedbackItems, playIssues, playReportItems, reportsQuery } from './lib/triage'; import { googleClient, REPORTING_API } from './play/google'; const TAG = 'triage'; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; const a = ctx.args; const both = !a.has('apple') && !a.has('play'); const days = a.num('days', 14); if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90'); const dry = a.has('dry'); const since = new Date(Date.now() - days * 86_400_000).toISOString(); const result: { apple?: { crashes: Cluster[]; feedback: Cluster[] } | { error: string }; play?: { issues: Cluster[] } | { error: string } } = {}; if ((both || a.has('apple')) && config.apple) { try { const c = await ascClient(root, config.apple.bundleId, config.apple.appId); const appId = dry ? '' : await c.app(); const buildFilter = a.get('build'); let buildIds: string[] = []; const builds = new Map(); if (!dry) for (const b of (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=200${buildFilter ? `&filter[version]=${encodeURIComponent(buildFilter)}` : ''}`)).data as J[]) builds.set(b.id, b.attributes.version); buildIds = [...builds.keys()]; if (buildFilter && !dry && !buildIds.length) throw new Error(`no build ${buildFilter}`); const q = (fields: string, kind: string) => `/v1/apps/${appId}/${kind}?sort=-createdDate&limit=200&include=build&fields[builds]=version&fields[${kind}]=${fields}${buildFilter && buildIds.length ? `&filter[build]=${buildIds.join(',')}` : ''}`; if (dry) { log(TAG, `would GET ${q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions')} + each crashLog`); log(TAG, `would GET ${q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions')}`); } else { const recent = (xs: J[]) => xs.filter(x => (x.attributes?.createdDate ?? '') >= since); const crashes = recent(await c.all(q(CRASH_FIELDS, 'betaFeedbackCrashSubmissions'))); log(TAG, `apple: ${crashes.length} TestFlight crash report(s) in ${days} days`); const sigs = new Map(); for (const s of crashes) { const logText = (await c.maybe(`/v1/betaFeedbackCrashSubmissions/${s.id}/crashLog`))?.data?.attributes?.logText as string | undefined; sigs.set(s.id, logText ? crashSignature(logText) : 'crash (no log)'); } const shots = recent(await c.all(q(FEEDBACK_FIELDS, 'betaFeedbackScreenshotSubmissions'))); log(TAG, `apple: ${shots.length} TestFlight screenshot feedback(s)`); const comments = a.num('comments', 0); const fb = feedbackItems(shots, builds).map(i => ({ ...i, signature: `feedback on ${i.device}` })); result.apple = { crashes: cluster(feedbackItems(crashes, builds, sigs)), feedback: cluster(fb, { comments }) }; } } catch (e) { result.apple = { error: (e as Error).message }; warn(TAG, `apple: ${(e as Error).message.slice(0, 300)}`); } } if ((both || a.has('play')) && config.play) { try { const app = `${REPORTING_API}/apps/${encodeURIComponent(config.play.packageName)}`; const end = ymd(new Date(), 0); const iq = issuesQuery(end, days, { pageSize: a.num('issues', 10) }); if (dry) log(TAG, `would GET ${app}/errorIssues:search?${iq} + errorReports:search per issue`); else { const g = googleClient(root, REPORTING_SCOPE); const issues = playIssues(await g.json('GET', `${app}/errorIssues:search?${iq}`)); log(TAG, `play: ${issues.length} top issue(s) in ${days} days`); const items = []; const startD = new Date(Date.UTC(end.year, end.month - 1, end.day) - days * 86_400_000), endD = new Date(Date.UTC(end.year, end.month - 1, end.day)); for (const i of issues) items.push(...playReportItems(await g.json('GET', `${app}/errorReports:search?${reportsQuery(i.id, startD, endD)}`), issues)); const cs = cluster(items); // issues with no sampled reports still show with Play's own counts for (const i of issues) { const s = `${i.type} ${i.cause}${i.location ? ` @ ${i.location}` : ''}`; if (!cs.some(c => c.signature === s)) cs.push({ signature: s, count: 0, devices: [], os: [], builds: [] }); } result.play = { issues: cs.map(c => ({ ...c, ...(() => { const i = issues.find(x => c.signature.startsWith(`${x.type} ${x.cause}`)); return i ? { reports: i.reports, users: i.users } : {}; })() })) }; } } catch (e) { result.play = { error: (e as Error).message }; warn(TAG, `play: ${(e as Error).message.slice(0, 300)}`); } } if (dry) { if (a.has('json')) printJson({ dryRun: true }); done(TAG, 'dry run: nothing read'); return; } if (a.has('json')) { printJson(result); return; } const head = ['n', 'signature', 'devices', 'os', 'builds']; if (result.apple && !('error' in result.apple)) { console.log(`\nTestFlight crashes (${days} days)\n` + (result.apple.crashes.length ? textTable(head, clusterRows(result.apple.crashes)) : 'none')); console.log(`\nTestFlight feedback (${days} days)\n` + (result.apple.feedback.length ? textTable(head, clusterRows(result.apple.feedback)) : 'none')); for (const c of result.apple.feedback) for (const t of c.comments ?? []) console.log(` ${c.signature}: "${t}"`); } if (result.play && !('error' in result.play)) console.log(`\nGoogle Play crashes and ANRs (${days} days; n = sampled reports)\n` + (result.play.issues.length ? textTable(head, clusterRows(result.play.issues)) : 'none')); done(TAG, 'triage done (fix the top signature first; storebound health for the rates)'); }