desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -16,6 +16,7 @@
// 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 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';
@ -89,11 +90,79 @@ export function issuesQuery(end: Ymd, days: number, o: { type?: 'CRASH' | 'ANR';
return p.toString();
}
export interface Issue { type: string; cause: string; location: string; reports: number; users: number; lastVersion?: string; uri?: string }
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;
}
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 }));
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 {
@ -111,7 +180,7 @@ 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 }[];
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 }[];
}
@ -262,7 +331,8 @@ export function healthMarkdown(h: HealthSnapshot, o: { appName: string; previous
['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.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) {