Skip to content

Commit 300b220

Browse files
authored
feat(replay): Change stop() to flush and remove current session (#7741)
`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. Closes: #7738
1 parent 7d10c44 commit 300b220

File tree

14 files changed

+86
-42
lines changed

14 files changed

+86
-42
lines changed

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,19 @@ sentryTest(
5454
sentryTest.skip();
5555
}
5656

57+
await page.route('https://dsn.ingest.sentry.io/**/*', route => {
58+
return route.fulfill({
59+
status: 200,
60+
contentType: 'application/json',
61+
body: JSON.stringify({ id: 'test-id' }),
62+
});
63+
});
64+
5765
const url = await getLocalTestPath({ testDir: __dirname });
5866
await page.goto(url);
5967

60-
await page.evaluate(() => {
61-
(window as unknown as TestWindow).Replay.stop();
68+
await page.evaluate(async () => {
69+
await (window as unknown as TestWindow).Replay.stop();
6270

6371
(window as unknown as TestWindow).Sentry.configureScope(scope => {
6472
scope.setUser({ id: 'user123', segment: 'segmentB' });

packages/replay/src/integration.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -219,12 +219,12 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
219219
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
220220
* does not support a teardown
221221
*/
222-
public stop(): void {
222+
public stop(): Promise<void> {
223223
if (!this._replay) {
224-
return;
224+
return Promise.resolve();
225225
}
226226

227-
this._replay.stop();
227+
return this._replay.stop();
228228
}
229229

230230
/**
@@ -234,9 +234,9 @@ Sentry.init({ replaysOnErrorSampleRate: ${errorSampleRate} })`,
234234
* Unless `continueRecording` is false, the replay will continue to record and
235235
* behave as a "session"-based replay.
236236
*/
237-
public flush(options?: SendBufferedReplayOptions): Promise<void> | void {
237+
public flush(options?: SendBufferedReplayOptions): Promise<void> {
238238
if (!this._replay || !this._replay.isEnabled()) {
239-
return;
239+
return Promise.resolve();
240240
}
241241

242242
return this._replay.sendBufferedReplayOrFlush(options);

packages/replay/src/replay.ts

Lines changed: 28 additions & 6 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 {
@@ -239,7 +240,7 @@ export class ReplayContainer implements ReplayContainerInterface {
239240
* Currently, this needs to be manually called (e.g. for tests). Sentry SDK
240241
* does not support a teardown
241242
*/
242-
public stop(reason?: string): void {
243+
public async stop(reason?: string): Promise<void> {
243244
if (!this._isEnabled) {
244245
return;
245246
}
@@ -255,12 +256,24 @@ export class ReplayContainer implements ReplayContainerInterface {
255256
log(msg);
256257
}
257258

259+
// We can't move `_isEnabled` after awaiting a flush, otherwise we can
260+
// enter into an infinite loop when `stop()` is called while flushing.
258261
this._isEnabled = false;
259262
this._removeListeners();
260263
this.stopRecording();
264+
265+
this._debouncedFlush.cancel();
266+
// See comment above re: `_isEnabled`, we "force" a flush, ignoring the
267+
// `_isEnabled` state of the plugin since it was disabled above.
268+
await this._flush({ force: true });
269+
270+
// After flush, destroy event buffer
261271
this.eventBuffer && this.eventBuffer.destroy();
262272
this.eventBuffer = null;
263-
this._debouncedFlush.cancel();
273+
274+
// Clear session from session storage, note this means if a new session
275+
// is started after, it will not have `previousSessionId`
276+
clearSession(this);
264277
} catch (err) {
265278
this._handleException(err);
266279
}
@@ -500,7 +513,7 @@ export class ReplayContainer implements ReplayContainerInterface {
500513
this.session = session;
501514

502515
if (!this.session.sampled) {
503-
this.stop('session unsampled');
516+
void this.stop('session unsampled');
504517
return false;
505518
}
506519

@@ -793,7 +806,7 @@ export class ReplayContainer implements ReplayContainerInterface {
793806
// This means we retried 3 times and all of them failed,
794807
// or we ran into a problem we don't want to retry, like rate limiting.
795808
// In this case, we want to completely stop the replay - otherwise, we may get inconsistent segments
796-
this.stop('sendReplay');
809+
void this.stop('sendReplay');
797810

798811
const client = getCurrentHub().getClient();
799812

@@ -807,8 +820,17 @@ export class ReplayContainer implements ReplayContainerInterface {
807820
* Flush recording data to Sentry. Creates a lock so that only a single flush
808821
* can be active at a time. Do not call this directly.
809822
*/
810-
private _flush: () => Promise<void> = async () => {
811-
if (!this._isEnabled) {
823+
private _flush = async ({
824+
force = false,
825+
}: {
826+
/**
827+
* If true, flush while ignoring the `_isEnabled` state of
828+
* Replay integration. (By default, flush is noop if integration
829+
* is stopped).
830+
*/
831+
force?: boolean;
832+
} = {}): Promise<void> => {
833+
if (!this._isEnabled && !force) {
812834
// This can happen if e.g. the replay was stopped because of exceeding the retry limit
813835
return;
814836
}

packages/replay/test/utils/clearSession.ts renamed to packages/replay/src/session/clearSession.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { REPLAY_SESSION_KEY, WINDOW } from '../../src/constants';
22
import type { ReplayContainer } from '../../src/types';
33

4-
export function clearSession(replay: ReplayContainer) {
4+
/**
5+
* Removes the session from Session Storage and unsets session in replay instance
6+
*/
7+
export function clearSession(replay: ReplayContainer): void {
58
deleteSession();
69
replay.session = undefined;
710
}

packages/replay/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ export interface ReplayContainer {
472472
isPaused(): boolean;
473473
getContext(): InternalEventContext;
474474
start(): void;
475-
stop(reason?: string): void;
475+
stop(reason?: string): Promise<void>;
476476
pause(): void;
477477
resume(): void;
478478
startRecording(): void;

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
@@ -9,13 +9,13 @@ import {
99
WINDOW,
1010
} from '../../src/constants';
1111
import type { ReplayContainer } from '../../src/replay';
12+
import { clearSession } from '../../src/session/clearSession';
1213
import { addEvent } from '../../src/util/addEvent';
1314
import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource';
1415
import type { RecordMock } from '../index';
1516
import { BASE_TIMESTAMP } from '../index';
1617
import { resetSdkMock } from '../mocks/resetSdkMock';
1718
import type { DomHandler } from '../types';
18-
import { clearSession } from '../utils/clearSession';
1919
import { useFakeTimers } from '../utils/use-fake-timers';
2020

2121
useFakeTimers();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { getCurrentHub } from '@sentry/core';
22

33
import { WINDOW } from '../../src/constants';
44
import type { ReplayContainer } from '../../src/replay';
5+
import { clearSession } from '../../src/session/clearSession';
56
import { addEvent } from '../../src/util/addEvent';
67
import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource';
78
import type { RecordMock } from '../index';
89
import { BASE_TIMESTAMP } from '../index';
910
import { resetSdkMock } from '../mocks/resetSdkMock';
10-
import { clearSession } from '../utils/clearSession';
1111
import { useFakeTimers } from '../utils/use-fake-timers';
1212

1313
useFakeTimers();

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import * as SentryUtils from '@sentry/utils';
22

33
import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants';
44
import type { ReplayContainer } from '../../src/replay';
5+
import { clearSession } from '../../src/session/clearSession';
56
import type { EventBuffer } from '../../src/types';
67
import * as AddMemoryEntry from '../../src/util/addMemoryEntry';
78
import { createPerformanceEntries } from '../../src/util/createPerformanceEntries';
89
import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
910
import * as SendReplay from '../../src/util/sendReplay';
1011
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
11-
import { clearSession } from '../utils/clearSession';
1212
import { useFakeTimers } from '../utils/use-fake-timers';
1313

1414
useFakeTimers();

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type { Transport } from '@sentry/types';
33

44
import { DEFAULT_FLUSH_MIN_DELAY } from '../../src/constants';
55
import type { ReplayContainer } from '../../src/replay';
6+
import { clearSession } from '../../src/session/clearSession';
67
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
78
import { BASE_TIMESTAMP, mockSdk } from '../index';
89
import { mockRrweb } from '../mocks/mockRrweb';
9-
import { clearSession } from '../utils/clearSession';
1010
import { useFakeTimers } from '../utils/use-fake-timers';
1111

1212
useFakeTimers();
@@ -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
@@ -4,10 +4,10 @@ import * as SentryUtils from '@sentry/utils';
44

55
import { DEFAULT_FLUSH_MIN_DELAY, WINDOW } from '../../src/constants';
66
import type { ReplayContainer } from '../../src/replay';
7+
import { clearSession } from '../../src/session/clearSession';
78
import { addEvent } from '../../src/util/addEvent';
89
import * as SendReplayRequest from '../../src/util/sendReplayRequest';
910
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
10-
import { clearSession } from '../utils/clearSession';
1111
import { useFakeTimers } from '../utils/use-fake-timers';
1212

1313
useFakeTimers();
@@ -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
@@ -9,13 +9,13 @@ import {
99
WINDOW,
1010
} from '../../src/constants';
1111
import type { ReplayContainer } from '../../src/replay';
12+
import { clearSession } from '../../src/session/clearSession';
1213
import type { Session } from '../../src/types';
1314
import { addEvent } from '../../src/util/addEvent';
1415
import { createPerformanceSpans } from '../../src/util/createPerformanceSpans';
1516
import { BASE_TIMESTAMP } from '../index';
1617
import type { RecordMock } from '../mocks/mockRrweb';
1718
import { resetSdkMock } from '../mocks/resetSdkMock';
18-
import { clearSession } from '../utils/clearSession';
1919
import { useFakeTimers } from '../utils/use-fake-timers';
2020

2121
useFakeTimers();

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import * as SentryUtils from '@sentry/utils';
33
import type { Replay } from '../../src';
44
import { WINDOW } from '../../src/constants';
55
import type { ReplayContainer } from '../../src/replay';
6+
import { clearSession } from '../../src/session/clearSession';
67
import { addEvent } from '../../src/util/addEvent';
78
// mock functions need to be imported first
89
import { BASE_TIMESTAMP, mockRrweb, mockSdk } from '../index';
9-
import { clearSession } from '../utils/clearSession';
1010
import { useFakeTimers } from '../utils/use-fake-timers';
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,33 @@ 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([TEST_EVENT]),
159+
});
143160
});
144161

145162
it('does not call core SDK `addInstrumentationHandler` after initial setup', async function () {
146163
// NOTE: We clear addInstrumentationHandler mock after every test
147-
integration.stop();
164+
await integration.stop();
148165
integration.start();
149-
integration.stop();
166+
await integration.stop();
150167
integration.start();
151168

152169
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';
3+
import { clearSession } from '../../src/session/clearSession';
34
import type { RecordingOptions, ReplayPluginOptions } from '../../src/types';
4-
import { clearSession } from './clearSession';
55

66
export function setupReplayContainer({
77
options,

0 commit comments

Comments
 (0)