diff --git a/packages/cli/src/telemetry/events.ts b/packages/cli/src/telemetry/events.ts index 3bc3db5e84..ad7bd9e1bb 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 362837ae8a..c802edf6c1 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 0a8e9bc55c..28d6df0d6a 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; undefined when the measurement itself failed. Observational — see collectSessionInitTelemetry. */ + elementCount?: number; }; capturePerf: { frames: number; @@ -406,8 +408,40 @@ 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). + // + // Coverage caveat, since the "every render reaches this" claim is easy to + // over-read: every render that SURVIVES TO END OF INIT reaches it. Renders + // that die in browser launch, navigation, or OOM before init completes + // never emit — and on the huge-DOM tail this field exists to characterize, + // those are disproportionately the ones that fail early. The distribution + // is therefore survivor-biased toward smaller comps; treat the tail as a + // lower bound (review finding). + // Deliberately `undefined` (not 0) when the measurement fails, breaking + // from the tweenCount fallback pattern directly above. A page.evaluate + // crash during init is most likely on exactly the huge-DOM compositions + // this field exists to observe, and a 0 there is indistinguishable from a + // legitimately empty comp — the fleet p50/p99 would silently absorb both + // (review finding). Mirrors the live/static provenance split the routing + // resolver already uses: "not measured" must not read as "measured small". + // getElementsByTagName over querySelectorAll: a live HTMLCollection's + // length avoids materializing a 40k-node static NodeList on the tail this + // is here to characterize. + let elementCount: number | undefined; + try { + elementCount = await page.evaluate(() => document.getElementsByTagName("*").length); + } catch { + elementCount = undefined; + } let tweenCount = 0; try { tweenCount = await page.evaluate(() => { @@ -431,7 +465,7 @@ async function collectSessionInitTelemetry( } catch { tweenCount = 0; } - return { initDurationMs, tweenCount }; + return { initDurationMs, tweenCount, elementCount }; } async function recordSessionInitTelemetry( @@ -442,7 +476,10 @@ 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}` + + // Omitted rather than zeroed when unmeasured, so the parser reports + // absent instead of inventing an empty DOM. + (telemetry.elementCount === undefined ? "" : ` elementCount=${telemetry.elementCount}`), ); } @@ -3788,6 +3825,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 dd5faa5e6b..8b33b2df5a 100644 --- a/packages/engine/src/types.ts +++ b/packages/engine/src/types.ts @@ -253,6 +253,26 @@ 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; undefined when the measurement + * failed (never 0 — see collectSessionInitTelemetry). + * + * WHICH FIELD TO QUERY — two element counts exist and they answer + * different questions: + * • `composition_element_count` (+ `_source`) is the ROUTING-RELEVANT + * one. Measured from the PROBE session before the routing decision, + * falling back to a static source scan. That is what the short-comp + * band actually gates on. + * • `observability_init_element_count` (this field) is the + * OBSERVATIONAL counterpart. Measured from the capture session's own + * DOM at end of init, on every surviving render — including the ~83% + * with no probe, where the routing signal is a blind static scan. + * They agree for most comps and diverge for one that mutates its DOM + * between probe launch and capture init. Use this for distribution and + * tail questions; use `composition_element_count` for anything about what + * the router did (review finding). + */ + 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 3e9070c63d..ef78948900 100644 --- a/packages/producer/src/services/render/observability.test.ts +++ b/packages/producer/src/services/render/observability.test.ts @@ -416,18 +416,46 @@ 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, elementCount: 3400 }, + }); + 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. + // The collector omits `elementCount=` entirely when the page.evaluate that + // measures it failed, rather than emitting 0 — a 0 there is + // indistinguishable from a legitimately empty comp, and evaluate failures + // concentrate on exactly the huge-DOM tail this field exists to observe. + it("leaves elementCount absent when the INIT line omits it (measurement failed)", () => { + const summary = makeRecorder().summary({ + lastBrowserConsole: ["[FrameCapture:INIT] complete initDurationMs=90 tweenCount=7"], + capture: { forceScreenshot: false, captureMode: "screenshot" }, + }); + expect(summary.init).toEqual({ initDurationMs: 90, tweenCount: 7, elementCount: undefined }); + expect(summary.init?.elementCount).not.toBe(0); + }); + + 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" }, - initFallback: { initDurationMs: 850, tweenCount: 1200 }, }); - expect(summary.init).toEqual({ initDurationMs: 1234, tweenCount: 1200 }); + 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 925f9fcfb0..6dbbc7143a 100644 --- a/packages/producer/src/services/render/observability.ts +++ b/packages/producer/src/services/render/observability.ts @@ -182,6 +182,21 @@ export interface RenderExtractionObservability { export interface RenderInitObservability { initDurationMs?: number; tweenCount?: number; + /** + * Live DOM element count at end of capture-session init; undefined when + * the measurement failed (never 0). 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. + * + * Not interchangeable with `RenderCaptureObservability.compositionElementCount`: + * that one is measured pre-routing from the probe session (or a static + * scan) and is what the band gates on. This one is measured post-routing + * from the capture session and covers renders the gate cannot see. Query + * the former for router behaviour, this for distribution/tail analysis. + */ + elementCount?: number; } export interface RenderObservabilitySummary { @@ -299,13 +314,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 && elementCount === undefined) { + return undefined; } - if (initDurationMs === undefined && tweenCount === undefined) return undefined; - return { initDurationMs, tweenCount }; + 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 752e65d95d..0fa1988e47 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", () => { @@ -1986,12 +1994,52 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => { }); it("does not false-positive on inline-script comparisons or void-prefixed words", () => { - // Only the closer counts: "if (a < b && x ")).toBe( + 0, + ); + }); + + // Review finding: the ` { + expect(countElementTags('
')).toBe( 1, ); + expect( + countElementTags("

"), + ).toBe(1); + }); + + it("strips ')).toBe(1); + }); + + // CodeQL "incomplete multi-character sanitization": a single-pass replace + // can reform the very pattern it removed. Impact is nil here (the stripped + // string is counted, never rendered) but a reformed tag would perturb the + // count, so the strip runs to a fixed point. + it("strips script tags that reform after one pass", () => { + // Inner ", + // which pass 2 removes. A single pass would leave a stray tag behind. + expect(countElementTags("
ipt>alert(1)")).toBe(1); + }); + + it("terminates on input with no closing tag rather than looping", () => { + expect(countElementTags("
', + ), + ).toBe(1); }); it("is stable on empty and malformed input rather than throwing", () => { diff --git a/packages/producer/src/services/renderOrchestrator.ts b/packages/producer/src/services/renderOrchestrator.ts index 777771a9bd..badf498285 100644 --- a/packages/producer/src/services/renderOrchestrator.ts +++ b/packages/producer/src/services/renderOrchestrator.ts @@ -1243,7 +1243,10 @@ export function envInt(name: string, fallback: number): number { const raw = process.env[name]; if (raw === undefined || raw.trim() === "") return fallback; const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : fallback; + // Integer-only, per the name: a fractional threshold would compare + // sensibly against integer counts but silently means something the knob + // never promised, so treat it as a typo and fall back (review nit). + return Number.isInteger(parsed) ? parsed : fallback; } /** @@ -1286,7 +1289,27 @@ export function envInt(name: string, fallback: number): number { * live count and uses this only when no such session exists. */ export function countElementTags(html: string): number { - const matches = html.match( + // Strip inline