diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts index c5a4e9ae2526..2a835c444550 100644 --- a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -115,7 +115,6 @@ sentryTest( expect(event0).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, error_ids: [errorEventId!], replay_type: 'buffer', }), @@ -150,7 +149,6 @@ sentryTest( 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: [], @@ -162,7 +160,6 @@ sentryTest( 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: [], @@ -266,7 +263,6 @@ sentryTest( expect(event0).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 0, session_sample_rate: 0 } }, error_ids: [errorEventId!], replay_type: 'buffer', }), @@ -372,7 +368,6 @@ sentryTest('[buffer-mode] can sample on each error event', async ({ getLocalTest expect(event0).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, error_ids: errorEventIds, replay_type: 'buffer', }), diff --git a/packages/browser-integration-tests/suites/replay/captureReplay/test.ts b/packages/browser-integration-tests/suites/replay/captureReplay/test.ts index 473d88ea53db..72cbc47efe99 100644 --- a/packages/browser-integration-tests/suites/replay/captureReplay/test.ts +++ b/packages/browser-integration-tests/suites/replay/captureReplay/test.ts @@ -64,7 +64,6 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT }, }, platform: 'javascript', - contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, }); expect(replayEvent1).toBeDefined(); @@ -103,6 +102,5 @@ sentryTest('should capture replays (@sentry/browser export)', async ({ getLocalT }, }, platform: 'javascript', - contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, }); }); diff --git a/packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts b/packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts index 03ee0f78e540..6caf1e4ea57c 100644 --- a/packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts +++ b/packages/browser-integration-tests/suites/replay/captureReplayFromReplayPackage/test.ts @@ -64,7 +64,6 @@ sentryTest('should capture replays (@sentry/replay export)', async ({ getLocalTe }, }, platform: 'javascript', - contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, }); expect(replayEvent1).toBeDefined(); @@ -103,6 +102,5 @@ sentryTest('should capture replays (@sentry/replay export)', async ({ getLocalTe }, }, platform: 'javascript', - contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, }); }); diff --git a/packages/browser-integration-tests/suites/replay/customEvents/test.ts b/packages/browser-integration-tests/suites/replay/customEvents/test.ts index ce8f27bf4995..585266746365 100644 --- a/packages/browser-integration-tests/suites/replay/customEvents/test.ts +++ b/packages/browser-integration-tests/suites/replay/customEvents/test.ts @@ -174,3 +174,60 @@ sentryTest( ); }, ); + +sentryTest( + 'replay recording should contain an "options" breadcrumb for Replay SDK configuration', + async ({ forceFlushReplay, getLocalTestPath, page, browserName }) => { + // TODO(replay): This is flakey on firefox and webkit where clicks are flakey + if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + const reqPromise1 = waitForReplayRequest(page, 1); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + 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 forceFlushReplay(); + + await page.click('#error'); + await forceFlushReplay(); + + const req0 = await reqPromise0; + const content0 = getReplayRecordingContent(req0); + + expect(content0.optionsEvents).toEqual([ + { + tag: 'options', + payload: { + sessionSampleRate: 1, + errorSampleRate: 0, + useCompressionOption: false, + blockAllMedia: false, + maskAllText: true, + maskAllInputs: true, + useCompression: false, + networkDetailHasUrls: false, + networkCaptureBodies: true, + networkRequestHasHeaders: true, + networkResponseHasHeaders: true, + }, + }, + ]); + + const req1 = await reqPromise1; + const content1 = getReplayRecordingContent(req1); + + // Should only be on first segment + expect(content1.optionsEvents).toEqual([]); + }, +); 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 fee9e05d4a49..aa452cdd9307 100644 --- a/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/errors/errorMode/test.ts @@ -84,7 +84,6 @@ sentryTest( expect(event0).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, error_ids: [errorEventId!], replay_type: 'buffer', }), @@ -119,7 +118,6 @@ sentryTest( expect(event1).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, replay_type: 'buffer', // although we're in session mode, we still send 'error' as replay_type segment_id: 1, urls: [], @@ -134,7 +132,6 @@ sentryTest( // we continue recording everything expect(event2).toEqual( getExpectedReplayEvent({ - contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } }, replay_type: 'buffer', segment_id: 2, urls: [], diff --git a/packages/browser-integration-tests/utils/replayEventTemplates.ts b/packages/browser-integration-tests/utils/replayEventTemplates.ts index e6ee4bda18d2..d88fbd1bf0e5 100644 --- a/packages/browser-integration-tests/utils/replayEventTemplates.ts +++ b/packages/browser-integration-tests/utils/replayEventTemplates.ts @@ -38,7 +38,6 @@ const DEFAULT_REPLAY_EVENT = { }, }, platform: 'javascript', - contexts: { replay: { session_sample_rate: 1, error_sample_rate: 0 } }, }; /** diff --git a/packages/browser-integration-tests/utils/replayHelpers.ts b/packages/browser-integration-tests/utils/replayHelpers.ts index b1448dc97d85..415dcd667414 100644 --- a/packages/browser-integration-tests/utils/replayHelpers.ts +++ b/packages/browser-integration-tests/utils/replayHelpers.ts @@ -160,6 +160,7 @@ type CustomRecordingContent = { type RecordingContent = { fullSnapshots: RecordingSnapshot[]; incrementalSnapshots: RecordingSnapshot[]; + optionsEvents: CustomRecordingEvent[]; } & CustomRecordingContent; /** @@ -207,6 +208,11 @@ export function getIncrementalRecordingSnapshots(resOrReq: Request | Response): return events.filter(isIncrementalSnapshot); } +function getOptionsEvents(replayRequest: Request): CustomRecordingEvent[] { + const events = getDecompressedRecordingEvents(replayRequest); + return getAllCustomRrwebRecordingEvents(events).filter(data => data.tag === 'options'); +} + function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] { const replayRequest = getRequest(resOrReq); return ( @@ -227,8 +233,9 @@ export function getReplayRecordingContent(resOrReq: Request | Response): Recordi const fullSnapshots = getFullRecordingSnapshots(replayRequest); const incrementalSnapshots = getIncrementalRecordingSnapshots(replayRequest); const customEvents = getCustomRecordingEvents(replayRequest); + const optionsEvents = getOptionsEvents(replayRequest); - return { fullSnapshots, incrementalSnapshots, ...customEvents }; + return { fullSnapshots, incrementalSnapshots, optionsEvents, ...customEvents }; } /** diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index da5ba529edd7..a22694a64304 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -7,6 +7,26 @@ export const ReplayRecordingData = [ data: { href: expect.stringMatching(/http:\/\/localhost:\d+\//), width: 1280, height: 720 }, timestamp: expect.any(Number), }, + { + data: { + payload: { + blockAllMedia: true, + errorSampleRate: 0, + maskAllInputs: true, + maskAllText: true, + networkCaptureBodies: true, + networkDetailHasUrls: false, + networkRequestHasHeaders: true, + networkResponseHasHeaders: true, + sessionSampleRate: 1, + useCompression: false, + useCompressionOption: true, + }, + tag: 'options', + }, + timestamp: expect.any(Number), + type: 5, + }, { type: 2, data: { diff --git a/packages/replay/src/eventBuffer/EventBufferArray.ts b/packages/replay/src/eventBuffer/EventBufferArray.ts index 42ce00aabbc6..eaebd1b174e7 100644 --- a/packages/replay/src/eventBuffer/EventBufferArray.ts +++ b/packages/replay/src/eventBuffer/EventBufferArray.ts @@ -1,4 +1,4 @@ -import type { AddEventResult, EventBuffer, RecordingEvent } from '../types'; +import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; /** @@ -18,6 +18,11 @@ export class EventBufferArray implements EventBuffer { return this.events.length > 0; } + /** @inheritdoc */ + public get type(): EventBufferType { + return 'sync'; + } + /** @inheritdoc */ public destroy(): void { this.events = []; diff --git a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts index 57ad449dac55..45696ea46bc9 100644 --- a/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts +++ b/packages/replay/src/eventBuffer/EventBufferCompressionWorker.ts @@ -1,6 +1,6 @@ import type { ReplayRecordingData } from '@sentry/types'; -import type { AddEventResult, EventBuffer, RecordingEvent } from '../types'; +import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { timestampToMs } from '../util/timestampToMs'; import { WorkerHandler } from './WorkerHandler'; @@ -22,6 +22,11 @@ export class EventBufferCompressionWorker implements EventBuffer { return !!this._earliestTimestamp; } + /** @inheritdoc */ + public get type(): EventBufferType { + return 'worker'; + } + /** * Ensure the worker is ready (or not). * This will either resolve when the worker is ready, or reject if an error occured. diff --git a/packages/replay/src/eventBuffer/EventBufferProxy.ts b/packages/replay/src/eventBuffer/EventBufferProxy.ts index 972fc9d58911..df50634bf3eb 100644 --- a/packages/replay/src/eventBuffer/EventBufferProxy.ts +++ b/packages/replay/src/eventBuffer/EventBufferProxy.ts @@ -1,7 +1,7 @@ import type { ReplayRecordingData } from '@sentry/types'; import { logger } from '@sentry/utils'; -import type { AddEventResult, EventBuffer, RecordingEvent } from '../types'; +import type { AddEventResult, EventBuffer, EventBufferType, RecordingEvent } from '../types'; import { EventBufferArray } from './EventBufferArray'; import { EventBufferCompressionWorker } from './EventBufferCompressionWorker'; @@ -24,6 +24,11 @@ export class EventBufferProxy implements EventBuffer { this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded(); } + /** @inheritdoc */ + public get type(): EventBufferType { + return this._used.type; + } + /** @inheritDoc */ public get hasEvents(): boolean { return this._used.hasEvents; diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index 81279947b969..d5d4115fffc7 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -123,6 +123,8 @@ export class Replay implements Integration { errorSampleRate, useCompression, blockAllMedia, + maskAllInputs, + maskAllText, networkDetailAllowUrls, networkCaptureBodies, networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders), diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 0d653f3a7817..aef61f57a07a 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -257,6 +257,16 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ blockAllMedia: boolean; + /** + * Mask all inputs in recordings + */ + maskAllInputs: boolean; + + /** + * Mask all text in recordings + */ + maskAllText: boolean; + /** * _experiments allows users to enable experimental or internal features. * We don't consider such features as part of the public API and hence we don't guarantee semver for them. @@ -435,12 +445,19 @@ export interface Session { shouldRefresh: boolean; } +export type EventBufferType = 'sync' | 'worker'; + export interface EventBuffer { /** * If any events have been added to the buffer. */ readonly hasEvents: boolean; + /** + * The buffer type + */ + readonly type: EventBufferType; + /** * Destroy the event buffer. */ diff --git a/packages/replay/src/types/rrweb.ts b/packages/replay/src/types/rrweb.ts index 7a794face4cb..7f2dfec78110 100644 --- a/packages/replay/src/types/rrweb.ts +++ b/packages/replay/src/types/rrweb.ts @@ -3,7 +3,7 @@ type blockClass = string | RegExp; type maskTextClass = string | RegExp; -enum EventType { +export enum EventType { DomContentLoaded = 0, Load = 1, FullSnapshot = 2, diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index edf9aa4946f1..f72850f5536c 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -1,7 +1,8 @@ import { logger } from '@sentry/utils'; import { saveSession } from '../session/saveSession'; -import type { RecordingEvent, ReplayContainer } from '../types'; +import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types'; +import { EventType } from '../types/rrweb'; import { addEvent } from './addEvent'; type RecordingEmitCallback = (event: RecordingEvent, isCheckout?: boolean) => void; @@ -48,6 +49,14 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa return false; } + // Additionally, create a meta event that will capture certain SDK settings. + // In order to handle buffer mode, this needs to either be done when we + // receive checkout events or at flush time. + // + // `isCheckout` is always true, but want to be explicit that it should + // only be added for checkouts + void addSettingsEvent(replay, isCheckout); + // If there is a previousSessionId after a full snapshot occurs, then // the replay session was started due to session expiration. The new session // is started before triggering a new checkout and contains the id @@ -84,3 +93,43 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa }); }; } + +/** + * Exported for tests + */ +export function createOptionsEvent(replay: ReplayContainer): RecordingEvent { + const options = replay.getOptions(); + return { + type: EventType.Custom, + timestamp: Date.now(), + data: { + tag: 'options', + payload: { + sessionSampleRate: options.sessionSampleRate, + errorSampleRate: options.errorSampleRate, + useCompressionOption: options.useCompression, + blockAllMedia: options.blockAllMedia, + maskAllText: options.maskAllText, + maskAllInputs: options.maskAllInputs, + useCompression: replay.eventBuffer ? replay.eventBuffer.type === 'worker' : false, + networkDetailHasUrls: options.networkDetailAllowUrls.length > 0, + networkCaptureBodies: options.networkCaptureBodies, + networkRequestHasHeaders: options.networkRequestHeaders.length > 0, + networkResponseHasHeaders: options.networkResponseHeaders.length > 0, + }, + }, + }; +} + +/** + * Add a "meta" event that contains a simplified view on current configuration + * options. This should only be included on the first segment of a recording. + */ +function addSettingsEvent(replay: ReplayContainer, isCheckout?: boolean): Promise { + // Only need to add this event when sending the first segment + if (!isCheckout || !replay.session || replay.session.segmentId !== 0) { + return Promise.resolve(null); + } + + return addEvent(replay, createOptionsEvent(replay), false); +} diff --git a/packages/replay/src/util/sendReplayRequest.ts b/packages/replay/src/util/sendReplayRequest.ts index 009fdc2067bf..65f217f857cd 100644 --- a/packages/replay/src/util/sendReplayRequest.ts +++ b/packages/replay/src/util/sendReplayRequest.ts @@ -18,7 +18,6 @@ export async function sendReplayRequest({ eventContext, timestamp, session, - options, }: SendReplayData): Promise { const preparedRecordingData = prepareRecordingData({ recordingData, @@ -60,15 +59,6 @@ export async function sendReplayRequest({ return; } - replayEvent.contexts = { - ...replayEvent.contexts, - replay: { - ...(replayEvent.contexts && replayEvent.contexts.replay), - session_sample_rate: options.sessionSampleRate, - error_sample_rate: options.errorSampleRate, - }, - }; - /* For reference, the fully built event looks something like this: { @@ -99,10 +89,6 @@ export async function sendReplayRequest({ }, "sdkProcessingMetadata": {}, "contexts": { - "replay": { - "session_sample_rate": 1, - "error_sample_rate": 0, - }, }, } */ diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index 16962bf5b2f8..74fde11f50f0 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -11,6 +11,7 @@ import { import type { ReplayContainer } from '../../src/replay'; import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; +import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; import type { RecordMock } from '../index'; import { BASE_TIMESTAMP } from '../index'; @@ -50,6 +51,7 @@ describe('Integration | errorSampleRate', () => { it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); + const optionsEvent = createOptionsEvent(replay); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).not.toHaveLastSentReplay(); @@ -72,15 +74,10 @@ describe('Integration | errorSampleRate', () => { recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', - contexts: { - replay: { - error_sample_rate: 1, - session_sample_rate: 0, - }, - }, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + optionsEvent, TEST_EVENT, { type: 5, @@ -104,12 +101,6 @@ describe('Integration | errorSampleRate', () => { recordingPayloadHeader: { segment_id: 1 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', - contexts: { - replay: { - error_sample_rate: 1, - session_sample_rate: 0, - }, - }, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, @@ -161,6 +152,7 @@ describe('Integration | errorSampleRate', () => { it('manually flushes replay and does not continue to record', async () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); + const optionsEvent = createOptionsEvent(replay); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).not.toHaveLastSentReplay(); @@ -183,15 +175,10 @@ describe('Integration | errorSampleRate', () => { recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', - contexts: { - replay: { - error_sample_rate: 1, - session_sample_rate: 0, - }, - }, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + optionsEvent, TEST_EVENT, { type: 5, @@ -224,15 +211,10 @@ describe('Integration | errorSampleRate', () => { recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', - contexts: { - replay: { - error_sample_rate: 1, - session_sample_rate: 0, - }, - }, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + optionsEvent, TEST_EVENT, { type: 5, @@ -538,6 +520,7 @@ describe('Integration | errorSampleRate', () => { it('has the correct timestamps with deferred root event and last replay update', async () => { const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); + const optionsEvent = createOptionsEvent(replay); expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); expect(replay).not.toHaveLastSentReplay(); @@ -554,7 +537,11 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).toHaveSentReplay({ - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + optionsEvent, + TEST_EVENT, + ]), replayEventPayload: expect.objectContaining({ replay_start_timestamp: BASE_TIMESTAMP / 1000, // the exception happens roughly 10 seconds after BASE_TIMESTAMP @@ -590,6 +577,7 @@ describe('Integration | errorSampleRate', () => { // in production, this happens at a time interval // session started time should be updated to this current timestamp mockRecord.takeFullSnapshot(true); + const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); jest.advanceTimersByTime(20); @@ -617,6 +605,7 @@ describe('Integration | errorSampleRate', () => { timestamp: BASE_TIMESTAMP + ELAPSED + 20, type: 2, }, + optionsEvent, ]), }); }); @@ -732,8 +721,9 @@ it('sends a replay after loading the session multiple times', async () => { }, autoStart: false, }); - // @ts-ignore this is protected, but we want to call it for this test - integration._initialize(); + integration['_initialize'](); + + const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); @@ -750,12 +740,18 @@ it('sends a replay after loading the session multiple times', async () => { await new Promise(process.nextTick); expect(replay).toHaveSentReplay({ - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, TEST_EVENT]), + recordingPayloadHeader: { segment_id: 0 }, + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, + optionsEvent, + TEST_EVENT, + ]), }); // Latest checkout when we call `startRecording` again after uploading segment // after an error occurs (e.g. when we switch to session replay recording) expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5040, type: 2 }]), }); }); diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index e0b229e1c82d..b95faffa59da 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -130,12 +130,6 @@ describe('Integration | events', () => { expect(replay).toHaveLastSentReplay({ replayEventPayload: expect.objectContaining({ replay_start_timestamp: (BASE_TIMESTAMP - 10000) / 1000, - contexts: { - replay: { - error_sample_rate: 0, - session_sample_rate: 1, - }, - }, urls: ['http://localhost/'], // this doesn't truly test if we are capturing the right URL as we don't change URLs, but good enough }), }); diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 6b0f942af616..304059659078 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -14,6 +14,7 @@ import { clearSession } from '../../src/session/clearSession'; import type { Session } from '../../src/types'; import { addEvent } from '../../src/util/addEvent'; import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; +import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; import { BASE_TIMESTAMP } from '../index'; import type { RecordMock } from '../mocks/mockRrweb'; import { resetSdkMock } from '../mocks/resetSdkMock'; @@ -196,6 +197,8 @@ describe('Integration | session', () => { // Replay does not send immediately because checkout was due to expired session expect(replay).not.toHaveLastSentReplay(); + const optionsEvent = createOptionsEvent(replay); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); const newTimestamp = BASE_TIMESTAMP + ELAPSED + 20; @@ -204,6 +207,7 @@ describe('Integration | session', () => { recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + optionsEvent, { type: 5, timestamp: newTimestamp, @@ -381,6 +385,7 @@ describe('Integration | session', () => { type: 3, }; mockRecord._emitter(NEW_TEST_EVENT); + const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); @@ -388,7 +393,8 @@ describe('Integration | session', () => { expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + ELAPSED, type: 2 }, + optionsEvent, { type: 5, timestamp: newTimestamp, diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index a477ee8e044f..cc0e28195244 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -5,6 +5,7 @@ import { WINDOW } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; import { clearSession } from '../../src/session/clearSession'; import { addEvent } from '../../src/util/addEvent'; +import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; // mock functions need to be imported first import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; import { useFakeTimers } from '../utils/use-fake-timers'; @@ -95,6 +96,7 @@ describe('Integration | stop', () => { // re-enable replay integration.start(); + const optionsEvent = createOptionsEvent(replay); // will be different session expect(replay.session?.id).not.toEqual(previousSessionId); @@ -121,6 +123,7 @@ describe('Integration | stop', () => { jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ // This event happens when we call `replay.start` { @@ -128,10 +131,12 @@ describe('Integration | stop', () => { timestamp: BASE_TIMESTAMP + ELAPSED + EXTRA_TICKS, type: 2, }, + optionsEvent, TEST_EVENT, hiddenBreadcrumb, ]), }); + // Session's last activity is last updated when we call `setup()` and *NOT* // when tab is blurred expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + 20); diff --git a/packages/replay/test/unit/util/handleRecordingEmit.test.ts b/packages/replay/test/unit/util/handleRecordingEmit.test.ts index 4762b875ce5b..a4c7f82c425d 100644 --- a/packages/replay/test/unit/util/handleRecordingEmit.test.ts +++ b/packages/replay/test/unit/util/handleRecordingEmit.test.ts @@ -1,13 +1,16 @@ import { EventType } from '@sentry-internal/rrweb'; import { BASE_TIMESTAMP } from '../..'; +import type { RecordingEvent } from '../../../src/types'; import * as SentryAddEvent from '../../../src/util/addEvent'; -import { getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit'; +import { createOptionsEvent, getHandleRecordingEmit } from '../../../src/util/handleRecordingEmit'; import { setupReplayContainer } from '../../utils/setupReplayContainer'; import { useFakeTimers } from '../../utils/use-fake-timers'; useFakeTimers(); +let optionsEvent: RecordingEvent; + describe('Unit | util | handleRecordingEmit', () => { let addEventMock: jest.SpyInstance; @@ -29,6 +32,7 @@ describe('Unit | util | handleRecordingEmit', () => { sessionSampleRate: 1, }, }); + optionsEvent = createOptionsEvent(replay); const handler = getHandleRecordingEmit(replay); @@ -43,13 +47,14 @@ describe('Unit | util | handleRecordingEmit', () => { handler(event); await new Promise(process.nextTick); - expect(addEventMock).toBeCalledTimes(1); - expect(addEventMock).toHaveBeenLastCalledWith(replay, event, true); + expect(addEventMock).toBeCalledTimes(2); + expect(addEventMock).toHaveBeenNthCalledWith(1, replay, event, true); + expect(addEventMock).toHaveBeenLastCalledWith(replay, optionsEvent, false); handler(event); await new Promise(process.nextTick); - expect(addEventMock).toBeCalledTimes(2); + expect(addEventMock).toBeCalledTimes(3); expect(addEventMock).toHaveBeenLastCalledWith(replay, event, false); }); @@ -60,6 +65,7 @@ describe('Unit | util | handleRecordingEmit', () => { sessionSampleRate: 1, }, }); + optionsEvent = createOptionsEvent(replay); const handler = getHandleRecordingEmit(replay); @@ -74,13 +80,16 @@ describe('Unit | util | handleRecordingEmit', () => { handler(event, true); await new Promise(process.nextTick); - expect(addEventMock).toBeCalledTimes(1); - expect(addEventMock).toHaveBeenLastCalledWith(replay, event, true); + // Called twice, once for event and once for settings on checkout only + expect(addEventMock).toBeCalledTimes(2); + expect(addEventMock).toHaveBeenNthCalledWith(1, replay, event, true); + expect(addEventMock).toHaveBeenLastCalledWith(replay, optionsEvent, false); handler(event, true); await new Promise(process.nextTick); - expect(addEventMock).toBeCalledTimes(2); - expect(addEventMock).toHaveBeenLastCalledWith(replay, event, true); + expect(addEventMock).toBeCalledTimes(4); + expect(addEventMock).toHaveBeenNthCalledWith(3, replay, event, true); + expect(addEventMock).toHaveBeenLastCalledWith(replay, { ...optionsEvent, timestamp: BASE_TIMESTAMP + 20 }, false); }); }); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index cf4812da5f2f..83ced117c464 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -3,24 +3,30 @@ import { ReplayContainer } from '../../src/replay'; import { clearSession } from '../../src/session/clearSession'; import type { RecordingOptions, ReplayPluginOptions } from '../../src/types'; +const DEFAULT_OPTIONS = { + flushMinDelay: 100, + flushMaxDelay: 100, + stickySession: false, + sessionSampleRate: 0, + errorSampleRate: 1, + useCompression: false, + blockAllMedia: true, + networkDetailAllowUrls: [], + networkCaptureBodies: true, + networkRequestHeaders: [], + networkResponseHeaders: [], + _experiments: {}, +}; + export function setupReplayContainer({ options, recordingOptions, }: { options?: Partial; recordingOptions?: Partial } = {}): ReplayContainer { const replay = new ReplayContainer({ options: { - flushMinDelay: 100, - flushMaxDelay: 100, - stickySession: false, - sessionSampleRate: 0, - errorSampleRate: 1, - useCompression: false, - blockAllMedia: true, - networkDetailAllowUrls: [], - networkCaptureBodies: true, - networkRequestHeaders: [], - networkResponseHeaders: [], - _experiments: {}, + ...DEFAULT_OPTIONS, + maskAllInputs: !!recordingOptions?.maskAllInputs, + maskAllText: !!recordingOptions?.maskAllText, ...options, }, recordingOptions: { @@ -39,3 +45,17 @@ export function setupReplayContainer({ return replay; } + +export const DEFAULT_OPTIONS_EVENT_PAYLOAD = { + sessionSampleRate: DEFAULT_OPTIONS.sessionSampleRate, + errorSampleRate: DEFAULT_OPTIONS.errorSampleRate, + useCompressionOption: false, + blockAllMedia: DEFAULT_OPTIONS.blockAllMedia, + maskAllText: false, + maskAllInputs: false, + useCompression: DEFAULT_OPTIONS.useCompression, + networkDetailHasUrls: DEFAULT_OPTIONS.networkDetailAllowUrls.length > 0, + networkCaptureBodies: DEFAULT_OPTIONS.networkCaptureBodies, + networkRequestHeaders: DEFAULT_OPTIONS.networkRequestHeaders.length > 0, + networkResponseHeaders: DEFAULT_OPTIONS.networkResponseHeaders.length > 0, +};