Skip to content

Commit e5cd9fa

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 62056c1 commit e5cd9fa

File tree

5 files changed

+130
-94
lines changed

5 files changed

+130
-94
lines changed

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

Lines changed: 84 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -299,76 +299,80 @@ sentryTest(
299299

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

324-
return route.fulfill({
325-
status: 200,
326-
contentType: 'application/json',
327-
body: JSON.stringify({ id: 'test-id' }),
310+
enableConsole();
311+
312+
let callsToSentry = 0;
313+
const errorEventIds: string[] = [];
314+
const reqPromise0 = waitForReplayRequest(page, 0);
315+
const reqErrorPromise0 = 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++;
326+
}
327+
328+
return route.fulfill({
329+
status: 200,
330+
contentType: 'application/json',
331+
body: JSON.stringify({ id: 'test-id' }),
332+
});
328333
});
329-
});
330-
331-
const url = await getLocalTestPath({ testDir: __dirname });
332-
333-
await page.goto(url);
334-
// Start buffering and assert that it is enabled
335-
expect(
336-
await page.evaluate(() => {
337-
const replayIntegration = (window as unknown as Window & { Replay: InstanceType<typeof Replay> }).Replay;
338-
const replay = replayIntegration['_replay'];
339-
replayIntegration.startBuffering();
340-
return replay.isEnabled();
341-
}),
342-
).toBe(true);
343334

344-
await page.click('#go-background');
345-
await page.click('#error');
346-
await new Promise(resolve => setTimeout(resolve, 1000));
335+
const url = await getLocalTestPath({ testDir: __dirname });
347336

348-
// 1 unsampled error, no replay
349-
const reqError0 = await reqErrorPromise;
350-
const errorEvent0 = envelopeRequestParser(reqError0);
351-
expect(callsToSentry).toEqual(1);
352-
expect(errorEvent0.tags?.replayId).toBeUndefined();
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);
353347

354-
await page.evaluate(async () => {
355-
const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
356-
replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
357-
});
348+
await page.click('#go-background');
349+
await page.click('#error');
350+
await new Promise(resolve => setTimeout(resolve, 1000));
358351

359-
// Error sample rate is now at 1.0, this error should create a replay
360-
await page.click('#error2');
352+
// 1 unsampled error, no replay
353+
const reqError0 = await reqErrorPromise0;
354+
const errorEvent0 = envelopeRequestParser(reqError0);
355+
expect(callsToSentry).toEqual(1);
356+
expect(errorEvent0.tags?.replayId).toBeUndefined();
361357

362-
const req0 = await reqPromise0;
358+
await page.evaluate(async () => {
359+
const replayIntegration = (window as unknown as Window & { Replay: Replay }).Replay;
360+
replayIntegration['_replay'].getOptions().errorSampleRate = 1.0;
361+
});
363362

364-
// 1 unsampled error, 1 sampled error -> 1 flush
365-
const reqError1 = await reqErrorPromise;
366-
const errorEvent1 = envelopeRequestParser(reqError1);
367-
expect(callsToSentry).toEqual(3);
368-
expect(errorEvent1.tags?.replayId).toBeDefined();
363+
// Error sample rate is now at 1.0, this error should create a replay
364+
const reqErrorPromise1 = waitForErrorRequest(page);
365+
await page.click('#error2');
366+
// 1 unsampled error, 1 sampled error -> 1 flush
367+
const req0 = await reqPromise0;
368+
const reqError1 = await reqErrorPromise1;
369+
const errorEvent1 = envelopeRequestParser(reqError1);
370+
expect(callsToSentry).toEqual(3);
371+
expect(errorEvent0.event_id).not.toEqual(errorEvent1.event_id);
372+
expect(errorEvent1.tags?.replayId).toBeDefined();
369373

370-
const event0 = getReplayEvent(req0);
371-
const content0 = getReplayRecordingContent(req0);
374+
const event0 = getReplayEvent(req0);
375+
const content0 = getReplayRecordingContent(req0);
372376

373377
expect(event0).toEqual(
374378
getExpectedReplayEvent({
@@ -393,27 +397,28 @@ sentryTest('[buffer-mode] can sample on each error event', async ({ getLocalTest
393397
attributes: {
394398
id: 'error',
395399
},
396-
id: expect.any(Number),
397-
tagName: 'button',
398-
textContent: '***** *****',
400+
id: expect.any(Number),
401+
tagName: 'button',
402+
textContent: '***** *****',
403+
},
399404
},
400405
},
401-
},
402-
{
403-
...expectedClickBreadcrumb,
404-
message: 'body > button#error2',
405-
data: {
406-
nodeId: expect.any(Number),
407-
node: {
408-
attributes: {
409-
id: 'error2',
406+
{
407+
...expectedClickBreadcrumb,
408+
message: 'body > button#error2',
409+
data: {
410+
nodeId: expect.any(Number),
411+
node: {
412+
attributes: {
413+
id: 'error2',
414+
},
415+
id: expect.any(Number),
416+
tagName: 'button',
417+
textContent: '******* *****',
410418
},
411-
id: expect.any(Number),
412-
tagName: 'button',
413-
textContent: '******* *****',
414419
},
415420
},
416-
},
417-
]),
418-
);
419-
});
421+
]),
422+
);
423+
},
424+
);

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)