From 8d716ba4b591b5743b816105a3b25ce9eb11bc3a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 18:18:17 +0000 Subject: [PATCH 1/7] feat(session-replay-browser): expose start and stop recording APIs Allow customers to pause and resume session replay capture without tearing down the SDK. start/stop are available on the standalone browser SDK, the analytics plugin, and the unified client. Co-authored-by: Jesse Wang --- .../plugin-session-replay-browser/README.md | 17 ++++ .../src/session-replay.ts | 22 +++++- .../test/session-replay.test.ts | 31 +++++++- packages/session-replay-browser/README.md | 17 +++- packages/session-replay-browser/src/index.ts | 2 + .../src/session-replay-factory.ts | 2 + .../src/session-replay.ts | 34 ++++++++ .../src/typings/session-replay.ts | 11 +++ .../session-replay-browser/test/index.test.ts | 4 +- .../test/session-replay.test.ts | 77 +++++++++++++++++++ 10 files changed, 211 insertions(+), 6 deletions(-) diff --git a/packages/plugin-session-replay-browser/README.md b/packages/plugin-session-replay-browser/README.md index 0925d6c6f7..e89206a960 100644 --- a/packages/plugin-session-replay-browser/README.md +++ b/packages/plugin-session-replay-browser/README.md @@ -72,6 +72,23 @@ const sessionReplayTracking = sessionReplayPlugin({ amplitude.add(sessionReplayTracking); ``` +### 4. Start and stop recording (optional) + +The plugin instance exposes `start()` and `stop()` so you can pause and resume capture without removing the plugin. Sampling, targeting, and opt-out still apply. + +```typescript +const sessionReplayTracking = sessionReplayPlugin({ + sampleRate: 1, +}); +amplitude.add(sessionReplayTracking); + +// Pause capture, for example on a sensitive screen +sessionReplayTracking.stop(); + +// Resume capture +await sessionReplayTracking.start(); +``` + ## Privacy By default, the session replay will mask all inputs, meaning the text in inputs will appear in a session replay as asterisks: `***`. You may require more specific masking controls based on your use case, so we offer the following controls: diff --git a/packages/plugin-session-replay-browser/src/session-replay.ts b/packages/plugin-session-replay-browser/src/session-replay.ts index da33fdc2a5..8e9c01dd19 100644 --- a/packages/plugin-session-replay-browser/src/session-replay.ts +++ b/packages/plugin-session-replay-browser/src/session-replay.ts @@ -13,6 +13,8 @@ import { getSessionId, getSessionReplayProperties, flush, + start, + stop, shutdown, evaluateTargetingAndCapture, AmplitudeSessionReplay, @@ -40,6 +42,8 @@ export class SessionReplayPlugin implements EnrichmentPlugin EnrichmentPlugin = ( +export const sessionReplayPlugin: (options?: SessionReplayOptions) => SessionReplayPlugin = ( options?: SessionReplayOptions, ) => { return new SessionReplayPlugin(options); diff --git a/packages/plugin-session-replay-browser/test/session-replay.test.ts b/packages/plugin-session-replay-browser/test/session-replay.test.ts index b86ff35152..3014099303 100644 --- a/packages/plugin-session-replay-browser/test/session-replay.test.ts +++ b/packages/plugin-session-replay-browser/test/session-replay.test.ts @@ -20,8 +20,16 @@ type MockedLogger = jest.Mocked; type MockedBrowserClient = jest.Mocked; describe('SessionReplayPlugin', () => { - const { init, setSessionId, getSessionReplayProperties, shutdown, getSessionId, evaluateTargetingAndCapture } = - sessionReplayBrowser as MockedSessionReplayBrowser; + const { + init, + setSessionId, + getSessionReplayProperties, + start, + stop, + shutdown, + getSessionId, + evaluateTargetingAndCapture, + } = sessionReplayBrowser as MockedSessionReplayBrowser; const mockLoggerProviderDebug = jest.fn(); const mockLoggerProvider: MockedLogger = { error: jest.fn(), @@ -71,6 +79,9 @@ describe('SessionReplayPlugin', () => { setSessionId.mockReturnValue({ promise: Promise.resolve(), }); + start.mockReturnValue({ + promise: Promise.resolve(), + }); getSessionReplayProperties.mockImplementation(() => { return { '[Amplitude] Session Replay ID': 'foo/bar' }; }); @@ -821,6 +832,22 @@ describe('SessionReplayPlugin', () => { }); }); + describe('start and stop', () => { + test('should call session replay start', async () => { + const sessionReplay = sessionReplayPlugin(); + await sessionReplay.setup?.(mockConfig, mockAmplitude); + await sessionReplay.start(); + expect(start).toHaveBeenCalled(); + }); + + test('should call session replay stop', async () => { + const sessionReplay = sessionReplayPlugin(); + await sessionReplay.setup?.(mockConfig, mockAmplitude); + sessionReplay.stop(); + expect(stop).toHaveBeenCalled(); + }); + }); + describe('getSessionReplayProperties', () => { test('should return session replay properties', async () => { const sessionReplay = sessionReplayPlugin() as SessionReplayPlugin; diff --git a/packages/session-replay-browser/README.md b/packages/session-replay-browser/README.md index 9e99f30539..040fc4ed5c 100644 --- a/packages/session-replay-browser/README.md +++ b/packages/session-replay-browser/README.md @@ -77,8 +77,21 @@ You can optionally pass a new device id as a second argument as well: sessionReplay.setSessionId(UNIX_TIMESTAMP, deviceId) ``` -### 6. Shutdown (optional) -If at any point you would like to discontinue collection of session replays, for example in a part of your application where you would not like sessions to be collected, you can use the following method to stop collection and remove collection event listeners. +### 6. Start and stop recording (optional) +Use `stop()` to pause capture without tearing down the SDK (session id, config, and event listeners stay in place). Call `start()` to resume. Sampling, targeting, and opt-out still apply — `start()` will not record a session that is opted out, not sampled, or excluded by targeting. + +```typescript +// Pause capture, for example on a sensitive screen +sessionReplay.stop() + +// Resume capture +sessionReplay.start() +``` + +`stop()` flushes any events already captured. Events while recording is stopped are not tagged with session replay properties. + +### 7. Shutdown (optional) +If at any point you would like to discontinue collection of session replays, for example in a part of your application where you would not like sessions to be collected, you can use the following method to stop collection and remove collection event listeners. After `shutdown()`, call `init()` again to restart — `start()` alone is not enough because listeners have been removed. ```typescript sessionReplay.shutdown() ``` diff --git a/packages/session-replay-browser/src/index.ts b/packages/session-replay-browser/src/index.ts index c30d0c847f..e6a51a5769 100644 --- a/packages/session-replay-browser/src/index.ts +++ b/packages/session-replay-browser/src/index.ts @@ -5,6 +5,8 @@ export const { getSessionId, getSessionReplayProperties, flush, + start, + stop, shutdown, evaluateTargetingAndCapture, } = sessionReplay; diff --git a/packages/session-replay-browser/src/session-replay-factory.ts b/packages/session-replay-browser/src/session-replay-factory.ts index 27ec1e55bc..4fdee78439 100644 --- a/packages/session-replay-browser/src/session-replay-factory.ts +++ b/packages/session-replay-browser/src/session-replay-factory.ts @@ -37,6 +37,8 @@ const createInstance: () => AmplitudeSessionReplay = () => { getLogConfig(sessionReplay), ), flush: debugWrapper(sessionReplay.flush.bind(sessionReplay), 'flush', getLogConfig(sessionReplay)), + start: debugWrapper(sessionReplay.start.bind(sessionReplay), 'start', getLogConfig(sessionReplay)), + stop: debugWrapper(sessionReplay.stop.bind(sessionReplay), 'stop', getLogConfig(sessionReplay)), shutdown: debugWrapper(sessionReplay.shutdown.bind(sessionReplay), 'shutdown', getLogConfig(sessionReplay)), }; }; diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 534afbaec2..f07a8a6c87 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -117,6 +117,12 @@ export class SessionReplay implements AmplitudeSessionReplay { // Cache the dynamically imported record function private recordFunction: RecordFunction | null = null; private recordEventsInFlight = false; + /** + * When false, capture is paused by a customer `stop()` (or `shutdown()`) call. + * Focus/targeting/session-change paths still invoke `recordEvents()`, but + * `getShouldRecord()` will refuse to start rrweb until `start()` or a new `init()`. + */ + private recordingEnabled = true; private pendingEmitEvents: Array<{ event: eventWithTime; sessionId: string | number }> = []; /** Current page URL, kept in sync with SPA navigations for URL-based masking */ @@ -231,6 +237,8 @@ export class SessionReplay implements AmplitudeSessionReplay { // Re-init should always tear down any previous URL-change subscription, even when the // next config has no targeting config and we don't subscribe again. this.urlChangeCleanup?.(); + // A new init always allows capture again. `stop()` only pauses the current instance. + this.recordingEnabled = true; this.loggerProvider = new SafeLoggerProvider(options.loggerProvider || new Logger()); Object.prototype.hasOwnProperty.call(options, 'logLevel') && @@ -544,6 +552,9 @@ export class SessionReplay implements AmplitudeSessionReplay { }; focusListener = () => { + if (!this.recordingEnabled) { + return; + } if (this.recordCancelCallback && this.recordFunction) { // Recording is already active. The on-focus full snapshot is tunable: when // `captureFullSnapshotOnFocus` is false we skip it entirely so high focus-churn pages @@ -756,6 +767,13 @@ export class SessionReplay implements AmplitudeSessionReplay { return false; } + if (!this.recordingEnabled) { + this.loggerProvider.log( + `Session ${this.identifiers.sessionId} not being captured because recording was stopped.`, + ); + return false; + } + if (!this.config.captureEnabled) { this.loggerProvider.log( `Session ${this.identifiers.sessionId} not being captured due to capture being disabled for project or because the remote config could not be fetched.`, @@ -1207,6 +1225,21 @@ export class SessionReplay implements AmplitudeSessionReplay { return this.identifiers?.sessionId; } + start() { + return returnWrapper(this._start()); + } + + private async _start() { + this.recordingEnabled = true; + await this.recordEvents(); + } + + stop() { + this.recordingEnabled = false; + this.stopRecordingEvents(); + this.sendEvents(); + } + async flush(useRetry = false) { // Intentionally not gated on min_session_duration_ms. flush() forwards payloads // already queued in trackDestination, and every code path that queues into it — @@ -1217,6 +1250,7 @@ export class SessionReplay implements AmplitudeSessionReplay { } shutdown() { + this.recordingEnabled = false; this.urlChangeCleanup?.(); this.crossOriginParentSignalCleanup?.(); this.crossOriginParentSignalCleanup = null; diff --git a/packages/session-replay-browser/src/typings/session-replay.ts b/packages/session-replay-browser/src/typings/session-replay.ts index 59f3754fb1..3fd4b8b25b 100644 --- a/packages/session-replay-browser/src/typings/session-replay.ts +++ b/packages/session-replay-browser/src/typings/session-replay.ts @@ -107,6 +107,17 @@ export interface AmplitudeSessionReplay { forceTargetingReevaluation?: boolean, ) => Promise; flush: (useRetry: boolean) => Promise; + /** + * Start or resume session replay recording. + * Recording still respects sample rate, targeting, opt-out, and remote capture flags. + */ + start: () => AmplitudeReturn; + /** + * Stop session replay recording without tearing down the SDK. + * Call {@link AmplitudeSessionReplay.start} to resume. Prefer {@link AmplitudeSessionReplay.shutdown} + * when you want to fully discontinue collection and remove event listeners. + */ + stop: () => void; shutdown: () => void; } diff --git a/packages/session-replay-browser/test/index.test.ts b/packages/session-replay-browser/test/index.test.ts index a1630a2e86..0a64790126 100644 --- a/packages/session-replay-browser/test/index.test.ts +++ b/packages/session-replay-browser/test/index.test.ts @@ -1,10 +1,12 @@ -import { getSessionReplayProperties, init, setSessionId, shutdown } from '../src/index'; +import { getSessionReplayProperties, init, setSessionId, start, stop, shutdown } from '../src/index'; describe('index', () => { test('should expose apis', () => { expect(typeof init).toBe('function'); expect(typeof setSessionId).toBe('function'); expect(typeof getSessionReplayProperties).toBe('function'); + expect(typeof start).toBe('function'); + expect(typeof stop).toBe('function'); expect(typeof shutdown).toBe('function'); }); }); diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 54051e81d9..4b3722e809 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -2078,6 +2078,14 @@ describe('SessionReplay', () => { const shouldRecord = sessionReplay.getShouldRecord(); expect(shouldRecord).toBe(false); }); + test('should return false after stop() until start() is called', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + expect(sessionReplay.getShouldRecord()).toBe(true); + sessionReplay.stop(); + expect(sessionReplay.getShouldRecord()).toBe(false); + await sessionReplay.start().promise; + expect(sessionReplay.getShouldRecord()).toBe(true); + }); test('should return false if captureEnabled is false', async () => { mockRemoteConfig = { sr_sampling_config: { @@ -3643,6 +3651,75 @@ describe('SessionReplay', () => { }); }); + describe('start and stop', () => { + test('stop should halt recording, send queued events, and leave focus listeners attached', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + const createEventsIDBStoreInstance = await SessionReplayIDB.SessionReplayEventsIDBStore.new('replay', { + loggerProvider: mockLoggerProvider, + apiKey, + }); + const stopRecordingMock = jest.fn(); + sessionReplay.recordCancelCallback = stopRecordingMock; + if (!sessionReplay.eventsManager) { + throw new Error('Did not call init'); + } + await createEventsIDBStoreInstance?.addEventToCurrentSequence(123, mockEventString); + const sendEventsMock = jest.spyOn(sessionReplay.eventsManager, 'sendCurrentSequenceEvents'); + removeEventListenerMock.mockReset(); + sessionReplay.stop(); + expect(stopRecordingMock).toHaveBeenCalled(); + expect(sessionReplay.recordCancelCallback).toBe(null); + expect(sendEventsMock).toHaveBeenCalled(); + expect(removeEventListenerMock).not.toHaveBeenCalled(); + }); + + test('stop should prevent recordEvents from starting capture', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + await jest.runAllTimersAsync(); + mockRecordFunction.mockClear(); + sessionReplay.stop(); + await sessionReplay.recordEvents(); + expect(mockRecordFunction).not.toHaveBeenCalled(); + }); + + test('start should resume capture after stop', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + await jest.runAllTimersAsync(); + sessionReplay.stop(); + mockRecordFunction.mockClear(); + await sessionReplay.start().promise; + expect(mockRecordFunction).toHaveBeenCalled(); + }); + + test('start should not record when the session is opted out', async () => { + await sessionReplay.init(apiKey, { ...mockOptions, optOut: true }).promise; + mockRecordFunction.mockClear(); + await sessionReplay.start().promise; + expect(mockRecordFunction).not.toHaveBeenCalled(); + }); + + test('focusListener should not restart recording after stop', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + sessionReplay.stop(); + sessionReplay.recordCancelCallback = null; + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (sessionReplay as any).recordEventsInFlight = false; + const recordEventsSpy = jest.spyOn(sessionReplay, 'recordEvents'); + + sessionReplay.focusListener(); + + expect(recordEventsSpy).not.toHaveBeenCalled(); + }); + + test('init after stop should allow recording again', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + sessionReplay.stop(); + expect(sessionReplay.getShouldRecord()).toBe(false); + await sessionReplay.init(apiKey, mockOptions).promise; + expect(sessionReplay.getShouldRecord()).toBe(true); + }); + }); + describe('getCurrentUrl', () => { test('returns url if exists', () => { globalSpy.mockImplementation(() => ({ From 2c701868e2f9d5737c14f5627d29184796f4db81 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 18:34:00 +0000 Subject: [PATCH 2/7] test(session-replay-browser): cover start/stop with rrweb e2e Add Playwright tests that mutate the DOM against a live rrweb recorder to prove stop() actually cancels capture and start() resumes it. Also assert the unit path invokes the cancel callback returned by record(). --- .../e2e/start-stop.spec.ts | 158 ++++++++++++++++++ .../test/session-replay.test.ts | 11 ++ 2 files changed, 169 insertions(+) create mode 100644 packages/session-replay-browser/e2e/start-stop.spec.ts diff --git a/packages/session-replay-browser/e2e/start-stop.spec.ts b/packages/session-replay-browser/e2e/start-stop.spec.ts new file mode 100644 index 0000000000..1ef7ebd39a --- /dev/null +++ b/packages/session-replay-browser/e2e/start-stop.spec.ts @@ -0,0 +1,158 @@ +/** + * End-to-end tests for the customer-facing start() / stop() recording APIs. + * + * These drive a real page + real rrweb (not a mocked record()). The goal is to + * prove that stop() actually cancels the rrweb recorder: DOM mutations after + * stop() never appear in the track payload, and start() begins a new recording. + */ + +import { test, expect, Page } from '@playwright/test'; +import { + TEST_SESSION_ID, + SNAPSHOT_SETTLE_MS, + remoteConfigRecording, + mockRemoteConfig, + buildUrl, + waitForReady, + captureTrackRequests, +} from './helpers'; + +const SR_PROPERTY_KEY = '[Amplitude] Session Replay ID'; +const MUTATION_SOURCE = 0; // IncrementalSource.Mutation +const EVENT_INCREMENTAL_SNAPSHOT = 3; + +function gotoCapturePage(page: Page) { + return page.goto( + buildUrl('/session-replay-browser/sr-capture-test.html', { + sessionId: TEST_SESSION_ID, + // Opt into eager send + on-focus full snapshot so drain/flush is deterministic. + eagerFullSnapshotSend: true, + captureFullSnapshotOnFocus: true, + }), + ); +} + +async function appendMarker(page: Page, id: string): Promise { + await page.evaluate((markerId) => { + document.body.appendChild(Object.assign(document.createElement('div'), { id: markerId })); + }, id); +} + +async function drainAndFlush(page: Page): Promise { + await page.evaluate(() => window.dispatchEvent(new Event('focus'))); + await page.evaluate(() => (window as any).sessionReplay.flush(false) as Promise); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); +} + +function bodiesContainMarker(rawBodies: string[], markerId: string): boolean { + return rawBodies.some((body) => body.includes(markerId)); +} + +function decodeMutationAdds(rawBodies: string[]): string[] { + const ids: string[] = []; + for (const body of rawBodies) { + if (!body) continue; + let payload: { events?: unknown[] }; + try { + payload = JSON.parse(body) as { events?: unknown[] }; + } catch { + continue; + } + if (!Array.isArray(payload.events)) continue; + for (const eventStr of payload.events) { + if (typeof eventStr !== 'string') continue; + try { + const event = JSON.parse(eventStr) as { + type: number; + data: { source: number; adds?: Array<{ node?: { attributes?: Record } }> }; + }; + if (event.type === EVENT_INCREMENTAL_SNAPSHOT && event.data.source === MUTATION_SOURCE) { + for (const add of event.data.adds ?? []) { + const id = add.node?.attributes?.id; + if (id) ids.push(id); + } + } + } catch { + // skip unparseable + } + } + } + return ids; +} + +test.describe('start and stop', () => { + test('stop() cancels rrweb so later DOM mutations are not captured', async ({ page }) => { + await mockRemoteConfig(page, remoteConfigRecording); + const { getBodies } = await captureTrackRequests(page); + + await gotoCapturePage(page); + await waitForReady(page); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); + + await appendMarker(page, 'sr-before-stop'); + await drainAndFlush(page); + + expect(decodeMutationAdds(getBodies())).toContain('sr-before-stop'); + + await page.evaluate(() => (window as any).sessionReplay.stop() as void); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); + + await appendMarker(page, 'sr-after-stop'); + // Focus must not restart the recorder after stop(). + await drainAndFlush(page); + + expect(bodiesContainMarker(getBodies(), 'sr-after-stop')).toBe(false); + expect(decodeMutationAdds(getBodies())).not.toContain('sr-after-stop'); + }); + + test('start() resumes rrweb capture after stop()', async ({ page }) => { + await mockRemoteConfig(page, remoteConfigRecording); + const { getBodies } = await captureTrackRequests(page); + + await gotoCapturePage(page); + await waitForReady(page); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); + + await page.evaluate(() => (window as any).sessionReplay.stop() as void); + await appendMarker(page, 'sr-while-stopped'); + await drainAndFlush(page); + expect(bodiesContainMarker(getBodies(), 'sr-while-stopped')).toBe(false); + + await page.evaluate(() => (window as any).sessionReplay.start().promise as Promise); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); + + await appendMarker(page, 'sr-after-start'); + await drainAndFlush(page); + + expect(decodeMutationAdds(getBodies())).toContain('sr-after-start'); + // The node added while stopped may appear in a later full snapshot of the live DOM, + // but it must never have been captured as an incremental mutation. + expect(decodeMutationAdds(getBodies())).not.toContain('sr-while-stopped'); + }); + + test('stop() clears session replay properties until start()', async ({ page }) => { + await mockRemoteConfig(page, remoteConfigRecording); + await captureTrackRequests(page); + + await gotoCapturePage(page); + await waitForReady(page); + await page.waitForTimeout(SNAPSHOT_SETTLE_MS); + + const recordingProps = await page.evaluate( + () => (window as any).sessionReplay.getSessionReplayProperties() as Record, + ); + expect(recordingProps[SR_PROPERTY_KEY]).toBeTruthy(); + + await page.evaluate(() => (window as any).sessionReplay.stop() as void); + const stoppedProps = await page.evaluate( + () => (window as any).sessionReplay.getSessionReplayProperties() as Record, + ); + expect(stoppedProps[SR_PROPERTY_KEY]).toBeFalsy(); + + await page.evaluate(() => (window as any).sessionReplay.start().promise as Promise); + const resumedProps = await page.evaluate( + () => (window as any).sessionReplay.getSessionReplayProperties() as Record, + ); + expect(resumedProps[SR_PROPERTY_KEY]).toBeTruthy(); + }); +}); diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 4b3722e809..23660b30a7 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -3652,6 +3652,17 @@ describe('SessionReplay', () => { }); describe('start and stop', () => { + test('stop should invoke the cancel callback returned by rrweb record()', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + await jest.runAllTimersAsync(); + expect(mockRecordFunction).toHaveBeenCalled(); + const cancelFn = mockRecordFunction.mock.results[mockRecordFunction.mock.results.length - 1].value as jest.Mock; + cancelFn.mockClear(); + sessionReplay.stop(); + expect(cancelFn).toHaveBeenCalled(); + expect(sessionReplay.recordCancelCallback).toBe(null); + }); + test('stop should halt recording, send queued events, and leave focus listeners attached', async () => { await sessionReplay.init(apiKey, mockOptions).promise; const createEventsIDBStoreInstance = await SessionReplayIDB.SessionReplayEventsIDBStore.new('replay', { From cad01adc386644bbfdfa1c0a8f0012b042a8b349 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 17:12:43 +0000 Subject: [PATCH 3/7] fix(session-replay-browser): drop in-flight record after stop() Abort _recordEvents after each await if stop() or shutdown() ran, so a start that already passed getShouldRecord cannot call rrweb record() after the customer paused capture. Co-authored-by: Jesse Wang --- .../src/session-replay.ts | 42 ++++++++++++++++--- .../test/session-replay.test.ts | 20 +++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index f07a8a6c87..ab9664b171 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -123,6 +123,11 @@ export class SessionReplay implements AmplitudeSessionReplay { * `getShouldRecord()` will refuse to start rrweb until `start()` or a new `init()`. */ private recordingEnabled = true; + /** + * Bumped by `stop()` / `shutdown()` so an in-flight `_recordEvents()` that already + * passed `getShouldRecord()` cannot start rrweb after capture was paused. + */ + private recordingGeneration = 0; private pendingEmitEvents: Array<{ event: eventWithTime; sessionId: string | number }> = []; /** Current page URL, kept in sync with SPA navigations for URL-based masking */ @@ -949,6 +954,10 @@ export class SessionReplay implements AmplitudeSessionReplay { } } + private shouldAbandonRecordStart(generation: number): boolean { + return !this.recordingEnabled || generation !== this.recordingGeneration; + } + private async _recordEvents(shouldLogMetadata = true) { const config = this.config; const shouldRecord = this.getShouldRecord(); @@ -957,15 +966,19 @@ export class SessionReplay implements AmplitudeSessionReplay { return; } this.stopRecordingEvents(); + const generation = this.recordingGeneration; const recordFunction = await this.getRecordFunction(); // May be undefined if cannot import rrweb-record - if (!recordFunction) { + if (!recordFunction || this.shouldAbandonRecordStart(generation)) { return; } await this.initializeNetworkObservers(); + if (this.shouldAbandonRecordStart(generation)) { + return; + } const networkLoggingConfig = config.loggingConfig?.network; const trackUrl = getServerUrl(config.serverZone, config.trackServerUrl); @@ -1003,10 +1016,18 @@ export class SessionReplay implements AmplitudeSessionReplay { const childMode = crossOriginIframesEnabled && isInIframe(); if (childMode && coordinateChildren) { + if (this.shouldAbandonRecordStart(generation)) { + return; + } // Child mode: don't self-start; wait for a start signal from the parent. // (The previous listener, if any, was already removed by stopRecordingEvents above.) this.crossOriginParentSignalCleanup = listenForParentSignals({ - onStart: () => this._recordEventsInChildMode(recordFunction, sessionId, config, hooks), + onStart: () => { + if (!this.recordingEnabled) { + return; + } + this._recordEventsInChildMode(recordFunction, sessionId, config, hooks); + }, onStop: () => { try { // Only cancel the rrweb recording — do NOT call stopRecordingEvents() here, @@ -1025,13 +1046,22 @@ export class SessionReplay implements AmplitudeSessionReplay { return; } + const plugins = await this.getRecordingPlugins(loggingConfig); + if (this.shouldAbandonRecordStart(generation)) { + return; + } + this.recordCancelCallback = recordFunction({ ...this.buildRRWebRecordOptions( config, hooks, (event: eventWithTime) => { - if (this.shouldOptOut()) { - this.loggerProvider.log(`Opting session ${sessionId} out of recording due to optOut config.`); + if (this.shouldOptOut() || !this.recordingEnabled) { + this.loggerProvider.log( + this.shouldOptOut() + ? `Opting session ${sessionId} out of recording due to optOut config.` + : `Session Replay capture stopping for ${sessionId}.`, + ); this.stopRecordingEvents(); this.sendEvents(); return; @@ -1052,7 +1082,7 @@ export class SessionReplay implements AmplitudeSessionReplay { }, 'Error while capturing replay: ', ), - plugins: await this.getRecordingPlugins(loggingConfig), + plugins, recordCrossOriginIframes: crossOriginIframesEnabled, }); @@ -1236,6 +1266,7 @@ export class SessionReplay implements AmplitudeSessionReplay { stop() { this.recordingEnabled = false; + this.recordingGeneration++; this.stopRecordingEvents(); this.sendEvents(); } @@ -1251,6 +1282,7 @@ export class SessionReplay implements AmplitudeSessionReplay { shutdown() { this.recordingEnabled = false; + this.recordingGeneration++; this.urlChangeCleanup?.(); this.crossOriginParentSignalCleanup?.(); this.crossOriginParentSignalCleanup = null; diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 23660b30a7..c5a3dc94ce 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -3684,6 +3684,26 @@ describe('SessionReplay', () => { expect(removeEventListenerMock).not.toHaveBeenCalled(); }); + test('stop during in-flight recordEvents should not start rrweb afterward', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + await jest.runAllTimersAsync(); + mockRecordFunction.mockClear(); + + let resolveObservers: (() => void) | undefined; + const observersStarted = new Promise((resolve) => { + resolveObservers = resolve; + }); + jest.spyOn(sessionReplay as any, 'initializeNetworkObservers').mockImplementation(() => observersStarted); + + const recordPromise = sessionReplay.recordEvents(); + sessionReplay.stop(); + resolveObservers?.(); + await recordPromise; + + expect(mockRecordFunction).not.toHaveBeenCalled(); + expect(sessionReplay.recordCancelCallback).toBe(null); + }); + test('stop should prevent recordEvents from starting capture', async () => { await sessionReplay.init(apiKey, mockOptions).promise; await jest.runAllTimersAsync(); From 770308eff2e2c8fa5d35adfaf65c7361e7452a88 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 17:18:07 +0000 Subject: [PATCH 4/7] test(session-replay-browser): cover stop() races during in-flight record Ensure stop() during initializeNetworkObservers does not start rrweb, and that a child iframe onStart after stop() is ignored. --- .../src/session-replay.ts | 3 -- .../test/session-replay.test.ts | 28 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index ab9664b171..ced02c3f6c 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -1016,9 +1016,6 @@ export class SessionReplay implements AmplitudeSessionReplay { const childMode = crossOriginIframesEnabled && isInIframe(); if (childMode && coordinateChildren) { - if (this.shouldAbandonRecordStart(generation)) { - return; - } // Child mode: don't self-start; wait for a start signal from the parent. // (The previous listener, if any, was already removed by stopRecordingEvents above.) this.crossOriginParentSignalCleanup = listenForParentSignals({ diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index c5a3dc94ce..2e649146b0 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -2724,6 +2724,16 @@ describe('SessionReplay', () => { expect(allEvents).toEqual([mockEventString]); // exactly the one pre-existing event }); + test('should stop recording if emit fires after stop()', async () => { + await sessionReplay.init(apiKey, mockOptions).promise; + await sessionReplay.recordEvents(); + const recordArg = mockRecordFunction.mock.calls[0][0] as { emit?: (event: unknown) => void }; + sessionReplay.stop(); + mockRecordFunction.mockClear(); + recordArg?.emit && recordArg.emit(mockEvent); + expect(sessionReplay.recordCancelCallback).toBe(null); + }); + test('should add an error handler', async () => { await sessionReplay.init(apiKey, mockOptions).promise; await sessionReplay.recordEvents(); @@ -3687,6 +3697,7 @@ describe('SessionReplay', () => { test('stop during in-flight recordEvents should not start rrweb afterward', async () => { await sessionReplay.init(apiKey, mockOptions).promise; await jest.runAllTimersAsync(); + await sessionReplay.recordEvents(); mockRecordFunction.mockClear(); let resolveObservers: (() => void) | undefined; @@ -3696,6 +3707,8 @@ describe('SessionReplay', () => { jest.spyOn(sessionReplay as any, 'initializeNetworkObservers').mockImplementation(() => observersStarted); const recordPromise = sessionReplay.recordEvents(); + // Flush the getRecordFunction() microtask so we block on initializeNetworkObservers. + await Promise.resolve(); sessionReplay.stop(); resolveObservers?.(); await recordPromise; @@ -5466,6 +5479,21 @@ describe('SessionReplay', () => { expect(mockRecordFunction).toHaveBeenCalledWith(expect.objectContaining({ recordCrossOriginIframes: true })); }); + test('onStart after stop does not start child recording', async () => { + await sessionReplay.init(apiKey, crossOriginOptions).promise; + await sessionReplay.recordEvents(); + await jest.runAllTimersAsync(); + + const { onStart } = (mockListenForParentSignals as jest.Mock).mock.calls[0][0] as { + onStart: () => void; + }; + sessionReplay.stop(); + mockRecordFunction.mockClear(); + onStart(); + + expect(mockRecordFunction).not.toHaveBeenCalled(); + }); + test('stops recording when onStop callback is invoked', async () => { await sessionReplay.init(apiKey, crossOriginOptions).promise; await sessionReplay.recordEvents(); From 3d36667731063a4627a209ad90be03a29cc3a522 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 20:11:54 +0000 Subject: [PATCH 5/7] fix(session-replay-browser): self-start child capture on start() Coordinated iframe children wait for a parent start signal on init. After a customer stop()/start() the parent may already be recording and will not send another signal, so start() now begins child recording immediately while still listening for later parent stop/start. --- .../session-replay-browser/src/session-replay.ts | 13 +++++++++++++ .../test/session-replay.test.ts | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index ced02c3f6c..3756e27be7 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -128,6 +128,11 @@ export class SessionReplay implements AmplitudeSessionReplay { * passed `getShouldRecord()` cannot start rrweb after capture was paused. */ private recordingGeneration = 0; + /** + * Set by customer `start()` so coordinated child iframes self-start instead of + * waiting for a parent signal that may never be resent. + */ + private startChildRecordingOnSetup = false; private pendingEmitEvents: Array<{ event: eventWithTime; sessionId: string | number }> = []; /** Current page URL, kept in sync with SPA navigations for URL-based masking */ @@ -962,6 +967,8 @@ export class SessionReplay implements AmplitudeSessionReplay { const config = this.config; const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; + const selfStartChild = this.startChildRecordingOnSetup; + this.startChildRecordingOnSetup = false; if (!shouldRecord || !sessionId || !config) { return; } @@ -1040,6 +1047,11 @@ export class SessionReplay implements AmplitudeSessionReplay { } }, }); + // Customer start() must not wait for a parent signal: the parent may already + // be recording and will not send another start. Init still waits. + if (selfStartChild) { + this._recordEventsInChildMode(recordFunction, sessionId, config, hooks); + } return; } @@ -1258,6 +1270,7 @@ export class SessionReplay implements AmplitudeSessionReplay { private async _start() { this.recordingEnabled = true; + this.startChildRecordingOnSetup = true; await this.recordEvents(); } diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index 2e649146b0..dbead11136 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -5494,6 +5494,21 @@ describe('SessionReplay', () => { expect(mockRecordFunction).not.toHaveBeenCalled(); }); + test('start() after stop() self-starts child recording without a parent signal', async () => { + await sessionReplay.init(apiKey, crossOriginOptions).promise; + await sessionReplay.recordEvents(); + await jest.runAllTimersAsync(); + + sessionReplay.stop(); + mockRecordFunction.mockClear(); + (mockListenForParentSignals as jest.Mock).mockClear(); + + await sessionReplay.start().promise; + + expect(mockListenForParentSignals).toHaveBeenCalled(); + expect(mockRecordFunction).toHaveBeenCalledWith(expect.objectContaining({ recordCrossOriginIframes: true })); + }); + test('stops recording when onStop callback is invoked', async () => { await sessionReplay.init(apiKey, crossOriginOptions).promise; await sessionReplay.recordEvents(); From 51db2a72422284dd00a17d468e60f641f1ccebeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 22:02:32 +0000 Subject: [PATCH 6/7] fix(session-replay-browser): keep child self-start until capture begins A gated start() was clearing startChildRecordingOnSetup before getShouldRecord() could fail, so coordinated child iframes waited forever for a parent signal that would never be resent. --- .../src/session-replay.ts | 14 +++++--- .../test/session-replay.test.ts | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/session-replay-browser/src/session-replay.ts b/packages/session-replay-browser/src/session-replay.ts index 3756e27be7..003a3a009f 100644 --- a/packages/session-replay-browser/src/session-replay.ts +++ b/packages/session-replay-browser/src/session-replay.ts @@ -130,7 +130,9 @@ export class SessionReplay implements AmplitudeSessionReplay { private recordingGeneration = 0; /** * Set by customer `start()` so coordinated child iframes self-start instead of - * waiting for a parent signal that may never be resent. + * waiting for a parent signal that may never be resent. Kept until recording + * actually starts so a gated `start()` (targeting, sampling, remote capture) + * still self-starts when those gates later pass. */ private startChildRecordingOnSetup = false; private pendingEmitEvents: Array<{ event: eventWithTime; sessionId: string | number }> = []; @@ -249,6 +251,7 @@ export class SessionReplay implements AmplitudeSessionReplay { this.urlChangeCleanup?.(); // A new init always allows capture again. `stop()` only pauses the current instance. this.recordingEnabled = true; + this.startChildRecordingOnSetup = false; this.loggerProvider = new SafeLoggerProvider(options.loggerProvider || new Logger()); Object.prototype.hasOwnProperty.call(options, 'logLevel') && @@ -967,8 +970,6 @@ export class SessionReplay implements AmplitudeSessionReplay { const config = this.config; const shouldRecord = this.getShouldRecord(); const sessionId = this.identifiers?.sessionId; - const selfStartChild = this.startChildRecordingOnSetup; - this.startChildRecordingOnSetup = false; if (!shouldRecord || !sessionId || !config) { return; } @@ -1049,12 +1050,15 @@ export class SessionReplay implements AmplitudeSessionReplay { }); // Customer start() must not wait for a parent signal: the parent may already // be recording and will not send another start. Init still waits. - if (selfStartChild) { + if (this.startChildRecordingOnSetup) { + this.startChildRecordingOnSetup = false; this._recordEventsInChildMode(recordFunction, sessionId, config, hooks); } return; } + this.startChildRecordingOnSetup = false; + const plugins = await this.getRecordingPlugins(loggingConfig); if (this.shouldAbandonRecordStart(generation)) { return; @@ -1276,6 +1280,7 @@ export class SessionReplay implements AmplitudeSessionReplay { stop() { this.recordingEnabled = false; + this.startChildRecordingOnSetup = false; this.recordingGeneration++; this.stopRecordingEvents(); this.sendEvents(); @@ -1292,6 +1297,7 @@ export class SessionReplay implements AmplitudeSessionReplay { shutdown() { this.recordingEnabled = false; + this.startChildRecordingOnSetup = false; this.recordingGeneration++; this.urlChangeCleanup?.(); this.crossOriginParentSignalCleanup?.(); diff --git a/packages/session-replay-browser/test/session-replay.test.ts b/packages/session-replay-browser/test/session-replay.test.ts index dbead11136..1a3fd8f2f2 100644 --- a/packages/session-replay-browser/test/session-replay.test.ts +++ b/packages/session-replay-browser/test/session-replay.test.ts @@ -5509,6 +5509,40 @@ describe('SessionReplay', () => { expect(mockRecordFunction).toHaveBeenCalledWith(expect.objectContaining({ recordCrossOriginIframes: true })); }); + test('start() while capture is gated still self-starts after the gate later passes', async () => { + await sessionReplay.init(apiKey, crossOriginOptions).promise; + await sessionReplay.recordEvents(); + await jest.runAllTimersAsync(); + + sessionReplay.stop(); + mockRecordFunction.mockClear(); + (mockListenForParentSignals as jest.Mock).mockClear(); + + sessionReplay.config!.captureEnabled = false; + await sessionReplay.start().promise; + expect(mockRecordFunction).not.toHaveBeenCalled(); + + sessionReplay.config!.captureEnabled = true; + await sessionReplay.recordEvents(); + await jest.runAllTimersAsync(); + + expect(mockListenForParentSignals).toHaveBeenCalled(); + expect(mockRecordFunction).toHaveBeenCalledWith(expect.objectContaining({ recordCrossOriginIframes: true })); + }); + + test('stop() after a gated start() drops the pending child self-start', async () => { + await sessionReplay.init(apiKey, crossOriginOptions).promise; + await sessionReplay.recordEvents(); + await jest.runAllTimersAsync(); + + sessionReplay.config!.captureEnabled = false; + await sessionReplay.start().promise; + expect((sessionReplay as any).startChildRecordingOnSetup).toBe(true); + + sessionReplay.stop(); + expect((sessionReplay as any).startChildRecordingOnSetup).toBe(false); + }); + test('stops recording when onStop callback is invoked', async () => { await sessionReplay.init(apiKey, crossOriginOptions).promise; await sessionReplay.recordEvents(); From a507e88fef2780654af8da949f1de3044dbec9fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 27 Aug 2026 16:58:56 +0000 Subject: [PATCH 7/7] test(session-replay-browser): re-drive sendEvents in TRC URL e2e poll Properties can become true as soon as targeting matches, before rrweb events are in the current sequence. flush() only drains the track destination, so a one-shot blur plus polling flush() never sends. --- .../session-replay-browser/e2e/trc-url-rule.spec.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/session-replay-browser/e2e/trc-url-rule.spec.ts b/packages/session-replay-browser/e2e/trc-url-rule.spec.ts index c8a64bae12..bf3a9597bb 100644 --- a/packages/session-replay-browser/e2e/trc-url-rule.spec.ts +++ b/packages/session-replay-browser/e2e/trc-url-rule.spec.ts @@ -181,16 +181,15 @@ test.describe('TRC URL rule — happy path', () => { expect(propsAfter[SR_PROPERTY_KEY]).toBeTruthy(); expect(String(propsAfter[SR_PROPERTY_KEY])).toContain(`/${TEST_SESSION_ID}`); - await page.evaluate(() => window.dispatchEvent(new Event('blur'))); - - // The full snapshot is captured asynchronously after targeting flips recording on, so a - // single flush can run before any rrweb event has been queued and deliver nothing. Poll: - // flush repeatedly until a batch actually reaches the track API (or time out). This removes - // the snapshot-vs-flush race that made this assertion flaky (it fails ~2/3 attempts even on - // main); flush(false) is a no-op when the queue is empty, so re-driving it is safe. + // Properties flip as soon as targeting matches, which can be before rrweb has emitted + // (or before those events have been moved from the current sequence into the track + // destination). flush() only drains the destination queue, so a one-shot blur + poll + // of flush() stays at 0 forever if the first sendEvents() ran against an empty sequence. + // Re-drive blur (sendEvents) and flush until a batch reaches the track API. await expect .poll( async () => { + await page.evaluate(() => window.dispatchEvent(new Event('blur'))); await page.evaluate(() => (window as any).sessionReplay.flush(false) as Promise); return getBodies().length; },