// Crash and vitals health of a released app or game, as pure parts: request builders, response parsers, threshold findings // and the report. `storebound health` and `storebound validate` collect the data (read-only) and use these. // // Google Play Developer Reporting API v1beta1 (scope playdeveloperreporting; the service account needs "View app // information (read-only)" for the app in Play Console, and the API enabled in its Cloud project): // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.crashrate // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.anrrate // https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/vitals.errors.issues/search // DAILY timelines only take the America/Los_Angeles time zone, dates without hours; endTime is exclusive. // errorIssues:search is the other way round: its interval must be hour-aligned and in UTC (America/Los_Angeles // answers 400 "Unsupported timezone"). // Bad behavior thresholds (user-perceived, 28-day average, checked daily): crash 1.09 %, ANR 0.47 % overall; 8 % on a // single phone model. https://developer.android.com/topic/performance/vitals and Play Console Help answer 9844486 // https://support.google.com/googleplay/android-developer/answer/9844486 // App Store Connect (power and performance, read-only): // https://developer.apple.com/documentation/appstoreconnectapi/power-and-performance-metrics-and-logs // GET /v1/apps/{id}/perfPowerMetrics (application/vnd.apple.xcode-metrics+json), GET /v1/builds/{id}/diagnosticSignatures // (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+). import type { Finding, Level } from './validate-rules'; import { mdTable, pct, textTable } from './table'; import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors'; import { reportingEnableUrl } from './store-apps'; export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const; /** Warn when a rate reaches this share of its threshold. */ export const WARN_AT = 0.8; export const CRASH_METRICS = ['userPerceivedCrashRate', 'userPerceivedCrashRate28dUserWeighted', 'crashRate', 'distinctUsers'] as const; export const ANR_METRICS = ['userPerceivedAnrRate', 'userPerceivedAnrRate28dUserWeighted', 'anrRate', 'distinctUsers'] as const; export interface Ymd { year: number; month: number; day: number } const LA = { id: 'America/Los_Angeles' }; /** Calendar date `days` before `d` (UTC arithmetic on the date only). */ export function ymd(d: Date, minusDays = 0): Ymd { const t = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() - minusDays)); return { year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate() }; } export const ymdString = (x: Ymd): string => `${x.year}-${String(x.month).padStart(2, '0')}-${String(x.day).padStart(2, '0')}`; /** Body of POST v1beta1/apps/{package}/{crashRate|anrRate}MetricSet:query: a DAILY timeline [end - days, end). */ export function metricQuery(metrics: readonly string[], end: Ymd, days: number, dimensions: string[] = []): Record { const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); return { timelineSpec: { aggregationPeriod: 'DAILY', startTime: { ...start, timeZone: LA }, endTime: { ...end, timeZone: LA } }, ...(dimensions.length ? { dimensions } : {}), metrics: [...metrics], pageSize: 100000, }; } /** Latest complete day of a metric set (freshnessInfo.freshnesses[DAILY].latestEndTime, exclusive). */ export function latestDailyEnd(metricSet: { freshnessInfo?: { freshnesses?: { aggregationPeriod?: string; latestEndTime?: Partial }[] } }): Ymd | null { const f = metricSet.freshnessInfo?.freshnesses?.find(x => x.aggregationPeriod === 'DAILY')?.latestEndTime; return f?.year && f.month && f.day ? { year: f.year, month: f.month, day: f.day } : null; } export interface MetricRow { date: string; dims: Record; values: Record } interface ApiRow { startTime?: Partial; dimensions?: { dimension: string; stringValue?: string; int64Value?: string; valueLabel?: string }[]; metrics?: { metric: string; decimalValue?: { value?: string } }[]; } /** Query response rows -> plain rows (rates as fractions, e.g. 0.0109 = 1.09 %). */ export function parseMetricRows(resp: { rows?: ApiRow[] }): MetricRow[] { return (resp.rows ?? []).map(r => ({ date: r.startTime?.year ? ymdString(r.startTime as Ymd) : '', dims: Object.fromEntries((r.dimensions ?? []).map(d => [d.dimension, d.valueLabel ?? d.stringValue ?? d.int64Value ?? ''])), values: Object.fromEntries((r.metrics ?? []).filter(m => m.decimalValue?.value !== undefined).map(m => [m.metric, Number(m.decimalValue!.value)])), })).sort((a, b) => a.date.localeCompare(b.date)); } /** errorIssues:search query string: the top issues of a window, most reports first. The interval is UTC midnights * (the API takes only hour-aligned UTC times here, unlike the DAILY metric timelines). */ export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR'; pageSize?: number } = {}): string { const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); const p = new URLSearchParams(); for (const [k, v] of Object.entries({ start, end })) { const x = v as Ymd; p.set(`interval.${k}Time.year`, String(x.year)); p.set(`interval.${k}Time.month`, String(x.month)); p.set(`interval.${k}Time.day`, String(x.day)); p.set(`interval.${k}Time.hours`, '0'); p.set(`interval.${k}Time.timeZone.id`, 'UTC'); } if (o.type) p.set('filter', `errorIssueType = ${o.type}`); p.set('orderBy', 'errorReportCount desc'); p.set('pageSize', String(o.pageSize ?? 10)); return p.toString(); } export interface Issue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string } export function parseIssues(resp: { errorIssues?: { type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; lastAppVersion?: { versionCode?: string }; issueUri?: string }[] }): Issue[] { return (resp.errorIssues ?? []).map(i => ({ 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), lastVersion: i.lastAppVersion?.versionCode, uri: i.issueUri })); } export interface PlayHealth { window: { start: string; end: string; days: number }; crash: MetricRow[]; anr: MetricRow[]; /** Worst device models by user-perceived crash / ANR rate over the window (optional). */ devices?: { model: string; crash?: number; anr?: number; users?: number }[]; issues: Issue[]; /** errorIssues:search failed: the rates above are still valid, only the issue list is missing. */ issuesError?: string; } export interface AppleHealth { /** Latest values per metric identifier (e.g. hangRate, launchTime) from perfPowerMetrics, with the app version. */ metrics: { category: string; metric: string; unit: string; version: string; value: number; goal?: string }[]; /** Top diagnostic signatures of the latest builds. */ signatures: { build: string; type: string; signature: string; weight: number }[]; /** TestFlight crash submissions in the window (count per build). */ betaCrashes: { build: string; count: number }[]; } /** A store that could not be read: `error` is one plain line (never a JSON body), `finding` the typed Google error * (google-errors.ts), `detail` the raw API text for logs and a collapsed "Details". */ export interface StoreReadError { error: string; finding?: GoogleFinding; detail?: string } export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError } /** Last value of a metric over the rows (the 28-day rolling metrics: the latest row is the store's current view). */ export function lastValue(rows: MetricRow[], metric: string): number | null { for (let i = rows.length - 1; i >= 0; i--) { const v = rows[i]!.values[metric]; if (v !== undefined && !Number.isNaN(v)) return v; } return null; } /** Distinct-users-weighted average of a daily metric. */ export function weightedAverage(rows: MetricRow[], metric: string): number | null { let num = 0, den = 0; for (const r of rows) { const v = r.values[metric], u = r.values.distinctUsers ?? 1; if (v === undefined) continue; num += v * u; den += u; } return den ? num / den : null; } /** Per device model over the window: users-weighted user-perceived crash / ANR rates, only models with at least * `minUsers` (summed daily distinct users, so small samples do not raise alarms), worst first, top `n`. */ export function deviceBreakdown(crash: MetricRow[], anr: MetricRow[], o: { minUsers?: number; n?: number } = {}): NonNullable { const agg = new Map(); const add = (rows: MetricRow[], metric: string, which: 'c' | 'a') => { for (const r of rows) { const m = r.dims.deviceModel; const v = r.values[metric], u = r.values.distinctUsers ?? 0; if (!m || v === undefined) continue; const x = agg.get(m) ?? { cn: 0, cd: 0, an: 0, ad: 0 }; if (which === 'c') { x.cn += v * u; x.cd += u; } else { x.an += v * u; x.ad += u; } agg.set(m, x); } }; add(crash, 'userPerceivedCrashRate', 'c'); add(anr, 'userPerceivedAnrRate', 'a'); return [...agg.entries()].filter(([, x]) => Math.max(x.cd, x.ad) >= (o.minUsers ?? 500)) .map(([model, x]) => ({ model, crash: x.cd ? x.cn / x.cd : undefined, anr: x.ad ? x.an / x.ad : undefined, users: Math.max(x.cd, x.ad) })) .sort((a, b) => Math.max(b.crash ?? 0, b.anr ?? 0) - Math.max(a.crash ?? 0, a.anr ?? 0)).slice(0, o.n ?? 5); } /** TestFlight crash submissions -> count per build number within [since, now]. */ export function betaCrashCounts(subs: { attributes?: { createdDate?: string }; relationships?: { build?: { data?: { id: string } } } }[], builds: Map, since: string): AppleHealth['betaCrashes'] { const n = new Map(); for (const s of subs) { if ((s.attributes?.createdDate ?? '') < since) continue; const b = builds.get(s.relationships?.build?.data?.id ?? '') ?? '?'; n.set(b, (n.get(b) ?? 0) + 1); } return [...n.entries()].map(([build, count]) => ({ build, count })).sort((a, b) => b.count - a.count); } /** perfPowerMetrics (xcode-metrics JSON) -> latest point per metric. */ export function parsePerfPower(j: { productData?: { metricCategories?: { identifier?: string; metrics?: { identifier?: string; unit?: { displayName?: string; identifier?: string }; datasets?: { filterCriteria?: { device?: string; percentile?: string }; points?: { version?: string; value?: number; goal?: string }[] }[] }[] }[] }[] }): AppleHealth['metrics'] { const out: AppleHealth['metrics'] = []; for (const p of j.productData ?? []) for (const c of p.metricCategories ?? []) for (const m of c.metrics ?? []) { const ds = (m.datasets ?? []).find(d => (d.filterCriteria?.device ?? 'all_iphones') === 'all_iphones' && /fifty|50|typical/i.test(d.filterCriteria?.percentile ?? 'percentile.fifty')) ?? m.datasets?.[0]; const pt = ds?.points?.at(-1); if (!pt || pt.value === undefined) continue; out.push({ category: c.identifier ?? '', metric: m.identifier ?? '', unit: m.unit?.displayName ?? m.unit?.identifier ?? '', version: pt.version ?? '', value: pt.value, goal: pt.goal }); } return out; } const levelFor = (v: number | null, limit: number): Level => (v === null ? 'ok' : v >= limit ? 'blocker' : v >= limit * WARN_AT ? 'warn' : 'ok'); /** Health -> validate findings. Over a Play bad behavior threshold = blocker (Play lowers discoverability and may * warn users on the listing), from 80 % of it = warn. Apple publishes no thresholds: "poor" goals and heavy * diagnostic signatures are warnings. */ /** A Google 403 for an API that is not enabled in the caller's Cloud project. */ export const REPORTING_OFF = /SERVICE_DISABLED|has not been used in project|accessNotConfigured/; export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; principal?: string | null } = {}): Finding[] { const f: Finding[] = []; const play = h.play; if (play && 'error' in play) { const raw = play.detail ?? play.error; const g = play.finding ?? parseGoogleError(raw); const on = reportingEnableUrl(raw, o.principal ?? null); f.push(g?.kind === 'api-disabled' || REPORTING_OFF.test(raw) ? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the ${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'} is off in the service account's Google Cloud project${(g?.kind === 'api-disabled' ? g.projectId : on.project) ? ` ${g?.kind === 'api-disabled' ? g.projectId : on.project}` : ''}`, fix: `enable "${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'}" for that project: ${g?.kind === 'api-disabled' ? g.activationUrl : on.url} (Enable), wait a few minutes (store-health skill)` } : { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${g ? findingText(g) : plainError(raw).slice(0, 200)}`, fix: g?.kind === 'no-app-access' ? `Play Console > Users and permissions: ${g.usersUrl}` : 'store-health skill (Reporting API access)' }); } else if (play) { const crash = lastValue(play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(play.crash, 'userPerceivedCrashRate'); const anr = lastValue(play.anr, 'userPerceivedAnrRate28dUserWeighted') ?? weightedAverage(play.anr, 'userPerceivedAnrRate'); const fix = 'storebound health; fix the top issues, ship a build with a higher versionCode (store-health skill)'; if (crash === null && anr === null) f.push({ store: 'play', level: 'ok', code: 'vitals', msg: 'no vitals data yet (too few users or no release)' }); else { f.push({ store: 'play', level: levelFor(crash, PLAY_THRESHOLDS.crash), code: 'vitals-crash', msg: `user-perceived crash rate (28 d) ${pct(crash)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.crash)})`, fix }); f.push({ store: 'play', level: levelFor(anr, PLAY_THRESHOLDS.anr), code: 'vitals-anr', msg: `user-perceived ANR rate (28 d) ${pct(anr)} (bad behavior threshold ${pct(PLAY_THRESHOLDS.anr)})`, fix }); } if (play.issuesError) f.push({ store: 'play', level: 'warn', code: 'vitals-issues', msg: `top crash / ANR issues not read: ${play.issuesError.slice(0, 200)}`, fix: 'Play Console > Android vitals > Crashes and ANRs' }); for (const d of play.devices ?? []) { const worst = Math.max(d.crash ?? 0, d.anr ?? 0); const lv = levelFor(worst, PLAY_THRESHOLDS.perDevice); if (lv !== 'ok') f.push({ store: 'play', level: lv, code: 'vitals-device', msg: `${d.model}: crash ${pct(d.crash ?? null)}, ANR ${pct(d.anr ?? null)} (per-device threshold ${pct(PLAY_THRESHOLDS.perDevice)})`, fix }); } } const apple = h.apple; if (apple && 'error' in apple) f.push({ store: 'apple', level: o.unreadable ?? 'warn', code: 'diagnostics', msg: `diagnostics not readable: ${plainError(apple.error).slice(0, 200)}` }); else if (apple) { const poor = apple.metrics.filter(m => m.goal === 'poor'); for (const m of poor) f.push({ store: 'apple', level: 'warn', code: 'perf', msg: `${m.metric} ${m.value} ${m.unit} on ${m.version}: Apple rates it poor`, fix: 'Xcode Organizer > Metrics; store-health skill' }); const heavy = apple.signatures.filter(s => s.weight >= 0.25); for (const s of heavy) f.push({ store: 'apple', level: 'warn', code: 'diagnostics', msg: `build ${s.build} ${s.type}: ${s.signature.slice(0, 120)} (${Math.round(s.weight * 100)} % of reports)`, fix: 'Xcode Organizer > Hangs / Disk Writes / Launches' }); if (!poor.length && !heavy.length) f.push({ store: 'apple', level: 'ok', code: 'diagnostics', msg: `${apple.metrics.length} metrics, ${apple.signatures.length} diagnostic signatures, nothing rated poor` }); } return f; } /** Terminal summary table of a snapshot. */ export function healthTable(h: HealthSnapshot): string { const rows: (string | number)[][] = []; if (h.play && !('error' in h.play)) { const p = h.play; rows.push(['play', 'crash rate (user-perceived, 28 d)', pct(lastValue(p.crash, 'userPerceivedCrashRate28dUserWeighted')), pct(PLAY_THRESHOLDS.crash)]); rows.push(['play', `crash rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.crash, 'userPerceivedCrashRate')), '']); rows.push(['play', 'ANR rate (user-perceived, 28 d)', pct(lastValue(p.anr, 'userPerceivedAnrRate28dUserWeighted')), pct(PLAY_THRESHOLDS.anr)]); rows.push(['play', `ANR rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.anr, 'userPerceivedAnrRate')), '']); rows.push(['play', 'top issues', p.issuesError ? 'not read' : p.issues.length, '']); } else if (h.play) rows.push(['play', 'not readable', h.play.finding ? h.play.finding.kind : h.play.error.slice(0, 80), '']); if (h.apple && !('error' in h.apple)) { for (const m of h.apple.metrics.slice(0, 12)) rows.push(['apple', `${m.metric} (${m.version})`, `${m.value} ${m.unit}`.trim(), m.goal ?? '']); rows.push(['apple', 'diagnostic signatures', h.apple.signatures.length, '']); rows.push(['apple', 'TestFlight crash reports', h.apple.betaCrashes.reduce((n, b) => n + b.count, 0), '']); } else if (h.apple) rows.push(['apple', 'not readable', h.apple.finding ? h.apple.finding.kind : h.apple.error.slice(0, 80), '']); return textTable(['store', 'metric', 'value', 'threshold / goal'], rows); } /** Markdown report (weekly summary): table, findings, top issues, week-over-week change when `previous` is given. */ export function healthMarkdown(h: HealthSnapshot, o: { appName: string; previous?: HealthSnapshot | null } = { appName: '' }): string { const out: string[] = [`# Health report · ${o.appName} · ${h.date}`, '']; const f = healthFindings(h); const icon: Record = { blocker: 'OVER THRESHOLD', warn: 'watch', ok: 'ok' }; out.push('## Summary', '', mdTable(['store', 'status', 'finding'], f.map(x => [x.store, icon[x.level], x.msg])), ''); const p = h.play && !('error' in h.play) ? h.play : null; const prev = o.previous?.play && !('error' in o.previous.play) ? o.previous.play : null; if (p) { out.push(`## Google Play vitals (${p.window.start} to ${p.window.end})`, ''); const delta = (cur: number | null, old: number | null) => (cur === null || old === null ? '' : `${cur >= old ? '+' : ''}${((cur - old) * 100).toFixed(2)} pt`); const c = weightedAverage(p.crash, 'userPerceivedCrashRate'), a = weightedAverage(p.anr, 'userPerceivedAnrRate'); out.push(mdTable(['metric', 'this window', 'previous', 'change', 'threshold'], [ ['user-perceived crash rate', pct(c), pct(prev ? weightedAverage(prev.crash, 'userPerceivedCrashRate') : null), delta(c, prev ? weightedAverage(prev.crash, 'userPerceivedCrashRate') : null), pct(PLAY_THRESHOLDS.crash)], ['user-perceived ANR rate', pct(a), pct(prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), delta(a, prev ? weightedAverage(prev.anr, 'userPerceivedAnrRate') : null), pct(PLAY_THRESHOLDS.anr)], ]), ''); if (p.issues.length) out.push('### Top issues', '', mdTable(['type', 'cause', 'location', 'reports', 'users', 'last versionCode'], p.issues.map(i => [i.type, i.cause.slice(0, 80), i.location.slice(0, 80), i.reports, i.users, i.lastVersion ?? ''])), ''); } const ap = h.apple && !('error' in h.apple) ? h.apple : null; if (ap) { out.push('## App Store diagnostics', ''); if (ap.metrics.length) out.push(mdTable(['category', 'metric', 'version', 'value', 'goal'], ap.metrics.map(m => [m.category, m.metric, m.version, `${m.value} ${m.unit}`.trim(), m.goal ?? ''])), ''); if (ap.signatures.length) out.push('### Diagnostic signatures', '', mdTable(['build', 'type', 'signature', 'weight'], ap.signatures.map(s => [s.build, s.type, s.signature.slice(0, 100), `${Math.round(s.weight * 100)} %`])), ''); if (ap.betaCrashes.length) out.push('### TestFlight crash reports', '', mdTable(['build', 'reports'], ap.betaCrashes.map(b => [b.build, b.count])), ''); } out.push('Sources: Play Developer Reporting API (vitals, 28-day user-weighted rates, America/Los_Angeles days) and App Store Connect power and performance metrics. Thresholds: developer.android.com/topic/performance/vitals.', ''); return out.join('\n'); }