feat(cli): report command failure reasons to telemetry (de-blind browser/info) (#1484)

Observability showed `browser` (~75% fail, ~1.3k users/day) and `info` (~60%
fail) failing at high rates with no captured reason — only
`cli_command_result success=false`. citty's `runMain` catches a command's
thrown error and `process.exit(1)`s without re-throwing, so a thrown failure
never reached the existing `cli_error` telemetry (which only fired from the
uncaughtException / unhandledRejection handlers).

Wrap every command's `run()` at the dispatch boundary (cli.ts) so a thrown
failure reports its reason via `cli_error` (kind=command_error) before being
re-thrown unchanged — citty's print + exit-1 behavior is preserved. This
de-blinds every throw-style command at once: `browser ensure` (Chrome
download), `tts`, `inspect`, `render`, etc.

Paths that bypass the wrapper are handled inline:
- `browser` self-exits (`path` download failure, unknown subcommand) — report
  inline; the ARM64 `ensure` branch previously swallowed a failed install and
  returned success, now reports and exits 1.
- `resolveProject()` self-exits on InvalidProjectError (the dominant `info`
  failure — run outside a project) — report inline before exit.

Hardening:
- PII: `trackCliError` now redacts error_message + stack_trace via
  redactTelemetryString (matching render_* events) — CLI errors and stacks
  carry absolute install paths / cache dirs / user args.
- Race: the wrapper awaits an on-demand telemetry import before re-throwing, so
  a command that fails before the lazy telemetry import settles still reports
  (a telemetry failure is swallowed and never masks the real error).

Pure helpers in utils/command-failure-tracking.ts with unit tests for the
throw / success / no-run / onFailure-rejection cases, the reporter wiring, and
trackCliError redaction. CommandDef<any> mirrors citty's SubCommandsDef.

Known scope: commands that print + `process.exit(1)` on their own validation
paths (tts/validate/lint argument errors) remain wrapper-blind — follow-up.
This commit is contained in:
Miguel Ángel
2026-06-16 01:39:34 -04:00
committed by GitHub
parent 897692be65
commit 5f6ced116d
7 changed files with 201 additions and 7 deletions
+7 -1
View File
@@ -17,7 +17,7 @@ import {
CACHE_DIR,
isLinuxArm,
} from "../browser/manager.js";
import { trackBrowserInstall } from "../telemetry/events.js";
import { trackBrowserInstall, trackCommandFailure } from "../telemetry/events.js";
async function runEnsure(): Promise<void> {
clack.intro(c.bold("hyperframes browser ensure"));
@@ -50,8 +50,12 @@ async function runEnsure(): Promise<void> {
console.log();
clack.outro(c.success("Chromium ready. You can now render on ARM64."));
} catch (err) {
// The ARM64 auto-install failed: the browser is NOT ready, so this is a
// real failure (exit 1), not a success. Report it and stop swallowing.
trackCommandFailure("browser", err);
clack.log.error(err instanceof Error ? err.message : String(err));
clack.outro(c.warn("Manual setup required (see instructions above)."));
process.exit(1);
}
return;
}
@@ -108,6 +112,7 @@ async function runPath(): Promise<void> {
const ensured = await ensureBrowser();
process.stdout.write(ensured.executablePath + "\n");
} catch (err: unknown) {
trackCommandFailure("browser", err);
console.error(err instanceof Error ? err.message : "Failed to find browser");
process.exit(1);
}
@@ -167,6 +172,7 @@ ${c.bold("EXAMPLES:")}
case "clear":
return runClear();
default:
trackCommandFailure("browser", `Unknown subcommand: ${subcommand}`);
console.error(
`${c.error("Unknown subcommand:")} ${subcommand}\n\nRun ${c.accent("hyperframes browser --help")} for usage.`,
);