Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2064,6 +2064,9 @@ function trackRenderMetrics(
deVerifyInitMs: perf?.drawElement?.verifyInitMs,
deSelfVerifyFallback: perf?.drawElement?.selfVerifyFallback,
deFallbackReason: perf?.drawElement?.fallbackReason,
deFallbackFailedDb: perf?.drawElement?.fallbackFailedDb,
deFallbackFrameIndex: perf?.drawElement?.fallbackFrameIndex,
deFallbackThresholdDb: perf?.drawElement?.fallbackThresholdDb,
deBlankSuspects: perf?.drawElement?.blankSuspects,
deBlankDeterministicAccepts: perf?.drawElement?.blankDeterministicAccepts,
deBlankRecaptures: perf?.drawElement?.blankRecaptures,
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/telemetry/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,32 @@ describe("render telemetry events", () => {
);
});

it("carries the failing dB, frame index, and threshold on render_error for a psnr fallback that failed hard afterward", () => {
trackRenderError({
fps: 30,
quality: "standard",
docker: false,
errorMessage: "worker crashed after a psnr fallback",
captureDeParallelRouter: "reverted",
captureDeSelfVerifyFallback: true,
captureDeFallbackReason: "psnr",
captureDeFallbackFailedDb: 28.4,
captureDeFallbackFrameIndex: 649,
captureDeFallbackThresholdDb: 32,
});

expect(trackEvent).toHaveBeenCalledWith(
"render_error",
expect.objectContaining({
de_fallback_reason: "psnr",
de_fallback_failed_db: 28.4,
de_fallback_frame_index: 649,
de_fallback_threshold_db: 32,
}),
undefined,
);
});

