Skip to content

Commit 69b46b3

Browse files
authored
fix(replay): Do not renew session in error mode (#6948)
1 parent b86ac10 commit 69b46b3

File tree

9 files changed

+95
-30
lines changed

9 files changed

+95
-30
lines changed

packages/replay/src/replay.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ export class ReplayContainer implements ReplayContainerInterface {
155155
public start(): void {
156156
this._setInitialState();
157157

158-
this._loadSession({ expiry: SESSION_IDLE_DURATION });
158+
if (!this._loadAndCheckSession()) {
159+
return;
160+
}
159161

160162
// If there is no session, then something bad has happened - can't continue
161163
if (!this.session) {
@@ -234,6 +236,10 @@ export class ReplayContainer implements ReplayContainerInterface {
234236
* does not support a teardown
235237
*/
236238
public stop(): void {
239+
if (!this._isEnabled) {
240+
return;
241+
}
242+
237243
try {
238244
__DEBUG_BUILD__ && logger.log('[Replay] Stopping Replays');
239245
this._isEnabled = false;
@@ -264,6 +270,10 @@ export class ReplayContainer implements ReplayContainerInterface {
264270
* new DOM checkout.`
265271
*/
266272
public resume(): void {
273+
if (!this._loadAndCheckSession()) {
274+
return;
275+
}
276+
267277
this._isPaused = false;
268278
this.startRecording();
269279
}
@@ -310,7 +320,9 @@ export class ReplayContainer implements ReplayContainerInterface {
310320
if (!this._stopRecording) {
311321
// Create a new session, otherwise when the user action is flushed, it
312322
// will get rejected due to an expired session.
313-
this._loadSession({ expiry: SESSION_IDLE_DURATION });
323+
if (!this._loadAndCheckSession()) {
324+
return;
325+
}
314326

315327
// Note: This will cause a new DOM checkout
316328
this.resume();
@@ -348,7 +360,7 @@ export class ReplayContainer implements ReplayContainerInterface {
348360
* Returns true if session is not expired, false otherwise.
349361
* @hidden
350362
*/
351-
public checkAndHandleExpiredSession({ expiry = SESSION_IDLE_DURATION }: { expiry?: number } = {}): boolean | void {
363+
public checkAndHandleExpiredSession(expiry?: number): boolean | void {
352364
const oldSessionId = this.getSessionId();
353365

354366
// Prevent starting a new session if the last user activity is older than
@@ -363,7 +375,9 @@ export class ReplayContainer implements ReplayContainerInterface {
363375

364376
// --- There is recent user activity --- //
365377
// This will create a new session if expired, based on expiry length
366-
this._loadSession({ expiry });
378+
if (!this._loadAndCheckSession(expiry)) {
379+
return;
380+
}
367381

368382
// Session was expired if session ids do not match
369383
const expired = oldSessionId !== this.getSessionId();
@@ -388,10 +402,10 @@ export class ReplayContainer implements ReplayContainerInterface {
388402
}
389403

390404
/**
391-
* Loads a session from storage, or creates a new one if it does not exist or
392-
* is expired.
405+
* Loads (or refreshes) the current session.
406+
* Returns false if session is not recorded.
393407
*/
394-
private _loadSession({ expiry }: { expiry: number }): void {
408+
private _loadAndCheckSession(expiry = SESSION_IDLE_DURATION): boolean {
395409
const { type, session } = getSession({
396410
expiry,
397411
stickySession: Boolean(this._options.stickySession),
@@ -412,6 +426,13 @@ export class ReplayContainer implements ReplayContainerInterface {
412426
}
413427

414428
this.session = session;
429+
430+
if (!this.session.sampled) {
431+
this.stop();
432+
return false;
433+
}
434+
435+
return true;
415436
}
416437

417438
/**
@@ -618,9 +639,7 @@ export class ReplayContainer implements ReplayContainerInterface {
618639
return;
619640
}
620641

621-
const isSessionActive = this.checkAndHandleExpiredSession({
622-
expiry: VISIBILITY_CHANGE_TIMEOUT,
623-
});
642+
const isSessionActive = this.checkAndHandleExpiredSession(VISIBILITY_CHANGE_TIMEOUT);
624643

625644
if (!isSessionActive) {
626645
// If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT

packages/replay/src/session/getSession.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { Session, SessionOptions } from '../types';
44
import { isSessionExpired } from '../util/isSessionExpired';
55
import { createSession } from './createSession';
66
import { fetchSession } from './fetchSession';
7+
import { makeSession } from './Session';
78

89
interface GetSessionParams extends SessionOptions {
910
/**
@@ -38,6 +39,10 @@ export function getSession({
3839

3940
if (!isExpired) {
4041
return { type: 'saved', session };
42+
} else if (session.sampled === 'error') {
43+
// Error samples should not be re-created when expired, but instead we stop when the replay is done
44+
const discardedSession = makeSession({ sampled: false });
45+
return { type: 'new', session: discardedSession };
4146
} else {
4247
__DEBUG_BUILD__ && logger.log('[Replay] Session has expired');
4348
}

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

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { captureException } from '@sentry/core';
1+
import { captureException, getCurrentHub } from '@sentry/core';
22

33
import {
44
DEFAULT_FLUSH_MIN_DELAY,
55
ERROR_CHECKOUT_TIME,
6+
MAX_SESSION_LIFE,
67
REPLAY_SESSION_KEY,
78
VISIBILITY_CHANGE_TIMEOUT,
89
WINDOW,
@@ -264,12 +265,10 @@ describe('Integration | errorSampleRate', () => {
264265
expect(replay).not.toHaveLastSentReplay();
265266
});
266267

267-
it('does not upload if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
268+
it('stops replay if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
268269
// Idle for 15 minutes
269270
jest.advanceTimersByTime(15 * 60000);
270271

271-
// TBD: We are currently deciding that this event will get dropped, but
272-
// this could/should change in the future.
273272
const TEST_EVENT = {
274273
data: { name: 'lost event' },
275274
timestamp: BASE_TIMESTAMP,
@@ -281,15 +280,11 @@ describe('Integration | errorSampleRate', () => {
281280
jest.runAllTimers();
282281
await new Promise(process.nextTick);
283282

284-
// Instead of recording the above event, a full snapshot will occur.
285-
//
286-
// TODO: We could potentially figure out a way to save the last session,
287-
// and produce a checkout based on a previous checkout + updates, and then
288-
// replay the event on top. Or maybe replay the event on top of a refresh
289-
// snapshot.
283+
// We stop recording after 15 minutes of inactivity in error mode
290284

291285
expect(replay).not.toHaveLastSentReplay();
292-
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledWith(true);
286+
expect(replay.isEnabled()).toBe(false);
287+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
293288
});
294289

295290
it('has the correct timestamps with deferred root event and last replay update', async () => {
@@ -375,6 +370,52 @@ describe('Integration | errorSampleRate', () => {
375370
]),
376371
});
377372
});
373+
374+
it('stops replay when session expires', async () => {
375+
jest.setSystemTime(BASE_TIMESTAMP);
376+
377+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
378+
mockRecord._emitter(TEST_EVENT);
379+
380+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
381+
expect(replay).not.toHaveLastSentReplay();
382+
383+
jest.runAllTimers();
384+
await new Promise(process.nextTick);
385+
386+
captureException(new Error('testing'));
387+
388+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
389+
await new Promise(process.nextTick);
390+
391+
expect(replay).toHaveLastSentReplay();
392+
393+
// Wait a bit, shortly before session expires
394+
jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
395+
await new Promise(process.nextTick);
396+
397+
mockRecord._emitter(TEST_EVENT);
398+
replay.triggerUserActivity();
399+
400+
expect(replay).toHaveLastSentReplay();
401+
402+
// Now wait after session expires - should stop recording
403+
mockRecord.takeFullSnapshot.mockClear();
404+
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
405+
406+
jest.advanceTimersByTime(10_000);
407+
await new Promise(process.nextTick);
408+
409+
mockRecord._emitter(TEST_EVENT);
410+
replay.triggerUserActivity();
411+
412+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
413+
await new Promise(process.nextTick);
414+
415+
expect(replay).not.toHaveLastSentReplay();
416+
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
417+
expect(replay.isEnabled()).toBe(false);
418+
});
378419
});
379420

380421
/**

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ describe('Integration | events', () => {
4040
// Create a new session and clear mocks because a segment (from initial
4141
// checkout) will have already been uploaded by the time the tests run
4242
clearSession(replay);
43-
replay['_loadSession']({ expiry: 0 });
43+
replay['_loadAndCheckSession'](0);
4444
mockTransportSend.mockClear();
4545
});
4646

@@ -93,7 +93,7 @@ describe('Integration | events', () => {
9393

9494
it('has correct timestamps when there are events earlier than initial timestamp', async function () {
9595
clearSession(replay);
96-
replay['_loadSession']({ expiry: 0 });
96+
replay['_loadAndCheckSession'](0);
9797
mockTransportSend.mockClear();
9898
Object.defineProperty(document, 'visibilityState', {
9999
configurable: true,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ describe('Integration | flush', () => {
9595
jest.setSystemTime(new Date(BASE_TIMESTAMP));
9696
sessionStorage.clear();
9797
clearSession(replay);
98-
replay['_loadSession']({ expiry: SESSION_IDLE_DURATION });
98+
replay['_loadAndCheckSession'](SESSION_IDLE_DURATION);
9999
mockRecord.takeFullSnapshot.mockClear();
100100
Object.defineProperty(WINDOW, 'location', {
101101
value: prevLocation,

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ describe('Integration | rate-limiting behaviour', () => {
4646
// Create a new session and clear mocks because a segment (from initial
4747
// checkout) will have already been uploaded by the time the tests run
4848
clearSession(replay);
49-
replay['_loadSession']({ expiry: 0 });
49+
replay['_loadAndCheckSession'](0);
5050

5151
mockSendReplayRequest.mockClear();
5252
});
@@ -57,7 +57,7 @@ describe('Integration | rate-limiting behaviour', () => {
5757
jest.setSystemTime(new Date(BASE_TIMESTAMP));
5858
clearSession(replay);
5959
jest.clearAllMocks();
60-
replay['_loadSession']({ expiry: SESSION_IDLE_DURATION });
60+
replay['_loadAndCheckSession'](SESSION_IDLE_DURATION);
6161
});
6262

6363
afterAll(() => {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ describe('Integration | sendReplayEvent', () => {
5959
// Create a new session and clear mocks because a segment (from initial
6060
// checkout) will have already been uploaded by the time the tests run
6161
clearSession(replay);
62-
replay['_loadSession']({ expiry: 0 });
62+
replay['_loadAndCheckSession'](0);
6363

6464
mockSendReplayRequest.mockClear();
6565
});
@@ -69,7 +69,7 @@ describe('Integration | sendReplayEvent', () => {
6969
await new Promise(process.nextTick);
7070
jest.setSystemTime(new Date(BASE_TIMESTAMP));
7171
clearSession(replay);
72-
replay['_loadSession']({ expiry: SESSION_IDLE_DURATION });
72+
replay['_loadAndCheckSession'](SESSION_IDLE_DURATION);
7373
});
7474

7575
afterAll(() => {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe('Integration | session', () => {
451451

452452
it('increases segment id after each event', async () => {
453453
clearSession(replay);
454-
replay['_loadSession']({ expiry: 0 });
454+
replay['_loadAndCheckSession'](0);
455455

456456
Object.defineProperty(document, 'visibilityState', {
457457
configurable: true,

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ describe('Integration | stop', () => {
4444
jest.setSystemTime(new Date(BASE_TIMESTAMP));
4545
sessionStorage.clear();
4646
clearSession(replay);
47-
replay['_loadSession']({ expiry: SESSION_IDLE_DURATION });
47+
replay['_loadAndCheckSession'](SESSION_IDLE_DURATION);
4848
mockRecord.takeFullSnapshot.mockClear();
4949
mockAddInstrumentationHandler.mockClear();
5050
Object.defineProperty(WINDOW, 'location', {

0 commit comments

Comments
 (0)