Skip to content
17 changes: 17 additions & 0 deletions packages/plugin-session-replay-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
22 changes: 21 additions & 1 deletion packages/plugin-session-replay-browser/src/session-replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
getSessionId,
getSessionReplayProperties,
flush,
start,
stop,
shutdown,
evaluateTargetingAndCapture,
AmplitudeSessionReplay,
Expand Down Expand Up @@ -40,6 +42,8 @@ export class SessionReplayPlugin implements EnrichmentPlugin<BrowserClient, Brow
getSessionReplayProperties: getSessionReplayProperties,
init: init,
setSessionId: setSessionId,
start: start,
stop: stop,
shutdown: shutdown,
evaluateTargetingAndCapture: evaluateTargetingAndCapture,
};
Expand Down Expand Up @@ -223,9 +227,25 @@ export class SessionReplayPlugin implements EnrichmentPlugin<BrowserClient, Brow
getSessionReplayProperties() {
return this.sessionReplay.getSessionReplayProperties();
}

/**
* Start or resume session replay recording.
* Recording still respects sample rate, targeting, opt-out, and remote capture flags.
*/
async start() {
await this.sessionReplay.start().promise;
}

/**
* Stop session replay recording without removing the plugin.
* Call {@link SessionReplayPlugin.start} to resume. Plugin teardown still uses shutdown.
*/
stop() {
this.sessionReplay.stop();
}
}

export const sessionReplayPlugin: (options?: SessionReplayOptions) => EnrichmentPlugin = (
export const sessionReplayPlugin: (options?: SessionReplayOptions) => SessionReplayPlugin = (
options?: SessionReplayOptions,
) => {
return new SessionReplayPlugin(options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,16 @@ type MockedLogger = jest.Mocked<ILogger>;
type MockedBrowserClient = jest.Mocked<BrowserClient>;

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(),
Expand Down Expand Up @@ -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' };
});
Expand Down Expand Up @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions packages/session-replay-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()
```
Expand Down
158 changes: 158 additions & 0 deletions packages/session-replay-browser/e2e/start-stop.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await page.evaluate((markerId) => {
document.body.appendChild(Object.assign(document.createElement('div'), { id: markerId }));
}, id);
}

async function drainAndFlush(page: Page): Promise<void> {
await page.evaluate(() => window.dispatchEvent(new Event('focus')));
await page.evaluate(() => (window as any).sessionReplay.flush(false) as Promise<void>);
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<string, string> } }> };
};
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<void>);
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<string, unknown>,
);
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<string, unknown>,
);
expect(stoppedProps[SR_PROPERTY_KEY]).toBeFalsy();

await page.evaluate(() => (window as any).sessionReplay.start().promise as Promise<void>);
const resumedProps = await page.evaluate(
() => (window as any).sessionReplay.getSessionReplayProperties() as Record<string, unknown>,
);
expect(resumedProps[SR_PROPERTY_KEY]).toBeTruthy();
});
});
13 changes: 6 additions & 7 deletions packages/session-replay-browser/e2e/trc-url-rule.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>);
return getBodies().length;
},
Expand Down
2 changes: 2 additions & 0 deletions packages/session-replay-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const {
getSessionId,
getSessionReplayProperties,
flush,
start,
stop,
shutdown,
evaluateTargetingAndCapture,
} = sessionReplay;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
};
};
Expand Down
Loading
Loading