// Crash and vitals health of the released app or game, read-only on both stores. // storebound health [--apple] [--play] [--days 7] [--json] // storebound health --weekly [--out .storebound/health] [--dry] also writes .md (+ .json for the next // comparison); --dry prints the report instead of writing it // Google Play: Play Developer Reporting API (user-perceived crash and ANR rates, 28-day user-weighted, the worst // device models, top error issues). The service account needs "View app information (read-only)" and the API enabled // in its Cloud project. App Store: power and performance metrics (perfPowerMetrics), diagnostic signatures of the // latest builds, TestFlight crash submissions. Thresholds and sources: cli/lib/health.ts. `validate` reuses the // same collection and adds the findings (over a Play bad behavior threshold = blocker). import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { ascClient, type J } from './apple/asc'; import type { Ctx } from './lib/ctx'; import { REPORTING_SCOPE } from './lib/google-auth'; import { type AppleHealth, ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable, type IssueSample, issuesQuery, latestDailyEnd, metricQuery, parseDiagnosticLogs, parseErrorReports, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, reportsQuery, ymd, ymdString, } from './lib/health'; import { done, log, printJson, warn } from './lib/log'; import { plainError, storeReadError } from './lib/google-errors'; import { formatFindings } from './lib/validate-rules'; import { googleClient, REPORTING_API } from './play/google'; export async function collectPlayHealth(root: string, pkg: string, days: number): Promise { const g = googleClient(root, REPORTING_SCOPE); const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`; log('health', 'play: vitals freshness'); const set = await g.json[0]>('GET', `${app}/crashRateMetricSet`); const end = latestDailyEnd(set) ?? ymd(new Date(), 2); log('health', `play: crash and ANR rates to ${ymdString(end)} (${days} days, America/Los_Angeles days)`); const crash = parseMetricRows(await g.json('POST', `${app}/crashRateMetricSet:query`, metricQuery(CRASH_METRICS, end, days))); const anr = parseMetricRows(await g.json('POST', `${app}/anrRateMetricSet:query`, metricQuery(ANR_METRICS, end, days))); log('health', 'play: per device model (28 days) and top issues'); const crashDev = parseMetricRows(await g.json('POST', `${app}/crashRateMetricSet:query`, metricQuery(['userPerceivedCrashRate', 'distinctUsers'], end, 28, ['deviceModel']))); const anrDev = parseMetricRows(await g.json('POST', `${app}/anrRateMetricSet:query`, metricQuery(['userPerceivedAnrRate', 'distinctUsers'], end, 28, ['deviceModel']))); // the top issues are extra: a failure there must not hide the crash and ANR rates already read let issues: PlayHealth['issues'] = [], issuesError: string | undefined; try { issues = parseIssues(await g.json('GET', `${app}/errorIssues:search?${issuesQuery(end, days)}`)); } catch (e) { issuesError = plainError((e as Error).message, { app: pkg }); warn('health', `play: top issues not read: ${issuesError.slice(0, 200)}`); } const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); return { window: { start: ymdString(start), end: ymdString(end), days }, crash, anr, devices: deviceBreakdown(crashDev, anrDev), issues, ...(issuesError ? { issuesError } : {}) }; } export async function collectAppleHealth(root: string, bundleId: string, appIdHint: string | undefined, days: number): Promise { const c = await ascClient(root, bundleId, appIdHint); const appId = await c.app(); log('health', 'apple: power and performance metrics'); // App Store Connect answers 404 (not an empty list) while there is no field data yet: a new app, or TestFlight-only builds const none = (fallback: T) => (e: unknown): T => { if (/-> 404\b/.test(String(e))) return fallback; throw e; }; const metrics = parsePerfPower(await c.getAs(`/v1/apps/${appId}/perfPowerMetrics?filter[platform]=IOS`, 'application/vnd.apple.xcode-metrics+json').catch(none({}))); const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&filter[processingState]=VALID&sort=-uploadedDate&limit=3`)).data as J[]; const signatures: AppleHealth['signatures'] = []; for (const b of builds) { log('health', `apple: diagnostic signatures of build ${b.attributes.version}`); for (const s of ((await c.asc('GET', `/v1/builds/${b.id}/diagnosticSignatures?limit=10`).catch(none({ data: [] }))).data ?? []) as J[]) signatures.push({ build: b.attributes.version, type: s.attributes.diagnosticType, signature: s.attributes.signature ?? '', weight: Number(s.attributes.weight ?? 0), ...(s.id ? { id: String(s.id) } : {}) }); } signatures.sort((a, b) => b.weight - a.weight); log('health', 'apple: TestFlight crash reports'); const since = new Date(Date.now() - days * 86_400_000).toISOString(); const subs = await c.all(`/v1/apps/${appId}/betaFeedbackCrashSubmissions?sort=-createdDate&limit=200`).catch(e => { warn('health', `TestFlight crash reports: ${(e as Error).message.slice(0, 160)}`); return []; }); const buildNo = new Map(builds.map(b => [b.id, b.attributes.version] as const)); return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) }; } /** One sample report of a Play error issue (`--issue `, errorReports:search over the last `days`). */ export async function playIssueSample(root: string, pkg: string, issueId: string, days: number): Promise { const g = googleClient(root, REPORTING_SCOPE); const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`; const set = await g.json[0]>('GET', `${app}/crashRateMetricSet`); const end = latestDailyEnd(set) ?? ymd(new Date(), 2); log('health', `play: a sample report of issue ${issueId}`); return parseErrorReports(await g.json('GET', `${app}/errorReports:search?${reportsQuery(end, days, issueId)}`)); } /** The logs of one App Store diagnostic signature (`--signature `). */ export async function appleSignatureSample(root: string, bundleId: string, appIdHint: string | undefined, id: string): Promise { if (!/^[A-Za-z0-9_-]{1,200}$/.test(id)) throw new Error('--signature: a diagnostic signature id'); const c = await ascClient(root, bundleId, appIdHint); log('health', `apple: logs of diagnostic signature ${id}`); return parseDiagnosticLogs(await c.getAs(`/v1/diagnosticSignatures/${encodeURIComponent(id)}/logs?limit=1`, 'application/vnd.apple.diagnostic-logs+json')); } /** Both stores (or the chosen one); a store that cannot be read becomes { error } instead of failing the run. */ export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean; days: number }): Promise { const { config, root } = ctx.loaded; const h: HealthSnapshot = { date: new Date().toISOString().slice(0, 10) }; if (o.play && config.play) { try { h.play = await collectPlayHealth(root, config.play.packageName, o.days); } catch (e) { h.play = storeReadError((e as Error).message, { app: config.play.packageName }); } } if (o.apple && config.apple) { try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = storeReadError((e as Error).message); } } return h; } /** Newest earlier snapshot in the health folder (for week-over-week). */ export function previousSnapshot(dir: string, before: string): HealthSnapshot | null { if (!existsSync(dir)) return null; const f = readdirSync(dir).filter(n => /^\d{4}-\d{2}-\d{2}\.json$/.test(n) && n.slice(0, 10) < before).sort().at(-1); return f ? JSON.parse(readFileSync(join(dir, f), 'utf8')) as HealthSnapshot : null; } export async function run(ctx: Ctx): Promise { const a = ctx.args; const both = !a.has('apple') && !a.has('play'); const weekly = a.has('weekly'); const days = a.num('days', 7); if (!(days >= 1 && days <= 90)) throw new Error('--days: 1-90'); const issue = a.get('issue'), signature = a.get('signature'); if (issue || signature) { // one issue's sample stack (the desktop's Health detail): --issue or --signature const { config, root } = ctx.loaded; if (issue && !config.play) throw new Error('--issue: the config has no play section'); if (signature && !config.apple) throw new Error('--signature: the config has no apple section'); const sample = issue ? await playIssueSample(root, config.play!.packageName, issue, Math.max(days, 28)) : await appleSignatureSample(root, config.apple!.bundleId, config.apple!.appId, signature!); if (a.has('json')) { printJson({ sample }); return; } console.log(sample ? [`${sample.store} ${sample.type ?? ''} ${sample.version ?? ''} ${sample.device ?? ''} ${sample.os ?? ''}`.replace(/\s+/g, ' ').trim(), ...sample.stack].join('\n') : 'no sample report in the window'); return; } const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days }); if (a.has('json')) { printJson(h); return; } console.log(healthTable(h)); for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`); const play = h.play && !('error' in h.play) ? h.play : null; for (const i of play?.issues.filter(x => x.hint) ?? []) console.log(`\nlikely cause of ${i.cause.slice(0, 80)}: ${i.hint!.cause}\nfix: ${i.hint!.fix}`); console.log('\n' + formatFindings(healthFindings(h))); if (weekly) { const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health'); const md = healthMarkdown(h, { appName: ctx.loaded.config.app.name, previous: previousSnapshot(dir, h.date) }); if (a.has('dry')) { console.log('\n' + md); log('health', `dry run: would write ${join(dir, `${h.date}.md`)}`); return; } mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, `${h.date}.md`), md); writeFileSync(join(dir, `${h.date}.json`), JSON.stringify(h, null, 2) + '\n'); log('health', `report: ${join(dir, `${h.date}.md`)}`); } const over = healthFindings(h).filter(f => f.level === 'blocker').length; done('health', over ? `${over} metric(s) over a store threshold` : 'no metric over a store threshold'); if (over) process.exitCode = 1; }