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
22
cli/lib/crash-hints.ts
Normal file
22
cli/lib/crash-hints.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// "Likely cause" hints for known crash signatures, shared by `storebound health` and the desktop Health tab.
|
||||
// Only entries verified in a real app (or documented by the library itself) go here: a wrong hint costs more than none.
|
||||
|
||||
export interface CrashHint { id: string; cause: string; fix: string }
|
||||
|
||||
interface Rule extends CrashHint { match: (text: string) => boolean }
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{
|
||||
id: 'capacitor-permission-states-npe',
|
||||
match: x => /NullPointerException/.test(x) && /com\.getcapacitor\.Bridge\.getPermissionStates/.test(x),
|
||||
cause: 'A Capacitor plugin\'s checkPermissions() (for example FirebaseMessaging) ran during an activity transition (focus / visibilitychange while the app resumes or rotates), when the bridge has no activity.',
|
||||
fix: 'Ask for the permission state only when App.getState() says active, the page is visible and the app has been active for a short delay (e.g. 500 ms); do not call checkPermissions() from focus / visibilitychange handlers directly.',
|
||||
},
|
||||
];
|
||||
|
||||
/** The hint for an issue's text (cause, location, top frames), or null. */
|
||||
export function crashHint(...texts: (string | undefined | null)[]): CrashHint | null {
|
||||
const text = texts.filter(Boolean).join('\n');
|
||||
const r = RULES.find(x => x.match(text));
|
||||
return r ? { id: r.id, cause: r.cause, fix: r.fix } : null;
|
||||
}
|
||||
|
|
@ -75,6 +75,8 @@ export interface ToolResult {
|
|||
outcome?: Outcome;
|
||||
/** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */
|
||||
error?: string;
|
||||
/** Store API quota the run saw (App Store Connect X-Rate-Limit, Google 429): the desktop's read scheduler slows down on it. */
|
||||
quota?: import('./quota').QuotaInfo;
|
||||
}
|
||||
|
||||
export type Outcome = 'ok' | 'findings' | 'declined' | 'failed';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
51
cli/lib/quota.ts
Normal file
51
cli/lib/quota.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Store API quota seen by a command, passed to `storebound serve` (the desktop's read scheduler) as one marked stderr
|
||||
// line per change: App Store Connect's X-Rate-Limit header ("user-hour-lim:3600;user-hour-rem:3412;") and 429 /
|
||||
// RESOURCE_EXHAUSTED answers of the Google APIs. Outside serve (STOREBOUND_MACHINE unset) nothing is printed.
|
||||
|
||||
/** Prefix of the quota line on stderr; runner.ts takes it out of the output. */
|
||||
export const QUOTA_MARK = '\u001estorebound-quota ';
|
||||
|
||||
export interface QuotaInfo {
|
||||
store: 'apple' | 'play';
|
||||
/** Requests per hour of this key (App Store Connect). */
|
||||
limit?: number;
|
||||
/** Requests left in the current hour. */
|
||||
remaining?: number;
|
||||
/** The store answered 429 / RESOURCE_EXHAUSTED at least once in this run. */
|
||||
throttled?: boolean;
|
||||
}
|
||||
|
||||
/** "user-hour-lim:3600;user-hour-rem:3412;" -> { limit: 3600, remaining: 3412 }; null when it is not there. */
|
||||
export function parseAscRateLimit(header: string | null | undefined): { limit: number; remaining: number } | null {
|
||||
if (!header) return null;
|
||||
const lim = /user-hour-lim:(\d+)/i.exec(header), rem = /user-hour-rem:(\d+)/i.exec(header);
|
||||
return lim && rem ? { limit: Number(lim[1]), remaining: Number(rem[1]) } : null;
|
||||
}
|
||||
|
||||
let last = '';
|
||||
function emit(q: QuotaInfo): void {
|
||||
if (process.env.STOREBOUND_MACHINE !== '1') return;
|
||||
const line = JSON.stringify(q);
|
||||
if (line === last) return;
|
||||
last = line;
|
||||
process.stderr.write(QUOTA_MARK + line + '\n');
|
||||
}
|
||||
|
||||
/** After every App Store Connect answer. */
|
||||
export function noteAsc(r: { status: number; headers: { get(n: string): string | null } }): void {
|
||||
const rl = parseAscRateLimit(r.headers.get('x-rate-limit'));
|
||||
if (rl) emit({ store: 'apple', ...rl, ...(r.status === 429 ? { throttled: true } : {}) });
|
||||
else if (r.status === 429) emit({ store: 'apple', throttled: true });
|
||||
}
|
||||
|
||||
/** After every Google answer (Play Developer API, Reporting API). */
|
||||
export function noteGoogle(status: number): void {
|
||||
if (status === 429) emit({ store: 'play', throttled: true });
|
||||
}
|
||||
|
||||
/** Folds the marked lines of one run into one value (the latest counts; throttled if any line said so). */
|
||||
export function mergeQuota(a: QuotaInfo | undefined, b: QuotaInfo): QuotaInfo {
|
||||
if (!a || a.store !== b.store) return b;
|
||||
const remaining = b.remaining ?? a.remaining, limit = b.limit ?? a.limit;
|
||||
return { store: b.store, ...(limit === undefined ? {} : { limit }), ...(remaining === undefined ? {} : { remaining }), ...(a.throttled || b.throttled ? { throttled: true } : {}) };
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { RESULT_MARK } from './log';
|
||||
import { mergeQuota, QUOTA_MARK, type QuotaInfo } from './quota';
|
||||
import { toolPath } from './tools';
|
||||
import * as rt from './rt';
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export interface RunOptions {
|
|||
}
|
||||
|
||||
/** `err`: the stderr lines only (for the command's own error line). */
|
||||
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean }
|
||||
export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean; /** Store quota the run saw (cli/lib/quota.ts). */ quota?: QuotaInfo }
|
||||
|
||||
export interface Running { result: Promise<RunResult>; cancel(): void }
|
||||
|
||||
|
|
@ -73,11 +74,16 @@ export function runCommand(argv: string[], o: RunOptions): Running {
|
|||
const lines: string[] = [];
|
||||
const errs: string[] = [];
|
||||
let data: unknown;
|
||||
let quota: QuotaInfo | undefined;
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
|
||||
if (kind === 'stdout' && line.startsWith(RESULT_MARK)) {
|
||||
try { data = JSON.parse(line.slice(RESULT_MARK.length)); } catch { lines.push(line); }
|
||||
return;
|
||||
}
|
||||
if (kind === 'stderr' && line.startsWith(QUOTA_MARK)) {
|
||||
try { quota = mergeQuota(quota, JSON.parse(line.slice(QUOTA_MARK.length)) as QuotaInfo); } catch { /* a broken quota line is dropped */ }
|
||||
return;
|
||||
}
|
||||
lines.push(line);
|
||||
if (kind === 'stderr') errs.push(line);
|
||||
o.onLine?.(line, kind);
|
||||
|
|
@ -85,7 +91,7 @@ export function runCommand(argv: string[], o: RunOptions): Running {
|
|||
const result = (async (): Promise<RunResult> => {
|
||||
await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]);
|
||||
const code = await p.exited;
|
||||
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled };
|
||||
return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), ...(quota ? { quota } : {}), cancelled };
|
||||
})();
|
||||
return { result, cancel: () => { cancelled = true; p.kill(); } };
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue