diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 1916c0975..0c093d12e 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -49,6 +49,7 @@ import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; import { isDevMode } from "../utils/env.js"; import { buildDockerRunArgs } from "../utils/dockerRunArgs.js"; +import { normalizeErrorMessage } from "../utils/errorMessage.js"; import type { RenderJob } from "@hyperframes/producer"; import { normalizeResolutionFlag, @@ -863,7 +864,7 @@ function handleRenderError( docker: boolean, hint: string, ): never { - const message = error instanceof Error ? error.message : String(error); + const message = normalizeErrorMessage(error); trackRenderError({ fps: fpsToNumber(options.fps), quality: options.quality, diff --git a/packages/cli/src/utils/errorMessage.ts b/packages/cli/src/utils/errorMessage.ts new file mode 100644 index 000000000..f2928f140 --- /dev/null +++ b/packages/cli/src/utils/errorMessage.ts @@ -0,0 +1,18 @@ +export function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (typeof error === "object" && error !== null) { + const msg = (error as Record).message; + if (typeof msg === "string") return msg; + try { + return JSON.stringify(error); + } catch { + try { + return `{${Object.keys(error as object).join(", ")}}`; + } catch { + /* truly opaque object */ + } + } + } + return String(error ?? "unknown error"); +} diff --git a/packages/producer/src/index.ts b/packages/producer/src/index.ts index 3d3de5117..44e2af832 100644 --- a/packages/producer/src/index.ts +++ b/packages/producer/src/index.ts @@ -68,6 +68,7 @@ export { } from "./server.js"; // ── Utilities ─────────────────────────────────────────────────────────────── +export { normalizeErrorMessage } from "./utils/errorMessage.js"; export { quantizeTimeToFrame } from "./utils/parityContract.js"; export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js"; diff --git a/packages/producer/src/services/render/captureCost.ts b/packages/producer/src/services/render/captureCost.ts index 2ca552074..c4f3b2cb9 100644 --- a/packages/producer/src/services/render/captureCost.ts +++ b/packages/producer/src/services/render/captureCost.ts @@ -26,6 +26,7 @@ import type { CompiledComposition } from "../htmlCompiler.js"; import type { FileServerHandle } from "../fileServer.js"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import type { RenderJob } from "../renderOrchestrator.js"; +import { normalizeErrorMessage } from "../../utils/errorMessage.js"; export interface CaptureCostEstimate { multiplier: number; @@ -417,7 +418,7 @@ export async function runCaptureCalibration(input: { * protocol errors that recover cleanly under screenshot mode. */ export function shouldFallbackToScreenshotAfterCalibrationError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); + const message = normalizeErrorMessage(error); return /HeadlessExperimental\.beginFrame timed out|beginFrame probe timeout|Another frame is pending|Frame still pending|Protocol error.*HeadlessExperimental\.beginFrame|Runtime\.callFunctionOn timed out|Runtime\.evaluate timed out/i.test( message, ); diff --git a/packages/producer/src/services/render/cleanup.ts b/packages/producer/src/services/render/cleanup.ts index 48b0c3ab8..d926840a0 100644 --- a/packages/producer/src/services/render/cleanup.ts +++ b/packages/producer/src/services/render/cleanup.ts @@ -9,6 +9,7 @@ import { type CaptureSession, closeCaptureSession } from "@hyperframes/engine"; import type { FileServerHandle } from "../fileServer.js"; import { defaultLogger, type ProducerLogger } from "../../logger.js"; import type { HdrDiagnostics, RenderJob } from "../renderOrchestrator.js"; +import { normalizeErrorMessage } from "../../utils/errorMessage.js"; /** * Wrap a cleanup operation so it never throws, but logs any failure. @@ -80,7 +81,7 @@ export function buildRenderErrorDetails(input: { perfStages: Record; hdrDiagnostics: HdrDiagnostics; }): NonNullable { - const errorMessage = input.error instanceof Error ? input.error.message : String(input.error); + const errorMessage = normalizeErrorMessage(input.error); const errorStack = input.error instanceof Error ? input.error.stack : undefined; return { message: errorMessage, diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 836a1a573..95700671e 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -93,6 +93,7 @@ import { updateJobStatus, } from "./render/shared.js"; import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js"; +import { normalizeErrorMessage } from "../utils/errorMessage.js"; import { resolveEffectiveHdrMode } from "./render/hdrMode.js"; import { buildRenderPerfSummary } from "./render/perfSummary.js"; import { @@ -566,7 +567,7 @@ export function getNextRetryWorkerCount(currentWorkers: number): number { } export function isRecoverableParallelCaptureError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); + const message = normalizeErrorMessage(error); return ( message.includes("[Parallel] Capture failed") && /Runtime\.callFunctionOn timed out|HeadlessExperimental\.beginFrame timed out|Waiting failed|timeout exceeded|timed out|Navigation timeout|Protocol error|Target closed/i.test( @@ -2096,7 +2097,7 @@ export async function executeRenderJob( ? error : new RenderCancelledError("render_cancelled"); } - const errorMessage = error instanceof Error ? error.message : String(error); + const errorMessage = normalizeErrorMessage(error); // Suggest single-worker retry on parallel capture timeout. // Video-heavy compositions often cause multi-worker timeouts because diff --git a/packages/producer/src/utils/errorMessage.test.ts b/packages/producer/src/utils/errorMessage.test.ts new file mode 100644 index 000000000..c645d7d67 --- /dev/null +++ b/packages/producer/src/utils/errorMessage.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { normalizeErrorMessage } from "./errorMessage.js"; + +describe("normalizeErrorMessage", () => { + it("extracts message from Error instances", () => { + expect(normalizeErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("passes through strings", () => { + expect(normalizeErrorMessage("oops")).toBe("oops"); + }); + + it("extracts .message from plain objects", () => { + expect(normalizeErrorMessage({ message: "hidden error" })).toBe("hidden error"); + }); + + it("JSON-stringifies objects without .message", () => { + expect(normalizeErrorMessage({ code: 42 })).toBe('{"code":42}'); + }); + + it("handles null", () => { + expect(normalizeErrorMessage(null)).toBe("unknown error"); + }); + + it("handles undefined", () => { + expect(normalizeErrorMessage(undefined)).toBe("unknown error"); + }); + + it("handles numbers", () => { + expect(normalizeErrorMessage(42)).toBe("42"); + }); + + it("handles objects with non-string .message", () => { + expect(normalizeErrorMessage({ message: 123 })).toBe('{"message":123}'); + }); + + it("handles circular references gracefully", () => { + const obj: Record = {}; + obj.self = obj; + // Falls through JSON.stringify failure to Object.keys() + expect(normalizeErrorMessage(obj)).toBe("{self}"); + }); +}); diff --git a/packages/producer/src/utils/errorMessage.ts b/packages/producer/src/utils/errorMessage.ts new file mode 100644 index 000000000..edc88225d --- /dev/null +++ b/packages/producer/src/utils/errorMessage.ts @@ -0,0 +1,31 @@ +/** + * Normalize an unknown thrown value into a human-readable string. + * + * The default `String(error)` pattern produces `[object Object]` when the + * thrown value is a plain object — masking the real error in telemetry. + * This utility tries, in order: + * 1. `Error.message` + * 2. Raw string pass-through + * 3. `.message` property on a plain object + * 4. `JSON.stringify` for any other object + * 5. `String()` fallback for primitives (number, boolean, symbol, bigint) + * 6. `"unknown error"` for null / undefined + */ +export function normalizeErrorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if (typeof error === "object" && error !== null) { + const msg = (error as Record).message; + if (typeof msg === "string") return msg; + try { + return JSON.stringify(error); + } catch { + try { + return `{${Object.keys(error as object).join(", ")}}`; + } catch { + /* truly opaque object */ + } + } + } + return String(error ?? "unknown error"); +}