Skip to content

Commit 6200b3c

Browse files
authored
feat(replay): Allow to configure beforeErrorSampling (#9470)
This adds a new option to `new Replay()` which allows to ignore certain errors for error-based sampling: ```js new Replay({ beforeErrorSampling: (event) => !event.message.includes('ignore me') }); ``` When returning `false` from this callback, the event will not trigger an error-based sampling. Note that returning `true` there does not mean this will 100% be sampled, but just that it will check the `replaysOnErrorSampleRate`. The purpose of this callback is to be able to ignore certain groups of errors from triggering an error-based replay at all. Closes #9413 Related to #8462 (not 100% but partially)
1 parent 6ddf14d commit 6200b3c

File tree

6 files changed

+130
-5
lines changed

6 files changed

+130
-5
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = new Sentry.Replay({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
minReplayDuration: 0,
8+
beforeErrorSampling: event => !event.message.includes('[drop]'),
9+
});
10+
11+
Sentry.init({
12+
dsn: 'https://[email protected]/1337',
13+
sampleRate: 1,
14+
replaysSessionSampleRate: 0.0,
15+
replaysOnErrorSampleRate: 1.0,
16+
17+
integrations: [window.Replay],
18+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../../utils/fixtures';
4+
import { getReplaySnapshot, shouldSkipReplayTest, waitForReplayRunning } from '../../../../utils/replayHelpers';
5+
6+
sentryTest(
7+
'[error-mode] should not flush if error event is ignored in beforeErrorSampling',
8+
async ({ getLocalTestPath, page, browserName, forceFlushReplay }) => {
9+
// Skipping this in webkit because it is flakey there
10+
if (shouldSkipReplayTest() || browserName === 'webkit') {
11+
sentryTest.skip();
12+
}
13+
14+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
15+
return route.fulfill({
16+
status: 200,
17+
contentType: 'application/json',
18+
body: JSON.stringify({ id: 'test-id' }),
19+
});
20+
});
21+
22+
const url = await getLocalTestPath({ testDir: __dirname });
23+
24+
await page.goto(url);
25+
await waitForReplayRunning(page);
26+
27+
await page.click('#drop');
28+
await forceFlushReplay();
29+
30+
expect(await getReplaySnapshot(page)).toEqual(
31+
expect.objectContaining({
32+
_isEnabled: true,
33+
_isPaused: false,
34+
recordingMode: 'buffer',
35+
}),
36+
);
37+
},
38+
);

packages/replay/src/coreHandlers/handleAfterSendEvent.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,19 @@ function handleErrorEvent(replay: ReplayContainer, event: ErrorEvent): void {
6363

6464
// If error event is tagged with replay id it means it was sampled (when in buffer mode)
6565
// Need to be very careful that this does not cause an infinite loop
66-
if (replay.recordingMode === 'buffer' && event.tags && event.tags.replayId) {
67-
setTimeout(() => {
68-
// Capture current event buffer as new replay
69-
void replay.sendBufferedReplayOrFlush();
70-
});
66+
if (replay.recordingMode !== 'buffer' || !event.tags || !event.tags.replayId) {
67+
return;
7168
}
69+
70+
const { beforeErrorSampling } = replay.getOptions();
71+
if (typeof beforeErrorSampling === 'function' && !beforeErrorSampling(event)) {
72+
return;
73+
}
74+
75+
setTimeout(() => {
76+
// Capture current event buffer as new replay
77+
void replay.sendBufferedReplayOrFlush();
78+
});
7279
}
7380

7481
function isBaseTransportSend(): boolean {

packages/replay/src/integration.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export class Replay implements Integration {
9090
maskFn,
9191

9292
beforeAddRecordingEvent,
93+
beforeErrorSampling,
9394

9495
// eslint-disable-next-line deprecation/deprecation
9596
blockClass,
@@ -178,6 +179,7 @@ export class Replay implements Integration {
178179
networkRequestHeaders: _getMergedNetworkHeaders(networkRequestHeaders),
179180
networkResponseHeaders: _getMergedNetworkHeaders(networkResponseHeaders),
180181
beforeAddRecordingEvent,
182+
beforeErrorSampling,
181183

182184
_experiments,
183185
};

packages/replay/src/types/replay.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
Breadcrumb,
3+
ErrorEvent,
34
FetchBreadcrumbHint,
45
HandlerDataFetch,
56
ReplayRecordingData,
@@ -211,6 +212,16 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
211212
*/
212213
beforeAddRecordingEvent?: BeforeAddRecordingEvent;
213214

215+
/**
216+
* An optional callback to be called before we decide to sample based on an error.
217+
* If specified, this callback will receive an error that was captured by Sentry.
218+
* Return `true` to continue sampling for this error, or `false` to ignore this error for replay sampling.
219+
* Note that returning `true` means that the `replaysOnErrorSampleRate` will be checked,
220+
* not that it will definitely be sampled.
221+
* Use this to filter out groups of errors that should def. not be sampled.
222+
*/
223+
beforeErrorSampling?: (event: ErrorEvent) => boolean;
224+
214225
/**
215226
* _experiments allows users to enable experimental or internal features.
216227
* We don't consider such features as part of the public API and hence we don't guarantee semver for them.

packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,53 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => {
411411

412412
expect(mockSend).toHaveBeenCalledTimes(0);
413413
});
414+
415+
it('calls beforeErrorSampling if defined', async () => {
416+
const error1 = Error({ event_id: 'err1', tags: { replayId: 'replayid1' } });
417+
const error2 = Error({ event_id: 'err2', tags: { replayId: 'replayid1' } });
418+
419+
const beforeErrorSampling = jest.fn(event => event === error2);
420+
421+
({ replay } = await resetSdkMock({
422+
replayOptions: {
423+
stickySession: false,
424+
beforeErrorSampling,
425+
},
426+
sentryOptions: {
427+
replaysSessionSampleRate: 0.0,
428+
replaysOnErrorSampleRate: 1.0,
429+
},
430+
}));
431+
432+
const mockSend = getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>;
433+
434+
const handler = handleAfterSendEvent(replay);
435+
436+
expect(replay.recordingMode).toBe('buffer');
437+
438+
handler(error1, { statusCode: 200 });
439+
440+
jest.runAllTimers();
441+
await new Promise(process.nextTick);
442+
443+
expect(beforeErrorSampling).toHaveBeenCalledTimes(1);
444+
445+
// Not flushed yet
446+
expect(mockSend).toHaveBeenCalledTimes(0);
447+
expect(replay.recordingMode).toBe('buffer');
448+
expect(Array.from(replay.getContext().errorIds)).toEqual(['err1']);
449+
450+
handler(error2, { statusCode: 200 });
451+
452+
jest.runAllTimers();
453+
await new Promise(process.nextTick);
454+
455+
expect(beforeErrorSampling).toHaveBeenCalledTimes(2);
456+
457+
// Triggers session
458+
expect(mockSend).toHaveBeenCalledTimes(1);
459+
expect(replay.recordingMode).toBe('session');
460+
expect(replay.isEnabled()).toBe(true);
461+
expect(replay.isPaused()).toBe(false);
462+
});
414463
});

0 commit comments

Comments
 (0)