Files
hyperframes/packages/cli/src/utils/command-failure-tracking.ts
T
Miguel Ángel 5f6ced116d 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.
2026-06-16 01:39:34 -04:00

60 lines
2.2 KiB
TypeScript

import type { CommandDef } from "citty";
// citty types subcommands as `CommandDef<any>` (SubCommandsDef); mirror that so
// each command's specific args type is accepted without per-command generics.
type AnyCommandDef = CommandDef<any>;
/**
* Wrap a lazy command loader so a thrown failure is reported via `onFailure`
* before it propagates. citty's `runMain` catches command errors and exits 1
* without re-throwing, so this is the only place to capture the reason. The
* error is re-thrown unchanged, preserving citty's print + exit-1 behavior.
*
* `onFailure` is awaited so it can resolve the (lazily-loaded) telemetry module
* before the error propagates — otherwise a command that throws before the
* telemetry import settles would lose its event. A throw from `onFailure` is
* swallowed so telemetry can never mask the real command failure.
*
* Commands that call `process.exit()` themselves bypass this (the process is
* already gone) and must report their failure inline.
*/
export function trackCommandFailures(
load: () => Promise<AnyCommandDef>,
onFailure: (err: unknown) => void | Promise<void>,
): () => Promise<AnyCommandDef> {
return () =>
load().then((cmd) => {
const run = cmd.run;
if (typeof run !== "function") return cmd;
return {
...cmd,
run: async (ctx: Parameters<typeof run>[0]) => {
try {
return await run(ctx);
} catch (err) {
try {
await onFailure(err);
} catch {
// Telemetry must never mask the real command failure.
}
throw err;
}
},
};
});
}
/**
* Report a command failure to telemetry, loading the telemetry module on demand
* (keeps it off the CLI cold-start path) and awaiting it so the event is
* enqueued before the caller re-throws / exits. Best-effort — never throws.
*/
export async function reportCommandFailure(command: string, err: unknown): Promise<void> {
try {
const { trackCommandFailure } = await import("../telemetry/events.js");
trackCommandFailure(command, err);
} catch {
// ignore: a telemetry failure must not affect the command's exit path
}
}