Skip to content
4 changes: 3 additions & 1 deletion packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1433,6 +1432,9 @@ function trackRenderMetrics(
deClampReason: perf?.drawElement?.clampReason,
deWorkerInversion: perf?.drawElement?.workerInversion,
dePreInversionWorkers: perf?.drawElement?.preInversionWorkers,
compositionElementCount: perf?.drawElement?.compositionElementCount,
compositionElementCountSource: perf?.drawElement?.compositionElementCountSource,
deShortBand: perf?.drawElement?.shortBand,
deParallelRouter: perf?.drawElement?.parallelRouter,
dePreRouterWorkers: perf?.drawElement?.preRouterWorkers,
deGateReason: perf?.drawElement?.gateReason,
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/telemetry/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ export interface RenderObservabilityTelemetryPayload {
// the more authoritative perfSummary value wins when both are present.
captureDeWorkerInversion?: string;
captureDePreInversionWorkers?: number;
captureCompositionElementCount?: number;
captureCompositionElementCountSource?: string;
captureDeShortBand?: string;
captureDeParallelRouter?: string;
captureDeGpuRenderer?: string;
captureDePreRouterWorkers?: number;
Expand Down Expand Up @@ -130,6 +133,9 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
capture_memory_exhaustion_detected: props.captureMemoryExhaustionDetected,
de_worker_inversion: props.captureDeWorkerInversion,
de_pre_inversion_workers: props.captureDePreInversionWorkers,
composition_element_count: props.captureCompositionElementCount,
composition_element_count_source: props.captureCompositionElementCountSource,
de_short_band: props.captureDeShortBand,
de_parallel_router: props.captureDeParallelRouter,
gpu_renderer: props.captureDeGpuRenderer,
de_pre_router_workers: props.captureDePreRouterWorkers,
Expand Down Expand Up @@ -204,6 +210,9 @@ export function trackRenderComplete(
deClampReason?: string;
deWorkerInversion?: string;
dePreInversionWorkers?: number;
compositionElementCount?: number;
compositionElementCountSource?: string;
deShortBand?: string;
deParallelRouter?: string;
dePreRouterWorkers?: number;
deGateReason?: string;
Expand Down Expand Up @@ -303,6 +312,9 @@ export function trackRenderComplete(
de_clamp_reason: props.deClampReason,
de_worker_inversion: props.deWorkerInversion,
de_pre_inversion_workers: props.dePreInversionWorkers,
composition_element_count: props.compositionElementCount,
composition_element_count_source: props.compositionElementCountSource,
de_short_band: props.deShortBand,
de_parallel_router: props.deParallelRouter,
de_pre_router_workers: props.dePreRouterWorkers,
de_gate_reason: props.deGateReason,
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/telemetry/renderObservability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export function renderObservabilityTelemetryPayload(
captureMemoryExhaustionDetected: capture.memoryExhaustionDetected,
captureDeWorkerInversion: capture.deWorkerInversion,
captureDePreInversionWorkers: capture.dePreInversionWorkers,
captureCompositionElementCount: capture.compositionElementCount,
captureCompositionElementCountSource: capture.compositionElementCountSource,
captureDeShortBand: capture.deShortBand,
captureDeParallelRouter: capture.deParallelRouter,
captureDeGpuRenderer: capture.deGpuRenderer,
captureDePreRouterWorkers: capture.dePreRouterWorkers,
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
/**
Expand Down
31 changes: 31 additions & 0 deletions packages/producer/src/services/render/observability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
71 changes: 58 additions & 13 deletions packages/producer/src/services/render/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,43 @@ export interface RenderCaptureObservability {
deWorkerInversion?: "inverted" | "reverted";
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
dePreInversionWorkers?: number;
/**
* Element count for the short-comp band gate (`resolveCompositionElementCount`):
* the LIVE DOM size from the already-running probe session when one is
* initialized, falling back to a static scan of the compiled HTML
* (`countElementTags`) otherwise. Live is authoritative — a static scan
* cannot see elements a composition's own script creates at runtime.
*
* Emitted on every render, not just inverted ones — this is the variable the
* short-comp inversion band is gated on, and the fleet distribution of it is
* unknown. Without it there is no way to tell whether the 2500 ceiling opens
* the band for most short comps or almost none, and no way to re-derive the
* threshold from real content instead of synthetic sweeps.
*/
compositionElementCount?: number;
/**
* Provenance of `compositionElementCount`: "live" (measured from the probe
* session's real DOM — sees runtime-generated elements) or "static" (source
* markup scan, which does not). The probe is CONDITIONAL, so this is not a
* detail: only a `live` count may open the short band, and the fleet rate of
* "static" sizes the population a future conditional-probe-launch would
* unlock for the band.
*/
compositionElementCountSource?: "live" | "static";
/**
* Short-comp band decision, emitted only when the band is DECISIVE — every
* other inversion-eligibility condition passed and only the floor (250 vs
* 900) differed. "applied": the element count cleared the ceiling too, so
* with routing enabled (HF_DE_SHORT_BAND_ROUTE) this render inverts; in the
* baseline release the same value is the COUNTERFACTUAL "would have
* inverted". "skipped_elements": the element ceiling was the only blocker.
* Unset: the band could not have affected this render (ineligible for some
* other reason, or already inverting at 900+). The selector is computed
* identically before and after the routing flip, and the skipped/oversize
* renders form the concurrent control for the difference-in-differences
* read — that is the entire point of the field.
*/
deShortBand?: "applied" | "skipped_elements" | "unmeasured";
/** DE parallel-router outcome: "routed" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
deParallelRouter?: "routed" | "reverted";
/**
Expand Down Expand Up @@ -246,20 +283,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 };
Expand Down Expand Up @@ -378,6 +421,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 {
Expand All @@ -392,7 +437,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),
};
}

Expand Down
9 changes: 9 additions & 0 deletions packages/producer/src/services/render/perfSummary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export interface DrawElementPerfInput {
workerInversion?: "inverted" | "reverted";
/** Auto-resolved worker count before the inversion pinned it to 1 (set only when the inversion fired). */
preInversionWorkers?: number;
/** Rough compiled-composition element count — gate variable for the short-comp inversion band. */
compositionElementCount?: number;
/** Provenance of the element count: "live" (probe DOM, trusted to gate) | "static" (source scan, not). */
compositionElementCountSource?: "live" | "static";
/** Short-comp band decision when the band was DECISIVE: "applied" (inverts once HF_DE_SHORT_BAND_ROUTE is on; counterfactual in the baseline release) | "skipped_elements" (element ceiling was the only blocker); unset when the band could not have affected this render. */
shortBand?: "applied" | "skipped_elements" | "unmeasured";
parallelRouter?: "routed" | "reverted";
/** Auto-resolved worker count before the router pinned it to 3 (set only when the router fired). */
preRouterWorkers?: number;
Expand Down Expand Up @@ -121,6 +127,9 @@ function aggregateDrawElement(
clampReason: de.clampReason,
workerInversion: de.workerInversion ?? "none",
preInversionWorkers: de.preInversionWorkers,
compositionElementCount: de.compositionElementCount,
compositionElementCountSource: de.compositionElementCountSource,
shortBand: de.shortBand,
parallelRouter: de.parallelRouter ?? "none",
preRouterWorkers: de.preRouterWorkers,
gateReason: gateReasons.length > 0 ? gateReasons.join("|") : undefined,
Expand Down
46 changes: 39 additions & 7 deletions packages/producer/src/services/render/stages/probeStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,37 @@ function hasRuntimeInsertedMedia(html: string): boolean {
);
}

/**
* Does this render need a browser probe at all?
*
* Extracted as a pure predicate because whether a probe runs decides whether
* a LIVE DOM element count is available downstream, and the short-comp
* inversion band fails closed without one (see
* `resolveCompositionElementCount` / `resolveDeShortBand`). Notably NONE of
* these conditions fire for a known-duration, media-free composition that
* builds thousands of `div`/`span` nodes in its own init script —
* `hasRuntimeInsertedMedia` matches only `createElement("video"|"audio")` —
* so that shape is measured statically and must never reach the band's
* `applied` cohort (review finding, R4).
*/
export function probeRequiresBrowser(args: {
durationSeconds: number;
unresolvedCompositionCount: number;
hasAutoStart: boolean;
hasScriptedAudio: boolean;
hasVariableMedia: boolean;
hasInsertedMedia: boolean;
}): boolean {
return (
args.durationSeconds <= 0 ||
args.unresolvedCompositionCount > 0 ||
args.hasAutoStart ||
args.hasScriptedAudio ||
args.hasVariableMedia ||
args.hasInsertedMedia
);
}

export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageResult> {
const {
projectDir,
Expand Down Expand Up @@ -217,13 +248,14 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
);
const hasVariableMedia = hasVariableBoundMedia(compiled.html, job.config.variables);
const hasInsertedMedia = hasRuntimeInsertedMedia(compiled.html);
const needsBrowser =
composition.duration <= 0 ||
compiled.unresolvedCompositions.length > 0 ||
hasAutoStart ||
hasScriptedAudio ||
hasVariableMedia ||
hasInsertedMedia;
const needsBrowser = probeRequiresBrowser({
durationSeconds: composition.duration,
unresolvedCompositionCount: compiled.unresolvedCompositions.length,
hasAutoStart,
hasScriptedAudio,
hasVariableMedia,
hasInsertedMedia,
});

if (needsBrowser) {
const reasons = [];
Expand Down
Loading
Loading