Skip to content

Commit 5543808

Browse files
authored
feat(replay): Add beforeAddRecordingEvent Replay option (#8124)
Allows you to modify/filter custom recording events for replays. Note this is only a recording event, not the replay event. Custom means that the events do not relate or affect the DOM recording, but rather they are additional events that the Replay integration adds for additional features. This adds an option for the Replay integration `beforeAddRecordingEvent` to process a recording (rrweb) event before it is added to the event buffer. Example: ```javascript new Sentry.Replay({ beforeAddRecordingEvent: (event) => { // Filter out specific events if (event.data.tag === 'foo') { return null; } // Remember to return an event if you want to keep it! return event; } }); ``` Closes #8127
1 parent 8f74eb3 commit 5543808

File tree

5 files changed

+180
-1
lines changed

5 files changed

+180
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- feat(replay): Add `beforeAddRecordingEvent` Replay option
6+
57
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
68

79
## 7.52.1

packages/replay/src/integration.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ export class Replay implements Integration {
7272
ignore = [],
7373
maskFn,
7474

75+
beforeAddRecordingEvent,
76+
7577
// eslint-disable-next-line deprecation/deprecation
7678
blockClass,
7779
// eslint-disable-next-line deprecation/deprecation
@@ -129,6 +131,7 @@ export class Replay implements Integration {
129131
networkCaptureBodies,
130132
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
131133
networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders),
134+
beforeAddRecordingEvent,
132135

133136
_experiments,
134137
};

packages/replay/src/types.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ export interface WorkerResponse {
183183

184184
export type AddEventResult = void;
185185

186+
export interface BeforeAddRecordingEvent {
187+
(event: RecordingEvent): RecordingEvent | null | undefined;
188+
}
189+
186190
export interface ReplayNetworkOptions {
187191
/**
188192
* Capture request/response details for XHR/Fetch requests that match the given URLs.
@@ -267,6 +271,19 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
267271
*/
268272
maskAllText: boolean;
269273

274+
/**
275+
* Callback before adding a custom recording event
276+
*
277+
* Events added by the underlying DOM recording library can *not* be modified,
278+
* only custom recording events from the Replay integration will trigger the
279+
* callback listeners. This can be used to scrub certain fields in an event (e.g. URLs from navigation events).
280+
*
281+
* Returning a `null` will drop the event completely. Note, dropping a recording
282+
* event is not the same as dropping the replay, the replay will still exist and
283+
* continue to function.
284+
*/
285+
beforeAddRecordingEvent?: BeforeAddRecordingEvent;
286+
270287
/**
271288
* _experiments allows users to enable experimental or internal features.
272289
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.

packages/replay/src/util/addEvent.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { getCurrentHub } from '@sentry/core';
22
import { logger } from '@sentry/utils';
33

44
import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
5+
import { EventType } from '../types/rrweb';
56
import { timestampToMs } from './timestampToMs';
67

78
/**
@@ -38,7 +39,18 @@ export async function addEvent(
3839
replay.eventBuffer.clear();
3940
}
4041

41-
return await replay.eventBuffer.addEvent(event);
42+
const replayOptions = replay.getOptions();
43+
44+
const eventAfterPossibleCallback =
45+
typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom
46+
? replayOptions.beforeAddRecordingEvent(event)
47+
: event;
48+
49+
if (!eventAfterPossibleCallback) {
50+
return;
51+
}
52+
53+
return await replay.eventBuffer.addEvent(eventAfterPossibleCallback);
4254
} catch (error) {
4355
__DEBUG_BUILD__ && logger.error(error);
4456
await replay.stop('addEvent');
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import * as SentryCore from '@sentry/core';
2+
import type { Transport } from '@sentry/types';
3+
import * as SentryUtils from '@sentry/utils';
4+
5+
import type { Replay } from '../../src';
6+
import type { ReplayContainer } from '../../src/replay';
7+
import { clearSession } from '../../src/session/clearSession';
8+
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
9+
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
10+
import { useFakeTimers } from '../utils/use-fake-timers';
11+
12+
useFakeTimers();
13+
14+
async function advanceTimers(time: number) {
15+
jest.advanceTimersByTime(time);
16+
await new Promise(process.nextTick);
17+
}
18+
19+
type MockTransportSend = jest.MockedFunction<Transport['send']>;
20+
21+
describe('Integration | beforeAddRecordingEvent', () => {
22+
let replay: ReplayContainer;
23+
let integration: Replay;
24+
let mockTransportSend: MockTransportSend;
25+
let mockSendReplayRequest: jest.SpyInstance<any>;
26+
let domHandler: (args: any) => any;
27+
const { record: mockRecord } = mockRrweb();
28+
29+
beforeAll(async () => {
30+
jest.setSystemTime(new Date(BASE_TIMESTAMP));
31+
jest.spyOn(SentryUtils, 'addInstrumentationHandler').mockImplementation((type, handler: (args: any) => any) => {
32+
if (type === 'dom') {
33+
domHandler = handler;
34+
}
35+
});
36+
37+
({ replay, integration } = await mockSdk({
38+
replayOptions: {
39+
beforeAddRecordingEvent: event => {
40+
const eventData = event.data as Record<string, any>;
41+
42+
if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') {
43+
return {
44+
...event,
45+
data: {
46+
...eventData,
47+
payload: {
48+
...eventData.payload,
49+
message: 'beforeAddRecordingEvent',
50+
},
51+
},
52+
};
53+
}
54+
55+
// This should not do anything because callback should not be called
56+
// for `event.type != 5`
57+
if (event.type === 2) {
58+
return null;
59+
}
60+
61+
if (eventData.tag === 'options') {
62+
return null;
63+
}
64+
65+
return event;
66+
},
67+
_experiments: {
68+
captureExceptions: true,
69+
},
70+
},
71+
}));
72+
73+
mockSendReplayRequest = jest.spyOn(SendReplayRequest, 'sendReplayRequest');
74+
75+
jest.runAllTimers();
76+
mockTransportSend = SentryCore.getCurrentHub()?.getClient()?.getTransport()?.send as MockTransportSend;
77+
});
78+
79+
beforeEach(() => {
80+
jest.setSystemTime(new Date(BASE_TIMESTAMP));
81+
mockRecord.takeFullSnapshot.mockClear();
82+
mockTransportSend.mockClear();
83+
84+
// Create a new session and clear mocks because a segment (from initial
85+
// checkout) will have already been uploaded by the time the tests run
86+
clearSession(replay);
87+
replay['_loadAndCheckSession']();
88+
89+
mockSendReplayRequest.mockClear();
90+
});
91+
92+
afterEach(async () => {
93+
jest.runAllTimers();
94+
await new Promise(process.nextTick);
95+
jest.setSystemTime(new Date(BASE_TIMESTAMP));
96+
clearSession(replay);
97+
replay['_loadAndCheckSession']();
98+
});
99+
100+
afterAll(() => {
101+
integration && integration.stop();
102+
});
103+
104+
it('changes click breadcrumbs message', async () => {
105+
domHandler({
106+
name: 'click',
107+
});
108+
109+
await advanceTimers(5000);
110+
111+
expect(replay).toHaveLastSentReplay({
112+
recordingPayloadHeader: { segment_id: 0 },
113+
recordingData: JSON.stringify([
114+
{
115+
type: 5,
116+
timestamp: BASE_TIMESTAMP,
117+
data: {
118+
tag: 'breadcrumb',
119+
payload: {
120+
timestamp: BASE_TIMESTAMP / 1000,
121+
type: 'default',
122+
category: 'ui.click',
123+
message: 'beforeAddRecordingEvent',
124+
data: {},
125+
},
126+
},
127+
},
128+
]),
129+
});
130+
});
131+
132+
it('filters out the options event, but *NOT* full snapshot', async () => {
133+
mockTransportSend.mockClear();
134+
await integration.stop();
135+
136+
integration.start();
137+
138+
jest.runAllTimers();
139+
await new Promise(process.nextTick);
140+
expect(replay).toHaveLastSentReplay({
141+
recordingPayloadHeader: { segment_id: 0 },
142+
recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }]),
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)