Skip to content

Commit 5641265

Browse files
committed
feat(replay): Change stop() to flush and remove current session
`stop()` will now flush the eventBuffer before clearing it, as well as removing the session from Session Storage. Due to the flushing, `stop()` is now async. Ref: #7738
1 parent 52b764e commit 5641265

12 files changed

+69
-33
lines changed

packages/replay/src/integration.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,12 +207,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
207207
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
208208
* does not support a teardown
209209
*/
210-
public stop(): void {
210+
public async stop(): Promise<void> {
211211
if (!this._replay) {
212212
return;
213213
}
214214

215-
this._replay.stop();
215+
return this._replay.stop();
216216
}
217217

218218
/**

packages/replay/src/replay.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { logger } from '@sentry/utils';
77
import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from './constants';
88
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
99
import { createEventBuffer } from './eventBuffer';
10+
import { clearSession } from './session/clearSession';
1011
import { getSession } from './session/getSession';
1112
import { saveSession } from './session/saveSession';
1213
import type {
@@ -85,6 +86,11 @@ export class ReplayContainer implements ReplayContainerInterface {
8586
*/
8687
private _isEnabled: boolean = false;
8788

89+
/**
90+
* If true, will flush regardless of `_isEnabled` property
91+
*/
92+
private _shouldFinalFlush: boolean = false;
93+
8894
/**
8995
* Paused is a state where:
9096
* - DOM Recording is not listening at all
@@ -237,7 +243,7 @@ export class ReplayContainer implements ReplayContainerInterface {
237243
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
238244
* does not support a teardown
239245
*/
240-
public stop(reason?: string): void {
246+
public async stop(reason?: string): Promise<void> {
241247
if (!this._isEnabled) {
242248
return;
243249
}
@@ -253,14 +259,31 @@ export class ReplayContainer implements ReplayContainerInterface {
253259
log(msg);
254260
}
255261

262+
// Set this property so that it ignores `_isEnabled` = false
263+
// We can't move `_isEnabled` after awaiting a flush, otherwise we can
264+
// enter into an infinite loop when `stop()` is called while flushing.
265+
this._shouldFinalFlush = true;
256266
this._isEnabled = false;
257267
this._removeListeners();
258268
this.stopRecording();
269+
270+
// Flush event buffer before stopping
271+
await this.flushImmediate()
272+
273+
this._shouldFinalFlush = false;
274+
275+
// After flush, destroy event buffer
259276
this.eventBuffer && this.eventBuffer.destroy();
260277
this.eventBuffer = null;
261-
this._debouncedFlush.cancel();
278+
279+
// Clear session from session storage, note this means if a new session
280+
// is started after, it will not have `previousSessionId`
281+
clearSession(this);
282+
262283
} catch (err) {
263284
this._handleException(err);
285+
} finally {
286+
this._shouldFinalFlush = false;
264287
}
265288
}
266289

@@ -760,7 +783,7 @@ export class ReplayContainer implements ReplayContainerInterface {
760783
// This means we retried 3 times and all of them failed,
761784
// or we ran into a problem we don't want to retry, like rate limiting.
762785
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
763-
this.stop('sendReplay');
786+
void this.stop('sendReplay');
764787

765788
const client = getCurrentHub().getClient();
766789

@@ -775,7 +798,7 @@ export class ReplayContainer implements ReplayContainerInterface {
775798
* can be active at a time. Do not call this directly.
776799
*/
777800
private _flush: () => Promise<void> = async () => {
778-
if (!this._isEnabled) {
801+
if (!this._isEnabled && !this._shouldFinalFlush) {
779802
// This can happen if e.g. the replay was stopped because of exceeding the retry limit
780803
return;
781804
}

packages/replay/src/util/addEvent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export async function addEvent(
4646
return await replay.eventBuffer.addEvent(event, isCheckout);
4747
} catch (error) {
4848
__DEBUG_BUILD__ && logger.error(error);
49-
replay.stop('addEvent');
49+
await replay.stop('addEvent');
5050

5151
const client = getCurrentHub().getClient();
5252

packages/replay/test/integration/errorSampleRate.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import type { RecordMock } from '../index';
1515
import { BASE_TIMESTAMP } from '../index';
1616
import { resetSdkMock } from '../mocks/resetSdkMock';
1717
import type { DomHandler } from '../types';
18-
import { clearSession } from '../utils/clearSession';
1918
import { useFakeTimers } from '../utils/use-fake-timers';
19+
import { clearSession } from '../../src/session/clearSession';
2020

2121
useFakeTimers();
2222

packages/replay/test/integration/events.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'
77
import type { RecordMock } from '../index';
88
import { BASE_TIMESTAMP } from '../index';
99
import { resetSdkMock } from '../mocks/resetSdkMock';
10-
import { clearSession } from '../utils/clearSession';
1110
import { useFakeTimers } from '../utils/use-fake-timers';
11+
import { clearSession } from '../../src/session/clearSession';
1212

1313
useFakeTimers();
1414

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { createPerformanceEntries } from '../../src/util/createPerformanceEntrie
88
import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
99
import * as SendReplay from '../../src/util/sendReplay';
1010
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
11-
import { clearSession } from '../utils/clearSession';
1211
import { useFakeTimers } from '../utils/use-fake-timers';
12+
import { clearSession } from '../../src/session/clearSession';
1313

1414
useFakeTimers();
1515

packages/replay/test/integration/rateLimiting.test.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import type { ReplayContainer } from '../../src/replay';
66
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
77
import { BASE_TIMESTAMP, mockSdk } from '../index';
88
import { mockRrweb } from '../mocks/mockRrweb';
9-
import { clearSession } from '../utils/clearSession';
109
import { useFakeTimers } from '../utils/use-fake-timers';
10+
import { clearSession } from '../../src/session/clearSession';
1111

1212
useFakeTimers();
1313

@@ -86,8 +86,7 @@ describe('Integration | rate-limiting behaviour', () => {
8686
expect(replay.stop).toHaveBeenCalledTimes(1);
8787

8888
// No user activity to trigger an update
89-
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
90-
expect(replay.session?.segmentId).toBe(1);
89+
expect(replay.session).toBe(undefined);
9190

9291
// let's simulate the default rate-limit time of inactivity (60secs) and check that we
9392
// don't do anything in the meantime or after the time has passed

packages/replay/test/integration/sendReplayEvent.test.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import type { ReplayContainer } from '../../src/replay';
77
import { addEvent } from '../../src/util/addEvent';
88
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
99
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
10-
import { clearSession } from '../utils/clearSession';
1110
import { useFakeTimers } from '../utils/use-fake-timers';
11+
import { clearSession } from '../../src/session/clearSession';
1212

1313
useFakeTimers();
1414

@@ -396,13 +396,8 @@ describe('Integration | sendReplayEvent', () => {
396396
'Something bad happened',
397397
);
398398

399-
// No activity has occurred, session's last activity should remain the same
400-
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP);
401-
402-
// segmentId increases despite error
403-
expect(replay.session?.segmentId).toBe(1);
404-
405-
// Replay should be completely stopped now
399+
// Replay has stopped, no session should exist
400+
expect(replay.session).toBe(undefined);
406401
expect(replay.isEnabled()).toBe(false);
407402

408403
// Events are ignored now, because we stopped

packages/replay/test/integration/session.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
1515
import { BASE_TIMESTAMP } from '../index';
1616
import type { RecordMock } from '../mocks/mockRrweb';
1717
import { resetSdkMock } from '../mocks/resetSdkMock';
18-
import { clearSession } from '../utils/clearSession';
1918
import { useFakeTimers } from '../utils/use-fake-timers';
19+
import { clearSession } from '../../src/session/clearSession';
2020

2121
useFakeTimers();
2222

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

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import type { ReplayContainer } from '../../src/replay';
66
import { addEvent } from '../../src/util/addEvent';
77
// mock functions need to be imported first
88
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
9-
import { clearSession } from '../utils/clearSession';
109
import { useFakeTimers } from '../utils/use-fake-timers';
10+
import { clearSession } from '../../src/session/clearSession';
1111

1212
useFakeTimers();
1313

14+
type MockRunFlush = jest.MockedFunction<ReplayContainer['_runFlush']>;
15+
1416
describe('Integration | stop', () => {
1517
let replay: ReplayContainer;
1618
let integration: Replay;
@@ -20,6 +22,7 @@ describe('Integration | stop', () => {
2022
const { record: mockRecord } = mockRrweb();
2123

2224
let mockAddInstrumentationHandler: MockAddInstrumentationHandler;
25+
let mockRunFlush: MockRunFlush;
2326

2427
beforeAll(async () => {
2528
jest.setSystemTime(new Date(BASE_TIMESTAMP));
@@ -29,6 +32,10 @@ describe('Integration | stop', () => {
2932
) as MockAddInstrumentationHandler;
3033

3134
({ replay, integration } = await mockSdk());
35+
36+
// @ts-ignore private API
37+
mockRunFlush = jest.spyOn(replay, '_runFlush');
38+
3239
jest.runAllTimers();
3340
});
3441

@@ -68,9 +75,10 @@ describe('Integration | stop', () => {
6875
// Not sure where the 20ms comes from tbh
6976
const EXTRA_TICKS = 20;
7077
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
78+
const previousSessionId = replay.session?.id;
7179

7280
// stop replays
73-
integration.stop();
81+
await integration.stop();
7482

7583
// Pretend 5 seconds have passed
7684
jest.advanceTimersByTime(ELAPSED);
@@ -80,14 +88,17 @@ describe('Integration | stop', () => {
8088
await new Promise(process.nextTick);
8189
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
8290
expect(replay).not.toHaveLastSentReplay();
83-
// Session's last activity should not be updated
84-
expect(replay.session?.lastActivity).toEqual(BASE_TIMESTAMP);
91+
// Session's does not exist
92+
expect(replay.session).toEqual(undefined);
8593
// eventBuffer is destroyed
8694
expect(replay.eventBuffer).toBe(null);
8795

8896
// re-enable replay
8997
integration.start();
9098

99+
// will be different session
100+
expect(replay.session?.id).not.toEqual(previousSessionId)
101+
91102
jest.advanceTimersByTime(ELAPSED);
92103

93104
const timestamp = +new Date(BASE_TIMESTAMP + ELAPSED + ELAPSED + EXTRA_TICKS) / 1000;
@@ -126,27 +137,35 @@ describe('Integration | stop', () => {
126137
expect(replay.session?.lastActivity).toBe(BASE_TIMESTAMP + ELAPSED + 20);
127138
});
128139

129-
it('does not buffer events when stopped', async function () {
130-
WINDOW.dispatchEvent(new Event('blur'));
140+
it('does not buffer new events after being stopped', async function () {
141+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
142+
addEvent(replay, TEST_EVENT);
131143
expect(replay.eventBuffer?.hasEvents).toBe(true);
144+
expect(mockRunFlush).toHaveBeenCalledTimes(0);
132145

133146
// stop replays
134-
integration.stop();
147+
await integration.stop();
148+
149+
expect(mockRunFlush).toHaveBeenCalledTimes(1);
135150

136151
expect(replay.eventBuffer).toBe(null);
137152

138153
WINDOW.dispatchEvent(new Event('blur'));
139154
await new Promise(process.nextTick);
140155

141156
expect(replay.eventBuffer).toBe(null);
142-
expect(replay).not.toHaveLastSentReplay();
157+
expect(replay).toHaveLastSentReplay({
158+
recordingData: JSON.stringify([
159+
TEST_EVENT,
160+
]),
161+
});
143162
});
144163

145164
it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () {
146165
// NOTE: We clear addInstrumentationHandler mock after every test
147-
integration.stop();
166+
await integration.stop();
148167
integration.start();
149-
integration.stop();
168+
await integration.stop();
150169
integration.start();
151170

152171
expect(mockAddInstrumentationHandler).not.toHaveBeenCalled();

packages/replay/test/utils/setupReplayContainer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createEventBuffer } from '../../src/eventBuffer';
22
import { ReplayContainer } from '../../src/replay';
33
import type { RecordingOptions, ReplayPluginOptions } from '../../src/types';
4-
import { clearSession } from './clearSession';
4+
import { clearSession } from '../../src/session/clearSession';
55

66
export function setupReplayContainer({
77
options,

0 commit comments

Comments
 (0)