From 9a92079c8c87753a64690f6b3a19335da5fe8951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 15 Jun 2026 21:43:36 -0400 Subject: [PATCH] fix(cli): report transcribe failure reasons via cli_error (command_error) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transcribe failures are recorded as `cli_command_result success=false` but without a reason: the command catches its own error, prints it, and `process.exit(1)` — the message never reaches telemetry. `cli_error` was only emitted from the uncaughtException / unhandledRejection handlers, so self-handled command failures were invisible. That makes a high failure rate countable but not debuggable. Add `trackCommandFailure(command, err)` — a thin wrapper over the existing `trackCliError({ kind: "command_error" })` that normalizes an unknown reason to name/message/stack. It enqueues synchronously, so the process `exit` handler's flushSync ships it alongside `cli_command_result`. Respects the telemetry opt-out (gated in trackEvent) and reuses the existing PII redaction. Wire it into all three of transcribe's failure exits (file-not-found, empty-transcript import, and the transcribe() catch — ffmpeg / whisper-binary / model-download errors). Now each failure carries its reason, so we can see how much of the failure rate is environment vs user input. The helper is generic — the same one-liner can be dropped into other commands' failure paths, or centralized at the runMain boundary, as a follow-up. --- packages/cli/src/commands/transcribe.ts | 10 ++++-- packages/cli/src/telemetry/events.test.ts | 38 ++++++++++++++++++++++- packages/cli/src/telemetry/events.ts | 15 +++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/transcribe.ts b/packages/cli/src/commands/transcribe.ts index 4605ffdd39..7a283b736e 100644 --- a/packages/cli/src/commands/transcribe.ts +++ b/packages/cli/src/commands/transcribe.ts @@ -14,6 +14,7 @@ import { resolve, join, extname, dirname } from "node:path"; import * as clack from "@clack/prompts"; import { c } from "../ui/colors.js"; import { DEFAULT_MODEL } from "../whisper/manager.js"; +import { trackCommandFailure } from "../telemetry/events.js"; export default defineCommand({ meta: { @@ -52,7 +53,9 @@ export default defineCommand({ async run({ args }) { const inputPath = resolve(args.input); if (!existsSync(inputPath)) { - console.error(c.error(`File not found: ${args.input}`)); + const message = `File not found: ${args.input}`; + trackCommandFailure("transcribe", message); + console.error(c.error(message)); process.exit(1); } @@ -87,7 +90,9 @@ async function importTranscript(inputPath: string, dir: string, json: boolean): const { words, format } = loadTranscript(inputPath); if (words.length === 0) { - console.error(c.error("No words found in transcript.")); + const message = "No words found in transcript."; + trackCommandFailure("transcribe", message); + console.error(c.error(message)); process.exit(1); } @@ -170,6 +175,7 @@ async function transcribeAudio( } } catch (err) { const message = err instanceof Error ? err.message : String(err); + trackCommandFailure("transcribe", err); if (opts.json) { console.log(JSON.stringify({ ok: false, error: message })); } else { diff --git a/packages/cli/src/telemetry/events.test.ts b/packages/cli/src/telemetry/events.test.ts index 761b4ef674..924c2ebe06 100644 --- a/packages/cli/src/telemetry/events.test.ts +++ b/packages/cli/src/telemetry/events.test.ts @@ -5,7 +5,8 @@ vi.mock("./client.js", () => ({ trackEvent: (...args: unknown[]) => trackEvent(...args), })); -const { trackRenderError, trackRenderObservation } = await import("./events.js"); +const { trackRenderError, trackRenderObservation, trackCommandFailure } = + await import("./events.js"); describe("render telemetry events", () => { beforeEach(() => { @@ -50,3 +51,38 @@ describe("render telemetry events", () => { ); }); }); + +describe("trackCommandFailure", () => { + beforeEach(() => { + trackEvent.mockClear(); + }); + + it("reports an Error as a command_error with name/message/stack", () => { + const err = new Error("ffmpeg is required to extract audio"); + trackCommandFailure("transcribe", err); + + expect(trackEvent).toHaveBeenCalledWith( + "cli_error", + expect.objectContaining({ + kind: "command_error", + command: "transcribe", + error_name: "Error", + error_message: "ffmpeg is required to extract audio", + stack_trace: err.stack, + }), + ); + }); + + it("coerces a non-Error reason (e.g. a string) into the message", () => { + trackCommandFailure("transcribe", "No words found in transcript."); + + expect(trackEvent).toHaveBeenCalledWith( + "cli_error", + expect.objectContaining({ + kind: "command_error", + command: "transcribe", + error_message: "No words found in transcript.", + }), + ); + }); +}); diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 508b72f4f8..767f376374 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -284,6 +284,21 @@ export function trackCliError(props: { }); } +// Report why a command failed before it exits non-zero. cli_command_result +// records the failure but not the reason; this fills that gap via cli_error so +// command failures are diagnosable. Enqueues synchronously — the process `exit` +// handler flushes it. Drop this into any command's failure path. +export function trackCommandFailure(command: string, err: unknown): void { + const error = err instanceof Error ? err : new Error(String(err)); + trackCliError({ + error_name: error.name, + error_message: error.message, + stack_trace: error.stack, + command, + kind: "command_error", + }); +} + export function trackRenderFeedback(props: { rating: number; renderDurationMs: number;