Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(replay): Add beforeAddRecordingEvent Replay option #8124

Merged
merged 2 commits into from
May 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No action required, just as a side note: You don't have to add this entry to the unreleased section. We always create a changelog PR when we release which is when we add all entries.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah thought it would make it easier for whoever is publishing


- "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>;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Types are not ideal here :/

I'll work on a follow-up to see how we can improve them.


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 }]),
});
});
});