Skip to content

Commit

Permalink
feat(replay): Add beforeAddRecordingEvent Replay option (#8124)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
billyvg committed May 17, 2023
1 parent 8f74eb3 commit 5543808
Show file tree
Hide file tree
Showing 5 changed files with 180 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/replay/src/integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -129,6 +131,7 @@ export class Replay implements Integration {
networkCaptureBodies,
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders),
beforeAddRecordingEvent,

_experiments,
};
Expand Down
17 changes: 17 additions & 0 deletions packages/replay/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ export interface WorkerResponse {

export type AddEventResult = void;

export interface BeforeAddRecordingEvent {
(event: RecordingEvent): RecordingEvent | null | undefined;
}

export interface ReplayNetworkOptions {
/**
* Capture request/response details for XHR/Fetch requests that match the given URLs.
Expand Down Expand Up @@ -267,6 +271,19 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
*/
maskAllText: boolean;

/**
* 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?: BeforeAddRecordingEvent;

/**
* _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.
Expand Down
14 changes: 13 additions & 1 deletion packages/replay/src/util/addEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -38,7 +39,18 @@ export async function addEvent(
replay.eventBuffer.clear();
}

return await replay.eventBuffer.addEvent(event);
const replayOptions = replay.getOptions();

const eventAfterPossibleCallback =
typeof replayOptions.beforeAddRecordingEvent === 'function' && event.type === EventType.Custom
? replayOptions.beforeAddRecordingEvent(event)
: event;

if (!eventAfterPossibleCallback) {
return;
}

return await replay.eventBuffer.addEvent(eventAfterPossibleCallback);
} catch (error) {
__DEBUG_BUILD__ && logger.error(error);
await replay.stop('addEvent');
Expand Down
145 changes: 145 additions & 0 deletions packages/replay/test/integration/beforeAddRecordingEvent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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<Transport['send']>;

describe('Integration | beforeAddRecordingEvent', () => {
let replay: ReplayContainer;
let integration: Replay;
let mockTransportSend: MockTransportSend;
let mockSendReplayRequest: jest.SpyInstance<any>;
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<string, any>;

if (eventData.tag === 'breadcrumb' && eventData.payload.category === 'ui.click') {
return {
...event,
data: {
...eventData,
payload: {
...eventData.payload,
message: '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;
}

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, but *NOT* full snapshot', 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 }]),
});
});
});

0 comments on commit 5543808

Please sign in to comment.