diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/init.js b/packages/browser-integration-tests/suites/replay/bufferMode/init.js
new file mode 100644
index 000000000000..c75a803ae33e
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/bufferMode/init.js
@@ -0,0 +1,16 @@
+import * as Sentry from '@sentry/browser';
+
+window.Sentry = Sentry;
+window.Replay = new Sentry.Replay({
+ flushMinDelay: 1000,
+ flushMaxDelay: 1000,
+});
+
+Sentry.init({
+ dsn: 'https://public@dsn.ingest.sentry.io/1337',
+ sampleRate: 1,
+ replaysSessionSampleRate: 0.0,
+ replaysOnErrorSampleRate: 0.0,
+
+ integrations: [window.Replay],
+});
diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/subject.js b/packages/browser-integration-tests/suites/replay/bufferMode/subject.js
new file mode 100644
index 000000000000..be51a4baf6db
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/bufferMode/subject.js
@@ -0,0 +1,18 @@
+document.getElementById('go-background').addEventListener('click', () => {
+ Object.defineProperty(document, 'hidden', { value: true, writable: true });
+ const ev = document.createEvent('Event');
+ ev.initEvent('visibilitychange');
+ document.dispatchEvent(ev);
+});
+
+document.getElementById('error').addEventListener('click', () => {
+ throw new Error('Ooops');
+});
+
+document.getElementById('error2').addEventListener('click', () => {
+ throw new Error('Another error');
+});
+
+document.getElementById('log').addEventListener('click', () => {
+ console.log('Some message');
+});
diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/template.html b/packages/browser-integration-tests/suites/replay/bufferMode/template.html
new file mode 100644
index 000000000000..91b5ef47723d
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/bufferMode/template.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts
new file mode 100644
index 000000000000..4e98cd49c28c
--- /dev/null
+++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts
@@ -0,0 +1,420 @@
+import { expect } from '@playwright/test';
+import type { Replay } from '@sentry/replay';
+import type { ReplayContainer } from '@sentry/replay/build/npm/types/types';
+
+import { sentryTest } from '../../../utils/fixtures';
+import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers';
+import { expectedClickBreadcrumb, getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
+import {
+ getReplayEvent,
+ getReplayRecordingContent,
+ isReplayEvent,
+ shouldSkipReplayTest,
+ waitForReplayRequest,
+} from '../../../utils/replayHelpers';
+
+sentryTest(
+ '[buffer-mode] manually start buffer mode and capture buffer',
+ async ({ getLocalTestPath, page, browserName }) => {
+ // This was sometimes flaky on firefox/webkit, so skipping for now
+ if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) {
+ sentryTest.skip();
+ }
+
+ let callsToSentry = 0;
+ let errorEventId: string | undefined;
+ const reqPromise0 = waitForReplayRequest(page, 0);
+ const reqPromise1 = waitForReplayRequest(page, 1);
+ const reqPromise2 = waitForReplayRequest(page, 2);
+ const reqErrorPromise = waitForErrorRequest(page);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ const event = envelopeRequestParser(route.request());
+ // error events have no type field
+ if (event && !event.type && event.event_id) {
+ errorEventId = event.event_id;
+ }
+ // We only want to count errors & replays here
+ if (event && (!event.type || isReplayEvent(event))) {
+ callsToSentry++;
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+
+ await page.goto(url);
+ await page.click('#go-background');
+ await page.click('#error');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // error, no replays
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(1);
+
+ expect(
+ await page.evaluate(() => {
+ const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay;
+ const replay = replayIntegration._replay;
+ return replay.isEnabled();
+ }),
+ ).toBe(false);
+
+ // Start buffering and assert that it is enabled
+ expect(
+ await page.evaluate(() => {
+ const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay;
+ // @ts-ignore private
+ const replay = replayIntegration._replay;
+ replayIntegration.startBuffering();
+ return replay.isEnabled();
+ }),
+ ).toBe(true);
+
+ await page.click('#log');
+ await page.click('#go-background');
+ await page.click('#error2');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // 2 errors
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(2);
+
+ await page.evaluate(async () => {
+ const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
+ await replayIntegration.flush();
+ });
+
+ const req0 = await reqPromise0;
+
+ // 2 errors, 1 flush
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(3);
+
+ await page.click('#log');
+ await page.click('#go-background');
+
+ // Switches to session mode and then goes to background
+ const req1 = await reqPromise1;
+ const req2 = await reqPromise2;
+ expect(callsToSentry).toEqual(5);
+
+ const event0 = getReplayEvent(req0);
+ const content0 = getReplayRecordingContent(req0);
+
+ const event1 = getReplayEvent(req1);
+ const content1 = getReplayRecordingContent(req1);
+
+ const event2 = getReplayEvent(req2);
+ const content2 = getReplayRecordingContent(req2);
+
+ expect(event0).toEqual(
+ getExpectedReplayEvent({
+ contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } },
+ error_ids: [errorEventId!],
+ replay_type: 'buffer',
+ }),
+ );
+
+ // The first event should have both, full and incremental snapshots,
+ // as we recorded and kept all events in the buffer
+ expect(content0.fullSnapshots).toHaveLength(1);
+ // We don't know how many incremental snapshots we'll have (also browser-dependent),
+ // but we know that we have at least 5
+ expect(content0.incrementalSnapshots.length).toBeGreaterThan(5);
+ // We want to make sure that the event that triggered the error was recorded.
+ expect(content0.breadcrumbs).toEqual(
+ expect.arrayContaining([
+ {
+ ...expectedClickBreadcrumb,
+ message: 'body > button#error2',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: {
+ id: 'error2',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* *****',
+ },
+ },
+ },
+ ]),
+ );
+
+ expect(event1).toEqual(
+ getExpectedReplayEvent({
+ contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } },
+ replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type
+ segment_id: 1,
+ urls: [],
+ }),
+ );
+
+ // From switching to session mode
+ expect(content1.fullSnapshots).toHaveLength(1);
+
+ expect(event2).toEqual(
+ getExpectedReplayEvent({
+ contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } },
+ replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type
+ segment_id: 2,
+ urls: [],
+ }),
+ );
+
+ expect(content2.fullSnapshots).toHaveLength(0);
+ expect(content2.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb]));
+ },
+);
+
+sentryTest(
+ '[buffer-mode] manually start buffer mode and capture buffer, but do not continue as session',
+ async ({ getLocalTestPath, page, browserName }) => {
+ // This was sometimes flaky on firefox/webkit, so skipping for now
+ if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) {
+ sentryTest.skip();
+ }
+
+ let callsToSentry = 0;
+ let errorEventId: string | undefined;
+ const reqPromise0 = waitForReplayRequest(page, 0);
+ const reqErrorPromise = waitForErrorRequest(page);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ const event = envelopeRequestParser(route.request());
+ // error events have no type field
+ if (event && !event.type && event.event_id) {
+ errorEventId = event.event_id;
+ }
+ // We only want to count errors & replays here
+ if (event && (!event.type || isReplayEvent(event))) {
+ callsToSentry++;
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+
+ await page.goto(url);
+ await page.click('#go-background');
+ await page.click('#error');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // error, no replays
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(1);
+
+ expect(
+ await page.evaluate(() => {
+ const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay;
+ const replay = replayIntegration._replay;
+ return replay.isEnabled();
+ }),
+ ).toBe(false);
+
+ // Start buffering and assert that it is enabled
+ expect(
+ await page.evaluate(() => {
+ const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay;
+ // @ts-ignore private
+ const replay = replayIntegration._replay;
+ replayIntegration.startBuffering();
+ return replay.isEnabled();
+ }),
+ ).toBe(true);
+
+ await page.click('#log');
+ await page.click('#go-background');
+ await page.click('#error2');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // 2 errors
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(2);
+
+ await page.evaluate(async () => {
+ const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
+ await replayIntegration.flush({ continueRecording: false });
+ });
+
+ const req0 = await reqPromise0;
+
+ // 2 errors, 1 flush
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(3);
+
+ await page.click('#log');
+ await page.click('#go-background');
+
+ // Has stopped recording, should make no more calls to Sentry
+ expect(callsToSentry).toEqual(3);
+
+ const event0 = getReplayEvent(req0);
+ const content0 = getReplayRecordingContent(req0);
+
+ expect(event0).toEqual(
+ getExpectedReplayEvent({
+ contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } },
+ error_ids: [errorEventId!],
+ replay_type: 'buffer',
+ }),
+ );
+
+ // The first event should have both, full and incremental snapshots,
+ // as we recorded and kept all events in the buffer
+ expect(content0.fullSnapshots).toHaveLength(1);
+ // We don't know how many incremental snapshots we'll have (also browser-dependent),
+ // but we know that we have at least 5
+ expect(content0.incrementalSnapshots.length).toBeGreaterThan(5);
+ // We want to make sure that the event that triggered the error was recorded.
+ expect(content0.breadcrumbs).toEqual(
+ expect.arrayContaining([
+ {
+ ...expectedClickBreadcrumb,
+ message: 'body > button#error2',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: {
+ id: 'error2',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* *****',
+ },
+ },
+ },
+ ]),
+ );
+ },
+);
+
+// Doing this in buffer mode to test changing error sample rate after first
+// error happens.
+sentryTest('[buffer-mode] can sample on each error event', async ({ getLocalTestPath, page, browserName }) => {
+ // This was sometimes flaky on firefox/webkit, so skipping for now
+ if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) {
+ sentryTest.skip();
+ }
+
+ let callsToSentry = 0;
+ const errorEventIds: string[] = [];
+ const reqPromise0 = waitForReplayRequest(page, 0);
+ const reqErrorPromise = waitForErrorRequest(page);
+
+ await page.route('https://dsn.ingest.sentry.io/**/*', route => {
+ const event = envelopeRequestParser(route.request());
+ // error events have no type field
+ if (event && !event.type && event.event_id) {
+ errorEventIds.push(event.event_id);
+ }
+ // We only want to count errors & replays here
+ if (event && (!event.type || isReplayEvent(event))) {
+ callsToSentry++;
+ }
+
+ return route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ id: 'test-id' }),
+ });
+ });
+
+ const url = await getLocalTestPath({ testDir: __dirname });
+
+ await page.goto(url);
+ // Start buffering and assert that it is enabled
+ expect(
+ await page.evaluate(() => {
+ const replayIntegration = (window as unknown as Window & { Replay: InstanceType }).Replay;
+ const replay = replayIntegration['_replay'];
+ replayIntegration.startBuffering();
+ return replay.isEnabled();
+ }),
+ ).toBe(true);
+
+ await page.click('#go-background');
+ await page.click('#error');
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ // 1 error, no replay
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(1);
+
+ await page.evaluate(async () => {
+ const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
+ replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
+ });
+
+ // Error sample rate is now at 1.0, this error should create a replay
+ await page.click('#error2');
+
+ const req0 = await reqPromise0;
+
+ // 2 errors, 1 flush
+ await reqErrorPromise;
+ expect(callsToSentry).toEqual(3);
+
+ const event0 = getReplayEvent(req0);
+ const content0 = getReplayRecordingContent(req0);
+
+ expect(event0).toEqual(
+ getExpectedReplayEvent({
+ contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
+ error_ids: errorEventIds,
+ replay_type: 'buffer',
+ }),
+ );
+
+ // The first event should have both, full and incremental snapshots,
+ // as we recorded and kept all events in the buffer
+ expect(content0.fullSnapshots).toHaveLength(1);
+ // We want to make sure that the event that triggered the error was
+ // recorded, as well as the first error that did not get sampled.
+ expect(content0.breadcrumbs).toEqual(
+ expect.arrayContaining([
+ {
+ ...expectedClickBreadcrumb,
+ message: 'body > button#error',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: {
+ id: 'error',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '***** *****',
+ },
+ },
+ },
+ {
+ ...expectedClickBreadcrumb,
+ message: 'body > button#error2',
+ data: {
+ nodeId: expect.any(Number),
+ node: {
+ attributes: {
+ id: 'error2',
+ },
+ id: expect.any(Number),
+ tagName: 'button',
+ textContent: '******* *****',
+ },
+ },
+ },
+ ]),
+ );
+});
diff --git a/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts b/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts
index b63ca9a8b61f..9698326a082f 100644
--- a/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts
+++ b/packages/browser-integration-tests/suites/replay/errors/droppedError/test.ts
@@ -42,6 +42,6 @@ sentryTest(
expect(callsToSentry).toEqual(0);
const replay = await getReplaySnapshot(page);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
},
);
diff --git a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts
index 18dd4b40e2a1..fee9e05d4a49 100644
--- a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts
+++ b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts
@@ -59,6 +59,8 @@ sentryTest(
await page.click('#error');
const req0 = await reqPromise0;
+ expect(callsToSentry).toEqual(2); // 1 error, 1 replay event
+
await page.click('#go-background');
const req1 = await reqPromise1;
await reqErrorPromise;
@@ -84,7 +86,7 @@ sentryTest(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
error_ids: [errorEventId!],
- replay_type: 'error',
+ replay_type: 'buffer',
}),
);
@@ -118,7 +120,7 @@ sentryTest(
expect(event1).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
- replay_type: 'error', // although we're in session mode, we still send 'error' as replay_type
+ replay_type: 'buffer', // although we're in session mode, we still send 'error' as replay_type
segment_id: 1,
urls: [],
}),
@@ -133,7 +135,7 @@ sentryTest(
expect(event2).toEqual(
getExpectedReplayEvent({
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
- replay_type: 'error',
+ replay_type: 'buffer',
segment_id: 2,
urls: [],
}),
diff --git a/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts b/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts
index 963f47e9919d..89c6d0342983 100644
--- a/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts
+++ b/packages/browser-integration-tests/suites/replay/errors/errorNotSent/test.ts
@@ -36,6 +36,6 @@ sentryTest(
expect(callsToSentry).toEqual(1);
const replay = await getReplaySnapshot(page);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
},
);
diff --git a/packages/browser-integration-tests/suites/replay/sampling/test.ts b/packages/browser-integration-tests/suites/replay/sampling/test.ts
index 78ca3d8fcf6a..752c9c89a431 100644
--- a/packages/browser-integration-tests/suites/replay/sampling/test.ts
+++ b/packages/browser-integration-tests/suites/replay/sampling/test.ts
@@ -1,5 +1,4 @@
import { expect } from '@playwright/test';
-import type { ReplayContainer } from '@sentry/replay/build/npm/types/types';
import { sentryTest } from '../../../utils/fixtures';
import { getReplaySnapshot, shouldSkipReplayTest } from '../../../utils/replayHelpers';
@@ -25,13 +24,11 @@ sentryTest('should not send replays if both sample rates are 0', async ({ getLoc
await page.click('button');
- await page.waitForFunction(() => {
- const replayIntegration = (window as unknown as Window & { Replay: { _replay: ReplayContainer } }).Replay;
- return !!replayIntegration._replay.session;
- });
const replay = await getReplaySnapshot(page);
- expect(replay.session?.sampled).toBe(false);
+ expect(replay.session).toBe(undefined);
+ expect(replay._isEnabled).toBe(false);
+ expect(replay.recordingMode).toBe('session');
// Cannot wait on getFirstSentryEnvelopeRequest, as that never resolves
});
diff --git a/packages/core/test/lib/transports/offline.test.ts b/packages/core/test/lib/transports/offline.test.ts
index 0779dff6a8d7..d7d2ee7a90ae 100644
--- a/packages/core/test/lib/transports/offline.test.ts
+++ b/packages/core/test/lib/transports/offline.test.ts
@@ -35,7 +35,7 @@ const REPLAY_EVENT: ReplayEvent = {
urls: ['https://example.com'],
replay_id: 'MY_REPLAY_ID',
segment_id: 3,
- replay_type: 'error',
+ replay_type: 'buffer',
};
const DSN = dsnFromString('https://public@dsn.ingest.sentry.io/1337');
diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts
index f1f8627c120c..f9b452d3f04f 100644
--- a/packages/replay/src/constants.ts
+++ b/packages/replay/src/constants.ts
@@ -27,7 +27,7 @@ export const DEFAULT_FLUSH_MIN_DELAY = 5_000;
export const DEFAULT_FLUSH_MAX_DELAY = 5_500;
/* How long to wait for error checkouts */
-export const ERROR_CHECKOUT_TIME = 60_000;
+export const BUFFER_CHECKOUT_TIME = 60_000;
export const RETRY_BASE_INTERVAL = 5000;
export const RETRY_MAX_COUNT = 3;
diff --git a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts
index dc94022e5b4a..5c8e59d6be4e 100644
--- a/packages/replay/src/coreHandlers/handleAfterSendEvent.ts
+++ b/packages/replay/src/coreHandlers/handleAfterSendEvent.ts
@@ -4,6 +4,7 @@ import type { Event, Transport, TransportMakeRequestResponse } from '@sentry/typ
import { UNABLE_TO_SEND_REPLAY } from '../constants';
import type { ReplayContainer } from '../types';
import { isErrorEvent, isTransactionEvent } from '../util/eventUtils';
+import { isSampled } from '../util/isSampled';
type AfterSendEventCallback = (event: Event, sendResponse: TransportMakeRequestResponse | void) => void;
@@ -49,10 +50,14 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal
// Trigger error recording
// Need to be very careful that this does not cause an infinite loop
if (
- replay.recordingMode === 'error' &&
+ replay.recordingMode === 'buffer' &&
event.exception &&
event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing
) {
+ if (!isSampled(replay.getOptions().errorSampleRate)) {
+ return;
+ }
+
setTimeout(() => {
// Capture current event buffer as new replay
void replay.sendBufferedReplayOrFlush();
diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts
index 5103d4e4af8e..81279947b969 100644
--- a/packages/replay/src/integration.ts
+++ b/packages/replay/src/integration.ts
@@ -181,14 +181,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
}
/**
- * We previously used to create a transaction in `setupOnce` and it would
- * potentially create a transaction before some native SDK integrations have run
- * and applied their own global event processor. An example is:
- * https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
- *
- * So we call `replay.setup` in next event loop as a workaround to wait for other
- * global event processors to finish. This is no longer needed, but keeping it
- * here to avoid any future issues.
+ * Setup and initialize replay container
*/
public setupOnce(): void {
if (!isBrowser()) {
@@ -197,12 +190,20 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
this._setup();
- // XXX: See method comments above
- setTimeout(() => this.start());
+ // Once upon a time, we tried to create a transaction in `setupOnce` and it would
+ // potentially create a transaction before some native SDK integrations have run
+ // and applied their own global event processor. An example is:
+ // https://github.com/getsentry/sentry-javascript/blob/b47ceafbdac7f8b99093ce6023726ad4687edc48/packages/browser/src/integrations/useragent.ts
+ //
+ // So we call `this._initialize()` in next event loop as a workaround to wait for other
+ // global event processors to finish. This is no longer needed, but keeping it
+ // here to avoid any future issues.
+ setTimeout(() => this._initialize());
}
/**
- * Initializes the plugin.
+ * Start a replay regardless of sampling rate. Calling this will always
+ * create a new session. Will throw an error if replay is already in progress.
*
* Creates or loads a session, attaches listeners to varying events (DOM,
* PerformanceObserver, Recording, Sentry SDK, etc)
@@ -215,6 +216,18 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
this._replay.start();
}
+ /**
+ * Start replay buffering. Buffers until `flush()` is called or, if
+ * `replaysOnErrorSampleRate` > 0, until an error occurs.
+ */
+ public startBuffering(): void {
+ if (!this._replay) {
+ return;
+ }
+
+ this._replay.startBuffering();
+ }
+
/**
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
* does not support a teardown
@@ -228,11 +241,11 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
}
/**
- * Immediately send all pending events. In buffer-mode, this should be used
- * to capture the initial replay.
- *
+ * If not in "session" recording mode, flush event buffer which will create a new replay.
* Unless `continueRecording` is false, the replay will continue to record and
* behave as a "session"-based replay.
+ *
+ * Otherwise, queue up a flush.
*/
public flush(options?: SendBufferedReplayOptions): Promise {
if (!this._replay || !this._replay.isEnabled()) {
@@ -252,6 +265,16 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
return this._replay.getSessionId();
}
+ /**
+ * Initializes replay.
+ */
+ protected _initialize(): void {
+ if (!this._replay) {
+ return;
+ }
+
+ this._replay.initializeSampling();
+ }
/** Setup the integration. */
private _setup(): void {
diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts
index 241a711f20cd..24372cc1ab3d 100644
--- a/packages/replay/src/replay.ts
+++ b/packages/replay/src/replay.ts
@@ -5,7 +5,7 @@ import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types';
import { logger } from '@sentry/utils';
import {
- ERROR_CHECKOUT_TIME,
+ BUFFER_CHECKOUT_TIME,
MAX_SESSION_LIFE,
SESSION_IDLE_EXPIRE_DURATION,
SESSION_IDLE_PAUSE_DURATION,
@@ -56,9 +56,11 @@ export class ReplayContainer implements ReplayContainerInterface {
public session: Session | undefined;
/**
- * Recording can happen in one of two modes:
- * * session: Record the whole session, sending it continuously
- * * error: Always keep the last 60s of recording, and when an error occurs, send it immediately
+ * Recording can happen in one of three modes:
+ * - session: Record the whole session, sending it continuously
+ * - buffer: Always keep the last 60s of recording, requires:
+ * - having replaysOnErrorSampleRate > 0 to capture replay when an error occurs
+ * - or calling `flush()` to send the replay
*/
public recordingMode: ReplayRecordingMode = 'session';
@@ -157,49 +159,102 @@ export class ReplayContainer implements ReplayContainerInterface {
}
/**
- * Initializes the plugin.
- *
- * Creates or loads a session, attaches listeners to varying events (DOM,
- * _performanceObserver, Recording, Sentry SDK, etc)
+ * Initializes the plugin based on sampling configuration. Should not be
+ * called outside of constructor.
*/
- public start(): void {
- this.setInitialState();
+ public initializeSampling(): void {
+ const { errorSampleRate, sessionSampleRate } = this._options;
- if (!this._loadAndCheckSession()) {
+ // If neither sample rate is > 0, then do nothing - user will need to call one of
+ // `start()` or `startBuffering` themselves.
+ if (errorSampleRate <= 0 && sessionSampleRate <= 0) {
return;
}
- // If there is no session, then something bad has happened - can't continue
- if (!this.session) {
- this._handleException(new Error('No session found'));
+ // Otherwise if there is _any_ sample rate set, try to load an existing
+ // session, or create a new one.
+ const isSessionSampled = this._loadAndCheckSession();
+
+ if (!isSessionSampled) {
+ // This should only occur if `errorSampleRate` is 0 and was unsampled for
+ // session-based replay. In this case there is nothing to do.
return;
}
- if (!this.session.sampled) {
- // If session was not sampled, then we do not initialize the integration at all.
+ if (!this.session) {
+ // This should not happen, something wrong has occurred
+ this._handleException(new Error('Unable to initialize and create session'));
return;
}
- // If session is sampled for errors, then we need to set the recordingMode
- // to 'error', which will configure recording with different options.
- if (this.session.sampled === 'error') {
- this.recordingMode = 'error';
+ if (this.session.sampled && this.session.sampled !== 'session') {
+ // If not sampled as session-based, then recording mode will be `buffer`
+ // Note that we don't explicitly check if `sampled === 'buffer'` because we
+ // could have sessions from Session storage that are still `error` from
+ // prior SDK version.
+ this.recordingMode = 'buffer';
}
- // setup() is generally called on page load or manually - in both cases we
- // should treat it as an activity
- this._updateSessionActivity();
+ this._initializeRecording();
+ }
- this.eventBuffer = createEventBuffer({
- useCompression: this._options.useCompression,
+ /**
+ * Start a replay regardless of sampling rate. Calling this will always
+ * create a new session. Will throw an error if replay is already in progress.
+ *
+ * Creates or loads a session, attaches listeners to varying events (DOM,
+ * _performanceObserver, Recording, Sentry SDK, etc)
+ */
+ public start(): void {
+ if (this._isEnabled && this.recordingMode === 'session') {
+ throw new Error('Replay recording is already in progress');
+ }
+
+ if (this._isEnabled && this.recordingMode === 'buffer') {
+ throw new Error('Replay buffering is in progress, call `flush()` to save the replay');
+ }
+
+ const previousSessionId = this.session && this.session.id;
+
+ const { session } = getSession({
+ timeouts: this.timeouts,
+ stickySession: Boolean(this._options.stickySession),
+ currentSession: this.session,
+ // This is intentional: create a new session-based replay when calling `start()`
+ sessionSampleRate: 1,
+ allowBuffering: false,
});
- this._addListeners();
+ session.previousSessionId = previousSessionId;
+ this.session = session;
- // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
- this._isEnabled = true;
+ this._initializeRecording();
+ }
- this.startRecording();
+ /**
+ * Start replay buffering. Buffers until `flush()` is called or, if
+ * `replaysOnErrorSampleRate` > 0, an error occurs.
+ */
+ public startBuffering(): void {
+ if (this._isEnabled) {
+ throw new Error('Replay recording is already in progress');
+ }
+
+ const previousSessionId = this.session && this.session.id;
+
+ const { session } = getSession({
+ timeouts: this.timeouts,
+ stickySession: Boolean(this._options.stickySession),
+ currentSession: this.session,
+ sessionSampleRate: 0,
+ allowBuffering: true,
+ });
+
+ session.previousSessionId = previousSessionId;
+ this.session = session;
+
+ this.recordingMode = 'buffer';
+ this._initializeRecording();
}
/**
@@ -214,7 +269,7 @@ export class ReplayContainer implements ReplayContainerInterface {
// When running in error sampling mode, we need to overwrite `checkoutEveryNms`
// Without this, it would record forever, until an error happens, which we don't want
// instead, we'll always keep the last 60 seconds of replay before an error happened
- ...(this.recordingMode === 'error' && { checkoutEveryNms: ERROR_CHECKOUT_TIME }),
+ ...(this.recordingMode === 'buffer' && { checkoutEveryNms: BUFFER_CHECKOUT_TIME }),
emit: getHandleRecordingEmit(this),
onMutation: this._onMutationHandler,
});
@@ -340,6 +395,13 @@ export class ReplayContainer implements ReplayContainerInterface {
// Reset all "capture on error" configuration before
// starting a new recording
this.recordingMode = 'session';
+
+ // Once this session ends, we do not want to refresh it
+ if (this.session) {
+ this.session.shouldRefresh = false;
+ this._maybeSaveSession();
+ }
+
this.startRecording();
}
@@ -352,12 +414,12 @@ export class ReplayContainer implements ReplayContainerInterface {
* processing and hand back control to caller.
*/
public addUpdate(cb: AddUpdateCallback): void {
- // We need to always run `cb` (e.g. in the case of `this.recordingMode == 'error'`)
+ // We need to always run `cb` (e.g. in the case of `this.recordingMode == 'buffer'`)
const cbResult = cb();
// If this option is turned on then we will only want to call `flush`
// explicitly
- if (this.recordingMode === 'error') {
+ if (this.recordingMode === 'buffer') {
return;
}
@@ -484,6 +546,30 @@ export class ReplayContainer implements ReplayContainerInterface {
this._context.urls.push(url);
}
+ /**
+ * Initialize and start all listeners to varying events (DOM,
+ * Performance Observer, Recording, Sentry SDK, etc)
+ */
+ private _initializeRecording(): void {
+ this.setInitialState();
+
+ // this method is generally called on page load or manually - in both cases
+ // we should treat it as an activity
+ this._updateSessionActivity();
+
+ this.eventBuffer = createEventBuffer({
+ useCompression: this._options.useCompression,
+ });
+
+ this._removeListeners();
+ this._addListeners();
+
+ // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout
+ this._isEnabled = true;
+
+ this.startRecording();
+ }
+
/** A wrapper to conditionally capture exceptions. */
private _handleException(error: unknown): void {
__DEBUG_BUILD__ && logger.error('[Replay]', error);
@@ -503,7 +589,7 @@ export class ReplayContainer implements ReplayContainerInterface {
stickySession: Boolean(this._options.stickySession),
currentSession: this.session,
sessionSampleRate: this._options.sessionSampleRate,
- errorSampleRate: this._options.errorSampleRate,
+ allowBuffering: this._options.errorSampleRate > 0,
});
// If session was newly created (i.e. was not loaded from storage), then
@@ -718,7 +804,7 @@ export class ReplayContainer implements ReplayContainerInterface {
* Only flush if `this.recordingMode === 'session'`
*/
private _conditionalFlush(): void {
- if (this.recordingMode === 'error') {
+ if (this.recordingMode === 'buffer') {
return;
}
diff --git a/packages/replay/src/session/Session.ts b/packages/replay/src/session/Session.ts
index 9089ea54c76c..b5ecddcbdb84 100644
--- a/packages/replay/src/session/Session.ts
+++ b/packages/replay/src/session/Session.ts
@@ -1,7 +1,6 @@
import { uuid4 } from '@sentry/utils';
import type { Sampled, Session } from '../types';
-import { isSampled } from '../util/isSampled';
/**
* Get a session with defaults & applied sampling.
@@ -21,12 +20,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S
lastActivity,
segmentId,
sampled,
+ shouldRefresh: true,
};
}
-
-/**
- * Get the sampled status for a session based on sample rates & current sampled status.
- */
-export function getSessionSampleType(sessionSampleRate: number, errorSampleRate: number): Sampled {
- return isSampled(sessionSampleRate) ? 'session' : isSampled(errorSampleRate) ? 'error' : false;
-}
diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts
index bd6d18ad33e8..f5f2d120b92b 100644
--- a/packages/replay/src/session/createSession.ts
+++ b/packages/replay/src/session/createSession.ts
@@ -1,16 +1,24 @@
import { logger } from '@sentry/utils';
-import type { Session, SessionOptions } from '../types';
+import type { Sampled, Session, SessionOptions } from '../types';
+import { isSampled } from '../util/isSampled';
import { saveSession } from './saveSession';
-import { getSessionSampleType, makeSession } from './Session';
+import { makeSession } from './Session';
+
+/**
+ * Get the sampled status for a session based on sample rates & current sampled status.
+ */
+export function getSessionSampleType(sessionSampleRate: number, allowBuffering: boolean): Sampled {
+ return isSampled(sessionSampleRate) ? 'session' : allowBuffering ? 'buffer' : false;
+}
/**
* Create a new session, which in its current implementation is a Sentry event
* that all replays will be saved to as attachments. Currently, we only expect
* one of these Sentry events per "replay session".
*/
-export function createSession({ sessionSampleRate, errorSampleRate, stickySession = false }: SessionOptions): Session {
- const sampled = getSessionSampleType(sessionSampleRate, errorSampleRate);
+export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session {
+ const sampled = getSessionSampleType(sessionSampleRate, allowBuffering);
const session = makeSession({
sampled,
});
diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts
index 150fbe12c871..ff993887e64b 100644
--- a/packages/replay/src/session/getSession.ts
+++ b/packages/replay/src/session/getSession.ts
@@ -23,7 +23,7 @@ export function getSession({
currentSession,
stickySession,
sessionSampleRate,
- errorSampleRate,
+ allowBuffering,
}: GetSessionParams): { type: 'new' | 'saved'; session: Session } {
// If session exists and is passed, use it instead of always hitting session storage
const session = currentSession || (stickySession && fetchSession());
@@ -36,8 +36,9 @@ export function getSession({
if (!isExpired) {
return { type: 'saved', session };
- } else if (session.sampled === 'error') {
- // Error samples should not be re-created when expired, but instead we stop when the replay is done
+ } else if (!session.shouldRefresh) {
+ // In this case, stop
+ // This is the case if we have an error session that is completed (=triggered an error)
const discardedSession = makeSession({ sampled: false });
return { type: 'new', session: discardedSession };
} else {
@@ -49,7 +50,7 @@ export function getSession({
const newSession = createSession({
stickySession,
sessionSampleRate,
- errorSampleRate,
+ allowBuffering,
});
return { type: 'new', session: newSession };
diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts
index a443cede2e58..eb28b09a5d64 100644
--- a/packages/replay/src/types.ts
+++ b/packages/replay/src/types.ts
@@ -183,20 +183,6 @@ export interface WorkerResponse {
export type AddEventResult = void;
-export interface SampleRates {
- /**
- * The sample rate for session-long replays. 1.0 will record all sessions and
- * 0 will record none.
- */
- sessionSampleRate: number;
-
- /**
- * The sample rate for sessions that has had an error occur. This is
- * independent of `sessionSampleRate`.
- */
- errorSampleRate: number;
-}
-
export interface ReplayNetworkOptions {
/**
* Capture request/response details for XHR/Fetch requests that match the given URLs.
@@ -230,18 +216,25 @@ export interface ReplayNetworkOptions {
networkResponseHeaders: string[];
}
-/**
- * Session options that are configurable by the integration configuration
- */
-export interface SessionOptions extends SampleRates {
+export interface ReplayPluginOptions extends ReplayNetworkOptions {
+ /**
+ * The sample rate for session-long replays. 1.0 will record all sessions and
+ * 0 will record none.
+ */
+ sessionSampleRate: number;
+
+ /**
+ * The sample rate for sessions that has had an error occur. This is
+ * independent of `sessionSampleRate`.
+ */
+ errorSampleRate: number;
+
/**
* If false, will create a new session per pageload. Otherwise, saves session
* to Session Storage.
*/
stickySession: boolean;
-}
-export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOptions {
/**
* The amount of time to wait before sending a replay
*/
@@ -279,6 +272,18 @@ export interface ReplayPluginOptions extends SessionOptions, ReplayNetworkOption
}>;
}
+/**
+ * Session options that are configurable by the integration configuration
+ */
+export interface SessionOptions extends Pick {
+ /**
+ * Should buffer recordings to be saved later either by error sampling, or by
+ * manually calling `flush()`. This is only a factor if not sampled for a
+ * session-based replay.
+ */
+ allowBuffering: boolean;
+}
+
export interface ReplayIntegrationPrivacyOptions {
/**
* Mask text content for elements that match the CSS selectors in the list.
@@ -397,7 +402,7 @@ export interface InternalEventContext extends CommonEventContext {
earliestEvent: number | null;
}
-export type Sampled = false | 'session' | 'error';
+export type Sampled = false | 'session' | 'buffer';
export interface Session {
id: string;
@@ -424,9 +429,15 @@ export interface Session {
previousSessionId?: string;
/**
- * Is the session sampled? `false` if not sampled, otherwise, `session` or `error`
+ * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer`
*/
sampled: Sampled;
+
+ /**
+ * If this is false, the session should not be refreshed when it was inactive.
+ * This can be the case if you had a buffered session which is now recording because an error happened.
+ */
+ shouldRefresh: boolean;
}
export interface EventBuffer {
@@ -469,6 +480,7 @@ export interface ReplayContainer {
isEnabled(): boolean;
isPaused(): boolean;
getContext(): InternalEventContext;
+ initializeSampling(): void;
start(): void;
stop(reason?: string): Promise;
pause(): void;
diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts
index e9a4a16b5018..8a31f86ebf23 100644
--- a/packages/replay/src/util/handleRecordingEmit.ts
+++ b/packages/replay/src/util/handleRecordingEmit.ts
@@ -34,7 +34,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
// when an error occurs. Clear any state that happens before this current
// checkout. This needs to happen before `addEvent()` which updates state
// dependent on this reset.
- if (replay.recordingMode === 'error' && isCheckout) {
+ if (replay.recordingMode === 'buffer' && isCheckout) {
replay.setInitialState();
}
@@ -60,7 +60,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
// See note above re: session start needs to reflect the most recent
// checkout.
- if (replay.recordingMode === 'error' && replay.session) {
+ if (replay.recordingMode === 'buffer' && replay.session) {
const { earliestEvent } = replay.getContext();
if (earliestEvent) {
replay.session.started = earliestEvent;
diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts
index d46bacf4aede..9f4748253110 100644
--- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts
+++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts
@@ -86,7 +86,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
expect(Array.from(replay.getContext().traceIds)).toEqual(['tr2']);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
it('allows undefined send response when using custom transport', async () => {
@@ -140,7 +140,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
const handler = handleAfterSendEvent(replay);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(error1, { statusCode: 200 });
@@ -210,7 +210,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
const handler = handleAfterSendEvent(replay);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(profileEvent, { statusCode: 200 });
handler(replayEvent, { statusCode: 200 });
@@ -224,7 +224,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
expect(Array.from(replay.getContext().errorIds)).toEqual([]);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
it('does not flush in error mode when failing to send the error', async () => {
@@ -244,7 +244,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
const handler = handleAfterSendEvent(replay);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(error1, undefined);
@@ -258,7 +258,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
expect(Array.from(replay.getContext().errorIds)).toEqual([]);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
it('does not flush if error event has no exception', async () => {
@@ -278,7 +278,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
const handler = handleAfterSendEvent(replay);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(error1, { statusCode: 200 });
@@ -292,7 +292,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
expect(Array.from(replay.getContext().errorIds)).toEqual(['err1']);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
it('does not flush if error is replay send error', async () => {
@@ -312,7 +312,7 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
const handler = handleAfterSendEvent(replay);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(error1, { statusCode: 200 });
@@ -326,6 +326,6 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
expect(Array.from(replay.getContext().errorIds)).toEqual(['err1']);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
});
diff --git a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts
index 24e709707033..375c79fb6d1e 100644
--- a/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts
+++ b/packages/replay/test/integration/coreHandlers/handleGlobalEvent.test.ts
@@ -1,5 +1,6 @@
import type { Event } from '@sentry/types';
+import type { Replay as ReplayIntegration } from '../../../src';
import { REPLAY_EVENT_NAME } from '../../../src/constants';
import { handleGlobalEventListener } from '../../../src/coreHandlers/handleGlobalEvent';
import type { ReplayContainer } from '../../../src/replay';
@@ -84,8 +85,10 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
});
it('tags errors and transactions with replay id for session samples', async () => {
- ({ replay } = await resetSdkMock({}));
- replay.start();
+ let integration: ReplayIntegration;
+ ({ replay, integration } = await resetSdkMock({}));
+ // @ts-ignore protected but ok to use for testing
+ integration._initialize();
const transaction = Transaction();
const error = Error();
expect(handleGlobalEventListener(replay)(transaction, {})).toEqual(
@@ -163,7 +166,7 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
const handler = handleGlobalEventListener(replay);
const handler2 = handleGlobalEventListener(replay, true);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
handler(profileEvent, {});
handler(replayEvent, {});
@@ -179,7 +182,7 @@ describe('Integration | coreHandlers | handleGlobalEvent', () => {
expect(Array.from(replay.getContext().errorIds)).toEqual([]);
expect(replay.isEnabled()).toBe(true);
expect(replay.isPaused()).toBe(false);
- expect(replay.recordingMode).toBe('error');
+ expect(replay.recordingMode).toBe('buffer');
});
it('does not skip non-rrweb errors', () => {
diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts
index b9da6b081bbc..16962bf5b2f8 100644
--- a/packages/replay/test/integration/errorSampleRate.test.ts
+++ b/packages/replay/test/integration/errorSampleRate.test.ts
@@ -1,8 +1,8 @@
import { captureException, getCurrentHub } from '@sentry/core';
import {
+ BUFFER_CHECKOUT_TIME,
DEFAULT_FLUSH_MIN_DELAY,
- ERROR_CHECKOUT_TIME,
MAX_SESSION_LIFE,
REPLAY_SESSION_KEY,
SESSION_IDLE_EXPIRE_DURATION,
@@ -71,7 +71,7 @@ describe('Integration | errorSampleRate', () => {
expect(replay).toHaveSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
- replay_type: 'error',
+ replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
@@ -103,7 +103,7 @@ describe('Integration | errorSampleRate', () => {
expect(replay).toHaveLastSentReplay({
recordingPayloadHeader: { segment_id: 1 },
replayEventPayload: expect.objectContaining({
- replay_type: 'error',
+ replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
@@ -182,7 +182,7 @@ describe('Integration | errorSampleRate', () => {
expect(replay).toHaveSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
- replay_type: 'error',
+ replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
@@ -223,7 +223,7 @@ describe('Integration | errorSampleRate', () => {
expect(replay).toHaveLastSentReplay({
recordingPayloadHeader: { segment_id: 0 },
replayEventPayload: expect.objectContaining({
- replay_type: 'error',
+ replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 1,
@@ -373,9 +373,73 @@ describe('Integration | errorSampleRate', () => {
// sample rate of 0.0), or an error session that has no errors. Instead we
// simply stop the session replay completely and wait for a new page load to
// resample.
- it('stops replay if session exceeds MAX_SESSION_LIFE and does not start a new session thereafter', async () => {
- // Idle for 15 minutes
- jest.advanceTimersByTime(MAX_SESSION_LIFE + 1);
+ it.each([
+ ['MAX_SESSION_LIFE', MAX_SESSION_LIFE],
+ ['SESSION_IDLE_DURATION', SESSION_IDLE_EXPIRE_DURATION],
+ ])(
+ 'stops replay if session had an error and exceeds %s and does not start a new session thereafter',
+ async (_label, waitTime) => {
+ expect(replay.session?.shouldRefresh).toBe(true);
+
+ captureException(new Error('testing'));
+
+ await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+
+ // segment_id is 1 because it sends twice on error
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
+ });
+ expect(replay.session?.shouldRefresh).toBe(false);
+
+ // Idle for given time
+ jest.advanceTimersByTime(waitTime + 1);
+ await new Promise(process.nextTick);
+
+ const TEST_EVENT = {
+ data: { name: 'lost event' },
+ timestamp: BASE_TIMESTAMP,
+ type: 3,
+ };
+ mockRecord._emitter(TEST_EVENT);
+
+ jest.runAllTimers();
+ await new Promise(process.nextTick);
+
+ // We stop recording after 15 minutes of inactivity in error mode
+
+ // still no new replay sent
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 1 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
+ });
+
+ expect(replay.isEnabled()).toBe(false);
+
+ domHandler({
+ name: 'click',
+ });
+
+ // Remains disabled!
+ expect(replay.isEnabled()).toBe(false);
+ },
+ );
+
+ it.each([
+ ['MAX_SESSION_LIFE', MAX_SESSION_LIFE],
+ ['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION],
+ ])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => {
+ expect(replay).not.toHaveLastSentReplay();
+
+ // Idle for given time
+ jest.advanceTimersByTime(waitTime + 1);
+ await new Promise(process.nextTick);
const TEST_EVENT = {
data: { name: 'lost event' },
@@ -383,23 +447,49 @@ describe('Integration | errorSampleRate', () => {
type: 3,
};
mockRecord._emitter(TEST_EVENT);
- expect(replay).not.toHaveLastSentReplay();
jest.runAllTimers();
await new Promise(process.nextTick);
- // We stop recording after 15 minutes of inactivity in error mode
-
+ // still no new replay sent
expect(replay).not.toHaveLastSentReplay();
- expect(replay.isEnabled()).toBe(false);
- expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('buffer');
domHandler({
name: 'click',
});
- // Remains disabled!
- expect(replay.isEnabled()).toBe(false);
+ await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+
+ expect(replay).not.toHaveLastSentReplay();
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('buffer');
+
+ // should still react to errors later on
+ captureException(new Error('testing'));
+
+ await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
+ });
+
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('session');
+ expect(replay.session?.sampled).toBe('buffer');
+ expect(replay.session?.shouldRefresh).toBe(false);
});
// Should behave the same as above test
@@ -420,15 +510,29 @@ describe('Integration | errorSampleRate', () => {
// We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode
expect(replay).not.toHaveLastSentReplay();
- expect(replay.isEnabled()).toBe(false);
- expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('buffer');
- domHandler({
- name: 'click',
+ // should still react to errors later on
+ captureException(new Error('testing'));
+
+ await new Promise(process.nextTick);
+ jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
+ await new Promise(process.nextTick);
+
+ expect(replay).toHaveLastSentReplay({
+ recordingPayloadHeader: { segment_id: 0 },
+ replayEventPayload: expect.objectContaining({
+ replay_type: 'buffer',
+ }),
});
- // Remains disabled!
- expect(replay.isEnabled()).toBe(false);
+ expect(replay.isEnabled()).toBe(true);
+ expect(replay.isPaused()).toBe(false);
+ expect(replay.recordingMode).toBe('session');
+ expect(replay.session?.sampled).toBe('buffer');
+ expect(replay.session?.shouldRefresh).toBe(false);
});
it('has the correct timestamps with deferred root event and last replay update', async () => {
@@ -468,7 +572,7 @@ describe('Integration | errorSampleRate', () => {
});
it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => {
- const ELAPSED = ERROR_CHECKOUT_TIME;
+ const ELAPSED = BUFFER_CHECKOUT_TIME;
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
mockRecord._emitter(TEST_EVENT);
@@ -617,15 +721,19 @@ it('sends a replay after loading the session multiple times', async () => {
// Pretend that a session is already saved before loading replay
WINDOW.sessionStorage.setItem(
REPLAY_SESSION_KEY,
- `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"error","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`,
+ `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"buffer","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`,
);
- const { mockRecord, replay } = await resetSdkMock({
+ const { mockRecord, replay, integration } = await resetSdkMock({
replayOptions: {
stickySession: true,
},
+ sentryOptions: {
+ replaysOnErrorSampleRate: 1.0,
+ },
autoStart: false,
});
- replay.start();
+ // @ts-ignore this is protected, but we want to call it for this test
+ integration._initialize();
jest.runAllTimers();
diff --git a/packages/replay/test/integration/sampling.test.ts b/packages/replay/test/integration/sampling.test.ts
index 049329ebda3e..a038489280bb 100644
--- a/packages/replay/test/integration/sampling.test.ts
+++ b/packages/replay/test/integration/sampling.test.ts
@@ -1,12 +1,15 @@
-import { mockRrweb, mockSdk } from '../index';
+import { resetSdkMock } from '../mocks/resetSdkMock';
import { useFakeTimers } from '../utils/use-fake-timers';
useFakeTimers();
describe('Integration | sampling', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
it('does nothing if not sampled', async () => {
- const { record: mockRecord } = mockRrweb();
- const { replay } = await mockSdk({
+ const { mockRecord, replay } = await resetSdkMock({
replayOptions: {
stickySession: true,
},
@@ -20,14 +23,59 @@ describe('Integration | sampling', () => {
const spyAddListeners = jest.spyOn(replay, '_addListeners');
jest.runAllTimers();
- expect(replay.session?.sampled).toBe(false);
- expect(replay.getContext()).toEqual(
- expect.objectContaining({
- initialTimestamp: expect.any(Number),
- initialUrl: 'http://localhost/',
- }),
- );
+ expect(replay.session).toBe(undefined);
+
+ // This is what the `_context` member is initialized with
+ expect(replay.getContext()).toEqual({
+ errorIds: new Set(),
+ traceIds: new Set(),
+ urls: [],
+ earliestEvent: null,
+ initialTimestamp: expect.any(Number),
+ initialUrl: '',
+ });
expect(mockRecord).not.toHaveBeenCalled();
expect(spyAddListeners).not.toHaveBeenCalled();
+
+ // TODO(billy): Should we initialize recordingMode to something else? It's
+ // awkward that recordingMode is `session` when both sample rates are 0
+ expect(replay.recordingMode).toBe('session');
+ });
+
+ it('samples for error based session', async () => {
+ const { mockRecord, replay, integration } = await resetSdkMock({
+ replayOptions: {
+ stickySession: true,
+ },
+ sentryOptions: {
+ replaysSessionSampleRate: 0.0,
+ replaysOnErrorSampleRate: 1.0,
+ },
+ autoStart: false, // Needs to be false in order to spy on replay
+ });
+
+ // @ts-ignore private API
+ const spyAddListeners = jest.spyOn(replay, '_addListeners');
+
+ // @ts-ignore protected
+ integration._initialize();
+
+ jest.runAllTimers();
+
+ expect(replay.session?.id).toBeDefined();
+
+ // This is what the `_context` member is initialized with
+ expect(replay.getContext()).toEqual({
+ errorIds: new Set(),
+ earliestEvent: expect.any(Number),
+ initialTimestamp: expect.any(Number),
+ initialUrl: 'http://localhost/',
+ traceIds: new Set(),
+ urls: ['http://localhost/'],
+ });
+ expect(replay.recordingMode).toBe('buffer');
+
+ expect(spyAddListeners).toHaveBeenCalledTimes(1);
+ expect(mockRecord).toHaveBeenCalledTimes(1);
});
});
diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts
index 1338566be9aa..c99326730574 100644
--- a/packages/replay/test/integration/session.test.ts
+++ b/packages/replay/test/integration/session.test.ts
@@ -119,6 +119,7 @@ describe('Integration | session', () => {
it('creates a new session if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and comes back to click their mouse', async () => {
const initialSession = { ...replay.session } as Session;
+ expect(mockRecord).toHaveBeenCalledTimes(1);
expect(initialSession?.id).toBeDefined();
expect(replay.getContext()).toEqual(
expect.objectContaining({
diff --git a/packages/replay/test/mocks/mockSdk.ts b/packages/replay/test/mocks/mockSdk.ts
index af23a47cc5bc..4a27b7286d22 100644
--- a/packages/replay/test/mocks/mockSdk.ts
+++ b/packages/replay/test/mocks/mockSdk.ts
@@ -68,6 +68,10 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
public setupOnce(): void {
// do nothing
}
+
+ public initialize(): void {
+ return super._initialize();
+ }
}
const replayIntegration = new TestReplayIntegration({
@@ -91,7 +95,8 @@ export async function mockSdk({ replayOptions, sentryOptions, autoStart = true }
replayIntegration['_setup']();
if (autoStart) {
- replayIntegration.start();
+ // Only exists in our mock
+ replayIntegration.initialize();
}
const replay = replayIntegration['_replay']!;
diff --git a/packages/replay/test/mocks/resetSdkMock.ts b/packages/replay/test/mocks/resetSdkMock.ts
index d0cbc1b6d049..5d9782dc457d 100644
--- a/packages/replay/test/mocks/resetSdkMock.ts
+++ b/packages/replay/test/mocks/resetSdkMock.ts
@@ -1,3 +1,4 @@
+import type { Replay as ReplayIntegration } from '../../src';
import type { ReplayContainer } from '../../src/replay';
import type { RecordMock } from './../index';
import { BASE_TIMESTAMP } from './../index';
@@ -9,6 +10,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
domHandler: DomHandler;
mockRecord: RecordMock;
replay: ReplayContainer;
+ integration: ReplayIntegration;
}> {
let domHandler: DomHandler;
@@ -27,7 +29,7 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
const { mockRrweb } = await import('./mockRrweb');
const { record: mockRecord } = mockRrweb();
- const { replay } = await mockSdk({
+ const { replay, integration } = await mockSdk({
replayOptions,
sentryOptions,
autoStart,
@@ -43,5 +45,6 @@ export async function resetSdkMock({ replayOptions, sentryOptions, autoStart }:
domHandler,
mockRecord,
replay,
+ integration,
};
}
diff --git a/packages/replay/test/unit/session/createSession.test.ts b/packages/replay/test/unit/session/createSession.test.ts
index afe2ce3df374..891cc012edb6 100644
--- a/packages/replay/test/unit/session/createSession.test.ts
+++ b/packages/replay/test/unit/session/createSession.test.ts
@@ -34,7 +34,7 @@ describe('Unit | session | createSession', () => {
const newSession = createSession({
stickySession: false,
sessionSampleRate: 1.0,
- errorSampleRate: 0,
+ allowBuffering: false,
});
expect(captureEventMock).not.toHaveBeenCalled();
@@ -49,7 +49,7 @@ describe('Unit | session | createSession', () => {
const newSession = createSession({
stickySession: true,
sessionSampleRate: 1.0,
- errorSampleRate: 0,
+ allowBuffering: false,
});
expect(captureEventMock).not.toHaveBeenCalled();
diff --git a/packages/replay/test/unit/session/fetchSession.test.ts b/packages/replay/test/unit/session/fetchSession.test.ts
index 526c9c7969d1..cf1856e53356 100644
--- a/packages/replay/test/unit/session/fetchSession.test.ts
+++ b/packages/replay/test/unit/session/fetchSession.test.ts
@@ -28,6 +28,7 @@ describe('Unit | session | fetchSession', () => {
segmentId: 0,
sampled: 'session',
started: 1648827162630,
+ shouldRefresh: true,
});
});
@@ -43,6 +44,7 @@ describe('Unit | session | fetchSession', () => {
segmentId: 0,
sampled: false,
started: 1648827162630,
+ shouldRefresh: true,
});
});
diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts
index be01f0602e51..2905e1bd72d6 100644
--- a/packages/replay/test/unit/session/getSession.test.ts
+++ b/packages/replay/test/unit/session/getSession.test.ts
@@ -17,9 +17,9 @@ jest.mock('@sentry/utils', () => {
};
});
-const SAMPLE_RATES = {
+const SAMPLE_OPTIONS = {
sessionSampleRate: 1.0,
- errorSampleRate: 0,
+ allowBuffering: false,
};
function createMockSession(when: number = Date.now()) {
@@ -29,6 +29,7 @@ function createMockSession(when: number = Date.now()) {
lastActivity: when,
started: when,
sampled: 'session',
+ shouldRefresh: true,
});
}
@@ -53,7 +54,7 @@ describe('Unit | session | getSession', () => {
maxSessionLife: MAX_SESSION_LIFE,
},
stickySession: false,
- ...SAMPLE_RATES,
+ ...SAMPLE_OPTIONS,
});
expect(FetchSession.fetchSession).not.toHaveBeenCalled();
@@ -65,6 +66,7 @@ describe('Unit | session | getSession', () => {
lastActivity: expect.any(Number),
sampled: 'session',
started: expect.any(Number),
+ shouldRefresh: true,
});
// Should not have anything in storage
@@ -81,7 +83,7 @@ describe('Unit | session | getSession', () => {
maxSessionLife: MAX_SESSION_LIFE,
},
stickySession: false,
- ...SAMPLE_RATES,
+ ...SAMPLE_OPTIONS,
});
expect(FetchSession.fetchSession).not.toHaveBeenCalled();
@@ -98,7 +100,7 @@ describe('Unit | session | getSession', () => {
maxSessionLife: MAX_SESSION_LIFE,
},
stickySession: false,
- ...SAMPLE_RATES,
+ ...SAMPLE_OPTIONS,
currentSession: makeSession({
id: 'old_session_id',
lastActivity: Date.now() - 1001,
@@ -126,7 +128,7 @@ describe('Unit | session | getSession', () => {
},
stickySession: true,
sessionSampleRate: 1.0,
- errorSampleRate: 0.0,
+ allowBuffering: false,
});
expect(FetchSession.fetchSession).toHaveBeenCalled();
@@ -138,6 +140,7 @@ describe('Unit | session | getSession', () => {
lastActivity: expect.any(Number),
sampled: 'session',
started: expect.any(Number),
+ shouldRefresh: true,
});
// Should not have anything in storage
@@ -147,6 +150,7 @@ describe('Unit | session | getSession', () => {
lastActivity: expect.any(Number),
sampled: 'session',
started: expect.any(Number),
+ shouldRefresh: true,
});
});
@@ -162,7 +166,7 @@ describe('Unit | session | getSession', () => {
},
stickySession: true,
sessionSampleRate: 1.0,
- errorSampleRate: 0.0,
+ allowBuffering: false,
});
expect(FetchSession.fetchSession).toHaveBeenCalled();
@@ -174,6 +178,7 @@ describe('Unit | session | getSession', () => {
lastActivity: now,
sampled: 'session',
started: now,
+ shouldRefresh: true,
});
});
@@ -188,7 +193,7 @@ describe('Unit | session | getSession', () => {
maxSessionLife: MAX_SESSION_LIFE,
},
stickySession: true,
- ...SAMPLE_RATES,
+ ...SAMPLE_OPTIONS,
});
expect(FetchSession.fetchSession).toHaveBeenCalled();
@@ -208,7 +213,7 @@ describe('Unit | session | getSession', () => {
maxSessionLife: MAX_SESSION_LIFE,
},
stickySession: false,
- ...SAMPLE_RATES,
+ ...SAMPLE_OPTIONS,
currentSession: makeSession({
id: 'test_session_uuid_2',
lastActivity: +new Date() - 500,
diff --git a/packages/replay/test/unit/session/sessionSampling.test.ts b/packages/replay/test/unit/session/sessionSampling.test.ts
index cb730006d572..7e5b27175011 100644
--- a/packages/replay/test/unit/session/sessionSampling.test.ts
+++ b/packages/replay/test/unit/session/sessionSampling.test.ts
@@ -1,9 +1,10 @@
-import { getSessionSampleType, makeSession } from '../../../src/session/Session';
+import { getSessionSampleType } from '../../../src/session/createSession';
+import { makeSession } from '../../../src/session/Session';
describe('Unit | session | sessionSampling', () => {
it('does not sample', function () {
const newSession = makeSession({
- sampled: getSessionSampleType(0, 0),
+ sampled: getSessionSampleType(0, false),
});
expect(newSession.sampled).toBe(false);
@@ -11,7 +12,7 @@ describe('Unit | session | sessionSampling', () => {
it('samples using `sessionSampleRate`', function () {
const newSession = makeSession({
- sampled: getSessionSampleType(1.0, 0),
+ sampled: getSessionSampleType(1.0, false),
});
expect(newSession.sampled).toBe('session');
@@ -19,10 +20,10 @@ describe('Unit | session | sessionSampling', () => {
it('samples using `errorSampleRate`', function () {
const newSession = makeSession({
- sampled: getSessionSampleType(0, 1),
+ sampled: getSessionSampleType(0, true),
});
- expect(newSession.sampled).toBe('error');
+ expect(newSession.sampled).toBe('buffer');
});
it('does not run sampling function if existing session was sampled', function () {
diff --git a/packages/replay/test/unit/util/createReplayEnvelope.test.ts b/packages/replay/test/unit/util/createReplayEnvelope.test.ts
index e62c2f410d6a..76f22709b4cb 100644
--- a/packages/replay/test/unit/util/createReplayEnvelope.test.ts
+++ b/packages/replay/test/unit/util/createReplayEnvelope.test.ts
@@ -23,7 +23,7 @@ describe('Unit | util | createReplayEnvelope', () => {
name: 'sentry.javascript.unknown',
version: '7.25.0',
},
- replay_type: 'error',
+ replay_type: 'buffer',
contexts: {
replay: {
error_sample_rate: 0,
@@ -68,7 +68,7 @@ describe('Unit | util | createReplayEnvelope', () => {
event_id: REPLAY_ID,
platform: 'javascript',
replay_id: REPLAY_ID,
- replay_type: 'error',
+ replay_type: 'buffer',
sdk: { integrations: ['BrowserTracing', 'Replay'], name: 'sentry.javascript.unknown', version: '7.25.0' },
segment_id: 3,
tags: {},
@@ -110,7 +110,7 @@ describe('Unit | util | createReplayEnvelope', () => {
replay_id: REPLAY_ID,
sdk: { integrations: ['BrowserTracing', 'Replay'], name: 'sentry.javascript.unknown', version: '7.25.0' },
segment_id: 3,
- replay_type: 'error',
+ replay_type: 'buffer',
tags: {},
timestamp: 1670837008.634,
trace_ids: ['traceId'],
diff --git a/packages/types/src/replay.ts b/packages/types/src/replay.ts
index 975c1f0c8c59..65641ce011bd 100644
--- a/packages/types/src/replay.ts
+++ b/packages/types/src/replay.ts
@@ -24,4 +24,4 @@ export type ReplayRecordingData = string | Uint8Array;
* NOTE: These types are still considered Beta and subject to change.
* @hidden
*/
-export type ReplayRecordingMode = 'session' | 'error';
+export type ReplayRecordingMode = 'session' | 'buffer';