diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 3bc3db5e8..ad7bd9e1b 100644 --- a/packages/cli/src/telemetry/events.ts +++ b/packages/cli/src/telemetry/events.ts @@ -98,6 +98,7 @@ export interface RenderObservabilityTelemetryPayload { observabilityExtractCacheMisses?: number; observabilityInitDurationMs?: number; observabilityInitTweenCount?: number; + observabilityInitElementCount?: number; } function renderObservabilityEventProperties(props: RenderObservabilityTelemetryPayload) { @@ -157,6 +158,7 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP observability_extract_cache_misses: props.observabilityExtractCacheMisses, observability_init_duration_ms: props.observabilityInitDurationMs, observability_init_tween_count: props.observabilityInitTweenCount, + observability_init_element_count: props.observabilityInitElementCount, }; } diff --git a/packages/cli/src/telemetry/renderObservability.ts b/packages/cli/src/telemetry/renderObservability.ts index 362837ae8..c802edf6c 100644 --- a/packages/cli/src/telemetry/renderObservability.ts +++ b/packages/cli/src/telemetry/renderObservability.ts @@ -66,6 +66,7 @@ export function renderObservabilityTelemetryPayload( observabilityExtractCacheMisses: extraction?.cacheMisses, observabilityInitDurationMs: init?.initDurationMs, observabilityInitTweenCount: init?.tweenCount, + observabilityInitElementCount: init?.elementCount, }; } diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 0a8e9bc55..1d5f796b9 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -119,6 +119,8 @@ export interface CaptureSession { initTelemetry?: { initDurationMs: number; tweenCount: number; + /** Live DOM element count at end of init — observational; see collectSessionInitTelemetry. */ + elementCount: number; }; capturePerf: { frames: number; @@ -406,8 +408,24 @@ function appendBrowserDiagnostic(session: CaptureSession, text: string): void { async function collectSessionInitTelemetry( page: Page, initStart: number, -): Promise<{ initDurationMs: number; tweenCount: number }> { +): Promise<{ initDurationMs: number; tweenCount: number; elementCount: number }> { const initDurationMs = Date.now() - initStart; + // Live DOM size, measured once the init sequence has completed so + // script-generated elements are present. This is the SAME quantity the + // short-comp routing gate wants, but measured here it is observational + // only — capture has already started, so it is far too late to route on. + // Its job is coverage: the routing gate can only read a live count on the + // ~17% of renders that get a probe session, which leaves the fleet + // element-count distribution unknowable for the rest (and hides exactly + // the dangerous shape — small source markup, huge runtime DOM). Every + // render reaches this path, so the distribution becomes readable even + // where the gate stays blind. + let elementCount = 0; + try { + elementCount = await page.evaluate(() => document.querySelectorAll("*").length); + } catch { + elementCount = 0; + } let tweenCount = 0; try { tweenCount = await page.evaluate(() => { @@ -431,7 +449,7 @@ async function collectSessionInitTelemetry( } catch { tweenCount = 0; } - return { initDurationMs, tweenCount }; + return { initDurationMs, tweenCount, elementCount }; } async function recordSessionInitTelemetry( @@ -442,7 +460,7 @@ async function recordSessionInitTelemetry( session.initTelemetry = telemetry; appendBrowserDiagnostic( session, - `[FrameCapture:INIT] complete initDurationMs=${telemetry.initDurationMs} tweenCount=${telemetry.tweenCount}`, + `[FrameCapture:INIT] complete initDurationMs=${telemetry.initDurationMs} tweenCount=${telemetry.tweenCount} elementCount=${telemetry.elementCount}`, ); } @@ -3788,6 +3806,7 @@ export function getCapturePerfSummary(session: CaptureSession): CapturePerfSumma subTimelineWaitOutcome: session.subTimelineWaitOutcome, initDurationMs: session.initTelemetry?.initDurationMs, initTweenCount: session.initTelemetry?.tweenCount, + initElementCount: session.initTelemetry?.elementCount, 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 dd5faa5e6..c25e2a6ca 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -253,6 +253,14 @@ export interface CapturePerfSummary { initDurationMs?: number; /** GSAP tween count at init — the motion-axis signal for capture routing analysis. */ initTweenCount?: number; + /** + * Live DOM element count at end of init. Observational counterpart to the + * short-comp routing gate's own count: the gate can only measure the ~17% + * of renders that get a probe session, so without this the fleet + * element-count distribution — and any large-runtime-DOM tail — stays + * invisible for the rest. + */ + initElementCount?: 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 3e9070c63..8210a2e3d 100644 --- a/packages/producer/src/services/render/observability.test.ts +++ b/packages/producer/src/services/render/observability.test.ts @@ -416,18 +416,33 @@ describe("init observability fallback (parallel workers)", () => { const summary = makeRecorder().summary({ lastBrowserConsole: ["[FrameCapture:NAV] page.goto start"], capture: { forceScreenshot: false, captureMode: "screenshot" }, - initFallback: { initDurationMs: 850, tweenCount: 1200 }, + initFallback: { initDurationMs: 850, tweenCount: 1200, elementCount: 3400 }, }); - expect(summary.init).toEqual({ initDurationMs: 850, tweenCount: 1200 }); + expect(summary.init).toEqual({ initDurationMs: 850, tweenCount: 1200, elementCount: 3400 }); }); 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"], + lastBrowserConsole: [ + "[FrameCapture:INIT] complete initDurationMs=1234 tweenCount=42 elementCount=5000", + ], capture: { forceScreenshot: false, captureMode: "screenshot" }, - initFallback: { initDurationMs: 850, tweenCount: 1200 }, + initFallback: { initDurationMs: 850, tweenCount: 1200, elementCount: 3400 }, }); - expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 1200 }); + expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 1200, elementCount: 5000 }); + }); + + // The single-session path has no structured fallback — it parses the console + // line only. This is the path that covers renders the routing gate cannot + // measure, so the element count must survive it. + it("parses elementCount from the console INIT line with no fallback at all", () => { + const summary = makeRecorder().summary({ + lastBrowserConsole: [ + "[FrameCapture:INIT] complete initDurationMs=90 tweenCount=7 elementCount=1420", + ], + capture: { forceScreenshot: false, captureMode: "screenshot" }, + }); + expect(summary.init).toEqual({ initDurationMs: 90, tweenCount: 7, elementCount: 1420 }); }); it("stays undefined when neither source has anything", () => { diff --git a/packages/producer/src/services/render/observability.ts b/packages/producer/src/services/render/observability.ts index 925f9fcfb..5a734e6e6 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -182,6 +182,15 @@ export interface RenderExtractionObservability { export interface RenderInitObservability { initDurationMs?: number; tweenCount?: number; + /** + * Live DOM element count at end of capture-session init. Observational: + * measured after routing has already been decided, so it cannot gate — it + * exists because the routing gate's own count is only available on the + * ~17% of renders that get a probe session, leaving the fleet + * element-count distribution (and any large-runtime-DOM tail) unreadable + * for the rest. + */ + elementCount?: number; } export interface RenderObservabilitySummary { @@ -299,13 +308,17 @@ function summarizeInitObservability( // let the console parse (same max semantics) refine it. let initDurationMs: number | undefined = fallback?.initDurationMs; let tweenCount: number | undefined = fallback?.tweenCount; + let elementCount: number | undefined = fallback?.elementCount; for (const line of lines) { if (!line.includes("[FrameCapture:INIT]")) continue; initDurationMs = maxReading(initDurationMs, readUnsignedIntAfter(line, "initDurationMs=")); tweenCount = maxReading(tweenCount, readUnsignedIntAfter(line, "tweenCount=")); + elementCount = maxReading(elementCount, readUnsignedIntAfter(line, "elementCount=")); } - if (initDurationMs === undefined && tweenCount === undefined) return undefined; - return { initDurationMs, tweenCount }; + if (initDurationMs === undefined && tweenCount === undefined && elementCount === undefined) { + return undefined; + } + return { initDurationMs, tweenCount, elementCount }; } // fallow-ignore-next-line complexity diff --git a/packages/producer/src/services/renderOrchestrator.test.ts b/packages/producer/src/services/renderOrchestrator.test.ts index 752e65d95..c3213d23e 100644 --- a/packages/producer/src/services/renderOrchestrator.test.ts +++ b/packages/producer/src/services/renderOrchestrator.test.ts @@ -1936,17 +1936,25 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => { it("max-merges across workers and ignores workers that reported nothing", () => { expect( mergeWorkerInitObservability([ - { initDurationMs: 400, initTweenCount: 900 }, + { initDurationMs: 400, initTweenCount: 900, initElementCount: 1200 }, {}, - { initDurationMs: 1250, initTweenCount: 880 }, + { initDurationMs: 1250, initTweenCount: 880, initElementCount: 1190 }, ]), - ).toEqual({ initDurationMs: 1250, tweenCount: 900 }); + ).toEqual({ initDurationMs: 1250, tweenCount: 900, elementCount: 1200 }); }); it("returns undefined when no worker reported — summary.init must stay absent, not zeroed", () => { expect(mergeWorkerInitObservability([])).toBeUndefined(); expect(mergeWorkerInitObservability([{}, {}])).toBeUndefined(); }); + + it("surfaces an element count even when a worker reported nothing else", () => { + expect(mergeWorkerInitObservability([{ initElementCount: 4000 }])).toEqual({ + initDurationMs: undefined, + tweenCount: undefined, + elementCount: 4000, + }); + }); }); describe("countElementTags", () => { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 777771a9b..63e45932a 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1345,10 +1345,15 @@ export async function resolveCompositionElementCount( * initializes before the timeline is fully wired, not an expected disagreement. */ export function mergeWorkerInitObservability( - perfs: ReadonlyArray<{ initDurationMs?: number; initTweenCount?: number }>, -): { initDurationMs?: number; tweenCount?: number } | undefined { + perfs: ReadonlyArray<{ + initDurationMs?: number; + initTweenCount?: number; + initElementCount?: number; + }>, +): { initDurationMs?: number; tweenCount?: number; elementCount?: number } | undefined { let initDurationMs: number | undefined; let tweenCount: number | undefined; + let elementCount: number | undefined; for (const perf of perfs) { if (perf.initDurationMs !== undefined) { initDurationMs = @@ -1360,9 +1365,20 @@ export function mergeWorkerInitObservability( tweenCount = tweenCount === undefined ? perf.initTweenCount : Math.max(tweenCount, perf.initTweenCount); } + // Max across workers: every worker loads the same composition, so they + // should agree — max is defensive against a worker sampled before its + // init script finished populating the DOM. + if (perf.initElementCount !== undefined) { + elementCount = + elementCount === undefined + ? perf.initElementCount + : Math.max(elementCount, perf.initElementCount); + } } - if (initDurationMs === undefined && tweenCount === undefined) return undefined; - return { initDurationMs, tweenCount }; + if (initDurationMs === undefined && tweenCount === undefined && elementCount === undefined) { + return undefined; + } + return { initDurationMs, tweenCount, elementCount }; } /**