it("prefers the explicit perfSummary-sourced de_worker_inversion over the capture-observability fallback on render_complete", () => {
trackRenderComplete({
durationMs: 1000,
Expand All @@ -272,6 +298,32 @@ describe("render telemetry events", () => {
);
});

it("carries the perfSummary-sourced failing dB, frame index, and threshold on render_complete", () => {
trackRenderComplete({
durationMs: 1000,
fps: 30,
quality: "standard",
docker: false,
gpu: false,
deParallelRouter: "reverted",
deFallbackReason: "psnr",
deFallbackFailedDb: 28.4,
deFallbackFrameIndex: 649,
deFallbackThresholdDb: 32,
});

expect(trackEvent).toHaveBeenCalledWith(
"render_complete",
expect.objectContaining({
de_fallback_reason: "psnr",
de_fallback_failed_db: 28.4,
de_fallback_frame_index: 649,
de_fallback_threshold_db: 32,
}),
undefined,
);
});

it("emits render_preflight_rejected with the low-cardinality issue kind", () => {
trackRenderPreflightRejected({ kind: "aspect-mismatch" });
expect(trackEvent).toHaveBeenCalledWith("render_preflight_rejected", {
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ export interface RenderObservabilityTelemetryPayload {
captureDePreRouterWorkers?: number;
captureDeSelfVerifyFallback?: boolean;
captureDeFallbackReason?: string;
captureDeFallbackFailedDb?: number;
captureDeFallbackFrameIndex?: number;
captureDeFallbackThresholdDb?: number;
/** Non-DE parallel-streaming router outcome ("screenshot" | "beginframe" —
* routed; "eligible_off" — would route but the kill switch is off). */
captureParallelStream?: string;
Expand Down Expand Up @@ -108,6 +111,9 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
de_pre_router_workers: props.captureDePreRouterWorkers,
de_self_verify_fallback: props.captureDeSelfVerifyFallback,
de_fallback_reason: props.captureDeFallbackReason,
de_fallback_failed_db: props.captureDeFallbackFailedDb,
de_fallback_frame_index: props.captureDeFallbackFrameIndex,
de_fallback_threshold_db: props.captureDeFallbackThresholdDb,
capture_parallel_stream: props.captureParallelStream,
observability_extract_video_count: props.observabilityExtractVideoCount,
observability_extracted_video_count: props.observabilityExtractedVideoCount,
Expand Down Expand Up @@ -173,6 +179,9 @@ export function trackRenderComplete(
deVerifyInitMs?: number;
deSelfVerifyFallback?: boolean;
deFallbackReason?: string;
deFallbackFailedDb?: number;
deFallbackFrameIndex?: number;
deFallbackThresholdDb?: number;
deBlankSuspects?: number;
deBlankDeterministicAccepts?: number;
deBlankRecaptures?: number;
Expand Down Expand Up @@ -260,6 +269,9 @@ export function trackRenderComplete(
de_verify_init_ms: props.deVerifyInitMs,
de_self_verify_fallback: props.deSelfVerifyFallback,
de_fallback_reason: props.deFallbackReason,
de_fallback_failed_db: props.deFallbackFailedDb,
de_fallback_frame_index: props.deFallbackFrameIndex,
de_fallback_threshold_db: props.deFallbackThresholdDb,
de_blank_suspects: props.deBlankSuspects,
de_blank_deterministic_accepts: props.deBlankDeterministicAccepts,
de_blank_recaptures: props.deBlankRecaptures,
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/telemetry/renderObservability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,30 @@ describe("renderObservabilityTelemetryPayload — DE inversion/router cohort (fa
const payload = renderObservabilityTelemetryPayload(makeSummary({}));
expect(payload.captureDeFallbackReason).toBeUndefined();
});

it("carries the failing dB, frame index, and threshold for a psnr fallback, still visible on a hard failure", () => {
const payload = renderObservabilityTelemetryPayload(
makeSummary({
deParallelRouter: "routed",
deFallbackReason: "psnr",
deFallbackFailedDb: 28.4,
deFallbackFrameIndex: 649,
deFallbackThresholdDb: 32,
}),
);
expect(payload.captureDeFallbackFailedDb).toBe(28.4);
expect(payload.captureDeFallbackFrameIndex).toBe(649);
expect(payload.captureDeFallbackThresholdDb).toBe(32);
});

it("leaves failedDb/thresholdDb undefined for a blank/oom/capture_error fallback (no PSNR score exists)", () => {
const payload = renderObservabilityTelemetryPayload(
makeSummary({ deFallbackReason: "oom", deFallbackFrameIndex: undefined }),
);
expect(payload.captureDeFallbackFailedDb).toBeUndefined();
expect(payload.captureDeFallbackFrameIndex).toBeUndefined();
expect(payload.captureDeFallbackThresholdDb).toBeUndefined();
});
});

describe("renderObservabilityTelemetryPayload — non-DE parallel-stream router", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/telemetry/renderObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export function renderObservabilityTelemetryPayload(
captureDePreRouterWorkers: capture.dePreRouterWorkers,
captureDeSelfVerifyFallback: capture.deSelfVerifyFallback,
captureDeFallbackReason: capture.deFallbackReason,
captureDeFallbackFailedDb: capture.deFallbackFailedDb,
captureDeFallbackFrameIndex: capture.deFallbackFrameIndex,
captureDeFallbackThresholdDb: capture.deFallbackThresholdDb,
captureParallelStream: capture.captureParallelStream,
observabilityExtractVideoCount: extraction?.videoCount,
observabilityExtractedVideoCount: extraction?.extractedVideoCount,
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export {
captureFramesBatchPipelined,
DrawElementVerificationError,
isDrawElementVerificationError,
getDrawElementVerificationDetails,
type DrawElementVerificationDetails,
recaptureDrawElementFrameForVerify,
completeDeferredDrawElementInit,
writeCapturedFrame,
Expand Down
81 changes: 81 additions & 0 deletions packages/engine/src/services/frameCapture.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { describe, it, expect } from "vitest";
import {
DrawElementVerificationError,
formatHttpErrorDiagnostic,
formatConsoleDiagnostic,
formatNavigationFailureDiagnostic,
formatNavigationStartDiagnostic,
formatRequestFailureDiagnostic,
getDrawElementVerificationDetails,
isFontResourceError,
isDrawElementVerificationError,
sanitizeDiagnosticUrl,
} from "./frameCapture.js";

Expand Down Expand Up @@ -222,3 +225,81 @@ describe("navigation diagnostics", () => {
).toBe("[Browser:HTTP403] GET https://cdn.example.com/frame.png resource=image Forbidden");
});
});

describe("DrawElementVerificationError details", () => {
it("carries frameIndex/failedDb/verifyThresholdDb when provided", () => {
const err = new DrawElementVerificationError("drawElement self-verify failed at frame 649", {
kind: "psnr",
frameIndex: 649,
failedDb: 28.4,
verifyThresholdDb: 32,
});
expect(isDrawElementVerificationError(err)).toBe(true);
expect(getDrawElementVerificationDetails(err)).toEqual({
kind: "psnr",
frameIndex: 649,
failedDb: 28.4,
verifyThresholdDb: 32,
});
});

it("omits fields that weren't supplied (blank-frame throws have no dB)", () => {
const err = new DrawElementVerificationError("blank drawElement frame 12", {
kind: "blank",
frameIndex: 12,
});
expect(getDrawElementVerificationDetails(err)).toEqual({ kind: "blank", frameIndex: 12 });
});

it("returns undefined for a non-verification error", () => {
expect(getDrawElementVerificationDetails(new Error("boring"))).toBeUndefined();
});

it("finds details through a wrapping cause chain (producer's CaptureStageError)", () => {
const inner = new DrawElementVerificationError("psnr breach", {
kind: "psnr",
frameIndex: 5,
failedDb: 12.1,
});
const wrapper = new Error("capture stage failed", { cause: inner });
expect(isDrawElementVerificationError(wrapper)).toBe(true);
expect(getDrawElementVerificationDetails(wrapper)).toEqual({
kind: "psnr",
frameIndex: 5,
failedDb: 12.1,
});
});

it("carries kind='blank'/'psnr' as a structural field, not derived from message text", () => {
const blankErr = new DrawElementVerificationError("blank drawElement frame 12", {
kind: "blank",
frameIndex: 12,
});
const psnrErr = new DrawElementVerificationError(
"drawElement self-verify failed at frame 649",
{ kind: "psnr", frameIndex: 649, failedDb: 28.4, verifyThresholdDb: 32 },
);
expect(getDrawElementVerificationDetails(blankErr)?.kind).toBe("blank");
expect(getDrawElementVerificationDetails(psnrErr)?.kind).toBe("psnr");
});

it("kind survives even when the message text disagrees with (or omits) the word psnr/blank", () => {
// A reworded message that says neither "blank" nor "psnr" — a regex over
// message text would have no signal here; the structural kind must still
// report correctly.
const reworded = new DrawElementVerificationError("frame 12 failed verification", {
kind: "blank",
frameIndex: 12,
});
expect(getDrawElementVerificationDetails(reworded)?.kind).toBe("blank");

// Adversarial: a psnr failure whose message happens to mention "blank"
// (e.g. quoting a neighboring log line) — the structural kind must not
// flip just because the substring "blank" appears in the text.
const adversarial = new DrawElementVerificationError(
"drawElement self-verify failed at frame 5 (previous frame was blank-guard-accepted)",
{ kind: "psnr", frameIndex: 5, failedDb: 12.1, verifyThresholdDb: 32 },
);
expect(getDrawElementVerificationDetails(adversarial)?.kind).toBe("psnr");
});
});
60 changes: 59 additions & 1 deletion packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,40 @@ export interface CaptureSession {
* forceScreenshot. Discriminant-based guard (not instanceof) so it survives
* duplicated module instances across package boundaries.
*/
/**
* Structured detail carried alongside the human-readable message — lets
* telemetry report the actual failure kind / failing dB / frame index
* instead of the orchestrator having to regex them back out of formatted
* text (a message-text dependency is exactly the failure mode this shape
* exists to close — review finding: message wording, translation, or a
* cross-module/serialized error must never be able to flip the reported
* kind). All fields but `kind` are optional: a blank-frame trip has no PSNR
* score, so `failedDb`/`verifyThresholdDb` are omitted for that throw site.
*/
export interface DrawElementVerificationDetails {
kind: "blank" | "psnr";
frameIndex?: number;
failedDb?: number;
verifyThresholdDb?: number;
}

export class DrawElementVerificationError extends Error {
constructor(message: string) {
readonly kind: "blank" | "psnr";
readonly frameIndex?: number;
readonly failedDb?: number;
readonly verifyThresholdDb?: number;

constructor(message: string, details: DrawElementVerificationDetails) {
super(message);
this.name = "DrawElementVerificationError";
// Discriminant property, assigned dynamically: isDrawElementVerificationError
// reads it structurally so detection survives duplicated module instances
// across package boundaries (where instanceof fails).
(this as unknown as { deVerificationFailure: boolean }).deVerificationFailure = true;
this.kind = details.kind;
this.frameIndex = details.frameIndex;
this.failedDb = details.failedDb;
this.verifyThresholdDb = details.verifyThresholdDb;
}
}

Expand All @@ -222,6 +248,38 @@ export function isDrawElementVerificationError(err: unknown): boolean {
return false;
}

/**
* Extracts the structured details off a (possibly cause-wrapped) verification
* error — same chain-walk as isDrawElementVerificationError, structural
* (not instanceof) for the same duplicated-module-instance reason. Returns
* undefined when the error isn't a verification failure at all.
*/
export function getDrawElementVerificationDetails(
err: unknown,
): DrawElementVerificationDetails | undefined {
let e: unknown = err;
for (let depth = 0; depth < 5 && typeof e === "object" && e !== null; depth++) {
const rec = e as { deVerificationFailure?: boolean } & Partial<DrawElementVerificationDetails>;
if (rec.deVerificationFailure === true) {
// Every construction path sets `kind` (required on the constructor), so
// this only defends against a malformed cross-module-instance shape —
// treat anything other than exactly "blank" as "psnr", the same
// fallback polarity the old message regex had, but driven by a
// structural field instead of parsing text.
const details: DrawElementVerificationDetails = {
kind: rec.kind === "blank" ? "blank" : "psnr",
};
if (typeof rec.frameIndex === "number") details.frameIndex = rec.frameIndex;
if (typeof rec.failedDb === "number") details.failedDb = rec.failedDb;
if (typeof rec.verifyThresholdDb === "number")
details.verifyThresholdDb = rec.verifyThresholdDb;
return details;
}
e = (e as { cause?: unknown }).cause;
}
return undefined;
}

/** Wait for inline CSS background images introduced by the latest seek. */
export async function decodeDynamicCssBackgroundImages(page: Page): Promise<void> {
await page.evaluate(async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/producer/src/services/render/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ export interface RenderCaptureObservability {
* telemetry from one that never attempted any fallback.
*/
deFallbackReason?: string;
/** The failing PSNR (dB) when `deFallbackReason === "psnr"`; undefined for blank/oom/capture_error (no score exists). */
deFallbackFailedDb?: number;
/** Frame index the verification failure was detected at; set for both "psnr" and "blank" fallback reasons. */
deFallbackFrameIndex?: number;
/** The HF_DE_VERIFY_MIN_DB threshold the failing dB breached; only set alongside deFallbackFailedDb (psnr reason). */
deFallbackThresholdDb?: number;
/** Auto-parallel inversion outcome: "inverted" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
deWorkerInversion?: "inverted" | "reverted";
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
Expand Down
22 changes: 22 additions & 0 deletions packages/producer/src/services/render/perfSummary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { roundDb } from "./perfSummary.js";

describe("roundDb", () => {
it("rounds to 1 decimal", () => {
expect(roundDb(28.4373)).toBe(28.4);
expect(roundDb(28.45)).toBe(28.5);
});

it("clamps at 999 (an Infinity PSNR must never ship literally to telemetry)", () => {
expect(roundDb(Infinity)).toBe(999);
expect(roundDb(50000)).toBe(999);
});

it("passes undefined through unchanged", () => {
expect(roundDb(undefined)).toBeUndefined();
});

it("is idempotent — rounding an already-rounded value is a no-op", () => {
expect(roundDb(roundDb(28.4373))).toBe(28.4);
});
});
Loading
Loading