Skip to content

Commit fc7344f

Browse files
authored
fix(replay): Ensure we do not try to flush when we force stop replay (#8783)
In our `stop()` method, we always tried to force a final flush when in `session` mode. However, that may lead to weird behaviour when we internally `stop()` due to a failure - e.g. think a send replay request fails, we do not want to force a flush again in that case. Note that in tests this seems to be generally passing because `flush()` usually has a running `_flushLock` at this time and thus does not attempt to flush again immediately but only schedules a flush later. I added a test that properly failed for this before and is now fixed.
1 parent ad0cb9b commit fc7344f

File tree

8 files changed

+110
-12
lines changed

8 files changed

+110
-12
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button onclick="console.log('Test log')" id="button1">Click me</button>
8+
<button onclick="console.log('Test log 2')" id="button2">Click me</button>
9+
</body>
10+
</html>
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { envelopeRequestParser } from '../../../utils/helpers';
5+
import {
6+
getDecompressedRecordingEvents,
7+
getReplaySnapshot,
8+
isReplayEvent,
9+
REPLAY_DEFAULT_FLUSH_MAX_DELAY,
10+
shouldSkipReplayTest,
11+
waitForReplayRequest,
12+
} from '../../../utils/replayHelpers';
13+
14+
sentryTest(
15+
'should stop recording when running into eventBuffer error',
16+
async ({ getLocalTestPath, page, forceFlushReplay }) => {
17+
if (shouldSkipReplayTest()) {
18+
sentryTest.skip();
19+
}
20+
21+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
22+
return route.fulfill({
23+
status: 200,
24+
});
25+
});
26+
27+
const url = await getLocalTestPath({ testDir: __dirname });
28+
await page.goto(url);
29+
30+
await waitForReplayRequest(page);
31+
const replay = await getReplaySnapshot(page);
32+
expect(replay._isEnabled).toBe(true);
33+
34+
await forceFlushReplay();
35+
36+
let called = 0;
37+
38+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
39+
const event = envelopeRequestParser(route.request());
40+
41+
// We only want to count replays here
42+
if (event && isReplayEvent(event)) {
43+
const events = getDecompressedRecordingEvents(route.request());
44+
// this makes sure we ignore e.g. mouse move events which can otherwise lead to flakes
45+
if (events.length > 0) {
46+
called++;
47+
}
48+
}
49+
50+
return route.fulfill({
51+
status: 200,
52+
});
53+
});
54+
55+
called = 0;
56+
57+
/**
58+
* We test the following here:
59+
* 1. First click should add an event (so the eventbuffer is not empty)
60+
* 2. Second click should throw an error in eventBuffer (which should lead to stopping the replay)
61+
* 3. Nothing should be sent to API, as we stop the replay due to the eventBuffer error.
62+
*/
63+
await page.evaluate(`
64+
window._count = 0;
65+
window._addEvent = window.Replay._replay.eventBuffer.addEvent.bind(window.Replay._replay.eventBuffer);
66+
window.Replay._replay.eventBuffer.addEvent = (...args) => {
67+
window._count++;
68+
if (window._count === 2) {
69+
throw new Error('provoked error');
70+
}
71+
window._addEvent(...args);
72+
};
73+
`);
74+
75+
void page.click('#button1');
76+
void page.click('#button2');
77+
78+
// Should immediately skip retrying and just cancel, no backoff
79+
// This waitForTimeout call should be okay, as we're not checking for any
80+
// further network requests afterwards.
81+
await page.waitForTimeout(REPLAY_DEFAULT_FLUSH_MAX_DELAY + 100);
82+
83+
expect(called).toBe(0);
84+
85+
const replay2 = await getReplaySnapshot(page);
86+
87+
expect(replay2._isEnabled).toBe(false);
88+
},
89+
);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ function getOptionsEvents(replayRequest: Request): CustomRecordingEvent[] {
267267
return getAllCustomRrwebRecordingEvents(events).filter(data => data.tag === 'options');
268268
}
269269

