mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
Merge pull request #2891 from heygen-com/feat/live-element-count-everywhere
feat(engine): measure live DOM size on every render, not just probed ones
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export function renderObservabilityTelemetryPayload(
|
||||
observabilityExtractCacheMisses: extraction?.cacheMisses,
|
||||
observabilityInitDurationMs: init?.initDurationMs,
|
||||
observabilityInitTweenCount: init?.tweenCount,
|
||||
observabilityInitElementCount: init?.elementCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
/**
|
||||
|
||||
@@ -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 },
|
||||
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.
|
||||
// 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" },
|
||||
});
|
||||
expect(summary.init).toEqual({ initDurationMs: 90, tweenCount: 7, elementCount: 1420 });
|
||||
});
|
||||
|
||||
it("stays undefined when neither source has anything", () => {
|
||||
|
||||
@@ -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) return undefined;
|
||||
return { initDurationMs, tweenCount };
|
||||
if (initDurationMs === undefined && tweenCount === undefined && elementCount === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return { initDurationMs, tweenCount, elementCount };
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
|
||||
@@ -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 </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", () => {
|
||||
|
||||
@@ -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 <script>/<style> bodies BEFORE matching. Every alternation
|
||||
// below can fire on ordinary JS text — `const html = "</div>"` or a
|
||||
// template literal building `</span>` inflates the count once per
|
||||
// occurrence — and compiled comps embed large inline scripts. That bias is
|
||||
// systematic, not noise, and it lands entirely on the ~83% of renders with
|
||||
// no probe session, for which this scan is the only element signal (review
|
||||
// finding). Removing the bodies also drops their own closing tags, which
|
||||
// costs 1-2 counts against a threshold in the thousands.
|
||||
// Looped to a fixed point rather than a single pass: one pass can REFORM
|
||||
// the pattern it just removed (`<scr<script>ipt>` leaves `<script>`), which
|
||||
// CodeQL flags as incomplete multi-character sanitization. The impact here
|
||||
// is nil — the stripped string is counted and discarded, never rendered —
|
||||
// but the incompleteness is real, and a stray reformed tag would perturb
|
||||
// the count this gate reads. Converges: every iteration strictly shortens
|
||||
// the string or changes nothing and exits.
|
||||
let markup = html;
|
||||
for (let previous = ""; markup !== previous; ) {
|
||||
previous = markup;
|
||||
markup = markup.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
}
|
||||
const matches = markup.match(
|
||||
/<\/[a-zA-Z]|<(?:img|br|hr|input|source|track|area|base|col|embed|link|meta|param|wbr)\b|<[a-zA-Z][-a-zA-Z0-9]*\b[^>]*\/>/gi,
|
||||
);
|
||||
return matches === null ? 0 : matches.length;
|
||||
@@ -1321,7 +1344,9 @@ export async function resolveCompositionElementCount(
|
||||
if (probeSession?.isInitialized) {
|
||||
try {
|
||||
const liveCount = await probeSession.page.evaluate(
|
||||
() => document.querySelectorAll("*").length,
|
||||
// Live HTMLCollection length — avoids materializing a static NodeList
|
||||
// on the large-DOM comps this gate exists to catch (review nit).
|
||||
() => document.getElementsByTagName("*").length,
|
||||
);
|
||||
if (typeof liveCount === "number" && Number.isFinite(liveCount)) {
|
||||
return { count: liveCount, source: "live" };
|
||||
@@ -1345,10 +1370,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 +1390,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 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2672,6 +2713,11 @@ async function executeRenderPipeline(input: {
|
||||
...deInversionArgs,
|
||||
minFrames: Math.min(deSingleMinFrames, deShortBandMinFrames),
|
||||
});
|
||||
// Attribution runs even when routing is OFF — that is the whole point of
|
||||
// the baseline release: "applied" is the counterfactual "would have
|
||||
// inverted", and emitting it now is what establishes the DiD cohort
|
||||
// before the flip. Do not short-circuit this block behind
|
||||
// `deShortBandRoute` (review nit).
|
||||
const deShortBand = resolveDeShortBand({
|
||||
invertAtBaseFloor,
|
||||
invertAtBandFloor,
|
||||
|
||||
Reference in New Issue
Block a user