85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { redactTelemetryString } from "@hyperframes/core";
|
|
import type { ProducerLogger } from "../../logger.js";
|
|
import { normalizeErrorMessage } from "../../utils/errorMessage.js";
|
|
|
|
export type RenderObservationStatus = "start" | "end" | "error" | "checkpoint";
|
|
export type RenderObservationValue = string | number | boolean | null;
|
|
export type RenderObservationData = Record<string, RenderObservationValue>;
|
|
|
|
export interface RenderObservationEvent {
|
|
renderJobId?: string;
|
|
phase: string;
|
|
status: RenderObservationStatus;
|
|
elapsedMs: number;
|
|
durationMs?: number;
|
|
message?: string;
|
|
data?: RenderObservationData;
|
|
}
|
|
|
|
export interface BrowserDiagnosticSummary {
|
|
total: number;
|
|
/** Generic browser error lines after page/request/navigation/console-specific diagnostics are classified. */
|
|
errors: number;
|
|
pageErrors: number;
|
|
requestFailed: number;
|
|
httpErrors: number;
|
|
navigationStarts: number;
|
|
navigationFailures: number;
|
|
consoleErrors: number;
|
|
consoleWarnings: number;
|
|
}
|
|
|
|
export interface RenderCaptureObservability {
|
|
forceScreenshot: boolean;
|
|
captureMode: "screenshot" | "beginframe";
|
|
captureBeyondViewport?: boolean;
|
|
workerCount?: number;
|
|
useStreamingEncode?: boolean;
|
|
useLayeredComposite?: boolean;
|
|
usePageSideCompositing?: boolean;
|
|
hasHdrContent?: boolean;
|
|
browserGpuMode?: string;
|
|
/**
|
|
* drawElement per-render SELF-VERIFICATION tripped (blank/PSNR) → whole
|
|
* render re-ran via screenshot. NARROWED semantics since the pinned-fallback
|
|
* retry was widened (review): OOM- and generic-capture-error-triggered
|
|
* fallbacks report FALSE here, with `deFallbackReason` ∈ {oom,
|
|
* capture_error}. The "any fallback fired" signal is `deFallbackReason`
|
|
* being set, NOT this flag — dashboards keyed on `de_self_verify_fallback =
|
|
* true` as any-fallback must migrate to `de_fallback_reason IS NOT NULL`.
|
|
*/
|
|
deSelfVerifyFallback?: boolean;
|
|
/**
|
|
* Why the capture-stage retry (self-verify OR the pinned-worker-count
|
|
* fallback) fired: "blank"/"psnr" for a real self-verify trip,
|
|
* "oom"/"capture_error" for the widened generic-failure retry. Set
|
|
* whenever a fallback is attempted, independent of whether that retry
|
|
* itself later succeeds — so a render that fails AFTER a fallback attempt
|
|
* (perfSummary never built) is still distinguishable in failure-path
|
|
* telemetry from one that never attempted any fallback.
|
|
*/
|
|
deFallbackReason?: string;
|
|
/** Auto-parallel inversion outcome: "inverted" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
|
|
deWorkerInversion?: "inverted" | "reverted";
|
|
/** Worker count the resolver would have used absent the inversion; undefined if it never fired. */
|
|
dePreInversionWorkers?: number;
|
|
/** DE parallel-router outcome: "routed" (fired, held) | "reverted" (fired, self-verify retry rolled back). */
|
|
deParallelRouter?: "routed" | "reverted";
|
|
/** Worker count the resolver would have used absent the router; undefined if it never fired. */
|
|
dePreRouterWorkers?: number;
|
|
/**
|
|
* Non-DE parallel-streaming router outcome (HF_CAPTURE_PARALLEL_STREAM):
|
|
* "screenshot" | "beginframe" — the render passed every gate AND the kill
|
|
* switch was on, so it was routed through the interleaved streaming encoder
|
|
* (the value is the capture mode that streamed); "eligible_off" — the render
|
|
* passed every gate EXCEPT the kill switch (passive cohort-sizing signal for
|
|
* the default-off soak: how many renders WOULD route if enabled). Absent =
|
|
* ineligible regardless of the switch.
|
|
*/
|
|
captureParallelStream?: "screenshot" | "beginframe" | "eligible_off";
|
|
protocolTimeoutMs?: number;
|
|
pageNavigationTimeoutMs?: number;
|
|
playerReadyTimeoutMs?: number;
|
|
/**
|
|
* Render-reliability counters (see PostHog dashboard 1783183). Emitted so the
|
|
* capture-hardening in #1842 is measurable from a metric, not just logs:
|
|
* how often the bounded transient-tab-death retry fired on a render that
|
|
* ultimately succeeded, and whether the failure was classified as an
|
|
* out-of-memory exhaustion (`Set maximum size exceeded` and friends).
|
|
*/
|
|
transientRetries?: number;
|
|
memoryExhaustionDetected?: boolean;
|
|
}
|
|
|
|
export interface RenderExtractionObservability {
|
|
videoCount: number;
|
|
extractedVideoCount: number;
|
|
totalFramesExtracted: number;
|
|
maxFramesPerVideo: number;
|
|
avgFramesPerExtractedVideo?: number;
|
|
vfrProbeMs?: number;
|
|
vfrPreflightMs?: number;
|
|
vfrPreflightCount?: number;
|
|
cacheHits?: number;
|
|
cacheMisses?: number;
|
|
}
|
|
|
|
export interface RenderInitObservability {
|
|
initDurationMs?: number;
|
|
tweenCount?: number;
|
|
}
|
|
|
|
export interface RenderObservabilitySummary {
|
|
renderJobId?: string;
|
|
compositionHash?: string;
|
|
events: RenderObservationEvent[];
|
|
eventCount: number;
|
|
lastEvent?: RenderObservationEvent;
|
|
failedPhase?: string;
|
|
browserDiagnostics: BrowserDiagnosticSummary;
|
|
capture: RenderCaptureObservability;
|
|
extraction?: RenderExtractionObservability;
|
|
init?: RenderInitObservability;
|
|
}
|
|
|
|
const MAX_EVENTS = 160;
|
|
/** Allow-list of non-sensitive string fields accepted into structured render trace data. */
|
|
const ALLOWED_STRING_DATA_KEYS = new Set([
|
|
"browserGpuMode",
|
|
"captureMode",
|
|
"captureOperation",
|
|
"compositionHash",
|
|
"effectiveHdr",
|
|
"format",
|
|
"quality",
|
|
"renderJobId",
|
|
"requestedHdrMode",
|
|
"requestedWorkers",
|
|
]);
|
|
const RESERVED_LOG_KEYS = new Set([
|
|
"data",
|
|
"durationMs",
|
|
"elapsedMs",
|
|
"message",
|
|
"phase",
|
|
"renderJobId",
|
|
"status",
|
|
]);
|
|
|
|
export function sanitizeObservationMessage(value: string): string {
|
|
return redactTelemetryString(value);
|
|
}
|
|
|
|
export function computeCompositionObservabilityHash(compiledHtml: string): string {
|
|
return createHash("sha256").update(compiledHtml, "utf8").digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function sanitizeObservationData(
|
|
data: RenderObservationData | undefined,
|
|
): RenderObservationData | undefined {
|
|
if (!data) return undefined;
|
|
const sanitized: RenderObservationData = {};
|
|
for (const [key, value] of Object.entries(data)) {
|
|
if (RESERVED_LOG_KEYS.has(key)) continue;
|
|
if (typeof value === "string") {
|
|
if (!ALLOWED_STRING_DATA_KEYS.has(key)) continue;
|
|
sanitized[key] = sanitizeObservationMessage(value);
|
|
} else {
|
|
sanitized[key] = value;
|
|
}
|
|
}
|
|
return sanitized;
|
|
}
|
|
|
|
function isHttpErrorDiagnostic(line: string): boolean {
|
|
return /\[Browser:HTTP\d{3}\]/.test(line);
|
|
}
|
|
|
|
function readUnsignedIntAfter(line: string, prefix: string): number | undefined {
|
|
const start = line.indexOf(prefix);
|
|
if (start < 0) return undefined;
|
|
let value = 0;
|
|
let digits = 0;
|
|
for (let i = start + prefix.length; i < line.length; i++) {
|
|
const code = line.charCodeAt(i);
|
|
if (code < 48 || code > 57) break;
|
|
value = value * 10 + code - 48;
|
|
digits += 1;
|
|
if (value > Number.MAX_SAFE_INTEGER) return undefined;
|
|
}
|
|
return digits > 0 ? value : undefined;
|
|
}
|
|
|
|
function summarizeInitObservability(lines: string[]): RenderInitObservability | undefined {
|
|
let initDurationMs: number | undefined;
|
|
let tweenCount: number | undefined;
|
|
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);
|
|
}
|
|
}
|
|
if (initDurationMs === undefined && tweenCount === undefined) return undefined;
|
|
return { initDurationMs, tweenCount };
|
|
}
|
|
|
|
// fallow-ignore-next-line complexity
|
|
export function summarizeBrowserDiagnostics(lines: string[]): BrowserDiagnosticSummary {
|
|
let errors = 0;
|
|
let pageErrors = 0;
|
|
let requestFailed = 0;
|
|
let httpErrors = 0;
|
|
let navigationStarts = 0;
|
|
let navigationFailures = 0;
|
|
let consoleErrors = 0;
|
|
let consoleWarnings = 0;
|
|
|
|
for (const line of lines) {
|
|
const isPageError = line.includes("PAGEERROR");
|
|
const isRequestFailed = line.includes("REQUESTFAILED");
|
|
const isHttpError = isHttpErrorDiagnostic(line);
|
|
const isNavigationFailure = line.includes("[FrameCapture:ERROR] page.goto failed");
|
|
const isConsoleError = line.includes("[error]");
|
|
|
|
if (isPageError) pageErrors++;
|
|
if (isRequestFailed) requestFailed++;
|
|
if (isHttpError) httpErrors++;
|
|
if (line.includes("[FrameCapture:NAV] page.goto start")) navigationStarts++;
|
|
if (isNavigationFailure) navigationFailures++;
|
|
if (isConsoleError) consoleErrors++;
|
|
if (line.includes("[warn]")) consoleWarnings++;
|
|
if (
|
|
line.includes("ERROR") &&
|
|
!isPageError &&
|
|
!isRequestFailed &&
|
|
!isHttpError &&
|
|
!isNavigationFailure &&
|
|
!isConsoleError
|
|
) {
|
|
errors++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: lines.length,
|
|
errors,
|
|
pageErrors,
|
|
requestFailed,
|
|
httpErrors,
|
|
navigationStarts,
|
|
navigationFailures,
|
|
consoleErrors,
|
|
consoleWarnings,
|
|
};
|
|
}
|
|
|
|
export class RenderObservabilityRecorder {
|
|
private readonly events: RenderObservationEvent[] = [];
|
|
private eventCount = 0;
|
|
private failedPhase: string | undefined;
|
|
|
|
constructor(
|
|
private readonly input: {
|
|
pipelineStartMs: number;
|
|
log: ProducerLogger;
|
|
renderJobId?: string;
|
|
},
|
|
) {}
|
|
|
|
checkpoint(phase: string, message: string, data?: RenderObservationData): RenderObservationEvent {
|
|
return this.record({
|
|
phase,
|
|
status: "checkpoint",
|
|
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
|
message: sanitizeObservationMessage(message),
|
|
data: sanitizeObservationData(data),
|
|
});
|
|
}
|
|
|
|
stageStart(phase: string, data?: RenderObservationData): number {
|
|
this.record({
|
|
phase,
|
|
status: "start",
|
|
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
|
data: sanitizeObservationData(data),
|
|
});
|
|
return Date.now();
|
|
}
|
|
|
|
stageEnd(phase: string, startedAtMs: number, data?: RenderObservationData): void {
|
|
this.record({
|
|
phase,
|
|
status: "end",
|
|
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
|
durationMs: Date.now() - startedAtMs,
|
|
data: sanitizeObservationData(data),
|
|
});
|
|
}
|
|
|
|
stageError(
|
|
phase: string,
|
|
startedAtMs: number,
|
|
error: unknown,
|
|
data?: RenderObservationData,
|
|
): void {
|
|
this.failedPhase = phase;
|
|
this.record({
|
|
phase,
|
|
status: "error",
|
|
elapsedMs: Date.now() - this.input.pipelineStartMs,
|
|
durationMs: Date.now() - startedAtMs,
|
|
message: sanitizeObservationMessage(normalizeErrorMessage(error)),
|
|
data: sanitizeObservationData(data),
|
|
});
|
|
}
|
|
|
|
summary(input: {
|
|
lastBrowserConsole: string[];
|
|
capture: RenderCaptureObservability;
|
|
extraction?: RenderExtractionObservability;
|
|
compositionHash?: string;
|
|
}): RenderObservabilitySummary {
|
|
const lastEvent = this.events[this.events.length - 1];
|
|
return {
|
|
renderJobId: this.input.renderJobId,
|
|
compositionHash: input.compositionHash,
|
|
events: this.events.slice(),
|
|
eventCount: this.eventCount,
|
|
lastEvent,
|
|
failedPhase: this.failedPhase,
|
|
browserDiagnostics: summarizeBrowserDiagnostics(input.lastBrowserConsole),
|
|
capture: { ...input.capture },
|
|
extraction: input.extraction ? { ...input.extraction } : undefined,
|
|
init: summarizeInitObservability(input.lastBrowserConsole),
|
|
};
|
|
}
|
|
|
|
hasFailure(): boolean {
|
|
return this.failedPhase !== undefined;
|
|
}
|
|
|
|
/** A phase failure that was subsequently recovered (e.g. the drawElement
|
|
* self-verify fallback re-rendering via screenshot) should not brand the
|
|
* whole render as failed in the summary. */
|
|
clearFailure(phase: string): void {
|
|
if (this.failedPhase === phase) this.failedPhase = undefined;
|
|
}
|
|
|
|
private record(event: RenderObservationEvent): RenderObservationEvent {
|
|
this.eventCount++;
|
|
const eventWithJob = { ...event, renderJobId: this.input.renderJobId };
|
|
this.events.push(eventWithJob);
|
|
if (this.events.length > MAX_EVENTS) {
|
|
this.events.shift();
|
|
}
|
|
|
|
this.input.log.info("[Render:trace]", {
|
|
renderJobId: eventWithJob.renderJobId,
|
|
phase: eventWithJob.phase,
|
|
status: eventWithJob.status,
|
|
elapsedMs: eventWithJob.elapsedMs,
|
|
durationMs: eventWithJob.durationMs,
|
|
message: eventWithJob.message,
|
|
...eventWithJob.data,
|
|
});
|
|
|
|
return eventWithJob;
|
|
}
|
|
}
|
|
|
|
/** Heartbeat ramp before falling back to a steady repeat cadence. */
|
|
const HEARTBEAT_RAMP_MS = [30_000, 60_000, 120_000];
|
|
const HEARTBEAT_REPEAT_MS = 120_000;
|
|
const HEARTBEAT_RAMP_END_MS =
|
|
HEARTBEAT_RAMP_MS[HEARTBEAT_RAMP_MS.length - 1] ?? HEARTBEAT_REPEAT_MS;
|
|
|
|
/** Target elapsed-ms for the Nth heartbeat (0-indexed): ramp, then steady repeat so long stalls keep emitting breadcrumbs instead of going dark after the ramp. */
|
|
function heartbeatTargetMs(index: number): number {
|
|
const rampTarget = HEARTBEAT_RAMP_MS[index];
|
|
if (rampTarget !== undefined) return rampTarget;
|
|
const overflow = index - HEARTBEAT_RAMP_MS.length + 1;
|
|
return HEARTBEAT_RAMP_END_MS + overflow * HEARTBEAT_REPEAT_MS;
|
|
}
|
|
|
|
export async function observeRenderStage<T>(
|
|
recorder: RenderObservabilityRecorder,
|
|
phase: string,
|
|
data: RenderObservationData | undefined,
|
|
fn: () => Promise<T>,
|
|
): Promise<T> {
|
|
const startedAt = recorder.stageStart(phase, data);
|
|
let heartbeatCount = 0;
|
|
let lastFiredAtMs = 0;
|
|
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
const scheduleNextHeartbeat = () => {
|
|
const targetMs = heartbeatTargetMs(heartbeatCount);
|
|
heartbeatTimer = setTimeout(() => {
|
|
lastFiredAtMs = targetMs;
|
|
heartbeatCount += 1;
|
|
recorder.checkpoint(phase, "stage still running", {
|
|
...data,
|
|
heartbeatIndex: heartbeatCount,
|
|
stageElapsedMs: Date.now() - startedAt,
|
|
});
|
|
scheduleNextHeartbeat();
|
|
}, targetMs - lastFiredAtMs);
|
|
heartbeatTimer.unref?.();
|
|
};
|
|
scheduleNextHeartbeat();
|
|
const clearHeartbeats = () => {
|
|
clearTimeout(heartbeatTimer);
|
|
};
|
|
try {
|
|
const result = await fn();
|
|
clearHeartbeats();
|
|
recorder.stageEnd(phase, startedAt, data);
|
|
return result;
|
|
} catch (error) {
|
|
clearHeartbeats();
|
|
recorder.stageError(phase, startedAt, error, data);
|
|
throw error;
|
|
}
|
|
}
|