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
@@ -43,6 +43,15 @@ export interface RenderCaptureObservability {
|
||||
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 {
|
||||
|
||||
@@ -223,6 +223,9 @@ describe("executeDiskCaptureWithAdaptiveRetry — transient Target-closed single
|
||||
expect(vi.mocked(executeParallelCapture)).toHaveBeenCalledTimes(2);
|
||||
// Both attempts ran at the same worker count (transient retry doesn't halve).
|
||||
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.stringContaining("Transient browser failure"),
|
||||
expect.objectContaining({ transientRetriesUsed: 1 }),
|
||||
|
||||
@@ -367,7 +367,13 @@ export interface CaptureAttemptSummary {
|
||||
attempt: number;
|
||||
workers: 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 {
|
||||
@@ -665,6 +671,10 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
||||
let missingRanges: FrameRange[] | null = null;
|
||||
let attempt = 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;
|
||||
|
||||
while (true) {
|
||||
@@ -673,8 +683,9 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
||||
attempt,
|
||||
workers: currentWorkers,
|
||||
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 batches = missingRanges
|
||||
@@ -807,6 +818,7 @@ export async function executeDiskCaptureWithAdaptiveRetry(options: {
|
||||
);
|
||||
missingRanges = remaining;
|
||||
attempt++;
|
||||
pendingTransientRetry = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1025,6 +1037,14 @@ export async function executeRenderJob(
|
||||
? "screenshot"
|
||||
: "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
|
||||
// the sampler is created INSIDE the try so a synchronous throw
|
||||
// between declaration and the try-block (currently impossible, but
|
||||
@@ -1538,9 +1558,9 @@ export async function executeRenderJob(
|
||||
maxDurationSeconds: cfg.streamingEncodeMaxDurationSeconds,
|
||||
});
|
||||
|
||||
const captureAttempts: CaptureAttemptSummary[] = [];
|
||||
// Static-dedup perf, appended per sequential session / per parallel worker
|
||||
// by the capture stage, aggregated into the perf summary below.
|
||||
// `captureAttempts` is declared at function scope above (shared with the
|
||||
// catch block). Static-dedup perf, appended per sequential session / per
|
||||
// parallel worker by the capture stage, aggregated into the perf summary below.
|
||||
const dedupPerfs: CapturePerfSummary[] = [];
|
||||
|
||||
// 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 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 });
|
||||
const observabilitySummary = observability.summary({
|
||||
lastBrowserConsole,
|
||||
@@ -1998,6 +2021,14 @@ export async function executeRenderJob(
|
||||
height: captureCompositionHeight,
|
||||
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 carriedBrowserConsole = getCaptureStageBrowserConsole(error);
|
||||
if (carriedBrowserConsole.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user