270-
function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] {
270+
export function getDecompressedRecordingEvents(resOrReq: Request | Response): RecordingSnapshot[] {
271271
const replayRequest = getRequest(resOrReq);
272272
return (
273273
(replayEnvelopeRequestParser(replayRequest, 5) as eventWithTime[])
@@ -302,7 +302,7 @@ const replayEnvelopeRequestParser = (request: Request | null, envelopeIndex = 2)
302302
return envelope[envelopeIndex] as Event;
303303
};
304304

305-
const replayEnvelopeParser = (request: Request | null): unknown[] => {
305+
export const replayEnvelopeParser = (request: Request | null): unknown[] => {
306306
// https://develop.sentry.dev/sdk/envelopes/
307307
const envelopeBytes = request?.postDataBuffer() || '';
308308

packages/replay/src/integration.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
263263
return Promise.resolve();
264264
}
265265

266-
return this._replay.stop();
266+
return this._replay.stop({ forceFlush: this._replay.recordingMode === 'session' });
267267
}
268268

269269
/**

packages/replay/src/replay.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export class ReplayContainer implements ReplayContainerInterface {
368368
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
369369
* does not support a teardown
370370
*/
371-
public async stop(reason?: string): Promise<void> {
371+
public async stop({ forceFlush = false, reason }: { forceFlush?: boolean; reason?: string } = {}): Promise<void> {
372372
if (!this._isEnabled) {
373373
return;
374374
}
@@ -388,7 +388,7 @@ export class ReplayContainer implements ReplayContainerInterface {
388388
this._debouncedFlush.cancel();
389389
// See comment above re: `_isEnabled`, we "force" a flush, ignoring the
390390
// `_isEnabled` state of the plugin since it was disabled above.
391-
if (this.recordingMode === 'session') {
391+
if (forceFlush) {
392392
await this._flush({ force: true });
393393
}
394394

@@ -777,7 +777,7 @@ export class ReplayContainer implements ReplayContainerInterface {
777777
this.session = session;
778778

779779
if (!this.session.sampled) {
780-
void this.stop('session not refreshed');
780+
void this.stop({ reason: 'session not refreshed' });
781781
return false;
782782
}
783783

@@ -1099,7 +1099,7 @@ export class ReplayContainer implements ReplayContainerInterface {
10991099
// This means we retried 3 times and all of them failed,
11001100
// or we ran into a problem we don't want to retry, like rate limiting.
11011101
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
1102-
void this.stop('sendReplay');
1102+
void this.stop({ reason: 'sendReplay' });
11031103

11041104
const client = getCurrentHub().getClient();
11051105

@@ -1223,7 +1223,7 @@ export class ReplayContainer implements ReplayContainerInterface {
12231223

12241224
// Stop replay if over the mutation limit
12251225
if (overMutationLimit) {
1226-
void this.stop('mutationLimit');
1226+
void this.stop({ reason: 'mutationLimit', forceFlush: this.recordingMode === 'session' });
12271227
return false;
12281228
}
12291229

packages/replay/src/types/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ export interface ReplayContainer {
451451
getContext(): InternalEventContext;
452452
initializeSampling(): void;
453453
start(): void;
454-
stop(reason?: string): Promise<void>;
454+
stop(options?: { reason?: string; forceflush?: boolean }): Promise<void>;
455455
pause(): void;
456456
resume(): void;
457457
startRecording(): void;

packages/replay/src/util/addEvent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export async function addEvent(
7171
const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent';
7272

7373
__DEBUG_BUILD__ && logger.error(error);
74-
await replay.stop(reason);
74+
await replay.stop({ reason });
7575

7676
const client = getCurrentHub().getClient();
7777

packages/replay/test/integration/flush.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -472,10 +472,9 @@ describe('Integration | flush', () => {
472472
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP + 100, type: 2 };
473473
mockRecord._emitter(TEST_EVENT);
474474

475-
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
476475
await advanceTimers(160_000);
477476

478-
expect(mockFlush).toHaveBeenCalledTimes(2);
477+
expect(mockFlush).toHaveBeenCalledTimes(1);
479478
expect(mockSendReplay).toHaveBeenCalledTimes(0);
480479
expect(replay.isEnabled()).toBe(false);
481480

0 commit comments

Comments
 (0)