From f8cfe7341e22d39658e580f9c8852c05e07a85f9 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 15 May 2023 18:51:57 -0400 Subject: [PATCH 1/2] feat(replay): Add `beforeAddRecordingEvent` Replay option Allows you to modify/filter recording events for replays. Note this is only a recording event, not the replay event. --- CHANGELOG.md | 2 + packages/replay/src/integration.ts | 3 + packages/replay/src/types.ts | 9 ++ packages/replay/src/util/addEvent.ts | 13 +- .../beforeAddRecordingEvent.test.ts | 139 ++++++++++++++++++ 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 packages/replay/test/integration/beforeAddRecordingEvent.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index db0fc8c538b4..3baecec3d8f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- feat(replay): Add `beforeAddRecordingEvent` Replay option + - "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott ## 7.52.1 diff --git a/packages/replay/src/integration.ts b/packages/replay/src/integration.ts index d5d4115fffc7..114d015f2702 100644 --- a/packages/replay/src/integration.ts +++ b/packages/replay/src/integration.ts @@ -72,6 +72,8 @@ export class Replay implements Integration { ignore = [], maskFn, + beforeAddRecordingEvent, + // eslint-disable-next-line deprecation/deprecation blockClass, // eslint-disable-next-line deprecation/deprecation @@ -129,6 +131,7 @@ export class Replay implements Integration { networkCaptureBodies, networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders), networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders), + beforeAddRecordingEvent, _experiments, }; diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 12900dc22e74..9aa65b70efc8 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -183,6 +183,10 @@ export interface WorkerResponse { export type AddEventResult = void; +export interface BeforeAddRecoringEvent { + (event: RecordingEvent): RecordingEvent | null | undefined; +} + export interface ReplayNetworkOptions { /** * Capture request/response details for XHR/Fetch requests that match the given URLs. @@ -267,6 +271,11 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { */ maskAllText: boolean; + /** + * Callback before adding a recording event + */ + beforeAddRecordingEvent?: BeforeAddRecoringEvent; + /** * _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. diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index 8064515c3f87..d4bee6b3ec86 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -38,7 +38,18 @@ export async function addEvent( replay.eventBuffer.clear(); } - return await replay.eventBuffer.addEvent(event); + const replayOptions = replay.getOptions(); + + const eventAfterPossibleCallback = + typeof replayOptions.beforeAddRecordingEvent === 'function' + ? replayOptions.beforeAddRecordingEvent(event) + : event; + + if (!eventAfterPossibleCallback) { + return; + } + + return await replay.eventBuffer.addEvent(eventAfterPossibleCallback); } catch (error) { __DEBUG_BUILD__ && logger.error(error); await replay.stop('addEvent'); diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts new file mode 100644 index 000000000000..06837cb09150 --- /dev/null +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -0,0 +1,139 @@ +import * as SentryCore from '@sentry/core'; +import type { Transport } from '@sentry/types'; +import * as SentryUtils from '@sentry/utils'; + +import type { Replay } from '../../src'; +import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; +import * as SendReplayRequest from '../../src/util/sendReplayRequest'; +import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index'; +import { useFakeTimers } from '../utils/use-fake-timers'; + +useFakeTimers(); + +async function advanceTimers(time: number) { + jest.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +type MockTransportSend = jest.MockedFunction; + +describe('Integration | beforeAddRecordingEvent', () => { + let replay: ReplayContainer; + let integration: Replay; + let mockTransportSend: MockTransportSend; + let mockSendReplayRequest: jest.SpyInstance; + let domHandler: (args: any) => any; + const { record: mockRecord } = mockRrweb(); + + beforeAll(async () => { + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => { + if (type === 'dom') { + domHandler = handler; + } + }); + + ({ replay, integration } = await mockSdk({ + replayOptions: { + beforeAddRecordingEvent: event => { + const eventData = event.data as Record; + + if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') { + return { + ...event, + data: { + ...eventData, + payload: { + ...eventData.payload, + message: 'beforeAddRecordingEvent', + }, + }, + }; + } + + if (eventData.tag === 'options') { + return null; + } + + return event; + }, + _experiments: { + captureExceptions: true, + }, + }, + })); + + mockSendReplayRequest = jest.spyOn(SendReplayRequest, 'sendReplayRequest'); + + jest.runAllTimers(); + mockTransportSend = SentryCore.getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend; + }); + + beforeEach(() => { + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + mockRecord.takeFullSnapshot.mockClear(); + mockTransportSend.mockClear(); + + // Create a new session and clear mocks because a segment (from initial + // checkout) will have already been uploaded by the time the tests run + clearSession(replay); + replay['_loadAndCheckSession'](); + + mockSendReplayRequest.mockClear(); + }); + + afterEach(async () => { + jest.runAllTimers(); + await new Promise(process.nextTick); + jest.setSystemTime(new Date(BASE_TIMESTAMP)); + clearSession(replay); + replay['_loadAndCheckSession'](); + }); + + afterAll(() => { + integration && integration.stop(); + }); + + it('changes click breadcrumbs message', async () => { + domHandler({ + name: 'click', + }); + + await advanceTimers(5000); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + recordingData: JSON.stringify([ + { + type: 5, + timestamp: BASE_TIMESTAMP, + data: { + tag: 'breadcrumb', + payload: { + timestamp: BASE_TIMESTAMP / 1000, + type: 'default', + category: 'ui.click', + message: 'beforeAddRecordingEvent', + data: {}, + }, + }, + }, + ]), + }); + }); + + it('filters out the options event', async () => { + mockTransportSend.mockClear(); + await integration.stop(); + + integration.start(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }]), + }); + }); +}); From ed781f09b58d02dca0308a3fcc66df1c641664e1 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 16 May 2023 14:40:50 -0400 Subject: [PATCH 2/2] add comments + only call for event.type == 5 --- packages/replay/src/types.ts | 14 +++++++++++--- packages/replay/src/util/addEvent.ts | 3 ++- .../integration/beforeAddRecordingEvent.test.ts | 8 +++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/replay/src/types.ts b/packages/replay/src/types.ts index 9aa65b70efc8..f20a2a9b6ee1 100644 --- a/packages/replay/src/types.ts +++ b/packages/replay/src/types.ts @@ -183,7 +183,7 @@ export interface WorkerResponse { export type AddEventResult = void; -export interface BeforeAddRecoringEvent { +export interface BeforeAddRecordingEvent { (event: RecordingEvent): RecordingEvent | null | undefined; } @@ -272,9 +272,17 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { maskAllText: boolean; /** - * Callback before adding a recording event + * Callback before adding a custom recording event + * + * Events added by the underlying DOM recording library can *not* be modified, + * only custom recording events from the Replay integration will trigger the + * callback listeners. This can be used to scrub certain fields in an event (e.g. URLs from navigation events). + * + * Returning a `null` will drop the event completely. Note, dropping a recording + * event is not the same as dropping the replay, the replay will still exist and + * continue to function. */ - beforeAddRecordingEvent?: BeforeAddRecoringEvent; + beforeAddRecordingEvent?: BeforeAddRecordingEvent; /** * _experiments allows users to enable experimental or internal features. diff --git a/packages/replay/src/util/addEvent.ts b/packages/replay/src/util/addEvent.ts index d4bee6b3ec86..e40a9c2f6486 100644 --- a/packages/replay/src/util/addEvent.ts +++ b/packages/replay/src/util/addEvent.ts @@ -2,6 +2,7 @@ import { getCurrentHub } from '@sentry/core'; import { logger } from '@sentry/utils'; import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types'; +import { EventType } from '../types/rrweb'; import { timestampToMs } from './timestampToMs'; /** @@ -41,7 +42,7 @@ export async function addEvent( const replayOptions = replay.getOptions(); const eventAfterPossibleCallback = - typeof replayOptions.beforeAddRecordingEvent === 'function' + typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom ? replayOptions.beforeAddRecordingEvent(event) : event; diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts index 06837cb09150..c01140045389 100644 --- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -52,6 +52,12 @@ describe('Integration | beforeAddRecordingEvent', () => { }; } + // This should not do anything because callback should not be called + // for `event.type != 5` + if (event.type === 2) { + return null; + } + if (eventData.tag === 'options') { return null; } @@ -123,7 +129,7 @@ describe('Integration | beforeAddRecordingEvent', () => { }); }); - it('filters out the options event', async () => { + it('filters out the options event, but *NOT* full snapshot', async () => { mockTransportSend.mockClear(); await integration.stop();