storebound/cli/lib/health.ts

347 lines
25 KiB
TypeScript

// 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 { CONSOLE_PAGES, consoleLink, consoleUrl } from './console-links';
import { type CrashHint, crashHint } from './crash-hints';
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<string, unknown> {
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> }[] } }): 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<string, string>; values: Record<string, number> }
interface ApiRow {
startTime?: Partial<Ymd>;
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 {
/** Likely cause of a known signature (cli/lib/crash-hints.ts). */
hint?: CrashHint;
type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string;
/** The issue id (the last part of its resource name "apps/<pkg>/errorIssues/<id>"): `health --issue <id>` reads a sample report. */
id?: string;
/** versionCode of the first and last app version with the issue; Android API levels; the last report's time. */
firstVersion?: string; firstOs?: string; lastOs?: string; lastSeen?: string;
}
interface ApiIssue {
name?: string; type?: string; cause?: string; location?: string; errorReportCount?: string; distinctUsers?: string; issueUri?: string; lastErrorReportTime?: string;
firstAppVersion?: { versionCode?: string }; lastAppVersion?: { versionCode?: string }; firstOsVersion?: { apiLevel?: string }; lastOsVersion?: { apiLevel?: string };
}
export function parseIssues(resp: { errorIssues?: ApiIssue[] }): Issue[] {
return (resp.errorIssues ?? []).map(i => {
const extra: Partial<Issue> = {};
const id = i.name?.split('/').pop();
if (id) extra.id = id;
if (i.firstAppVersion?.versionCode) extra.firstVersion = i.firstAppVersion.versionCode;
if (i.firstOsVersion?.apiLevel) extra.firstOs = i.firstOsVersion.apiLevel;
if (i.lastOsVersion?.apiLevel) extra.lastOs = i.lastOsVersion.apiLevel;
if (i.lastErrorReportTime) extra.lastSeen = i.lastErrorReportTime;
const hint = crashHint(i.cause, i.location);
if (hint) extra.hint = hint;
return { 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, ...extra };
});
}
/** errorReports:search query of one issue (the newest report first), over the same UTC-midnight window as the issues. */
export function reportsQuery(end: Ymd, days: number, issueId: string): string {
if (!/^[A-Za-z0-9_-]{1,200}$/.test(issueId)) throw new Error(`--issue: an error issue id (letters, digits, - and _), not "${issueId.slice(0, 40)}"`);
const p = new URLSearchParams(issuesQuery(end, days));
p.delete('orderBy');
p.set('filter', `errorIssueId = ${issueId}`);
p.set('pageSize', '1');
return p.toString();
}
/** One sample report of an issue (Play errorReports, App Store diagnostic logs): the stack as lines, top frame first. */
export interface IssueSample { store: 'play' | 'apple'; time?: string; version?: string; os?: string; device?: string; type?: string; stack: string[] }
const MAX_FRAMES = 60;
export function parseErrorReports(resp: { errorReports?: { reportText?: string; eventTime?: string; type?: string; appVersion?: { versionCode?: string }; osVersion?: { apiLevel?: string };
deviceModel?: { marketingName?: string; deviceId?: { buildBrand?: string; buildDevice?: string } } }[] }): IssueSample | null {
const r = resp.errorReports?.[0];
if (!r) return null;
const stack = (r.reportText ?? '').split('\n').map(l => l.replace(/\s+$/, '')).filter(l => l.trim()).slice(0, MAX_FRAMES);
const dev = r.deviceModel?.marketingName ?? [r.deviceModel?.deviceId?.buildBrand, r.deviceModel?.deviceId?.buildDevice].filter(Boolean).join('/');
return { store: 'play', stack, ...(r.eventTime ? { time: r.eventTime } : {}), ...(r.appVersion?.versionCode ? { version: r.appVersion.versionCode } : {}),
...(r.osVersion?.apiLevel ? { os: `API ${r.osVersion.apiLevel}` } : {}), ...(dev ? { device: dev } : {}), ...(r.type ? { type: r.type } : {}) };
}
interface ApiFrame { symbolName?: string; binaryName?: string; offsetIntoSymbol?: number; subFrames?: ApiFrame[] }
/** GET /v1/diagnosticSignatures/{id}/logs (application/vnd.apple.diagnostic-logs+json): the first log's heaviest call
* stack, following the first sub frame down, as "binary symbol + offset" lines. */
export function parseDiagnosticLogs(resp: { productData?: { diagnosticLogs?: { callStackTree?: { callStacks?: { callStackRootFrames?: ApiFrame[] }[] }[];
diagnosticMetaData?: { appVersion?: string; buildVersion?: string; osVersion?: string; deviceType?: string; eventTime?: string } }[] }[] }): IssueSample | null {
const log = resp.productData?.flatMap(p => p.diagnosticLogs ?? [])[0];
if (!log) return null;
const stack: string[] = [];
let f: ApiFrame | undefined = log.callStackTree?.[0]?.callStacks?.[0]?.callStackRootFrames?.[0];
while (f && stack.length < MAX_FRAMES) {
stack.push(`${f.binaryName ?? '?'} ${f.symbolName ?? '???'}${f.offsetIntoSymbol !== undefined ? ` + ${f.offsetIntoSymbol}` : ''}`);
f = f.subFrames?.[0];
}
const m = log.diagnosticMetaData ?? {};
const version = [m.appVersion, m.buildVersion ? `(${m.buildVersion})` : ''].filter(Boolean).join(' ');
return { store: 'apple', stack, ...(version ? { version } : {}), ...(m.osVersion ? { os: m.osVersion } : {}), ...(m.deviceType ? { device: m.deviceType } : {}), ...(m.eventTime ? { time: m.eventTime } : {}) };
}
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; /** `health --signature <id>` reads its logs. */ id?: string }[];
/** 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<PlayHealth['devices']> {
const agg = new Map<string, { cn: number; cd: number; an: number; ad: number }>();
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<string, string>, since: string): AppleHealth['betaCrashes'] {
const n = new Map<string, number>();
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' ? `${CONSOLE_PAGES['play.users'].label}: ${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: consoleLink('play.vitals', '> Crashes and ANRs'), url: consoleUrl('play.vitals') });
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<Level, string> = { 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 ?? ''])), '',
...p.issues.filter(i => i.hint).map(i => `- ${i.cause.slice(0, 80)}: likely cause: ${i.hint!.cause} Fix: ${i.hint!.fix}`), ...(p.issues.some(i => i.hint) ? [''] : []));
}
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');
}