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';