mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(producer,cli): render-reliability telemetry counters for capture hardening (#1850)
Follow-up to the render-reliability batch (#1841/#1842/#1843). Threads two capture-reliability counters through the existing observability → CLI-telemetry pipeline (no new PostHog wiring) so #1842's hardening is measurable on dashboard 1783183: - transient-retry burn (CaptureAttemptSummary.reason gains "transient-retry"; counted into RenderCaptureObservability.transientRetries on BOTH the recovered and the still-failed paths via a shared helper). - OOM classification (memoryExhaustionDetected set when describeMemoryExhaustion classifies the failure). Surfaced as capture_transient_retries + capture_memory_exhaustion_detected render-event props. Tests cover the attempt tagging and the payload mapping. Further follow-up (different subsystems): encoder-frame-0-exit signal, and P1-3 pre-flight-rejection / P1-4 cli_env_check counters. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2186d3b72e
commit
733f88cb1f
@@ -28,6 +28,8 @@ export interface RenderObservabilityTelemetryPayload {
|
|||||||
captureProtocolTimeoutMs?: number;
|
captureProtocolTimeoutMs?: number;
|
||||||
capturePageNavigationTimeoutMs?: number;
|
capturePageNavigationTimeoutMs?: number;
|
||||||
capturePlayerReadyTimeoutMs?: number;
|
capturePlayerReadyTimeoutMs?: number;
|
||||||
|
captureTransientRetries?: number;
|
||||||
|
captureMemoryExhaustionDetected?: boolean;
|
||||||
observabilityExtractVideoCount?: number;
|
observabilityExtractVideoCount?: number;
|
||||||
observabilityExtractedVideoCount?: number;
|
observabilityExtractedVideoCount?: number;
|
||||||
observabilityExtractTotalFrames?: number;
|
observabilityExtractTotalFrames?: number;
|
||||||
@@ -70,6 +72,8 @@ function renderObservabilityEventProperties(props: RenderObservabilityTelemetryP
|
|||||||
capture_protocol_timeout_ms: props.captureProtocolTimeoutMs,
|
capture_protocol_timeout_ms: props.captureProtocolTimeoutMs,
|
||||||
capture_page_navigation_timeout_ms: props.capturePageNavigationTimeoutMs,
|
capture_page_navigation_timeout_ms: props.capturePageNavigationTimeoutMs,
|
||||||
capture_player_ready_timeout_ms: props.capturePlayerReadyTimeoutMs,
|
capture_player_ready_timeout_ms: props.capturePlayerReadyTimeoutMs,
|
||||||
|
capture_transient_retries: props.captureTransientRetries,
|
||||||
|
capture_memory_exhaustion_detected: props.captureMemoryExhaustionDetected,
|
||||||
observability_extract_video_count: props.observabilityExtractVideoCount,
|
observability_extract_video_count: props.observabilityExtractVideoCount,
|
||||||
observability_extracted_video_count: props.observabilityExtractedVideoCount,
|
observability_extracted_video_count: props.observabilityExtractedVideoCount,
|
||||||
observability_extract_total_frames: props.observabilityExtractTotalFrames,
|
observability_extract_total_frames: props.observabilityExtractTotalFrames,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { RenderObservabilitySummary } from "@hyperframes/producer";
|
||||||
|
import { renderObservabilityTelemetryPayload } from "./renderObservability.js";
|
||||||
|
|
||||||
|
function makeSummary(
|
||||||
|
capture: Partial<RenderObservabilitySummary["capture"]>,
|
||||||
|
): RenderObservabilitySummary {
|
||||||
|
return {
|
||||||
|
events: [],
|
||||||
|
eventCount: 0,
|
||||||
|
browserDiagnostics: {
|
||||||
|
total: 0,
|
||||||
|
errors: 0,
|
||||||
|
pageErrors: 0,
|
||||||
|
requestFailed: 0,
|
||||||
|
httpErrors: 0,
|
||||||
|
navigationStarts: 0,
|
||||||
|
navigationFailures: 0,
|
||||||
|
consoleErrors: 0,
|
||||||
|
consoleWarnings: 0,
|
||||||
|
},
|
||||||
|
capture: { forceScreenshot: false, captureMode: "beginframe", ...capture },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("renderObservabilityTelemetryPayload — render-reliability counters", () => {
|
||||||
|
it("maps the transient-retry and OOM counters through to the telemetry payload", () => {
|
||||||
|
const payload = renderObservabilityTelemetryPayload(
|
||||||
|
makeSummary({ transientRetries: 2, memoryExhaustionDetected: true }),
|
||||||
|
);
|
||||||
|
expect(payload.captureTransientRetries).toBe(2);
|
||||||
|
expect(payload.captureMemoryExhaustionDetected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the counters undefined when the render didn't retry or OOM", () => {
|
||||||
|
const payload = renderObservabilityTelemetryPayload(makeSummary({}));
|
||||||
|
expect(payload.captureTransientRetries).toBeUndefined();
|
||||||
|
expect(payload.captureMemoryExhaustionDetected).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,8 @@ export function renderObservabilityTelemetryPayload(
|
|||||||
captureProtocolTimeoutMs: capture.protocolTimeoutMs,
|
captureProtocolTimeoutMs: capture.protocolTimeoutMs,
|
||||||
capturePageNavigationTimeoutMs: capture.pageNavigationTimeoutMs,
|
capturePageNavigationTimeoutMs: capture.pageNavigationTimeoutMs,
|
||||||
capturePlayerReadyTimeoutMs: capture.playerReadyTimeoutMs,
|
capturePlayerReadyTimeoutMs: capture.playerReadyTimeoutMs,
|
||||||
|
captureTransientRetries: capture.transientRetries,
|
||||||
|
captureMemoryExhaustionDetected: capture.memoryExhaustionDetected,
|
||||||
observabilityExtractVideoCount: extraction?.videoCount,
|
observabilityExtractVideoCount: extraction?.videoCount,
|
||||||
observabilityExtractedVideoCount: extraction?.extractedVideoCount,
|
observabilityExtractedVideoCount: extraction?.extractedVideoCount,
|
||||||
observabilityExtractTotalFrames: extraction?.totalFramesExtracted,
|
observabilityExtractTotalFrames: extraction?.totalFramesExtracted,
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ export interface RenderCaptureObservability {
|
|||||||
protocolTimeoutMs?: number;
|
protocolTimeoutMs?: number;
|
||||||
pageNavigationTimeoutMs?: number;
|
pageNavigationTimeoutMs?: number;
|
||||||
playerReadyTimeoutMs?: 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 {
|
export interface RenderExtractionObservability {
|
||||||
|
|||||||
@@ -223,6 +223,9 @@ describe("executeDiskCaptureWithAdaptiveRetry — transient Target-closed single
|
|||||||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(2);
|
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(2);
|
||||||
// Both attempts ran at the same worker count (transient retry doesn't halve).
|
// Both attempts ran at the same worker count (transient retry doesn't halve).
|
||||||
expect(attempts.map((a) => a.workers)).toEqual([1, 1]);
|
expect(attempts.map((a) => a.workers)).toEqual([1, 1]);
|
||||||
|
// The retry attempt is tagged `transient-retry` (vs the worker-halving
|
||||||
|
// `retry`) so it's countable for telemetry (dashboard 1783183).
|
||||||
|
expect(attempts.map((a) => a.reason)).toEqual(["initial", "transient-retry"]);
|
||||||
expect(log.warn).toHaveBeenCalledWith(
|
expect(log.warn).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("Transient browser failure"),
|
expect.stringContaining("Transient browser failure"),
|
||||||
expect.objectContaining({ transientRetriesUsed: 1 }),
|
expect.objectContaining({ transientRetriesUsed: 1 }),
|
||||||
|
|||||||
@@ -367,7 +367,13 @@ export interface CaptureAttemptSummary {
|
|||||||
attempt: number;
|
attempt: number;
|
||||||
workers: number;
|
workers: number;
|
||||||
frameCount: number;
|
frameCount: number;
|
||||||
reason: "initial" | "retry";
|
/**
|
||||||
|
* `"transient-retry"` is a same-worker-count retry after a transient browser
|
||||||
|
* death (Target closed / tab crash); `"retry"` is the worker-halving retry
|
||||||
|
* after a recoverable timeout. Distinguished so transient-retry burn is
|
||||||
|
* countable for telemetry (dashboard 1783183).
|
||||||
|
*/
|
||||||
|
reason: "initial" | "retry" | "transient-retry";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderJob {
|
export interface RenderJob {
|
||||||
@@ -665,6 +671,10 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
|||||||
let missingRanges: FrameRange[] | null = null;
|
let missingRanges: FrameRange[] | null = null;
|
||||||
let attempt = 0;
|
let attempt = 0;
|
||||||
let transientRetriesUsed = 0;
|
let transientRetriesUsed = 0;
|
||||||
|
// Set when the *previous* iteration retried after a transient browser death,
|
||||||
|
// so the attempt it spawns is tagged `"transient-retry"` (vs the worker-halving
|
||||||
|
// `"retry"`) for telemetry. Reset after each attempt is recorded.
|
||||||
|
let pendingTransientRetry = false;
|
||||||
const rangeStart = options.frameRangeStart ?? 0;
|
const rangeStart = options.frameRangeStart ?? 0;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -673,8 +683,9 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
|||||||
attempt,
|
attempt,
|
||||||
workers: currentWorkers,
|
workers: currentWorkers,
|
||||||
frameCount,
|
frameCount,
|
||||||
reason: attempt === 0 ? "initial" : "retry",
|
reason: attempt === 0 ? "initial" : pendingTransientRetry ? "transient-retry" : "retry",
|
||||||
});
|
});
|
||||||
|
pendingTransientRetry = false;
|
||||||
|
|
||||||
const attemptWorkDir = join(options.workDir, `capture-attempt-${attempt}`);
|
const attemptWorkDir = join(options.workDir, `capture-attempt-${attempt}`);
|
||||||
const batches = missingRanges
|
const batches = missingRanges
|
||||||
@@ -807,6 +818,7 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
|||||||
);
|
);
|
||||||
missingRanges = remaining;
|
missingRanges = remaining;
|
||||||
attempt++;
|
attempt++;
|
||||||
|
pendingTransientRetry = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,6 +1037,14 @@ export async function executeRenderJob(
|
|||||||
? "screenshot"
|
? "screenshot"
|
||||||
: "beginframe";
|
: "beginframe";
|
||||||
};
|
};
|
||||||
|
// Function-scoped (not inside the try) so both the success path AND the catch
|
||||||
|
// can read it — the catch records transient-retry burn on renders that still
|
||||||
|
// failed, which is the more actionable signal for tuning the retry cap.
|
||||||
|
const captureAttempts: CaptureAttemptSummary[] = [];
|
||||||
|
const recordTransientRetryObservability = (): void => {
|
||||||
|
const count = captureAttempts.filter((a) => a.reason === "transient-retry").length;
|
||||||
|
if (count > 0) updateCaptureObservability({ transientRetries: count });
|
||||||
|
};
|
||||||
// Declared outside the try so `finally` can stop the interval, but
|
// Declared outside the try so `finally` can stop the interval, but
|
||||||
// the sampler is created INSIDE the try so a synchronous throw
|
// the sampler is created INSIDE the try so a synchronous throw
|
||||||
// between declaration and the try-block (currently impossible, but
|
// between declaration and the try-block (currently impossible, but
|
||||||
@@ -1538,9 +1558,9 @@ export async function executeRenderJob(
|
|||||||
maxDurationSeconds: cfg.streamingEncodeMaxDurationSeconds,
|
maxDurationSeconds: cfg.streamingEncodeMaxDurationSeconds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const captureAttempts: CaptureAttemptSummary[] = [];
|
// `captureAttempts` is declared at function scope above (shared with the
|
||||||
// Static-dedup perf, appended per sequential session / per parallel worker
|
// catch block). Static-dedup perf, appended per sequential session / per
|
||||||
// by the capture stage, aggregated into the perf summary below.
|
// parallel worker by the capture stage, aggregated into the perf summary below.
|
||||||
const dedupPerfs: CapturePerfSummary[] = [];
|
const dedupPerfs: CapturePerfSummary[] = [];
|
||||||
|
|
||||||
// png-sequence is "no container" — outputPath is treated as a directory and
|
// png-sequence is "no container" — outputPath is treated as a directory and
|
||||||
@@ -1909,6 +1929,9 @@ export async function executeRenderJob(
|
|||||||
const totalElapsed = Date.now() - pipelineStart;
|
const totalElapsed = Date.now() - pipelineStart;
|
||||||
|
|
||||||
const tmpPeakBytes = existsSync(workDir) ? sampleDirectoryBytes(workDir) : 0;
|
const tmpPeakBytes = existsSync(workDir) ? sampleDirectoryBytes(workDir) : 0;
|
||||||
|
// Record transient-tab-death retry burn (recovered case) so it's visible on
|
||||||
|
// dashboard 1783183, not just logs. The catch mirrors this for the failed case.
|
||||||
|
recordTransientRetryObservability();
|
||||||
observability.checkpoint("pipeline", "completed", { totalElapsedMs: totalElapsed });
|
observability.checkpoint("pipeline", "completed", { totalElapsedMs: totalElapsed });
|
||||||
const observabilitySummary = observability.summary({
|
const observabilitySummary = observability.summary({
|
||||||
lastBrowserConsole,
|
lastBrowserConsole,
|
||||||
@@ -1998,6 +2021,14 @@ export async function executeRenderJob(
|
|||||||
height: captureCompositionHeight,
|
height: captureCompositionHeight,
|
||||||
totalFrames: captureTotalFrames,
|
totalFrames: captureTotalFrames,
|
||||||
});
|
});
|
||||||
|
// Flag OOM-classified failures so the "is OOM the dominant tail?" question is
|
||||||
|
// answerable from a metric (dashboard 1783183), not just the error string.
|
||||||
|
if (memoryGuidance) {
|
||||||
|
updateCaptureObservability({ memoryExhaustionDetected: true });
|
||||||
|
}
|
||||||
|
// Retry burn on a render that STILL failed — the actionable signal for tuning
|
||||||
|
// MAX_TRANSIENT_CAPTURE_RETRIES (mirrors the success-path record above).
|
||||||
|
recordTransientRetryObservability();
|
||||||
const errorMessage = memoryGuidance ?? normalizeErrorMessage(error);
|
const errorMessage = memoryGuidance ?? normalizeErrorMessage(error);
|
||||||
const carriedBrowserConsole = getCaptureStageBrowserConsole(error);
|
const carriedBrowserConsole = getCaptureStageBrowserConsole(error);
|
||||||
if (carriedBrowserConsole.length > 0) {
|
if (carriedBrowserConsole.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user