diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6dcb77be8..f80711fa6 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1109,7 +1109,6 @@ let deParallelRouterTrialFiredThisProcess = false; * resetting outside a test process where many independent test cases share * one imported module instance. */ -// fallow-ignore-next-line unused-export export function __resetDeParallelRouterTrialStateForTests(): void { deParallelRouterTrialManagedByUs = false; deParallelRouterTrialFiredThisProcess = false; diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 4751f7a05..0a8e9bc55 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -3786,6 +3786,8 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma p95TotalMs: percentileOf(session.capturePerf.frameMs, 0.95), p99TotalMs: percentileOf(session.capturePerf.frameMs, 0.99), subTimelineWaitOutcome: session.subTimelineWaitOutcome, + initDurationMs: session.initTelemetry?.initDurationMs, + initTweenCount: session.initTelemetry?.tweenCount, warnings: cloneCaptureWarnings(session.warnings), staticDedupReused: session.staticDedupCount ?? 0, staticDedupEnabled: session.staticDedupEnabled ?? false, diff --git a/packages/engine/src/types.ts b/packages/engine/src/types.ts index 4f30c1efb..dd5faa5e6 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -241,6 +241,18 @@ export interface CapturePerfSummary { p99TotalMs: number; /** Sub-composition timeline wait outcome (absent pre-init). */ subTimelineWaitOutcome?: SubTimelineWaitOutcome; + /** + * Session init telemetry, mirrored from the `[FrameCapture:INIT]` console + * line so PARALLEL workers report it too: worker sessions' console buffers + * only propagate to the orchestrator on failure, which left the + * multi-worker path — the short-comp band's entire population — with 0% + * coverage of the motion axis (`observability_init_tween_count`) in fleet + * telemetry. Riding the perf summary reuses the one channel that already + * flows back per worker on success. + */ + initDurationMs?: number; + /** GSAP tween count at init — the motion-axis signal for capture routing analysis. */ + initTweenCount?: number; /** Correctness warnings observed before or during capture. */ warnings?: CaptureWarning[]; /** diff --git a/packages/producer/src/services/render/observability.test.ts b/packages/producer/src/services/render/observability.test.ts index 7e5289860..3e9070c63 100644 --- a/packages/producer/src/services/render/observability.test.ts +++ b/packages/producer/src/services/render/observability.test.ts @@ -407,3 +407,34 @@ describe("RenderObservabilityRecorder", () => { ); }); }); + +describe("init observability fallback (parallel workers)", () => { + const makeRecorder = () => + new RenderObservabilityRecorder({ renderJobId: "render-par", pipelineStartMs: Date.now() }); + + it("uses the structured fallback when the console has no INIT line — the parallel success path", () => { + const summary = makeRecorder().summary({ + lastBrowserConsole: ["[FrameCapture:NAV] page.goto start"], + capture: { forceScreenshot: false, captureMode: "screenshot" }, + initFallback: { initDurationMs: 850, tweenCount: 1200 }, + }); + expect(summary.init).toEqual({ initDurationMs: 850, tweenCount: 1200 }); + }); + + it("max-merges console INIT lines over the fallback, matching multi-session semantics", () => { + const summary = makeRecorder().summary({ + lastBrowserConsole: ["[FrameCapture:INIT] complete initDurationMs=1234 tweenCount=42"], + capture: { forceScreenshot: false, captureMode: "screenshot" }, + initFallback: { initDurationMs: 850, tweenCount: 1200 }, + }); + expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 1200 }); + }); + + it("stays undefined when neither source has anything", () => { + const summary = makeRecorder().summary({ + lastBrowserConsole: [], + capture: { forceScreenshot: false, captureMode: "screenshot" }, + }); + expect(summary.init).toBeUndefined(); + }); +}); diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts index 55b96961f..0996f6000 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -270,20 +270,26 @@ function readUnsignedIntAfter(line: string, prefix: string): number | undefined return digits > 0 ? value : undefined; } -function summarizeInitObservability(lines: string[]): RenderInitObservability | undefined { - let initDurationMs: number | undefined; - let tweenCount: number | undefined; +/** Max of two optional readings — multiple worker/session INIT records can appear; keep the worst. */ +function maxReading(current: number | undefined, next: number | undefined): number | undefined { + if (next === undefined) return current; + return current === undefined ? next : Math.max(current, next); +} + +function summarizeInitObservability( + lines: string[], + fallback?: RenderInitObservability, +): RenderInitObservability | undefined { + // Console parsing only sees THIS process's session buffer, so parallel + // workers' INIT lines never reach it — their init telemetry arrives + // structured via the per-worker perf summaries instead. Seed with that and + // let the console parse (same max semantics) refine it. + let initDurationMs: number | undefined = fallback?.initDurationMs; + let tweenCount: number | undefined = fallback?.tweenCount; for (const line of lines) { if (!line.includes("[FrameCapture:INIT]")) continue; - const duration = readUnsignedIntAfter(line, "initDurationMs="); - const tweens = readUnsignedIntAfter(line, "tweenCount="); - // Multiple worker/session INIT records can appear; keep the worst observed startup cost. - if (duration !== undefined) { - initDurationMs = initDurationMs === undefined ? duration : Math.max(initDurationMs, duration); - } - if (tweens !== undefined) { - tweenCount = tweenCount === undefined ? tweens : Math.max(tweenCount, tweens); - } + initDurationMs = maxReading(initDurationMs, readUnsignedIntAfter(line, "initDurationMs=")); + tweenCount = maxReading(tweenCount, readUnsignedIntAfter(line, "tweenCount=")); } if (initDurationMs === undefined && tweenCount === undefined) return undefined; return { initDurationMs, tweenCount }; @@ -402,6 +408,8 @@ export class RenderObservabilityRecorder { summary(input: { lastBrowserConsole: string[]; capture: RenderCaptureObservability; + /** Structured init telemetry from per-worker perf summaries — the only success-path channel parallel workers have (their console buffers propagate on failure only). */ + initFallback?: RenderInitObservability; extraction?: RenderExtractionObservability; compositionHash?: string; }): RenderObservabilitySummary { @@ -416,7 +424,7 @@ export class RenderObservabilityRecorder { browserDiagnostics: summarizeBrowserDiagnostics(input.lastBrowserConsole), capture: { ...input.capture }, extraction: input.extraction ? { ...input.extraction } : undefined, - init: summarizeInitObservability(input.lastBrowserConsole), + init: summarizeInitObservability(input.lastBrowserConsole, input.initFallback), }; } diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 2b25e5e4a..942299337 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -36,6 +36,7 @@ import { shouldRetryViaPinnedFallback, countElementTags, envInt, + mergeWorkerInitObservability, shouldPreferParallelDrawElement, shouldPreferSingleWorkerDrawElement, shouldStreamParallelCapture, @@ -1761,6 +1762,23 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => { }); }); + describe("mergeWorkerInitObservability", () => { + it("max-merges across workers and ignores workers that reported nothing", () => { + expect( + mergeWorkerInitObservability([ + { initDurationMs: 400, initTweenCount: 900 }, + {}, + { initDurationMs: 1250, initTweenCount: 880 }, + ]), + ).toEqual({ initDurationMs: 1250, tweenCount: 900 }); + }); + + it("returns undefined when no worker reported — summary.init must stay absent, not zeroed", () => { + expect(mergeWorkerInitObservability([])).toBeUndefined(); + expect(mergeWorkerInitObservability([{}, {}])).toBeUndefined(); + }); + }); + describe("countElementTags", () => { it("counts closing tags", () => { expect(countElementTags("
a
")).toBe(2); diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index acb8cd74a..114f37fcf 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1266,6 +1266,35 @@ export function countElementTags(html: string): number { return matches === null ? 0 : matches.length; } +/** + * Max-merge init telemetry across per-worker capture perf summaries — the + * success-path channel for PARALLEL renders, whose worker console buffers + * (and so the `[FrameCapture:INIT]` line) only propagate on failure. Max + * matches summarizeInitObservability's own multi-session semantics: keep the + * worst observed startup cost, and tween count is per-composition so any + * worker's reading is the reading. + */ +export function mergeWorkerInitObservability( + perfs: ReadonlyArray<{ initDurationMs?: number; initTweenCount?: number }>, +): { initDurationMs?: number; tweenCount?: number } | undefined { + let initDurationMs: number | undefined; + let tweenCount: number | undefined; + for (const perf of perfs) { + if (perf.initDurationMs !== undefined) { + initDurationMs = + initDurationMs === undefined + ? perf.initDurationMs + : Math.max(initDurationMs, perf.initDurationMs); + } + if (perf.initTweenCount !== undefined) { + tweenCount = + tweenCount === undefined ? perf.initTweenCount : Math.max(tweenCount, perf.initTweenCount); + } + } + if (initDurationMs === undefined && tweenCount === undefined) return undefined; + return { initDurationMs, tweenCount }; +} + /** * DE priority inversion predicate: should an AUTO-resolved multi-worker render * drop to single-worker verified drawElement streaming? @@ -3600,6 +3629,7 @@ async function executeRenderPipeline(input: { const observabilitySummary = observability.summary({ lastBrowserConsole, capture: captureObservability, + initFallback: mergeWorkerInitObservability(dedupPerfs), extraction: extractionObservability, compositionHash, }); @@ -3761,6 +3791,7 @@ async function executeRenderPipeline(input: { const observabilitySummary = observability.summary({ lastBrowserConsole, capture: captureObservability, + initFallback: mergeWorkerInitObservability(dedupPerfs), extraction: extractionObservability, compositionHash, });