A live run where only the token exchange, edit insert / delete and Reporting :query were let through: - health / validate: errorIssues:search answered 400 "Unsupported timezone" for America/Los_Angeles (it takes hour-aligned UTC only); the failure dropped the crash and ANR rates and the reason was cut off in the query string. Issues are asked in UTC; a failing issues search is a vitals-issues warning; Reporting errors leave out the query. - play listing --dry / play images --dry wrote into the edit (PUT details and listings, image clears and uploads, :validate) before discarding it. Now one throwaway edit is read (details, listings, image sha256) and compared with the local copy, then deleted. - play.read(): one helper for read-only edits (status, next-version-code), always deleted, warns if the delete fails; next-version-code counts distinct codes. - tests on sanitized recorded answers (tests/play-post-reads.test.ts)
109 lines
7.7 KiB
TypeScript
109 lines
7.7 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 { 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 = (e as Error).message;
|
|
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 = { error: (e as Error).message }; }
|
|
}
|
|
if (o.apple && config.apple) {
|
|
try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = { error: (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));
|
|
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;
|
|
}
|