Skip to content

Commit 9d724a3

Browse files
committed
fix(replay): Move error sampling to before send
In buffer mode, we were always tagging error events even if it was not sampled. This is because the sampling decision was moved from the initialization of the SDK to much further down the pipeline to right before flushing a replay. We now handle sampling of the error event before tagging event with replayId, and in the afterSend callback, we consider an error event as sampled if it has a replayId.
1 parent e3458fa commit 9d724a3

File tree

5 files changed

+161
-125
lines changed

5 files changed

+161
-125
lines changed

packages/browser-integration-tests/suites/replay/bufferMode/test.ts

Lines changed: 115 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -303,122 +303,127 @@ sentryTest(
303303

304304
// Doing this in buffer mode to test changing error sample rate after first
305305
// error happens.
306-
sentryTest('[buffer-mode] can sample on each error event', async ({ getLocalTestPath, page, browserName }) => {
307-
// This was sometimes flaky on firefox/webkit, so skipping for now
308-
if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) {
309-
sentryTest.skip();
310-
}
311-
312-
let callsToSentry = 0;
313-
const errorEventIds: string[] = [];
314-
const reqPromise0 = waitForReplayRequest(page, 0);
315-
const reqErrorPromise = waitForErrorRequest(page);
316-
317-
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
318-
const event = envelopeRequestParser(route.request());
319-
// error events have no type field
320-
if (event && !event.type && event.event_id) {
321-
errorEventIds.push(event.event_id);
322-
}
323-
// We only want to count errors & replays here
324-
if (event && (!event.type || isReplayEvent(event))) {
325-
callsToSentry++;
306+
sentryTest(
307+
'[buffer-mode] can sample on each error event',
308+
async ({ getLocalTestPath, page, browserName, enableConsole }) => {
309+
// This was sometimes flaky on firefox/webkit, so skipping for now
310+
if (shouldSkipReplayTest() || ['firefox', 'webkit'].includes(browserName)) {
311+
sentryTest.skip();
326312
}
327313

328-
return route.fulfill({
329-
status: 200,
330-
contentType: 'application/json',
331-
body: JSON.stringify({ id: 'test-id' }),
314+
enableConsole();
315+
316+
let callsToSentry = 0;
317+
const errorEventIds: string[] = [];
318+
const reqPromise0 = waitForReplayRequest(page, 0);
319+
const reqErrorPromise0 = waitForErrorRequest(page);
320+
321+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
322+
const event = envelopeRequestParser(route.request());
323+
// error events have no type field
324+
if (event && !event.type && event.event_id) {
325+
errorEventIds.push(event.event_id);
326+
}
327+
// We only want to count errors & replays here
328+
if (event && (!event.type || isReplayEvent(event))) {
329+
callsToSentry++;
330+
}
331+
332+
return route.fulfill({
333+
status: 200,
334+
contentType: 'application/json',
335+
body: JSON.stringify({ id: 'test-id' }),
336+
});
337+
});
338+
339+
const url = await getLocalTestPath({ testDir: __dirname });
340+
341+
await page.goto(url);
342+
// Start buffering and assert that it is enabled
343+
expect(
344+
await page.evaluate(() => {
345+
const replayIntegration = (window as unknown as Window & { Replay: InstanceType<typeof Replay> }).Replay;
346+
const replay = replayIntegration['_replay'];
347+
replayIntegration.startBuffering();
348+
return replay.isEnabled();
349+
}),
350+
).toBe(true);
351+
352+
await page.click('#go-background');
353+
await page.click('#error');
354+
await new Promise(resolve => setTimeout(resolve, 1000));
355+
356+
// 1 unsampled error, no replay
357+
const reqError0 = await reqErrorPromise0;
358+
const errorEvent0 = envelopeRequestParser(reqError0);
359+
expect(callsToSentry).toEqual(1);
360+
expect(errorEvent0.tags?.replayId).toBeUndefined();
361+
362+
await page.evaluate(async () => {
363+
const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
364+
replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
332365
});
333-
});
334-
335-
const url = await getLocalTestPath({ testDir: __dirname });
336-
337-
await page.goto(url);
338-
// Start buffering and assert that it is enabled
339-
expect(
340-
await page.evaluate(() => {
341-
const replayIntegration = (window as unknown as Window & { Replay: InstanceType<typeof Replay> }).Replay;
342-
const replay = replayIntegration['_replay'];
343-
replayIntegration.startBuffering();
344-
return replay.isEnabled();
345-
}),
346-
).toBe(true);
347-
348-
await page.click('#go-background');
349-
await page.click('#error');
350-
await new Promise(resolve => setTimeout(resolve, 1000));
351-
352-
// 1 unsampled error, no replay
353-
const reqError0 = await reqErrorPromise;
354-
const errorEvent0 = envelopeRequestParser(reqError0);
355-
expect(callsToSentry).toEqual(1);
356-
expect(errorEvent0.tags?.replayId).toBeUndefined();
357-
358-
await page.evaluate(async () => {
359-
const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
360-
replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
361-
});
362-
363-
// Error sample rate is now at 1.0, this error should create a replay
364-
await page.click('#error2');
365-
366-
const req0 = await reqPromise0;
367-
368-
// 1 unsampled error, 1 sampled error -> 1 flush
369-
const reqError1 = await reqErrorPromise;
370-
const errorEvent1 = envelopeRequestParser(reqError1);
371-
expect(callsToSentry).toEqual(3);
372-
expect(errorEvent1.tags?.replayId).toBeDefined();
373-
374-
const event0 = getReplayEvent(req0);
375-
const content0 = getReplayRecordingContent(req0);
376-
377-
expect(event0).toEqual(
378-
getExpectedReplayEvent({
379-
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
380-
error_ids: errorEventIds,
381-
replay_type: 'buffer',
382-
}),
383-
);
384-
385-
// The first event should have both, full and incremental snapshots,
386-
// as we recorded and kept all events in the buffer
387-
expect(content0.fullSnapshots).toHaveLength(1);
388-
// We want to make sure that the event that triggered the error was
389-
// recorded, as well as the first error that did not get sampled.
390-
expect(content0.breadcrumbs).toEqual(
391-
expect.arrayContaining([
392-
{
393-
...expectedClickBreadcrumb,
394-
message: 'body > button#error',
395-
data: {
396-
nodeId: expect.any(Number),
397-
node: {
398-
attributes: {
399-
id: 'error',
366+
367+
// Error sample rate is now at 1.0, this error should create a replay
368+
const reqErrorPromise1 = waitForErrorRequest(page);
369+
await page.click('#error2');
370+
// 1 unsampled error, 1 sampled error -> 1 flush
371+
const req0 = await reqPromise0;
372+
const reqError1 = await reqErrorPromise1;
373+
const errorEvent1 = envelopeRequestParser(reqError1);
374+
expect(callsToSentry).toEqual(3);
375+
expect(errorEvent0.event_id).not.toEqual(errorEvent1.event_id);
376+
expect(errorEvent1.tags?.replayId).toBeDefined();
377+
378+
const event0 = getReplayEvent(req0);
379+
const content0 = getReplayRecordingContent(req0);
380+
381+
expect(event0).toEqual(
382+
getExpectedReplayEvent({
383+
contexts: { replay: { error_sample_rate: 1, session_sample_rate: 0 } },
384+
error_ids: errorEventIds,
385+
replay_type: 'buffer',
386+
}),
387+
);
388+
389+
// The first event should have both, full and incremental snapshots,
390+
// as we recorded and kept all events in the buffer
391+
expect(content0.fullSnapshots).toHaveLength(1);
392+
// We want to make sure that the event that triggered the error was
393+
// recorded, as well as the first error that did not get sampled.
394+
expect(content0.breadcrumbs).toEqual(
395+
expect.arrayContaining([
396+
{
397+
...expectedClickBreadcrumb,
398+
message: 'body > button#error',
399+
data: {
400+
nodeId: expect.any(Number),
401+
node: {
402+
attributes: {
403+
id: 'error',
404+
},
405+
id: expect.any(Number),
406+
tagName: 'button',
407+
textContent: '***** *****',
400408
},
401-
id: expect.any(Number),
402-
tagName: 'button',
403-
textContent: '***** *****',
404409
},
405410
},
406-
},
407-
{
408-
...expectedClickBreadcrumb,
409-
message: 'body > button#error2',
410-
data: {
411-
nodeId: expect.any(Number),
412-
node: {
413-
attributes: {
414-
id: 'error2',
411+
{
412+
...expectedClickBreadcrumb,
413+
message: 'body > button#error2',
414+
data: {
415+
nodeId: expect.any(Number),
416+
node: {
417+
attributes: {
418+
id: 'error2',
419+
},
420+
id: expect.any(Number),
421+
tagName: 'button',
422+
textContent: '******* *****',
415423
},
416-
id: expect.any(Number),
417-
tagName: 'button',
418-
textContent: '******* *****',
419424
},
420425
},
421-
},
422-
]),
423-
);
424-
});
426+
]),
427+
);
428+
},
429+
);

packages/browser-integration-tests/utils/fixtures.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export type TestFixtures = {
2929
getLocalTestPath: (options: { testDir: string }) => Promise<string>;
3030
getLocalTestUrl: (options: { testDir: string; skipRouteHandler?: boolean }) => Promise<string>;
3131
forceFlushReplay: () => Promise<string>;
32+
enableConsole: () => void;
3233
runInChromium: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown;
3334
runInFirefox: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown;
3435
runInWebkit: (fn: (...args: unknown[]) => unknown, args?: unknown[]) => unknown;
@@ -109,6 +110,13 @@ const sentryTest = base.extend<TestFixtures>({
109110
`),
110111
);
111112
},
113+
114+
enableConsole: ({ page }, use) => {
115+
return use(() =>
116+
// eslint-disable-next-line no-console
117+
page.on('console', msg => console.log(msg.text())),
118+
);
119+
},
112120
});
113121

114122
export { sentryTest };

packages/replay/src/coreHandlers/handleAfterSendEvent.ts

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { getCurrentHub } from '@sentry/core';
22
import type { Event, Transport, TransportMakeRequestResponse } from '@sentry/types';
33

4-
import { UNABLE_TO_SEND_REPLAY } from '../constants';
54
import type { ReplayContainer } from '../types';
65
import { isErrorEvent, isTransactionEvent } from '../util/eventUtils';
7-
import { isSampled } from '../util/isSampled';
86

97
type AfterSendEventCallback = (event: Event, sendResponse: TransportMakeRequestResponse | void) => void;
108

@@ -42,22 +40,18 @@ export function handleAfterSendEvent(replay: ReplayContainer): AfterSendEventCal
4240
return;
4341
}
4442

45-
// Add error to list of errorIds of replay
43+
// Add error to list of errorIds of replay. This is ok to do even if not
44+
// sampled because context will get reset at next checkout.
45+
// XXX: There is also a race condition where it's possible to capture an
46+
// error to Sentry before Replay SDK has loaded, but response returns after
47+
// it was loaded, and this gets called.
4648
if (event.event_id) {
4749
replay.getContext().errorIds.add(event.event_id);
4850
}
4951

50-
// Trigger error recording
52+
// If error event is tagged with replay id it means it was sampled (when in buffer mode)
5153
// Need to be very careful that this does not cause an infinite loop
52-
if (
53-
replay.recordingMode === 'buffer' &&
54-
event.exception &&
55-
event.message !== UNABLE_TO_SEND_REPLAY // ignore this error because otherwise we could loop indefinitely with trying to capture replay and failing
56-
) {
57-
if (!isSampled(replay.getOptions().errorSampleRate)) {
58-
return;
59-
}
60-
54+
if (replay.recordingMode === 'buffer' && event.tags && event.tags.replayId) {
6155
setTimeout(() => {
6256
// Capture current event buffer as new replay
6357
void replay.sendBufferedReplayOrFlush();

packages/replay/src/coreHandlers/handleGlobalEvent.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { logger } from '@sentry/utils';
55
import type { ReplayContainer } from '../types';
66
import { isErrorEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils';
77
import { isRrwebError } from '../util/isRrwebError';
8+
import { isSampled } from '../util/isSampled';
9+
import { shouldTrySampleEvent } from '../util/shouldTrySampleEvent';
810
import { handleAfterSendEvent } from './handleAfterSendEvent';
911

1012
/**
@@ -36,8 +38,13 @@ export function handleGlobalEventListener(
3638
return null;
3739
}
3840

39-
// Only tag transactions with replayId if not waiting for an error
40-
if (isErrorEvent(event) || (isTransactionEvent(event) && replay.recordingMode === 'session')) {
41+
const isErrorEventSampled = shouldTrySampleEvent(replay, event) && isSampled(replay.getOptions().errorSampleRate);
42+
43+
// Tag errors if it has been sampled in buffer mode, or if it is session mode
44+
// Only tag transactions if in session mode
45+
const shouldTagReplayId = isErrorEventSampled || replay.recordingMode === 'session';
46+
47+
if (shouldTagReplayId) {
4148
event.tags = { ...event.tags, replayId: replay.getSessionId() };
4249
}
4350

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Event } from '@sentry/types';
2+
3+
import { UNABLE_TO_SEND_REPLAY } from '../constants';
4+
import type { ReplayContainer } from '../types';
5+
6+
/**
7+
* Determine if event should be sampled (only applies in buffer mode).
8+
*/
9+
export function shouldTrySampleEvent(replay: ReplayContainer, event: Event): boolean {
10+
if (replay.recordingMode !== 'buffer') {
11+
return false;
12+
}
13+
14+
// ignore this error because otherwise we could loop indefinitely with
15+
// trying to capture replay and failing
16+
if (event.message === UNABLE_TO_SEND_REPLAY) {
17+
return false;
18+
}
19+
20+
// Require the event to have an exception
21+
return !!event.exception;
22+
}

0 commit comments

Comments
 (0)