Skip to content

Commit 6535500

Browse files
authored
feat(replay): Clear event buffer when full and in buffer mode (#14078)
This makes a change so that when the event buffer is full, we will clear it and wait for the next checkout instead of erroring and stopping the replay buffering. This does mean that it's possible an exception occurs in-between the filled buffer and the next checkout, where we will have an empty replay
1 parent 85daf90 commit 6535500

File tree

7 files changed

+135
-33
lines changed

7 files changed

+135
-33
lines changed

packages/replay-internal/src/eventBuffer/EventBufferArray.ts

+4
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,16 @@ export class EventBufferArray implements EventBuffer {
1414
/** @inheritdoc */
1515
public hasCheckout: boolean;
1616

17+
/** @inheritdoc */
18+
public waitForCheckout: boolean;
19+
1720
private _totalSize: number;
1821

1922
public constructor() {
2023
this.events = [];
2124
this._totalSize = 0;
2225
this.hasCheckout = false;
26+
this.waitForCheckout = false;
2327
}
2428

2529
/** @inheritdoc */

packages/replay-internal/src/eventBuffer/EventBufferCompressionWorker.ts

+4
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export class EventBufferCompressionWorker implements EventBuffer {
1616
/** @inheritdoc */
1717
public hasCheckout: boolean;
1818

19+
/** @inheritdoc */
20+
public waitForCheckout: boolean;
21+
1922
private _worker: WorkerHandler;
2023
private _earliestTimestamp: number | null;
2124
private _totalSize;
@@ -25,6 +28,7 @@ export class EventBufferCompressionWorker implements EventBuffer {
2528
this._earliestTimestamp = null;
2629
this._totalSize = 0;
2730
this.hasCheckout = false;
31+
this.waitForCheckout = false;
2832
}
2933

3034
/** @inheritdoc */

packages/replay-internal/src/eventBuffer/EventBufferProxy.ts

+13-1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export class EventBufferProxy implements EventBuffer {
2525
this._ensureWorkerIsLoadedPromise = this._ensureWorkerIsLoaded();
2626
}
2727

28+
/** @inheritdoc */
29+
public get waitForCheckout(): boolean {
30+
return this._used.waitForCheckout;
31+
}
32+
2833
/** @inheritdoc */
2934
public get type(): EventBufferType {
3035
return this._used.type;
@@ -44,6 +49,12 @@ export class EventBufferProxy implements EventBuffer {
4449
this._used.hasCheckout = value;
4550
}
4651

52+
/** @inheritdoc */
53+
// eslint-disable-next-line @typescript-eslint/adjacent-overload-signatures
54+
public set waitForCheckout(value: boolean) {
55+
this._used.waitForCheckout = value;
56+
}
57+
4758
/** @inheritDoc */
4859
public destroy(): void {
4960
this._fallback.destroy();
@@ -99,14 +110,15 @@ export class EventBufferProxy implements EventBuffer {
99110

100111
/** Switch the used buffer to the compression worker. */
101112
private async _switchToCompressionWorker(): Promise<void> {
102-
const { events, hasCheckout } = this._fallback;
113+
const { events, hasCheckout, waitForCheckout } = this._fallback;
103114

104115
const addEventPromises: Promise<void>[] = [];
105116
for (const event of events) {
106117
addEventPromises.push(this._compression.addEvent(event));
107118
}
108119

109120
this._compression.hasCheckout = hasCheckout;
121+
this._compression.waitForCheckout = waitForCheckout;
110122

111123
// We switch over to the new buffer immediately - any further events will be added
112124
// after the previously buffered ones

packages/replay-internal/src/types/replay.ts

+6
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,12 @@ export interface EventBuffer {
400400
*/
401401
hasCheckout: boolean;
402402

403+
/**
404+
* If the event buffer needs to wait for a checkout event before it
405+
* starts buffering events.
406+
*/
407+
waitForCheckout: boolean;
408+
403409
/**
404410
* Destroy the event buffer.
405411
*/

packages/replay-internal/src/util/addEvent.ts

+21-6
Original file line numberDiff line numberDiff line change
@@ -54,17 +54,22 @@ async function _addEvent(
5454
event: RecordingEvent,
5555
isCheckout?: boolean,
5656
): Promise<AddEventResult | null> {
57-
if (!replay.eventBuffer) {
57+
const { eventBuffer } = replay;
58+
59+
if (!eventBuffer || (eventBuffer.waitForCheckout && !isCheckout)) {
5860
return null;
5961
}
6062

63+
const isBufferMode = replay.recordingMode === 'buffer';
64+
6165
try {
62-
if (isCheckout && replay.recordingMode === 'buffer') {
63-
replay.eventBuffer.clear();
66+
if (isCheckout && isBufferMode) {
67+
eventBuffer.clear();
6468
}
6569

6670
if (isCheckout) {
67-
replay.eventBuffer.hasCheckout = true;
71+
eventBuffer.hasCheckout = true;
72+
eventBuffer.waitForCheckout = false;
6873
}
6974

7075
const replayOptions = replay.getOptions();
@@ -75,9 +80,19 @@ async function _addEvent(
7580
return;
7681
}
7782

78-
return await replay.eventBuffer.addEvent(eventAfterPossibleCallback);
83+
return await eventBuffer.addEvent(eventAfterPossibleCallback);
7984
} catch (error) {
80-
const reason = error && error instanceof EventBufferSizeExceededError ? 'addEventSizeExceeded' : 'addEvent';
85+
const isExceeded = error && error instanceof EventBufferSizeExceededError;
86+
const reason = isExceeded ? 'addEventSizeExceeded' : 'addEvent';
87+
88+
if (isExceeded && isBufferMode) {
89+
// Clear buffer and wait for next checkout
90+
eventBuffer.clear();
91+
eventBuffer.waitForCheckout = true;
92+
93+
return null;
94+
}
95+
8196
replay.handleException(error);
8297

8398
await replay.stop({ reason });
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
6+
7+
import { WINDOW } from '../../src/constants';
8+
import type { Replay } from '../../src/integration';
9+
import type { ReplayContainer } from '../../src/replay';
10+
import { addEvent } from '../../src/util/addEvent';
11+
12+
// mock functions need to be imported first
13+
import { BASE_TIMESTAMP, mockSdk } from '../index';
14+
import { getTestEventCheckout, getTestEventIncremental } from '../utils/getTestEvent';
15+
import { useFakeTimers } from '../utils/use-fake-timers';
16+
17+
useFakeTimers();
18+
19+
describe('Integration | eventBuffer | Event Buffer Max Size', () => {
20+
let replay: ReplayContainer;
21+
let integration: Replay;
22+
const prevLocation = WINDOW.location;
23+
24+
beforeEach(async () => {
25+
vi.setSystemTime(new Date(BASE_TIMESTAMP));
26+
27+
({ replay, integration } = await mockSdk());
28+
29+
await vi.runAllTimersAsync();
30+
vi.clearAllMocks();
31+
});
32+
33+
afterEach(async () => {
34+
vi.setSystemTime(new Date(BASE_TIMESTAMP));
35+
integration && (await integration.stop());
36+
Object.defineProperty(WINDOW, 'location', {
37+
value: prevLocation,
38+
writable: true,
39+
});
40+
vi.clearAllMocks();
41+
});
42+
43+
it('does not add replay breadcrumb when stopped due to event buffer limit', async () => {
44+
const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
45+
46+
vi.mock('../../src/constants', async requireActual => ({
47+
...(await requireActual<any>()),
48+
REPLAY_MAX_EVENT_BUFFER_SIZE: 500,
49+
}));
50+
51+
await integration.stop();
52+
integration.startBuffering();
53+
54+
await addEvent(replay, TEST_EVENT);
55+
56+
expect(replay.eventBuffer?.hasEvents).toBe(true);
57+
expect(replay.eventBuffer?.['hasCheckout']).toBe(true);
58+
59+
// This should should go over max buffer size
60+
await addEvent(replay, TEST_EVENT);
61+
// buffer should be cleared and wait for next checkout
62+
expect(replay.eventBuffer?.hasEvents).toBe(false);
63+
expect(replay.eventBuffer?.['hasCheckout']).toBe(false);
64+
65+
await addEvent(replay, TEST_EVENT);
66+
expect(replay.eventBuffer?.hasEvents).toBe(false);
67+
expect(replay.eventBuffer?.['hasCheckout']).toBe(false);
68+
69+
await addEvent(replay, getTestEventCheckout({ timestamp: Date.now() }), true);
70+
expect(replay.eventBuffer?.hasEvents).toBe(true);
71+
expect(replay.eventBuffer?.['hasCheckout']).toBe(true);
72+
73+
vi.resetAllMocks();
74+
});
75+
});

packages/replay-internal/test/integration/stop.test.ts

+12-26
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,13 @@
33
*/
44

55
import type { MockInstance, MockedFunction } from 'vitest';
6-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
77

88
import * as SentryBrowserUtils from '@sentry-internal/browser-utils';
99

1010
import { WINDOW } from '../../src/constants';
1111
import type { Replay } from '../../src/integration';
1212
import type { ReplayContainer } from '../../src/replay';
13-
import { clearSession } from '../../src/session/clearSession';
1413
import { addEvent } from '../../src/util/addEvent';
1514
import { createOptionsEvent } from '../../src/util/handleRecordingEmit';
1615
// mock functions need to be imported first
@@ -32,7 +31,7 @@ describe('Integration | stop', () => {
3231
let mockAddDomInstrumentationHandler: MockInstance;
3332
let mockRunFlush: MockRunFlush;
3433

35-
beforeAll(async () => {
34+
beforeEach(async () => {
3635
vi.setSystemTime(new Date(BASE_TIMESTAMP));
3736
mockAddDomInstrumentationHandler = vi.spyOn(SentryBrowserUtils, 'addClickKeypressInstrumentationHandler');
3837

@@ -41,33 +40,18 @@ describe('Integration | stop', () => {
4140
// @ts-expect-error private API
4241
mockRunFlush = vi.spyOn(replay, '_runFlush');
4342

44-
vi.runAllTimers();
45-
});
46-
47-
beforeEach(() => {
48-
vi.setSystemTime(new Date(BASE_TIMESTAMP));
49-
replay.eventBuffer?.destroy();
43+
await vi.runAllTimersAsync();
5044
vi.clearAllMocks();
5145
});
5246

5347
afterEach(async () => {
54-
vi.runAllTimers();
55-
await new Promise(process.nextTick);
5648
vi.setSystemTime(new Date(BASE_TIMESTAMP));
57-
sessionStorage.clear();
58-
clearSession(replay);
59-
replay['_initializeSessionForSampling']();
60-
replay.setInitialState();
61-
mockRecord.takeFullSnapshot.mockClear();
62-
mockAddDomInstrumentationHandler.mockClear();
49+
integration && (await integration.stop());
6350
Object.defineProperty(WINDOW, 'location', {
6451
value: prevLocation,
6552
writable: true,
6653
});
67-
});
68-
69-
afterAll(() => {
70-
integration && integration.stop();
54+
vi.clearAllMocks();
7155
});
7256

7357
it('does not upload replay if it was stopped and can resume replays afterwards', async () => {
@@ -106,7 +90,7 @@ describe('Integration | stop', () => {
10690

10791
vi.advanceTimersByTime(ELAPSED);
10892

109-
const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED) / 1000;
93+
const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED + ELAPSED) / 1000;
11094

11195
const hiddenBreadcrumb = {
11296
type: 5,
@@ -123,15 +107,15 @@ describe('Integration | stop', () => {
123107

124108
addEvent(replay, TEST_EVENT);
125109
WINDOW.dispatchEvent(new Event('blur'));
126-
vi.runAllTimers();
110+
await vi.runAllTimersAsync();
127111
await new Promise(process.nextTick);
128112
expect(replay).toHaveLastSentReplay({
129113
recordingPayloadHeader: { segment_id: 0 },
130114
recordingData: JSON.stringify([
131115
// This event happens when we call `replay.start`
132116
{
133117
data: { isCheckout: true },
134-
timestamp: BASE_TIMESTAMP + ELAPSED,
118+
timestamp: BASE_TIMESTAMP + ELAPSED + ELAPSED,
135119
type: 2,
136120
},
137121
optionsEvent,
@@ -142,12 +126,14 @@ describe('Integration | stop', () => {
142126

143127
// Session's last activity is last updated when we call `setup()` and *NOT*
144128
// when tab is blurred
145-
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED);
129+
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + ELAPSED);
146130
});
147131

148132
it('does not buffer new events after being stopped', async function () {
133+
expect(replay.eventBuffer?.hasEvents).toBe(false);
134+
expect(mockRunFlush).toHaveBeenCalledTimes(0);
149135
const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP });
150-
addEvent(replay, TEST_EVENT);
136+
addEvent(replay, TEST_EVENT, true);
151137
expect(replay.eventBuffer?.hasEvents).toBe(true);
152138
expect(mockRunFlush).toHaveBeenCalledTimes(0);
153139

0 commit comments

Comments
 (0)