storebound/cli/health.ts

111 lines
7.9 KiB
TypeScript

// 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 <date>.md (+ <date>.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,
issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, 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<PlayHealth> {
const g = googleClient(root, REPORTING_SCOPE);
const app = `${REPORTING_API}/apps/${encodeURIComponent(pkg)}`;
log('health', 'play: vitals freshness');
const set = await g.json<Parameters<typeof latestDailyEnd>[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<AppleHealth> {
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 = <T>(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) });
}
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<string, string>(builds.map(b => [b.id, b.attributes.version] as const));
return { metrics, signatures: signatures.slice(0, 15), betaCrashes: betaCrashCounts(subs, buildNo, since) };
}
/** 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<HealthSnapshot> {
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<void> {
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 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}`);
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;
}