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:
parent
89256f1e80
commit
0fb4ce0001
46 changed files with 1531 additions and 141 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue