Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export interface RenderObservabilityTelemetryPayload {
observabilityExtractCacheMisses?: number;
observabilityInitDurationMs?: number;
observabilityInitTweenCount?: number;
observabilityInitElementCount?: number;
}

function renderObservabilityEventProperties(props: RenderObservabilityTelemetryPayload) {
Expand Down Expand Up @@ -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,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/telemetry/renderObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export function renderObservabilityTelemetryPayload(
observabilityExtractCacheMisses: extraction?.cacheMisses,
observabilityInitDurationMs: init?.initDurationMs,
observabilityInitTweenCount: init?.tweenCount,
observabilityInitElementCount: init?.elementCount,
};
}

Expand Down
44 changes: 41 additions & 3 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(() => {
Expand All @@ -431,7 +465,7 @@ async function collectSessionInitTelemetry(
} catch {
tweenCount = 0;
}
return { initDurationMs, tweenCount };
return { initDurationMs, tweenCount, elementCount };
}

async function recordSessionInitTelemetry(
Expand All @@ -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}`),
);
}

Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
/**
Expand Down
38 changes: 33 additions & 5 deletions packages/producer/src/services/render/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
23 changes: 21 additions & 2 deletions packages/producer/src/services/render/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
60 changes: 54 additions & 6 deletions packages/producer/src/services/renderOrchestrator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -1986,12 +1994,52 @@ describe("shouldPreferSingleWorkerDrawElement (DE priority inversion)", () => {
});

it("does not false-positive on inline-script comparisons or void-prefixed words", () => {
// Only the </script> closer counts: "<breadth" and "<imgWidth" hit the
// br/img alternatives but fail the \b word boundary (next char is a
// word char), and bare "a < b" comparisons match nothing.
// Script bodies are stripped wholesale (with their own closing tag), so
// nothing inside can match — including "<breadth" / "<imgWidth", which
// would anyway fail the \b word boundary.
expect(countElementTags("<script>if (a < b && x <breadth && y <imgWidth) {}</script>")).toBe(
0,
);
});

// Review finding: the `</[a-zA-Z]` alternation matches ANY "</" + letter,
// including inside JS strings and template literals. Compiled comps embed
// large inline scripts, so this bias is systematic — and it lands entirely
// on the ~83% of renders with no probe, for which this scan is the only
// element signal.
it("does not count closing tags written inside inline script strings", () => {
expect(countElementTags('<div></div><script>const h = "</div></div></div>";</script>')).toBe(
1,
);
expect(
countElementTags("<p></p><script>const t = words.map(w => `</span>`).join('');</script>"),
).toBe(1);
});

it("strips <style> bodies too — CSS content strings can carry the same shapes", () => {
expect(countElementTags('<div></div><style>a::after{content:"</div>"}</style>')).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 <script> removed by pass 1 leaves "<script>alert(1)</script>",
// which pass 2 removes. A single pass would leave a stray tag behind.
expect(countElementTags("<div></div><scr<script></script>ipt>alert(1)</script>")).toBe(1);
});

it("terminates on input with no closing tag rather than looping", () => {
expect(countElementTags("<div></div><script>unterminated")).toBe(1);
});

it("strips multiple and attributed script blocks, not just the first", () => {
expect(
countElementTags(
'<div></div><script type="module">"</span>"</script><script>"</span>"</script>',
),
).toBe(1);
});

it("is stable on empty and malformed input rather than throwing", () => {
Expand Down
Loading
Loading