347 lines
25 KiB
TypeScript
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');
|
|
}
|