import { SegmentUploader, type SegmentDurability, type SegmentUploaderConfig, } from "../chunk-uploader/segment-uploader.js"; import type { ChunkDropDetail, ChunkDropReason } from "../chunk-uploader/chunk-uploader.js"; import { AssessmentReplayRecorder, type AssessmentReplayChunk, type AssessmentReplayPhase, type AssessmentReplayRecorderOptions, type ReplayRecordStart, } from "./assessment-replay-recorder.js"; import { encodeSerializedReplayEvents } from "./assessment-replay-codec.js"; import { ReplayChunkStore } from "./replay-chunk-store.js"; import { newId } from "../internal/ids.js"; export type AssessmentReplayEndReason = | "completed" | "navigation" | "pagehide" | "superseded" | "startup-failed" | "capture-failed"; export interface ReplayControllerStore extends SegmentDurability { open(): Promise; close(): Promise; } export interface ReplayControllerUploader { upload(sequence: number, blob: Blob, timingHeaders?: Record): void; drain(timeoutMs?: number): Promise; stop(): void; } export interface ReplayControllerRecorder { start(): boolean; flush(): Promise; stop(): Promise; setPhase(phase: AssessmentReplayPhase): Promise; } type ReplayUploaderOptions = Omit & { durability: ReplayControllerStore; }; type ReplayRecorderOptions = AssessmentReplayRecorderOptions & { onChunk: (chunk: AssessmentReplayChunk) => void; onDropped: (sequence: number, reason: "memory-pressure") => void; onFailure: (error: Error) => void; }; export interface AssessmentReplayControllerOptions { sessionId: string; ingestUrl: string; appId?: string; fingerprintId?: string; runId?: string; initialPhase?: AssessmentReplayPhase; drainTimeoutMs?: number; now?: () => number; loadRecord?: () => Promise; createStore?: () => ReplayControllerStore; createUploader?: (options: ReplayUploaderOptions) => ReplayControllerUploader; createRecorder?: (options: ReplayRecorderOptions) => ReplayControllerRecorder; onStarted?: (startedAt: number) => void; onUploaded?: (sequence: number, byteSize: number) => void; onDropped?: ( sequence: number, reason: "memory-pressure" | ChunkDropReason, detail?: ChunkDropDetail, ) => void; onFailure?: (error: Error) => void; registerRun?: (input: { sessionId: string; runId: string; phase: AssessmentReplayPhase; fingerprintId?: string; startedAt: number; }) => Promise; closeRun?: (input: { sessionId: string; runId: string; initialPhase: AssessmentReplayPhase; startedAt: number; endedAt: number; endReason: AssessmentReplayEndReason; keepalive: boolean; }) => Promise; } /** Owns the always-on DOM replay capture, durable queue, and direct upload. */ export class AssessmentReplayController { private readonly options: Required< Pick< AssessmentReplayControllerOptions, | "sessionId" | "ingestUrl" | "now" | "loadRecord" | "createStore" | "createUploader" | "createRecorder" > > & Pick< AssessmentReplayControllerOptions, "appId" | "fingerprintId" | "onStarted" | "onUploaded" | "onDropped" | "onFailure" >; private store: ReplayControllerStore | null = null; private uploader: ReplayControllerUploader | null = null; private recorder: ReplayControllerRecorder | null = null; private startedAt = 0; private startPromise: Promise | null = null; private stopPromise: Promise | null = null; private captureFailed = false; readonly runId: string; private readonly initialPhase: AssessmentReplayPhase; private readonly registerRun: NonNullable; private readonly closeRun: NonNullable; private readonly drainTimeoutMs: number; constructor(options: AssessmentReplayControllerOptions) { this.runId = options.runId ?? newId("replay-run"); this.initialPhase = options.initialPhase ?? "runtime"; this.drainTimeoutMs = Math.max(250, options.drainTimeoutMs ?? 2_000); this.registerRun = options.registerRun ?? ((input) => this.postRun("start", input)); this.closeRun = options.closeRun ?? ((input) => this.postRun("end", input)); this.options = { ...options, now: options.now ?? Date.now, loadRecord: options.loadRecord ?? loadRrwebRecord, createStore: options.createStore ?? (() => new ReplayChunkStore({ sessionId: options.sessionId })), createUploader: options.createUploader ?? ((config) => new SegmentUploader(config as SegmentUploaderConfig)), createRecorder: options.createRecorder ?? ((config) => new AssessmentReplayRecorder(config)), }; } start(): Promise { this.startPromise ??= this.startInternal(); return this.startPromise; } async flush(): Promise { await this.startPromise?.catch(() => false); await this.recorder?.flush(); } async setPhase(phase: AssessmentReplayPhase): Promise { if (this.recorder) { await this.recorder.setPhase(phase); return; } await this.startPromise?.catch(() => false); const recorder = this.recorder as ReplayControllerRecorder | null; await recorder?.setPhase(phase); } stop( endReason: AssessmentReplayEndReason = "completed", options: { keepalive?: boolean } = {}, ): Promise { this.stopPromise ??= this.stopInternal(endReason, options.keepalive ?? false); return this.stopPromise; } private async startInternal(): Promise { try { this.store = this.options.createStore(); await this.store.open(); const record = await this.options.loadRecord(); this.startedAt = this.options.now(); this.uploader = this.options.createUploader({ sessionId: this.options.sessionId, kind: "replay", ingestUrl: this.options.ingestUrl, appId: this.options.appId, fingerprintId: this.options.fingerprintId, durability: this.store, maxConcurrency: 1, onUploaded: this.options.onUploaded, onDropped: (sequence, reason, detail) => this.options.onDropped?.(sequence, reason, detail), }); this.recorder = this.options.createRecorder({ record, encode: encodeSerializedReplayEvents, onChunk: (chunk) => this.uploadChunk(chunk), onDropped: (sequence, reason) => this.options.onDropped?.(sequence, reason), onFailure: (error) => { this.captureFailed = true; this.options.onFailure?.(error); }, initialPhase: this.initialPhase, }); if (!this.recorder.start()) { throw new Error("assessment replay recorder did not start"); } // Registration is deliberately off the capture critical path. Segment // reservation idempotently creates the same run if this request is // delayed or the browser goes offline immediately after resolution. void this.registerRun({ sessionId: this.options.sessionId, runId: this.runId, phase: this.initialPhase, ...(this.options.fingerprintId ? { fingerprintId: this.options.fingerprintId } : {}), startedAt: this.startedAt, }).catch((error) => this.options.onFailure?.(asError(error))); this.options.onStarted?.(this.startedAt); return true; } catch (error) { const failure = error instanceof Error ? error : new Error(String(error)); this.options.onFailure?.(failure); this.uploader?.stop(); this.uploader = null; this.recorder = null; await this.store?.close().catch(() => undefined); this.store = null; return false; } } private uploadChunk(chunk: AssessmentReplayChunk): void { this.uploader?.upload(chunk.sequence, chunk.blob, { "x-recording-started-at": String(this.startedAt), "x-captured-start-at": String(chunk.capturedStartAt), "x-captured-end-at": String(chunk.capturedEndAt), "x-replay-event-count": String(chunk.eventCount), "x-replay-uncompressed-bytes": String(chunk.uncompressedBytes), "x-replay-run-id": this.runId, "x-replay-phase": chunk.phase, }); } private async stopInternal( endReason: AssessmentReplayEndReason, keepalive: boolean, ): Promise { const startedAt = this.startedAt || this.options.now(); const endedAt = this.options.now(); const closeInput = (durableEndReason: AssessmentReplayEndReason) => ({ sessionId: this.options.sessionId, runId: this.runId, initialPhase: this.initialPhase, startedAt, endedAt, endReason: durableEndReason, keepalive, }); const durableEndReason = () => endReason === "completed" && this.captureFailed ? "capture-failed" : endReason; // During unload, initiate the tiny keepalive control request before any // encoding or upload drain yields. The chunk queue remains independently // durable and can be restored on a later page lifecycle. const earlyClose = keepalive ? this.closeRun(closeInput(durableEndReason())).catch((error) => this.options.onFailure?.(asError(error)), ) : null; await this.startPromise?.catch(() => false); try { await this.recorder?.stop(); await this.uploader ?.drain(this.drainTimeoutMs) .catch((error) => this.options.onFailure?.(asError(error))); } finally { this.uploader?.stop(); this.uploader = null; this.recorder = null; await this.store?.close().catch(() => undefined); this.store = null; if (earlyClose) await earlyClose; else { await this.closeRun(closeInput(durableEndReason())).catch((error) => this.options.onFailure?.(asError(error)), ); } } } private async postRun(action: "start" | "end", input: Record): Promise { const response = await fetch( `${new URL(this.options.ingestUrl).origin}/uploads/replay-runs/${action}`, { method: "POST", headers: { "content-type": "application/json", ...(this.options.appId ? { "x-app-id": this.options.appId } : {}), }, body: JSON.stringify(input), credentials: "omit", keepalive: input.keepalive === true, }, ); if (!response.ok) { throw new Error(`assessment replay run ${action} returned HTTP ${response.status}`); } } } function asError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } export async function loadRrwebRecord(): Promise { ensureRrwebCollectionConstructors(); const rrweb = await import("rrweb"); return rrweb.record as unknown as ReplayRecordStart; } /** Some embedded DOM implementations expose a collection name with an * undefined constructor. rrweb feature-detects with `name in window` and then * reads `.prototype`; fill only that inconsistent edge case. */ function ensureRrwebCollectionConstructors(): void { if (typeof window === "undefined") return; const target = window as unknown as Record; for (const name of ["NodeList", "DOMTokenList"] as const) { if (name in window && target[name] === undefined) { class DomCollectionFallback {} Object.defineProperty(target, name, { configurable: true, value: DomCollectionFallback, }); } } }