Skip to content

Commit d83a873

Browse files
fix(producer): normalize error messages to prevent [object Object] in telemetry (#1099)
* fix(producer): normalize error messages to prevent [object Object] in telemetry When a render fails and the caught value is a plain object (not an Error instance), String(error) produces [object Object], masking the real error in PostHog telemetry (~24 errors/day). Add normalizeErrorMessage() that tries Error.message, string passthrough, .message on plain objects, JSON.stringify, and String() as a last resort. Apply it on the two telemetry-feeding paths: the main render failure handler (renderOrchestrator.ts:2099) and buildRenderErrorDetails (cleanup.ts), plus the error classifier isRecoverableParallelCaptureError so timeout detection works even when the thrown value is a plain object. * fix: address review — normalize CLI telemetry path, captureCost fallback * fix: use local normalizeErrorMessage in CLI to avoid cross-package resolution The Vite test runner can't resolve runtime imports from @hyperframes/producer since its exports point to dist/. Copy the utility into the CLI package and import locally instead.
1 parent 2d7b9e5 commit d83a873

8 files changed

Lines changed: 102 additions & 5 deletions

File tree

packages/cli/src/commands/render.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { bytesToMb } from "../telemetry/system.js";
4949
import { VERSION } from "../version.js";
5050
import { isDevMode } from "../utils/env.js";
5151
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
52+
import { normalizeErrorMessage } from "../utils/errorMessage.js";
5253
import type { RenderJob } from "@hyperframes/producer";
5354
import {
5455
normalizeResolutionFlag,
@@ -863,7 +864,7 @@ function handleRenderError(
863864
docker: boolean,
864865
hint: string,
865866
): never {
866-
const message = error instanceof Error ? error.message : String(error);
867+
const message = normalizeErrorMessage(error);
867868
trackRenderError({
868869
fps: fpsToNumber(options.fps),
869870
quality: options.quality,
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export function normalizeErrorMessage(error: unknown): string {
2+
if (error instanceof Error) return error.message;
3+
if (typeof error === "string") return error;
4+
if (typeof error === "object" && error !== null) {
5+
const msg = (error as Record<string, unknown>).message;
6+
if (typeof msg === "string") return msg;
7+
try {
8+
return JSON.stringify(error);
9+
} catch {
10+
try {
11+
return `{${Object.keys(error as object).join(", ")}}`;
12+
} catch {
13+
/* truly opaque object */
14+
}
15+
}
16+
}
17+
return String(error ?? "unknown error");
18+
}

packages/producer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export {
6868
} from "./server.js";
6969

7070
// ── Utilities ───────────────────────────────────────────────────────────────
71+
export { normalizeErrorMessage } from "./utils/errorMessage.js";
7172
export { quantizeTimeToFrame } from "./utils/parityContract.js";
7273
export { resolveRenderPaths, type RenderPaths } from "./utils/paths.js";
7374

packages/producer/src/services/render/captureCost.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { CompiledComposition } from "../htmlCompiler.js";
2626
import type { FileServerHandle } from "../fileServer.js";
2727
import { defaultLogger, type ProducerLogger } from "../../logger.js";
2828
import type { RenderJob } from "../renderOrchestrator.js";
29+
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
2930

3031
export interface CaptureCostEstimate {
3132
multiplier: number;
@@ -417,7 +418,7 @@ export async function runCaptureCalibration(input: {
417418
* protocol errors that recover cleanly under screenshot mode.
418419
*/
419420
export function shouldFallbackToScreenshotAfterCalibrationError(error: unknown): boolean {
420-
const message = error instanceof Error ? error.message : String(error);
421+
const message = normalizeErrorMessage(error);
421422
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(
422423
message,
423424
);

packages/producer/src/services/render/cleanup.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { type CaptureSession, closeCaptureSession } from "@hyperframes/engine";
99
import type { FileServerHandle } from "../fileServer.js";
1010
import { defaultLogger, type ProducerLogger } from "../../logger.js";
1111
import type { HdrDiagnostics, RenderJob } from "../renderOrchestrator.js";
12+
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
1213

1314
/**
1415
* Wrap a cleanup operation so it never throws, but logs any failure.
@@ -80,7 +81,7 @@ export function buildRenderErrorDetails(input: {
8081
perfStages: Record<string, number>;
8182
hdrDiagnostics: HdrDiagnostics;
8283
}): NonNullable<RenderJob["errorDetails"]> {
83-
const errorMessage = input.error instanceof Error ? input.error.message : String(input.error);
84+
const errorMessage = normalizeErrorMessage(input.error);
8485
const errorStack = input.error instanceof Error ? input.error.stack : undefined;
8586
return {
8687
message: errorMessage,

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ import {
9393
updateJobStatus,
9494
} from "./render/shared.js";
9595
import { buildRenderErrorDetails, cleanupRenderResources, safeCleanup } from "./render/cleanup.js";
96+
import { normalizeErrorMessage } from "../utils/errorMessage.js";
9697
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
9798
import { buildRenderPerfSummary } from "./render/perfSummary.js";
9899
import {
@@ -566,7 +567,7 @@ export function getNextRetryWorkerCount(currentWorkers: number): number {
566567
}
567568

568569
export function isRecoverableParallelCaptureError(error: unknown): boolean {
569-
const message = error instanceof Error ? error.message : String(error);
570+
const message = normalizeErrorMessage(error);
570571
return (
571572
message.includes("[Parallel] Capture failed") &&
572573
/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(
20962097
? error
20972098
: new RenderCancelledError("render_cancelled");
20982099
}
2099-
const errorMessage = error instanceof Error ? error.message : String(error);
2100+
const errorMessage = normalizeErrorMessage(error);
21002101

21012102
// Suggest single-worker retry on parallel capture timeout.
21022103
// Video-heavy compositions often cause multi-worker timeouts because
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect } from "vitest";
2+
import { normalizeErrorMessage } from "./errorMessage.js";
3+
4+
describe("normalizeErrorMessage", () => {
5+
it("extracts message from Error instances", () => {
6+
expect(normalizeErrorMessage(new Error("boom"))).toBe("boom");
7+
});
8+
9+
it("passes through strings", () => {
10+
expect(normalizeErrorMessage("oops")).toBe("oops");
11+
});
12+
13+
it("extracts .message from plain objects", () => {
14+
expect(normalizeErrorMessage({ message: "hidden error" })).toBe("hidden error");
15+
});
16+
17+
it("JSON-stringifies objects without .message", () => {
18+
expect(normalizeErrorMessage({ code: 42 })).toBe('{"code":42}');
19+
});
20+
21+
it("handles null", () => {
22+
expect(normalizeErrorMessage(null)).toBe("unknown error");
23+
});
24+
25+
it("handles undefined", () => {
26+
expect(normalizeErrorMessage(undefined)).toBe("unknown error");
27+
});
28+
29+
it("handles numbers", () => {
30+
expect(normalizeErrorMessage(42)).toBe("42");
31+
});
32+
33+
it("handles objects with non-string .message", () => {
34+
expect(normalizeErrorMessage({ message: 123 })).toBe('{"message":123}');
35+
});
36+
37+
it("handles circular references gracefully", () => {
38+
const obj: Record<string, unknown> = {};
39+
obj.self = obj;
40+
// Falls through JSON.stringify failure to Object.keys()
41+
expect(normalizeErrorMessage(obj)).toBe("{self}");
42+
});
43+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Normalize an unknown thrown value into a human-readable string.
3+
*
4+
* The default `String(error)` pattern produces `[object Object]` when the
5+
* thrown value is a plain object — masking the real error in telemetry.
6+
* This utility tries, in order:
7+
* 1. `Error.message`
8+
* 2. Raw string pass-through
9+
* 3. `.message` property on a plain object
10+
* 4. `JSON.stringify` for any other object
11+
* 5. `String()` fallback for primitives (number, boolean, symbol, bigint)
12+
* 6. `"unknown error"` for null / undefined
13+
*/
14+
export function normalizeErrorMessage(error: unknown): string {
15+
if (error instanceof Error) return error.message;
16+
if (typeof error === "string") return error;
17+
if (typeof error === "object" && error !== null) {
18+
const msg = (error as Record<string, unknown>).message;
19+
if (typeof msg === "string") return msg;
20+
try {
21+
return JSON.stringify(error);
22+
} catch {
23+
try {
24+
return `{${Object.keys(error as object).join(", ")}}`;
25+
} catch {
26+
/* truly opaque object */
27+
}
28+
}
29+
}
30+
return String(error ?? "unknown error");
31+
}

0 commit comments

Comments
 (0)