fix(cli): report transcribe failure reasons via cli_error (command_error) (#1479)

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.
This commit is contained in:
Miguel Ángel
2026-06-15 22:17:44 -04:00
committed by GitHub
parent 8cbf4384e1
commit 40b49d4024
3 changed files with 60 additions and 3 deletions
+8 -2
View File
@@ -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 {
+37 -1
View File
@@ -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.",
}),
);
});
});
+15
View File
@@ -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;