diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index a3cd3cf50d..0040430f71 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -1861,4 +1861,28 @@ describe("render command batch options", () => { }, 60_000); }); +describe("normalizeStageCode", () => { + const { normalizeStageCode } = renderModule; + + it("maps every known updateJobStatus stage string to its code", () => { + expect(normalizeStageCode("Queued")).toBe("queued"); + expect(normalizeStageCode("Compiling composition")).toBe("compiling_composition"); + expect(normalizeStageCode("Extracting video frames")).toBe("extracting_video_frames"); + expect(normalizeStageCode("Processing audio tracks")).toBe("processing_audio_tracks"); + expect(normalizeStageCode("Starting frame capture")).toBe("starting_frame_capture"); + expect(normalizeStageCode("Render complete")).toBe("render_complete"); + expect(normalizeStageCode("Render cancelled")).toBe("render_cancelled"); + expect(normalizeStageCode("pipeline")).toBe("pipeline"); + }); + + it("slugifies an unrecognized stage string instead of bucketing it as unknown", () => { + expect(normalizeStageCode("Some New Stage!")).toBe("some_new_stage"); + }); + + it("falls back to unknown only when slugifying produces nothing usable", () => { + expect(normalizeStageCode("")).toBe("unknown"); + expect(normalizeStageCode("!!!")).toBe("unknown"); + }); +}); + // Variables-helper tests live in `../utils/variables.test.ts`. diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index a15b11b0e2..b7d0ce025d 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1500,6 +1500,37 @@ function reportDeParallelRouterBreakerTrip(quiet: boolean): void { ); } +/** + * `job.currentStage`/`failedStage` are free-text progress labels + * (`updateJobStatus`'s callers each pass their own human sentence — "Compiling + * composition", "Extracting video frames", …), which makes an exact string + * property unbounded in a telemetry event. This maps the known set to a + * stable snake_case code, and slugifies anything unrecognized instead of + * bucketing it into a single opaque "unknown" — a future stage string still + * gets a distinct, readable code without needing this map updated first. + */ +const KNOWN_STAGE_CODES: Readonly> = { + Queued: "queued", + "Compiling composition": "compiling_composition", + "Extracting video frames": "extracting_video_frames", + "Processing audio tracks": "processing_audio_tracks", + "Starting frame capture": "starting_frame_capture", + "Render complete": "render_complete", + "Render cancelled": "render_cancelled", + pipeline: "pipeline", +}; + +export function normalizeStageCode(stage: string): string { + const known = KNOWN_STAGE_CODES[stage]; + if (known) return known; + const slug = stage + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return slug || "unknown"; +} + function handleRenderError( error: unknown, options: RenderOptions, @@ -1520,6 +1551,14 @@ function handleRenderError( elapsedMs: Date.now() - startTime, errorMessage: message, failedStage, + // A bucketable failure taxonomy alongside the free-text error_message + // above: error.name is one of ~20 typed producer error classes + // (CaptureFailure, DrawElementCaptureError, SwiftShaderAssertionError, …); + // failed_stage_code is the same job.currentStage value normalized to a + // stable code. Error-conditional by nature — there is no equivalent on + // the render_complete success path, since nothing failed to name. + errorName: error instanceof Error ? error.name : "unknown", + failedStageCode: normalizeStageCode(failedStage || "pipeline"), ...renderJobObservabilityTelemetryPayload(job), ...getMemorySnapshot(), }); @@ -1622,6 +1661,9 @@ function trackRenderMetrics( dePreInversionWorkers: perf?.drawElement?.preInversionWorkers, compositionElementCount: perf?.drawElement?.compositionElementCount, compositionElementCountSource: perf?.drawElement?.compositionElementCountSource, + compositionElementTags: perf?.drawElement?.compositionElementTags, + arollVideoCount: perf?.drawElement?.arollVideoCount, + heygenVideoCount: perf?.drawElement?.heygenVideoCount, deShortBand: perf?.drawElement?.shortBand, deParallelRouter: perf?.drawElement?.parallelRouter, dePreRouterWorkers: perf?.drawElement?.preRouterWorkers, diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 838b1cfc30..a996748c20 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -75,6 +75,9 @@ export interface RenderObservabilityTelemetryPayload { captureDePreInversionWorkers?: number; captureCompositionElementCount?: number; captureCompositionElementCountSource?: string; + captureCompositionElementTags?: Readonly>; + captureArollVideoCount?: number; + captureHeygenVideoCount?: number; captureDeShortBand?: string; captureDeParallelRouter?: string; captureDeGpuRenderer?: string; @@ -137,6 +140,9 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP de_pre_inversion_workers: props.captureDePreInversionWorkers, composition_element_count: props.captureCompositionElementCount, composition_element_count_source: props.captureCompositionElementCountSource, + composition_element_tags: props.captureCompositionElementTags, + aroll_video_count: props.captureArollVideoCount, + heygen_video_count: props.captureHeygenVideoCount, de_short_band: props.captureDeShortBand, de_parallel_router: props.captureDeParallelRouter, gpu_renderer: props.captureDeGpuRenderer, @@ -276,6 +282,9 @@ export function trackRenderComplete( dePreInversionWorkers?: number; compositionElementCount?: number; compositionElementCountSource?: string; + compositionElementTags?: Readonly>; + arollVideoCount?: number; + heygenVideoCount?: number; deShortBand?: string; deParallelRouter?: string; dePreRouterWorkers?: number; @@ -380,6 +389,9 @@ export function trackRenderComplete( de_pre_inversion_workers: props.dePreInversionWorkers, composition_element_count: props.compositionElementCount, composition_element_count_source: props.compositionElementCountSource, + composition_element_tags: props.compositionElementTags, + aroll_video_count: props.arollVideoCount, + heygen_video_count: props.heygenVideoCount, de_short_band: props.deShortBand, de_parallel_router: props.deParallelRouter, de_pre_router_workers: props.dePreRouterWorkers, @@ -457,6 +469,10 @@ export function trackRenderError( gpu?: boolean; source?: "cli" | "studio"; failedStage?: string; + /** One of ~20 typed producer error classes (CaptureFailure, DrawElementCaptureError, …), or "unknown" for a non-Error throw. */ + errorName?: string; + /** failedStage normalized to a stable snake_case code. */ + failedStageCode?: string; errorMessage?: string; elapsedMs?: number; peakMemoryMb?: number; @@ -477,6 +493,8 @@ export function trackRenderError( gpu: props.gpu, source: props.source ?? "cli", failed_stage: props.failedStage, + error_name: props.errorName, + failed_stage_code: props.failedStageCode, error_message: props.errorMessage ? redactTelemetryMessage(props.errorMessage) : undefined, elapsed_ms: props.elapsedMs, peak_memory_mb: props.peakMemoryMb, diff --git a/packages/cli/src/telemetry/renderObservability.ts b/packages/cli/src/telemetry/renderObservability.ts index c802edf6c1..40afe4de42 100644 --- a/packages/cli/src/telemetry/renderObservability.ts +++ b/packages/cli/src/telemetry/renderObservability.ts @@ -44,6 +44,9 @@ export function renderObservabilityTelemetryPayload( captureDePreInversionWorkers: capture.dePreInversionWorkers, captureCompositionElementCount: capture.compositionElementCount, captureCompositionElementCountSource: capture.compositionElementCountSource, + captureCompositionElementTags: capture.compositionElementTags, + captureArollVideoCount: capture.arollVideoCount, + captureHeygenVideoCount: capture.heygenVideoCount, captureDeShortBand: capture.deShortBand, captureDeParallelRouter: capture.deParallelRouter, captureDeGpuRenderer: capture.deGpuRenderer, diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts index c757b986bd..0b77548363 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -74,7 +74,7 @@ export interface RenderCaptureObservability { * Element count for the short-comp band gate (`resolveCompositionElementCount`): * the LIVE DOM size from the already-running probe session when one is * initialized, falling back to a static scan of the compiled HTML - * (`countElementTags`) otherwise. Live is authoritative — a static scan + * (`scanElementTags`) otherwise. Live is authoritative — a static scan * cannot see elements a composition's own script creates at runtime. * * Emitted on every render, not just inverted ones — this is the variable the @@ -93,6 +93,30 @@ export interface RenderCaptureObservability { * unlock for the band. */ compositionElementCountSource?: "live" | "static"; + /** + * Per-tag breakdown of the same static scan behind `compositionElementCount` + * — one shared regex pass feeds both fields, so a fleet query summing this + * map's values always reconciles against the integer. Capped by + * `scanElementTags` (top tags by count + an `other` bucket) so a + * pathological composition's distinct tag count can't inflate the event + * payload. Only set when + * `compositionElementCountSource` is "static" — the live path measures a + * DOM node count directly and never runs this scan. + */ + compositionElementTags?: Readonly>; + /** + * `