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() -/** Set by the deferred chunk, which exports the error's breadcrumb trail. */ -let onErrorRecorded: ((spanContext: SpanContext) => void) | undefined +type ErrorRecordedListener = (spanContext: SpanContext) => void +/** Told about every recorded error: breadcrumbs export their trail, a buffered replay keeps itself. */ +const errorListeners = new Set() -export function setErrorRecordedHook(hook: ((spanContext: SpanContext) => void) | undefined): void { - onErrorRecorded = hook +export function onErrorRecorded(listener: ErrorRecordedListener): () => void { + errorListeners.add(listener) + return () => errorListeners.delete(listener) } /** Whether this exact error object was already recorded. */ @@ -77,7 +79,14 @@ export function recordFailure(span: Span, error: unknown): void { if (!alreadyReported(error)) { if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error) span.recordException(exceptionOf(normalized)) - if (span.isRecording()) onErrorRecorded?.(span.spanContext()) + if (span.isRecording()) { + for (const listener of errorListeners) { + // A listener must never turn one error into another. + try { + listener(span.spanContext()) + } catch {} + } + } } span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 6925a33ebe..2f0f7285bb 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -1,5 +1,5 @@ import { - claimReplaySample, + claimReplayMode, clearPendingEvents, clearSessionSink, configurePrivacy, @@ -28,7 +28,7 @@ import type { ReplaySessionHandle } from "@maple/browser-session/replay" import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" import { configureErrorFilters } from "./error-filters" -import { setupErrorCapture } from "./errors" +import { onErrorRecorded, setupErrorCapture } from "./errors" import { setLogIdentity } from "./logs" import { resetNavigation } from "./navigation" import { setupTracing } from "./tracing" @@ -109,7 +109,9 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { rotateOnNextStart = false // Rolled once per session and persisted on it, so a reload or the next // page of a multi-page app records (or skips) the same session consistently. - const recordReplay = replayEligible && claimReplaySample(config.replaySampleRate) + const replayMode = replayEligible + ? claimReplayMode(config.replaySampleRate, config.replayOnErrorSampleRate) + : "off" publishSessionSink(session.id) const sink = startEventSink( { @@ -160,7 +162,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { onSessionChange: publishSessionSink, }) - if (!recordReplay) { + if (replayMode === "off") { runtime = { initialSessionId: session.id, sink, metadata: startMetadata() } return } @@ -180,6 +182,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { ...shared, maskAllInputs: config.maskAllInputs, maskAllText: config.maskAllText, + mode: replayMode, }) }) .catch(() => { @@ -211,6 +214,10 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { await Promise.all([replayShutdown, metadataShutdown, previous.replayPending]) } + // A buffered replay keeps itself the moment an error is recorded. + const stopReplayTrigger = onErrorRecorded(() => { + void runtime?.replay?.trigger() + }) startRuntime() const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => { @@ -235,6 +242,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { if (stopped) return stopped = true stopConsentListener() + stopReplayTrigger() await stopRuntime(true) stopErrorCapture?.() stopErrorCapture = undefined diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index 5f0f38e796..fc60abcf8c 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -35,6 +35,7 @@ const CONFIG = { tracingCaptureErrors: false, replayEnabled: false, replaySampleRate: 0, + replayOnErrorSampleRate: 0, maskAllInputs: true, maskAllText: false, persistVisitorId: true, diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 7506dfe016..7d3178d2fd 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -44,6 +44,7 @@ const CONFIG = { tracingCaptureErrors: false, replayEnabled: false, replaySampleRate: 0, + replayOnErrorSampleRate: 0, maskAllInputs: true, maskAllText: false, persistVisitorId: true, diff --git a/packages/effect-sdk/src/client/standalone-session.ts b/packages/effect-sdk/src/client/standalone-session.ts index 3853fdd162..19e53f4b9c 100644 --- a/packages/effect-sdk/src/client/standalone-session.ts +++ b/packages/effect-sdk/src/client/standalone-session.ts @@ -49,6 +49,9 @@ const leaseCurrent = (): MetadataSessionHandle | undefined => { get sessionId() { return owned.sessionId }, + announce: () => { + if (!released) owned.announce() + }, shutdown: async (options) => { if (released) return released = true