feat(cli): emit render_preflight_rejected telemetry for P1-3 pre-flight saves (#1856)

The P1-3 aspect/alpha/HDR pre-flight (#1843) aborts an incompatible render before any browser/ffmpeg work, but that "save" was invisible on dashboard 1783183 — indistinguishable from a deep failure or a user giving up.

checkRenderResolutionPreflight now returns { message, kind } (kind = the existing low-cardinality OutputResolutionIssueKind), and the render command emits render_preflight_rejected { kind } before exiting. No parsers change — the helper already carried kind. trackRenderPreflightRejected is typed to the union so the metric can't carry free text.

Tests: preflight tests assert kind for all five kinds; an events test locks the emit.

Further follow-up (still log-only): encoder-frame-0-exit counter and a P1-4 doctor cli_env_check event.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-07-01 22:10:37 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 438474c968
commit 9b41891f3a
4 changed files with 63 additions and 15 deletions
+33 -10
View File
@@ -439,22 +439,22 @@ describe("checkRenderResolutionPreflight", () => {
expect(await checkRenderResolutionPreflight(portraitHtml, "portrait", noModes)).toBeUndefined(); expect(await checkRenderResolutionPreflight(portraitHtml, "portrait", noModes)).toBeUndefined();
}); });
it("returns a suggestion when a landscape preset is used on a portrait composition", async () => { it("returns a suggestion + aspect-mismatch kind when a landscape preset is used on a portrait composition", async () => {
const message = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes); const result = await checkRenderResolutionPreflight(portraitHtml, "landscape", noModes);
expect(message).toBeDefined(); expect(result?.message).toContain("--resolution portrait");
expect(message).toContain("--resolution portrait"); expect(result?.kind).toBe("aspect-mismatch");
}); });
it("suggests landscape for a landscape composition rendered with a portrait preset", async () => { it("suggests landscape for a landscape composition rendered with a portrait preset", async () => {
const message = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes); const result = await checkRenderResolutionPreflight(landscapeHtml, "portrait", noModes);
expect(message).toContain("--resolution landscape"); expect(result?.message).toContain("--resolution landscape");
}); });
it("preserves the 4K tier when suggesting a matching preset (square comp + landscape-4k → square-4k)", async () => { it("preserves the 4K tier when suggesting a matching preset (square comp + landscape-4k → square-4k)", async () => {
// Tier-aware suggestion is the load-bearing new behavior; square-4k is the // Tier-aware suggestion is the load-bearing new behavior; square-4k is the
// preset that only surfaces via a same-tier swap, so guard it explicitly. // preset that only surfaces via a same-tier swap, so guard it explicitly.
const message = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes); const result = await checkRenderResolutionPreflight(comp(2160, 2160), "landscape-4k", noModes);
expect(message).toContain("--resolution square-4k"); expect(result?.message).toContain("--resolution square-4k");
}); });
it("does not false-abort a landscape registry-block composition (data-width/height, no data-resolution)", async () => { it("does not false-abort a landscape registry-block composition (data-width/height, no data-resolution)", async () => {
@@ -467,11 +467,34 @@ describe("checkRenderResolutionPreflight", () => {
}); });
it("flags alpha output combined with outputResolution", async () => { it("flags alpha output combined with outputResolution", async () => {
const message = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", { const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape-4k", {
alphaRequested: true, alphaRequested: true,
hdrRequested: false, hdrRequested: false,
}); });
expect(message).toContain("alpha output"); expect(result?.message).toContain("alpha output");
expect(result?.kind).toBe("alpha-incompatible");
});
// The three remaining kinds share the same rejection sink (→ one emit each);
// guard their classification so the telemetry dimension stays accurate.
it("classifies an HDR + outputResolution combination as hdr-incompatible", async () => {
const result = await checkRenderResolutionPreflight(landscapeHtml, "landscape", {
alphaRequested: false,
hdrRequested: true,
});
expect(result?.kind).toBe("hdr-incompatible");
});
it("classifies a preset smaller than the composition as downsampling", async () => {
// 3840×2160 comp + landscape (1920×1080): same 16:9 aspect, target smaller.
const result = await checkRenderResolutionPreflight(comp(3840, 2160), "landscape", noModes);
expect(result?.kind).toBe("downsampling");
});
it("classifies a non-integer upscale as non-integer-scale", async () => {
// 1280×720 comp + landscape (1920×1080): same 16:9 aspect, 1.5× scale.
const result = await checkRenderResolutionPreflight(comp(1280, 720), "landscape", noModes);
expect(result?.kind).toBe("non-integer-scale");
}); });
it("returns undefined when composition dimensions can't be determined (defers to the pipeline)", async () => { it("returns undefined when composition dimensions can't be determined (defers to the pipeline)", async () => {
+12 -4
View File
@@ -61,6 +61,7 @@ import {
trackRenderComplete, trackRenderComplete,
trackRenderError, trackRenderError,
trackRenderObservation, trackRenderObservation,
trackRenderPreflightRejected,
} from "../telemetry/events.js"; } from "../telemetry/events.js";
import { maybePromptRenderFeedback } from "../telemetry/feedback.js"; import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js"; import { renderJobObservabilityTelemetryPayload } from "../telemetry/renderObservability.js";
@@ -86,6 +87,7 @@ import {
fpsToNumber, fpsToNumber,
fpsToFfmpegArg, fpsToFfmpegArg,
type CanvasResolution, type CanvasResolution,
type OutputResolutionIssueKind,
type Fps, type Fps,
type FpsParseResult, type FpsParseResult,
} from "@hyperframes/core"; } from "@hyperframes/core";
@@ -796,7 +798,7 @@ export default defineCommand({
// own defense-in-depth check rather than blocking a render we can't reason // own defense-in-depth check rather than blocking a render we can't reason
// about. See render-reliability workstream P1-3. // about. See render-reliability workstream P1-3.
if (outputResolution) { if (outputResolution) {
let resolutionIssue: string | undefined; let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined;
try { try {
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath; const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
resolutionIssue = await checkRenderResolutionPreflight( resolutionIssue = await checkRenderResolutionPreflight(
@@ -812,7 +814,11 @@ export default defineCommand({
// the real problem with full context. // the real problem with full context.
} }
if (resolutionIssue) { if (resolutionIssue) {
errorBox("Output resolution incompatible", resolutionIssue); // Count the pre-flight save so dashboard 1783183 can distinguish
// "caught early by pre-flight" from a deep render failure or a user who
// gave up — i.e. measure whether the P1-3 fix is doing its job.
trackRenderPreflightRejected({ kind: resolutionIssue.kind });
errorBox("Output resolution incompatible", resolutionIssue.message);
process.exit(1); process.exit(1);
} }
} }
@@ -1073,7 +1079,7 @@ export async function checkRenderResolutionPreflight(
compositionHtml: string, compositionHtml: string,
outputResolution: CanvasResolution | undefined, outputResolution: CanvasResolution | undefined,
modes: { alphaRequested: boolean; hdrRequested: boolean }, modes: { alphaRequested: boolean; hdrRequested: boolean },
): Promise<string | undefined> { ): Promise<{ message: string; kind: OutputResolutionIssueKind } | undefined> {
if (!outputResolution) return undefined; if (!outputResolution) return undefined;
const dims = await readCompositionDimensions(compositionHtml); const dims = await readCompositionDimensions(compositionHtml);
// Couldn't determine the composition's actual dimensions — defer to the // Couldn't determine the composition's actual dimensions — defer to the
@@ -1086,7 +1092,9 @@ export async function checkRenderResolutionPreflight(
alphaRequested: modes.alphaRequested, alphaRequested: modes.alphaRequested,
hdrRequested: modes.hdrRequested, hdrRequested: modes.hdrRequested,
}); });
return compat.ok ? undefined : compat.message; // Narrow to the incompatible case; `message`/`kind` are always set there.
if (compat.ok || !compat.message || !compat.kind) return undefined;
return { message: compat.message, kind: compat.kind };
} }
const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; const DOCKER_IMAGE_PREFIX = "hyperframes-renderer";
@@ -12,6 +12,7 @@ const {
trackCommandFailure, trackCommandFailure,
trackCliError, trackCliError,
trackRenderFeedback, trackRenderFeedback,
trackRenderPreflightRejected,
} = await import("./events.js"); } = await import("./events.js");
describe("render telemetry events", () => { describe("render telemetry events", () => {
@@ -39,6 +40,13 @@ describe("render telemetry events", () => {
); );
}); });
it("emits render_preflight_rejected with the low-cardinality issue kind", () => {
trackRenderPreflightRejected({ kind: "aspect-mismatch" });
expect(trackEvent).toHaveBeenCalledWith("render_preflight_rejected", {
kind: "aspect-mismatch",
});
});
it("forwards distinctId to trackEvent so studio renders attribute to the browser user", () => { it("forwards distinctId to trackEvent so studio renders attribute to the browser user", () => {
trackRenderError({ trackRenderError({
fps: 30, fps: 30,
+10 -1
View File
@@ -1,4 +1,4 @@
import { redactTelemetryString } from "@hyperframes/core"; import { redactTelemetryString, type OutputResolutionIssueKind } from "@hyperframes/core";
import { trackEvent } from "./client.js"; import { trackEvent } from "./client.js";
export interface RenderObservabilityTelemetryPayload { export interface RenderObservabilityTelemetryPayload {
@@ -308,6 +308,15 @@ export function trackBrowserInstall(): void {
trackEvent("browser_install", {}); trackEvent("browser_install", {});
} }
// A render was rejected by the output-resolution/alpha/HDR pre-flight (P1-3)
// before any browser/ffmpeg work. Counts the "caught early" saves on dashboard
// 1783183, distinct from deep render failures. `kind` is the low-cardinality
// `OutputResolutionIssueKind` (aspect-mismatch / alpha-incompatible / etc.),
// typed to the union so the metric can never carry free text.
export function trackRenderPreflightRejected(props: { kind: OutputResolutionIssueKind }): void {
trackEvent("render_preflight_rejected", { kind: props.kind });
}
export function trackCliError(props: { export function trackCliError(props: {
error_name: string; error_name: string;
error_message: string; error_message: string;