Skip to content
47 changes: 45 additions & 2 deletions sdks/capture/src/debugger/debugger-collector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { PAGE_BRIDGE_SOURCE } from "@crikket/capture-core/debugger/constants"
import {
clearPersistedSession,
loadPersistedSession,
persistSession,
} from "./session-storage"
import {
appendActionEventWithDedup,
appendEventWithRetentionPolicy,
Expand All @@ -20,6 +25,12 @@ export class DebuggerCollector {
private recentEvents: DebuggerEvent[] = []
private session: DebuggerSession | null = null

private readonly handlePageHide = (): void => {
if (this.session) {
persistSession(this.session)
}
}

private readonly handleWindowMessage = (event: MessageEvent<unknown>) => {
if (event.source !== window) {
return
Expand Down Expand Up @@ -63,6 +74,19 @@ export class DebuggerCollector {

installDebuggerPageRuntime()
window.addEventListener("message", this.handleWindowMessage)

const restored = loadPersistedSession()
if (restored) {
this.session = {
sessionId: restored.sessionId,
captureType: restored.captureType,
startedAt: restored.startedAt,
recordingStartedAt: restored.recordingStartedAt,
events: [...restored.events],
}
}

window.addEventListener("pagehide", this.handlePageHide, { capture: true })
this.installed = true
}

Expand All @@ -72,17 +96,27 @@ export class DebuggerCollector {
}

window.removeEventListener("message", this.handleWindowMessage)
window.removeEventListener("pagehide", this.handlePageHide, { capture: true })
this.installed = false
}

startSession(captureType: CaptureType, lookbackMs = 0): DebuggerSession {
const now = Date.now()

// If a session was restored from a previous page, carry its events forward
// so the cross-page trace is preserved in the new recording segment.
const priorSession = this.session
const inheritedEvents: DebuggerEvent[] = priorSession
? [...priorSession.events]
: []
const sessionStartedAt = priorSession?.startedAt ?? now

const nextSession: DebuggerSession = {
sessionId: createSessionId(),
captureType,
startedAt: now,
startedAt: sessionStartedAt,
recordingStartedAt: captureType === "screenshot" ? now : null,
events: [],
events: inheritedEvents,
}

if (lookbackMs > 0) {
Expand All @@ -107,6 +141,15 @@ export class DebuggerCollector {

clearSession(): void {
this.session = null
clearPersistedSession()
}

hasActiveSession(): boolean {
return this.session !== null
}

getSessionStartedAt(): number | null {
return this.session?.startedAt ?? null
}

finalizeSession(): ReviewSnapshot {
Expand Down
8 changes: 8 additions & 0 deletions sdks/capture/src/debugger/lazy-debugger-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ interface DebuggerCollectorInstance {
finalizeSession: () => ReviewSnapshot
markRecordingStarted: (recordingStartedAt: number) => void
startSession: (captureType: CaptureType, lookbackMs?: number) => void
hasActiveSession: () => boolean
getSessionStartedAt: () => number | null
}

export class LazyDebuggerCollector {
Expand Down Expand Up @@ -35,6 +37,12 @@ export class LazyDebuggerCollector {
this.collector?.clearSession()
}

async ensureSessionRestored(): Promise<number | null> {
const collector = await this.ensureCollector()
// ensureCollector() calls collector.install(), which restores the session from storage
return collector.getSessionStartedAt()
}

dispose(): void {
this.collectorPromise = null
this.collector?.dispose()
Expand Down
120 changes: 120 additions & 0 deletions sdks/capture/src/debugger/session-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { normalizeDebuggerEvent } from "@crikket/capture-core/debugger/normalize"
import type { DebuggerEvent } from "@crikket/capture-core/debugger/types"
import type { DebuggerSession } from "../types"

const STORAGE_KEY = "__crikketActiveSession"
const SESSION_VERSION = 1
const MAX_SESSION_AGE_MS = 5 * 60 * 1000

interface PersistedSession {
version: typeof SESSION_VERSION
sessionId: string
captureType: "video" | "screenshot"
startedAt: number
recordingStartedAt: number | null
events: DebuggerEvent[]
savedAt: number
}

export interface RestoredSession {
sessionId: string
captureType: "video" | "screenshot"
startedAt: number
recordingStartedAt: number | null
events: DebuggerEvent[]
}

export function persistSession(session: DebuggerSession): void {
try {
const persisted: PersistedSession = {
version: SESSION_VERSION,
sessionId: session.sessionId,
captureType: session.captureType,
startedAt: session.startedAt,
recordingStartedAt: session.recordingStartedAt,
events: session.events,
savedAt: Date.now(),
}
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(persisted))
} catch {
}
}

export function loadPersistedSession(): RestoredSession | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (raw === null) return null

let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
clearPersistedSession()
return null
}

if (
typeof parsed !== "object" ||
parsed === null ||
Array.isArray(parsed)
) {
clearPersistedSession()
return null
}

const record = parsed as Record<string, unknown>

if (record.version !== SESSION_VERSION) {
clearPersistedSession()
return null
}

const savedAt = record.savedAt
if (
typeof savedAt !== "number" ||
!Number.isFinite(savedAt) ||
Date.now() - savedAt > MAX_SESSION_AGE_MS
) {
clearPersistedSession()
return null
}

const { sessionId, captureType, startedAt, recordingStartedAt, events } =
record

if (
typeof sessionId !== "string" ||
!sessionId ||
(captureType !== "video" && captureType !== "screenshot") ||
typeof startedAt !== "number" ||
!Number.isFinite(startedAt)
) {
clearPersistedSession()
return null
}

const normalizedEvents: DebuggerEvent[] = Array.isArray(events)
? events
.map((e) => normalizeDebuggerEvent(e))
.filter((e): e is DebuggerEvent => e !== null)
: []

return {
sessionId,
captureType,
startedAt,
recordingStartedAt:
typeof recordingStartedAt === "number" ? recordingStartedAt : null,
events: normalizedEvents,
}
} catch {
return null
}
}

export function clearPersistedSession(): void {
try {
sessionStorage.removeItem(STORAGE_KEY)
} catch {
}
}
29 changes: 26 additions & 3 deletions sdks/capture/src/runtime/capture-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { LazyDebuggerCollector } from "../debugger/lazy-debugger-collector"
import { loadPersistedSession } from "../debugger/session-storage"
import {
captureScreenshot,
startDisplayRecording,
Expand Down Expand Up @@ -47,6 +48,10 @@ export class CaptureSdkRuntime implements CaptureRuntimeController {
this.mount(options.mountTarget)
}

if (loadPersistedSession()) {
this.resumePersistedSession()
}

return this
}

Expand Down Expand Up @@ -81,9 +86,16 @@ export class CaptureSdkRuntime implements CaptureRuntimeController {
}
},
onStopRecording: async () => {
const blob = await this.stopRecording()
if (!blob) {
throw new Error("Recording capture failed.")
if (this.activeRecording) {
const blob = await this.stopRecording()
if (!blob) {
throw new Error("Recording capture failed.")
}
} else {
const blob = await this.takeScreenshot()
if (!blob) {
throw new Error("Screenshot capture failed.")
}
}
},
onSubmit: (draft, options) => {
Expand Down Expand Up @@ -322,6 +334,17 @@ export class CaptureSdkRuntime implements CaptureRuntimeController {
this.mountedUi?.store.setTitleIfEmpty(captureTitle)
}

private resumePersistedSession(): void {
this.debuggerCollector
.ensureSessionRestored()
.then((startedAt) => {
if (startedAt !== null) {
this.mountedUi?.store.showRecording(startedAt)
}
})
.catch(() => undefined)
}

private getRuntimeConfig(): CaptureRuntimeConfig {
if (!this.runtimeConfig) {
throw new Error(
Expand Down
85 changes: 85 additions & 0 deletions sdks/capture/test/cross-page-recording.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from "bun:test"

import {
createSubmitTransport,
getCaptureSdk,
sdkTestState,
setupCaptureSdkTestHooks,
waitFor,
} from "./lib/sdk-test-harness"

setupCaptureSdkTestHooks()

describe("cross-page recording resume", () => {
it("does not start a new session or show the chooser when init detects no persisted session", async () => {
const capture = getCaptureSdk()
// restoredSessionStartedAt is null by default

capture.init({ key: "crk_no_session", host: "https://api.crikket.io" })
await new Promise((r) => setTimeout(r, 20))

expect(sdkTestState.startSessionCalls).toHaveLength(0)
expect(sdkTestState.uiOpenChooserCalls).toBe(0)
})

it("does not start a new session when init detects a persisted session", async () => {
const capture = getCaptureSdk()
sdkTestState.restoredSessionStartedAt = 1_700_000_000_000

capture.init({ key: "crk_resumed", host: "https://api.crikket.io" })
await new Promise((r) => setTimeout(r, 20))

// The resume path should NOT call startSession — that would be a fresh session
expect(sdkTestState.startSessionCalls).toHaveLength(0)
// And it should NOT open the chooser — the dock shows instead
expect(sdkTestState.uiOpenChooserCalls).toBe(0)
})

it("completes a screenshot capture when stop is called without an active recording (cross-page fallback)", async () => {
const capture = getCaptureSdk()
sdkTestState.restoredSessionStartedAt = 1_700_000_000_000

capture.init({
key: "crk_cross_page_stop",
host: "https://api.crikket.io",
submitTransport: createSubmitTransport(),
})
await new Promise((r) => setTimeout(r, 20))

// Take a screenshot via the public API (simulates stop with no active recording)
const blob = await capture.takeScreenshot()
expect(blob).toBe(sdkTestState.screenshotBlob)

// A session was started (for the screenshot) and finalized
expect(sdkTestState.startSessionCalls).toHaveLength(1)
expect(sdkTestState.startSessionCalls[0]).toMatchObject({ captureType: "screenshot" })
expect(sdkTestState.finalizeSessionCalls).toBe(1)
expect(sdkTestState.uiShowReviewInputs).toHaveLength(1)
expect(sdkTestState.uiShowReviewInputs[0].media.captureType).toBe("screenshot")
})

it("completes a full recording flow after resuming a persisted session", async () => {
const capture = getCaptureSdk()
sdkTestState.restoredSessionStartedAt = 1_700_000_000_000

capture.init({
key: "crk_cross_page_recording",
host: "https://api.crikket.io",
submitTransport: createSubmitTransport(),
})
await new Promise((r) => setTimeout(r, 20))

// User starts a fresh recording on the new page
const startResult = await capture.startRecording()
expect(startResult).toEqual({ startedAt: 1_700_000_000_000 })
expect(sdkTestState.startSessionCalls).toHaveLength(1)
expect(sdkTestState.startSessionCalls[0]).toMatchObject({ captureType: "video" })

// User stops the recording
const recordingBlob = await capture.stopRecording()
expect(recordingBlob).toBe(sdkTestState.recordingBlob)
expect(sdkTestState.finalizeSessionCalls).toBe(1)
expect(sdkTestState.uiShowReviewInputs).toHaveLength(1)
expect(sdkTestState.uiShowReviewInputs[0].media.captureType).toBe("video")
})
})
Loading