diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md
index dddf6b381b..2429b849d1 100644
--- a/docs/browser-sdk.md
+++ b/docs/browser-sdk.md
@@ -63,6 +63,7 @@ Every field accepted by `MapleBrowser.init`:
| `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. |
| `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. |
| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). |
+| `replay.onErrorSampleRate` | `number` | `0` | Fraction of the sessions not recorded that buffer the last minute in memory and keep it only if an error happens. See [Sampling](#sampling). |
| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. |
| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the rrweb recording and omit captured click-target text from session events. |
| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage + cookie) so unique visitors and new-vs-returning are measurable. Turning it off also purges any id already stored. |
@@ -456,6 +457,26 @@ privacy: {
To record only a fraction of sessions, set `replay.sampleRate` between `0` and `1`. For example, `0.1` records ~10% of sessions. Tracing is unaffected by this setting.
+### Replay on error
+
+`replay.onErrorSampleRate` covers the sessions `replay.sampleRate` leaves out. Those sessions run
+the recorder into memory only, keeping roughly the last minute (the segments since the
+second-to-last full snapshot, taken every 30s). Nothing is uploaded. When an error is recorded (an
+uncaught error, an unhandled rejection or `captureException`, after [filters](#filtering-errors)),
+the buffered minute is uploaded and the rest of the session is recorded normally, including its
+later page loads. The session is marked `maple.session.replay_trigger: "error"`, and its replay starts
+up to a minute before the error rather than at the start of the session.
+
+```ts
+MapleBrowser.init({
+ // ...
+ replay: { sampleRate: 0.05, onErrorSampleRate: 1 }, // 5% of sessions, plus every session with an error
+})
+```
+
+Buffered sessions download the replay chunk like recorded ones, and keep up to 4 MB of events in
+memory.
+
`tracing.sampleRate` does the same for traces. The decision is made once per session (a hash of
`session.id`), so a sampled session keeps every one of its traces and its replay never links to a
dropped one. Spans that record an error are always exported, whatever the rate.
diff --git a/packages/browser-session/src/events/meta-row.ts b/packages/browser-session/src/events/meta-row.ts
index dbb81bd0ac..96e2606824 100644
--- a/packages/browser-session/src/events/meta-row.ts
+++ b/packages/browser-session/src/events/meta-row.ts
@@ -60,6 +60,8 @@ export interface SessionMetaRowInput {
* rendering a player with nothing to play.
*/
readonly recorded: boolean
+ /** Why a recording exists when it did not start with the session: an error triggered it. */
+ readonly replayTrigger?: "error" | undefined
/**
* Whether this session owns its visit claim. The gateway bills
* `billable_start == 1 && version == 1`, and falls back to `version == 1`
@@ -119,6 +121,11 @@ export function buildSessionMetaRow(input: SessionMetaRowInput): SessionMetaRow
// first, and treats an absent key as "rrweb" — every session recorded
// before this marker existed is a browser recording.
"maple.session.replay_format": "rrweb",
+ // Replay buffered in memory and uploaded because an error happened: the
+ // recording starts up to a minute before the error, not at session start.
+ ...(input.recorded && input.replayTrigger
+ ? { "maple.session.replay_trigger": input.replayTrigger }
+ : undefined),
// Storage-blocked visitors get an in-memory id, so their sessions each
// look like a distinct visitor. Flag it rather than inflate silently.
...(input.visitorId && input.visitorIdPersisted === false
diff --git a/packages/browser-session/src/index.ts b/packages/browser-session/src/index.ts
index e82fd1c676..42265a7802 100644
--- a/packages/browser-session/src/index.ts
+++ b/packages/browser-session/src/index.ts
@@ -28,7 +28,14 @@ export { startMetadataSession } from "./session/metadata-session"
// package-internal: `startSessionLifecycle` owns those invariants, and an SDK
// reaching past it would write counts the lifecycle then overwrites.
export type { SessionRecord } from "./session/session"
-export { claimReplaySample, getSession, getSessionId, rotateSession } from "./session/session"
+export type { ReplayMode } from "./session/session"
+export {
+ claimReplayMode,
+ claimReplaySample,
+ getSession,
+ getSessionId,
+ rotateSession,
+} from "./session/session"
export type { MapleBrowserSessionSink } from "./session/sink"
export { clearSessionSink } from "./session/sink"
export { getObservedTraceIds, publishSessionSink, readSessionSink, recordTraceId } from "./session/sink"
diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts
index 79c9daedc0..b329bd3e19 100644
--- a/packages/browser-session/src/replay/record.test.ts
+++ b/packages/browser-session/src/replay/record.test.ts
@@ -41,7 +41,7 @@ vi.mock("../platform/transport", () => ({
}),
}))
-const { startRecording } = await import("./record")
+const { startBufferedRecording, startRecording } = await import("./record")
const CONFIG = {
endpoint: "https://ingest.example",
@@ -206,3 +206,59 @@ describe("startRecording", () => {
}
})
})
+
+const META = 4
+const meta = (timestamp: number) => ({
+ type: META,
+ timestamp,
+ data: { href: "https://app.example/?token=abc" },
+})
+/** One rrweb snapshot: a Meta event, then the FullSnapshot. */
+const snapshot = (timestamp: number) => {
+ emitRef!(meta(timestamp), true)
+ emitRef!(fullSnapshot(timestamp + 1), true)
+}
+
+describe("startBufferedRecording", () => {
+ beforeEach(() => {
+ posted.length = 0
+ outcomes.length = 0
+ stopFn.mockClear()
+ emitRef = undefined
+ })
+
+ it("uploads nothing until drained, then the last two snapshots' segments as checkpoints", async () => {
+ const recorder = startBufferedRecording(CONFIG, "session-1")
+ emitRef!(incremental(500))
+ snapshot(1_000)
+ emitRef!(incremental(1_500))
+ snapshot(31_000)
+ emitRef!(incremental(31_500))
+ snapshot(61_000)
+ emitRef!(incremental(61_500))
+ emitRef!(incremental(62_000))
+ expect(posted).toEqual([])
+
+ await recorder.drain()
+ expect(posted.map((chunk) => chunk.meta)).toEqual([
+ { sessionId: "session-1", chunkSeq: 1, isCheckpoint: true, eventCount: 3, durationMs: 500 },
+ { sessionId: "session-1", chunkSeq: 1, isCheckpoint: true, eventCount: 4, durationMs: 1_000 },
+ ])
+ const first = JSON.parse(posted[0]!.body) as Array<{ timestamp: number; data: { href?: string } }>
+ expect(first[0]?.timestamp).toBe(31_000)
+ expect(first[0]?.data.href).toBe("https://app.example/?token=REDACTED")
+
+ await recorder.drain()
+ expect(posted).toHaveLength(2)
+ })
+
+ it("discards the buffer on stop", async () => {
+ const recorder = startBufferedRecording(CONFIG, "session-1")
+ snapshot(1_000)
+ emitRef!(incremental(1_500))
+ recorder.stop()
+ await recorder.drain()
+ expect(posted).toEqual([])
+ expect(stopFn).toHaveBeenCalled()
+ })
+})
diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts
index c8dfe7fc04..1f181d1e52 100644
--- a/packages/browser-session/src/replay/record.ts
+++ b/packages/browser-session/src/replay/record.ts
@@ -2,7 +2,13 @@ import { record } from "rrweb"
import { BLOCK_SELECTOR } from "../privacy-markers"
import { markActivity, nextChunkSeq } from "../session/session"
import type { IngestConfig } from "../platform/transport"
-import { gzip, postSessionBlob, warnDropped, type ChunkMeta } from "../platform/transport"
+import {
+ type BlobPostOutcome,
+ gzip,
+ postSessionBlob,
+ warnDropped,
+ type ChunkMeta,
+} from "../platform/transport"
import { scrubUrl } from "../platform/url-privacy"
// rrweb event shape — typed loosely to avoid coupling to @rrweb/types across
@@ -54,6 +60,33 @@ export interface Recorder {
getClickCount: () => number
}
+/**
+ * gzip and POST one chunk. `seq` is claimed here, monotonic across reloads
+ * (persisted on the session record), so a refresh continues the sequence
+ * instead of overwriting the previous load's blobs.
+ */
+async function uploadChunk(
+ config: IngestConfig,
+ sessionId: string,
+ body: string,
+ chunk: Omit,
+ keepalive: boolean,
+): Promise {
+ const chunkSeq = nextChunkSeq()
+ // `gzip` rejects rather than returning a truncated stream, and callers run
+ // this as a floating promise: an escaping rejection would surface in the
+ // host app's console as ours. Dropping the chunk is the same outcome ingest
+ // produced by refusing it, minus the wasted POST.
+ let gzipped: Uint8Array
+ try {
+ gzipped = await gzip(new TextEncoder().encode(body))
+ } catch (error) {
+ warnDropped("chunk compression", error)
+ return undefined
+ }
+ return postSessionBlob(config, { sessionId, chunkSeq, ...chunk }, gzipped, keepalive)
+}
+
export function startRecording(config: IngestConfig, sessionId: string): Recorder {
// Events are serialized once at emit time and buffered as JSON strings, so
// flushing is a cheap `join` instead of re-stringifying the whole buffer
@@ -92,30 +125,14 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde
const isCheckpoint = bufferHasCheckpoint
const eventCount = parts.length
const durationMs = Math.max(0, lastTimestamp - firstTimestamp)
- // Monotonic across reloads (persisted on the session record), so a refresh
- // continues the sequence instead of overwriting the previous load's blobs.
- const seq = nextChunkSeq()
resetBuffer()
-
- // `gzip` now rejects rather than returning a truncated stream, and `flush`
- // is called as a floating promise — an escaping rejection would surface in
- // the host app's console as ours. Dropping the chunk is the same outcome
- // ingest produced by refusing it, minus the wasted POST.
- let gzipped: Uint8Array
- try {
- gzipped = await gzip(new TextEncoder().encode(body))
- } catch (error) {
- warnDropped("chunk compression", error)
- return
- }
- const meta: ChunkMeta = {
+ const outcome = await uploadChunk(
+ config,
sessionId,
- chunkSeq: seq,
- isCheckpoint,
- eventCount,
- durationMs,
- }
- const outcome = await postSessionBlob(config, meta, gzipped, keepalive)
+ body,
+ { isCheckpoint, eventCount, durationMs },
+ keepalive,
+ )
if (outcome === "exhausted" && !exhausted) {
exhausted = true
warnExhausted(sessionId)
@@ -243,3 +260,105 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde
getClickCount: () => clickCount,
}
}
+
+/** Buffer mode checks out often, so the retained window stays near a minute. */
+const BUFFER_CHECKOUT_MS = 30_000
+
+interface Segment {
+ parts: string[]
+ bytes: number
+ first: number
+ last: number
+}
+
+export interface BufferedRecorder {
+ /** Upload what is buffered, oldest first, each segment a checkpoint chunk. */
+ drain: (keepalive?: boolean) => Promise
+ stop: () => void
+ getClickCount: () => number
+}
+
+/**
+ * Record into memory only: the segments since the second-to-last checkout, so
+ * 30-60s of replay, and nothing is uploaded until `drain()`. For sessions that
+ * keep a replay only when an error happens.
+ */
+export function startBufferedRecording(config: IngestConfig, sessionId: string): BufferedRecorder {
+ let segments: Segment[] = []
+ let bytes = 0
+ let clickCount = 0
+ let stopped = false
+
+ const stopRecord = record({
+ emit: (event: unknown) => {
+ const active = markActivity()
+ if (stopped || (active && active.id !== sessionId)) return
+ const e = event as RrwebEvent
+ if (e.type === META && e.data && typeof e.data.href === "string")
+ e.data.href = scrubUrl(e.data.href)
+ if (
+ e.type === INCREMENTAL &&
+ e.data?.source === SOURCE_MOUSE_INTERACTION &&
+ e.data.type === MOUSE_CLICK
+ ) {
+ clickCount++
+ }
+ let json: string
+ try {
+ json = JSON.stringify(e)
+ } catch {
+ return
+ }
+ // Every snapshot, first or checkout, is a Meta event then a FullSnapshot.
+ if (e.type === META) {
+ segments.push({ parts: [], bytes: 0, first: e.timestamp, last: e.timestamp })
+ while (segments.length > 2) bytes -= segments.shift()?.bytes ?? 0
+ }
+ const segment = segments.at(-1)
+ // Nothing to play back before the first snapshot.
+ if (!segment) return
+ segment.parts.push(json)
+ segment.bytes += json.length
+ segment.last = e.timestamp
+ bytes += json.length
+ while (bytes > MAX_BUFFER_BYTES && segments.length > 0) bytes -= segments.shift()?.bytes ?? 0
+ },
+ maskAllInputs: config.maskAllInputs,
+ blockSelector: BLOCK_SELECTOR,
+ ...(config.maskAllText ? { maskTextSelector: "*" } : undefined),
+ checkoutEveryNms: BUFFER_CHECKOUT_MS,
+ })
+
+ return {
+ drain: async (keepalive = false) => {
+ const pending = segments
+ segments = []
+ bytes = 0
+ if (stopped) return
+ // Each call claims its chunk seq synchronously, so these stay ahead of
+ // whatever the streaming recorder that follows uploads.
+ await Promise.all(
+ pending.map((segment) =>
+ uploadChunk(
+ config,
+ sessionId,
+ `[${segment.parts.join(",")}]`,
+ {
+ isCheckpoint: true,
+ eventCount: segment.parts.length,
+ durationMs: Math.max(0, segment.last - segment.first),
+ },
+ keepalive,
+ ),
+ ),
+ )
+ },
+ stop: () => {
+ stopped = true
+ segments = []
+ bytes = 0
+ stopRecord?.()
+ },
+ getClickCount: () => clickCount,
+ }
+}
diff --git a/packages/browser-session/src/session/lifecycle.ts b/packages/browser-session/src/session/lifecycle.ts
index ff44c8db56..9b8adb6484 100644
--- a/packages/browser-session/src/session/lifecycle.ts
+++ b/packages/browser-session/src/session/lifecycle.ts
@@ -59,8 +59,11 @@ export interface SessionSuspendOptions {
/** The parts of the lifecycle each capture mode owns. */
export interface SessionLifecycleHooks {
- /** Whether the rows this owner posts are accompanied by an rrweb recording. */
- readonly recorded: boolean
+ /**
+ * Whether the rows this owner posts are accompanied by an rrweb recording.
+ * A function when it can change mid-run: a buffered session becomes recorded when an error happens.
+ */
+ readonly recorded: boolean | (() => boolean)
/** POST one metadata row. Best-effort — must never throw. */
readonly post: (row: Record, keepalive: boolean) => void
/**
@@ -83,6 +86,8 @@ export interface SessionLifecycleHooks {
export interface SessionLifecycleHandle {
readonly sessionId: string
+ /** Post a fresh `active` row now, e.g. after the session became recorded. */
+ readonly announce: () => void
readonly shutdown: (options?: { readonly flush?: boolean }) => Promise
}
@@ -112,6 +117,8 @@ export function startSessionLifecycle(
let errorCountBase = 0
let sinkClickCountAtStart = 0
let sinkErrorCountAtStart = 0
+ const isRecorded = (): boolean =>
+ typeof hooks.recorded === "function" ? hooks.recorded() : hooks.recorded
/**
* The persisted record of the session this lifecycle owns.
@@ -196,7 +203,8 @@ export function startSessionLifecycle(
pageViews: counts.pageViews,
errorCount: counts.errorCount,
traceIds: status === "ended" ? options.getTraceIds?.(record.id) : undefined,
- recorded: hooks.recorded,
+ recorded: isRecorded(),
+ replayTrigger: record.replayTrigger,
billableStart,
}),
keepalive,
@@ -215,7 +223,7 @@ export function startSessionLifecycle(
const record = liveRecord()
// A session minted by idle rotation mid-page has no sampling decision yet;
// it takes this page's mode so its later loads agree with it.
- adoptReplayDecision(record.id, hooks.recorded)
+ adoptReplayDecision(record.id, isRecorded())
rebaseCounts(record)
hooks.onStart?.(record)
post("active", false)
@@ -301,6 +309,9 @@ export function startSessionLifecycle(
get sessionId() {
return current.id
},
+ announce: () => {
+ if (running) post("active", false)
+ },
shutdown: async (shutdownOptions) => {
if (stopped) return
stopped = true
diff --git a/packages/browser-session/src/session/replay-session.browser.test.ts b/packages/browser-session/src/session/replay-session.browser.test.ts
new file mode 100644
index 0000000000..35d8956c88
--- /dev/null
+++ b/packages/browser-session/src/session/replay-session.browser.test.ts
@@ -0,0 +1,96 @@
+// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+const calls: string[] = []
+vi.mock("../replay/record", () => ({
+ startRecording: () => {
+ calls.push("stream")
+ return { stop: () => calls.push("stream.stop"), flush: async () => {}, getClickCount: () => 0 }
+ },
+ startBufferedRecording: () => {
+ calls.push("buffer")
+ return {
+ drain: async () => {
+ calls.push("buffer.drain")
+ },
+ stop: () => calls.push("buffer.stop"),
+ getClickCount: () => 2,
+ }
+ },
+}))
+vi.mock("../replay/events", () => ({
+ startEventCapture: () => {
+ calls.push("events")
+ return { flush: async () => {}, stop: () => {} }
+ },
+}))
+const rows: Array> = []
+vi.mock("../platform/transport", async (importOriginal) => ({
+ ...(await importOriginal()),
+ postSessionMeta: async (_config: unknown, row: Record) => {
+ rows.push(row)
+ },
+}))
+
+const { startReplaySession } = await import("./replay-session")
+const { claimReplayMode, getSession } = await import("./session")
+
+const resourceAttribute = (row: Record | undefined, key: string): string | undefined => {
+ const attributes = row?.resource_attributes
+ if (typeof attributes !== "object" || attributes === null) return undefined
+ const value = Object.entries(attributes).find(([name]) => name === key)?.[1]
+ return typeof value === "string" ? value : undefined
+}
+const recorded = (row: Record | undefined) =>
+ resourceAttribute(row, "maple.session.recorded")
+const trigger = (row: Record | undefined) =>
+ resourceAttribute(row, "maple.session.replay_trigger")
+
+afterEach(() => {
+ calls.length = 0
+ rows.length = 0
+ sessionStorage.clear()
+})
+
+describe("startReplaySession in buffer mode", () => {
+ it("buffers until triggered, then drains, streams, and re-announces the session as recorded", async () => {
+ getSession()
+ const handle = startReplaySession({
+ endpoint: "https://ingest.test",
+ sdk: "maple-test/0.0.0",
+ serviceName: "web",
+ maskAllInputs: true,
+ maskAllText: false,
+ mode: "buffer",
+ })
+ expect(calls).toEqual(["buffer"])
+ expect(recorded(rows.at(-1))).toBe("false")
+
+ await handle?.trigger()
+ expect(calls).toEqual(["buffer", "buffer.drain", "buffer.stop", "stream", "events"])
+ expect(recorded(rows.at(-1))).toBe("true")
+ expect(trigger(rows.at(-1))).toBe("error")
+ // The next load of this session records from the start.
+ expect(claimReplayMode(0, 0)).toBe("record")
+
+ await handle?.trigger()
+ expect(calls.filter((call) => call === "stream")).toHaveLength(1)
+ await handle?.shutdown()
+ })
+
+ it("records from the start in record mode, where trigger does nothing", async () => {
+ getSession()
+ const handle = startReplaySession({
+ endpoint: "https://ingest.test",
+ sdk: "maple-test/0.0.0",
+ serviceName: "web",
+ maskAllInputs: true,
+ maskAllText: false,
+ })
+ await handle?.trigger()
+ expect(calls).toEqual(["stream", "events"])
+ expect(recorded(rows.at(-1))).toBe("true")
+ expect(trigger(rows.at(-1))).toBeUndefined()
+ await handle?.shutdown()
+ })
+})
diff --git a/packages/browser-session/src/session/replay-session.ts b/packages/browser-session/src/session/replay-session.ts
index 61f82eae68..0ce3aafd8c 100644
--- a/packages/browser-session/src/session/replay-session.ts
+++ b/packages/browser-session/src/session/replay-session.ts
@@ -8,9 +8,15 @@
// wiring, idle rotation, metadata rows — lives in `./lifecycle`, shared
// with the metadata-only path. This module is the recorded configuration of it.
import { type EventCapture, startEventCapture } from "../replay/events"
-import { type Recorder, startRecording } from "../replay/record"
+import {
+ type BufferedRecorder,
+ type Recorder,
+ startBufferedRecording,
+ startRecording,
+} from "../replay/record"
import { postSessionMeta, type IngestConfig } from "../platform/transport"
import { type SessionLifecycleHandle, type SessionLifecycleOptions, startSessionLifecycle } from "./lifecycle"
+import { markReplayTriggered } from "./session"
import { getObservedTraceIds, publishSessionSink } from "./sink"
export { setActiveTraceIdProvider } from "../events/events-sink"
@@ -24,9 +30,20 @@ export interface ReplaySessionOptions extends SessionLifecycleOptions {
readonly maskAllText: boolean
/** Notifies consumers that the session id used for span linking changed. */
readonly onSessionChange?: ((sessionId: string) => void) | undefined
+ /**
+ * `record` (default) uploads as it goes. `buffer` keeps the last minute in
+ * memory and uploads nothing until `trigger()`, typically on an error.
+ */
+ readonly mode?: "record" | "buffer" | undefined
}
-export type ReplaySessionHandle = SessionLifecycleHandle
+export interface ReplaySessionHandle extends SessionLifecycleHandle {
+ /**
+ * Keep this session's replay: upload the buffered minute and record the rest
+ * of the session. A no-op in `record` mode or once triggered.
+ */
+ readonly trigger: () => Promise
+}
/**
* Start recording the current browser session. Publishes the session sink,
@@ -51,8 +68,13 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession
}
let recorder: Recorder | undefined
+ let buffered: BufferedRecorder | undefined
let events: EventCapture | undefined
let publishedSessionId: string | undefined
+ const buffering = options.mode === "buffer"
+ let triggered = !buffering
+ /** Clicks the buffered recorder saw before a trigger replaced it. */
+ let clicksBeforeTrigger = 0
const publish = (sessionId: string): void => {
if (publishedSessionId === sessionId) return
@@ -60,30 +82,39 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession
publishSessionSink(sessionId)
}
- return startSessionLifecycle(
+ const startStreaming = (sessionId: string): void => {
+ recorder = startRecording(engineConfig, sessionId)
+ // Distilled events (console/network/error/clicks) ride along. Navigation
+ // is observed by the sink, which runs whether or not replay is sampled.
+ events = startEventCapture(engineConfig, sessionId)
+ }
+
+ const lifecycle = startSessionLifecycle(
{ ...options, getTraceIds: getObservedTraceIds },
{
- // This path *is* the recorder — every row it posts has rrweb chunks.
- recorded: true,
+ // Recorded from the start, or from the moment a buffered session is triggered.
+ recorded: () => triggered,
post: (row, keepalive) => {
void postSessionMeta(engineConfig, row, keepalive)
},
// rrweb sees mouse interactions the distilled capture may mask away, so
// the recorder is the better click source while it is running.
- clicksSinceStart: () => recorder?.getClickCount() ?? 0,
+ clicksSinceStart: () =>
+ clicksBeforeTrigger + (recorder?.getClickCount() ?? buffered?.getClickCount() ?? 0),
onStart: (record) => {
publish(record.id)
- recorder = startRecording(engineConfig, record.id)
- // Distilled events (console/network/error/clicks) ride along.
- // Navigation is observed by the sink, which runs whether or not replay
- // is sampled.
- events = startEventCapture(engineConfig, record.id)
+ clicksBeforeTrigger = 0
+ if (triggered) startStreaming(record.id)
+ else buffered = startBufferedRecording(engineConfig, record.id)
},
onSuspend: ({ flush, keepalive }) => {
const stoppingRecorder = recorder
const stoppingEvents = events
recorder = undefined
events = undefined
+ // Untriggered, the buffer holds nothing anyone asked to keep.
+ buffered?.stop()
+ buffered = undefined
const flushed = flush
? Promise.all([
stoppingRecorder?.flush(keepalive),
@@ -98,9 +129,37 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession
return flushed
},
onSessionChange: (sessionId) => {
+ // A new session buffers again until its own error.
+ triggered = !buffering
publish(sessionId)
options.onSessionChange?.(sessionId)
},
},
)
+ if (!lifecycle) return undefined
+
+ return {
+ get sessionId() {
+ return lifecycle.sessionId
+ },
+ announce: lifecycle.announce,
+ shutdown: lifecycle.shutdown,
+ trigger: async () => {
+ if (triggered) return
+ triggered = true
+ const sessionId = lifecycle.sessionId
+ markReplayTriggered(sessionId)
+ const pending = buffered
+ buffered = undefined
+ // Suspended (a hidden page): the next run starts streaming by itself.
+ if (!pending) return
+ clicksBeforeTrigger = pending.getClickCount()
+ // Claims its chunk seqs before the streaming recorder can.
+ const drained = pending.drain()
+ pending.stop()
+ startStreaming(sessionId)
+ lifecycle.announce()
+ await drained
+ },
+ }
}
diff --git a/packages/browser-session/src/session/session.test.ts b/packages/browser-session/src/session/session.test.ts
index aa0e848800..cca32f7c76 100644
--- a/packages/browser-session/src/session/session.test.ts
+++ b/packages/browser-session/src/session/session.test.ts
@@ -1,11 +1,13 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import {
adoptReplayDecision,
+ claimReplayMode,
claimReplaySample,
getSession,
getSessionId,
isNewVisitorSession,
markActivity,
+ markReplayTriggered,
nextChunkSeq,
nextMetaVersion,
onSessionRotate,
@@ -433,6 +435,28 @@ describe("replay sampling", () => {
})
})
+describe("claimReplayMode", () => {
+ it("records at the sample rate, else buffers for errors, and keeps the answer", () => {
+ getSession()
+ expect(claimReplayMode(0, 1)).toBe("buffer")
+ expect(claimReplayMode(1, 0)).toBe("buffer")
+ })
+
+ it("is off when neither rate claims the session", () => {
+ getSession()
+ expect(claimReplayMode(0, 0)).toBe("off")
+ expect(claimReplayMode(0, 1)).toBe("off")
+ })
+
+ it("records later loads of a session an error triggered", () => {
+ const session = getSession()
+ expect(claimReplayMode(0, 1)).toBe("buffer")
+ markReplayTriggered(session.id)
+ expect(claimReplayMode(0, 1)).toBe("record")
+ expect(peekSession()?.replayTrigger).toBe("error")
+ })
+})
+
describe("quota exhaustion", () => {
it("keeps nextMetaVersion advancing when reads work but writes throw", () => {
// Pinned at 1, every heartbeat was billed as a new session.
diff --git a/packages/browser-session/src/session/session.ts b/packages/browser-session/src/session/session.ts
index 08bdc5413c..8446defb2e 100644
--- a/packages/browser-session/src/session/session.ts
+++ b/packages/browser-session/src/session/session.ts
@@ -88,6 +88,10 @@ export interface SessionRecord {
* recorded session "Not recorded".
*/
replaySampled?: boolean
+ /** Whether an unsampled session buffers replay in memory, uploading it only if an error happens. */
+ replayBuffered?: boolean
+ /** Set once an error turned a buffered session into a recorded one. */
+ replayTrigger?: "error"
}
/** Optional keys carrying a plain number. Absent is fine; wrongly typed is not. */
@@ -145,6 +149,14 @@ function parseSessionRecord(raw: string): SessionRecord | undefined {
if (typeof value.replaySampled !== "boolean") return undefined
record.replaySampled = value.replaySampled
}
+ if (value.replayBuffered !== undefined) {
+ if (typeof value.replayBuffered !== "boolean") return undefined
+ record.replayBuffered = value.replayBuffered
+ }
+ if (value.replayTrigger !== undefined) {
+ if (value.replayTrigger !== "error") return undefined
+ record.replayTrigger = value.replayTrigger
+ }
if (value.utm !== undefined) {
if (!isStringRecord(value.utm)) return undefined
record.utm = value.utm
@@ -615,6 +627,32 @@ export function claimReplaySample(sampleRate: number): boolean {
return sampled
}
+/** `record` uploads as it goes; `buffer` keeps the last minute in memory until an error. */
+export type ReplayMode = "record" | "buffer" | "off"
+
+/**
+ * This session's replay mode: recorded at `sampleRate`, otherwise buffered for
+ * errors at `onErrorSampleRate`. Rolled once per session and persisted, like
+ * `claimReplaySample`.
+ */
+export function claimReplayMode(sampleRate: number, onErrorSampleRate: number): ReplayMode {
+ if (claimReplaySample(sampleRate)) return "record"
+ const record = readRecord() ?? getSession()
+ if (record.replayBuffered === undefined) {
+ const buffered = onErrorSampleRate > 0 && uniformRandom() < onErrorSampleRate
+ writeRecord({ ...record, replayBuffered: buffered })
+ return buffered ? "buffer" : "off"
+ }
+ return record.replayBuffered ? "buffer" : "off"
+}
+
+/** An error made this buffered session a recorded one: later loads record it from the start. */
+export function markReplayTriggered(sessionId: string): void {
+ const record = readRecord()
+ if (!record || record.id !== sessionId) return
+ writeRecord({ ...record, replaySampled: true, replayTrigger: "error" })
+}
+
/**
* Pin a session to the capture mode a running page is already in. A session
* minted by idle rotation mid-page inherits that page's mode rather than
diff --git a/packages/browser/README.md b/packages/browser/README.md
index 0d896558f5..c1596c6405 100644
--- a/packages/browser/README.md
+++ b/packages/browser/README.md
@@ -145,6 +145,12 @@ LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry lo
(browser semantic conventions), linked to the `pageload` span and the session. Opt out with
`webVitals: false`.
+## Replay on error
+
+`replay: { sampleRate: 0.05, onErrorSampleRate: 1 }` records 5% of sessions, and has every other
+session keep the last minute in memory, uploading it and recording the rest of the session only if
+an error is recorded.
+
## Trace sampling
`tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per
diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts
index d7a90e599e..bab0b2299c 100644
--- a/packages/browser/src/config.ts
+++ b/packages/browser/src/config.ts
@@ -109,6 +109,12 @@ export interface MapleBrowserConfig {
readonly enabled?: boolean
/** Fraction of sessions to record, 0–1. Default 1. */
readonly sampleRate?: number
+ /**
+ * Fraction of the sessions not recorded that keep the last minute in
+ * memory, and upload it and record the rest of the session only if an
+ * error happens. 0–1, default 0.
+ */
+ readonly onErrorSampleRate?: number
}
readonly privacy?: {
/** Mask all `` values. Default true. */
@@ -177,6 +183,7 @@ export interface ResolvedConfig {
readonly reportBrowser: boolean
readonly replayEnabled: boolean
readonly replaySampleRate: number
+ readonly replayOnErrorSampleRate: number
readonly maskAllInputs: boolean
readonly maskAllText: boolean
readonly persistVisitorId: boolean
@@ -247,6 +254,10 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
reportBrowser: config.reporting?.browserReports ?? false,
replayEnabled: config.replay?.enabled ?? true,
replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate),
+ replayOnErrorSampleRate:
+ config.replay?.onErrorSampleRate === undefined
+ ? 0
+ : resolveSampleRate("replay.onErrorSampleRate", config.replay.onErrorSampleRate),
maskAllInputs: config.privacy?.maskAllInputs ?? true,
maskAllText: config.privacy?.maskAllText ?? false,
persistVisitorId: config.privacy?.persistVisitorId ?? true,
diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts
index 3f1c3eea44..f6927ccc16 100644
--- a/packages/browser/src/deferred/index.ts
+++ b/packages/browser/src/deferred/index.ts
@@ -2,7 +2,7 @@
// behind this chunk, so it stays off the eager bundle every page load pays for.
import type { SpanContext } from "@opentelemetry/api"
import type { ResolvedConfig } from "../config"
-import { setErrorRecordedHook } from "../errors"
+import { onErrorRecorded } from "../errors"
import { onDocumentPageload } from "../navigation"
import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs"
import { recordDocumentTiming } from "./document-timing"
@@ -22,14 +22,14 @@ export function startDeferred(config: ResolvedConfig): () => Promise {
breadcrumbs: config.breadcrumbs,
captureConsole: config.captureConsole,
})
- setErrorRecordedHook(flushBreadcrumbs)
+ const stopErrorListener = onErrorRecorded(flushBreadcrumbs)
const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser })
const stops = [
startLogs(config),
async () => {
stopVitals()
onDocumentPageload(undefined)
- setErrorRecordedHook(undefined)
+ stopErrorListener()
stopBreadcrumbs()
stopReports()
},
diff --git a/packages/browser/src/error-replay.browser.test.ts b/packages/browser/src/error-replay.browser.test.ts
new file mode 100644
index 0000000000..ac27a2e956
--- /dev/null
+++ b/packages/browser/src/error-replay.browser.test.ts
@@ -0,0 +1,63 @@
+// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
+import { getSession } from "@maple/browser-session"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({
+ OTLPTraceExporter: class {
+ export(_spans: unknown[], callback: (result: { code: number }) => void): void {
+ callback({ code: 0 })
+ }
+ forceFlush(): Promise {
+ return Promise.resolve()
+ }
+ shutdown(): Promise {
+ return Promise.resolve()
+ }
+ },
+}))
+
+const { MapleBrowser } = await import("./index")
+
+// Headless Chromium's user agent is classified as a bot, which never gets replay.
+const DESKTOP_UA =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36"
+
+let handle: ReturnType | undefined
+afterEach(async () => {
+ await handle?.shutdown()
+ handle = undefined
+ Reflect.deleteProperty(navigator, "userAgent")
+ sessionStorage.clear()
+ vi.unstubAllGlobals()
+})
+
+describe("replay.onErrorSampleRate", () => {
+ it("buffers an unsampled session, uploading nothing until an error keeps it", async () => {
+ const urls: string[] = []
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async (input: RequestInfo | URL) => {
+ urls.push(String(input))
+ return new Response("{}")
+ }),
+ )
+ Object.defineProperty(navigator, "userAgent", { value: DESKTOP_UA, configurable: true })
+ handle = MapleBrowser.init({
+ ingestKey: "k",
+ serviceName: "web",
+ endpoint: "https://ingest.test",
+ tracing: { instrumentFetch: false, instrumentXhr: false },
+ webVitals: false,
+ replay: { sampleRate: 0, onErrorSampleRate: 1 },
+ })
+ // The buffered session announces itself, unrecorded, once the replay chunk lands.
+ await vi.waitFor(() => expect(urls.some((url) => url.endsWith("/v1/sessionReplays/meta"))).toBe(true))
+ expect(getSession()).toMatchObject({ replaySampled: false, replayBuffered: true })
+ await new Promise((resolve) => setTimeout(resolve, 50))
+ expect(urls.some((url) => url.endsWith("/v1/sessionReplays/blob"))).toBe(false)
+
+ MapleBrowser.captureException(new Error("checkout failed"))
+ expect(getSession()).toMatchObject({ replaySampled: true, replayTrigger: "error" })
+ await vi.waitFor(() => expect(urls.some((url) => url.endsWith("/v1/sessionReplays/blob"))).toBe(true))
+ })
+})
diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts
index 1ba205636c..44d4c3b47a 100644
--- a/packages/browser/src/errors.ts
+++ b/packages/browser/src/errors.ts
@@ -48,11 +48,13 @@ const asError = (value: unknown): Error => {
*/
let reported = new WeakSet