Skip to content

Commit ffe504b

Browse files
committed
ref(replay): Make idle timeouts configurable & reusable
Also streamline that `SESSION_IDLE_DURATION` and `VISIBILITY_CHANGE_TIMEOUT` are the same thing (which they have already been).
1 parent 9c2c018 commit ffe504b

16 files changed

+75
-59
lines changed

packages/replay/src/constants.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay';
1414
// The idle limit for a session
1515
export const SESSION_IDLE_DURATION = 300_000; // 5 minutes in ms
1616

17-
// Grace period to keep a session when a user changes tabs or hides window
18-
export const VISIBILITY_CHANGE_TIMEOUT = SESSION_IDLE_DURATION;
19-
2017
// The maximum length of a session
2118
export const MAX_SESSION_LIFE = 3_600_000; // 60 minutes
2219

packages/replay/src/replay.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,7 @@ import { captureException, getCurrentHub } from '@sentry/core';
44
import type { Breadcrumb, ReplayRecordingMode } from '@sentry/types';
55
import { logger } from '@sentry/utils';
66

7-
import {
8-
ERROR_CHECKOUT_TIME,
9-
MAX_SESSION_LIFE,
10-
SESSION_IDLE_DURATION,
11-
VISIBILITY_CHANGE_TIMEOUT,
12-
WINDOW,
13-
} from './constants';
7+
import { ERROR_CHECKOUT_TIME, MAX_SESSION_LIFE, SESSION_IDLE_DURATION, WINDOW } from './constants';
148
import { setupPerformanceObserver } from './coreHandlers/performanceObserver';
159
import { createEventBuffer } from './eventBuffer';
1610
import { getSession } from './session/getSession';
@@ -60,6 +54,12 @@ export class ReplayContainer implements ReplayContainerInterface {
6054
*/
6155
public recordingMode: ReplayRecordingMode = 'session';
6256

57+
/** These are here so we can overwrite them in tests etc. */
58+
public timeouts = {
59+
sessionIdle: SESSION_IDLE_DURATION,
60+
maxSessionLife: MAX_SESSION_LIFE,
61+
};
62+
6363
/**
6464
* Options to pass to `rrweb.record()`
6565
*/
@@ -367,22 +367,22 @@ export class ReplayContainer implements ReplayContainerInterface {
367367
* Returns true if session is not expired, false otherwise.
368368
* @hidden
369369
*/
370-
public checkAndHandleExpiredSession(expiry?: number): boolean | void {
370+
public checkAndHandleExpiredSession(): boolean | void {
371371
const oldSessionId = this.getSessionId();
372372

373373
// Prevent starting a new session if the last user activity is older than
374374
// MAX_SESSION_LIFE. Otherwise non-user activity can trigger a new
375375
// session+recording. This creates noisy replays that do not have much
376376
// content in them.
377-
if (this._lastActivity && isExpired(this._lastActivity, MAX_SESSION_LIFE)) {
377+
if (this._lastActivity && isExpired(this._lastActivity, this.timeouts.maxSessionLife)) {
378378
// Pause recording
379379
this.pause();
380380
return;
381381
}
382382

383383
// --- There is recent user activity --- //
384384
// This will create a new session if expired, based on expiry length
385-
if (!this._loadAndCheckSession(expiry)) {
385+
if (!this._loadAndCheckSession()) {
386386
return;
387387
}
388388

@@ -412,9 +412,10 @@ export class ReplayContainer implements ReplayContainerInterface {
412412
* Loads (or refreshes) the current session.
413413
* Returns false if session is not recorded.
414414
*/
415-
private _loadAndCheckSession(expiry = SESSION_IDLE_DURATION): boolean {
415+
private _loadAndCheckSession(): boolean {
416416
const { type, session } = getSession({
417-
expiry,
417+
expiry: this.timeouts.sessionIdle,
418+
maxSessionLife: this.timeouts.maxSessionLife,
418419
stickySession: Boolean(this._options.stickySession),
419420
currentSession: this.session,
420421
sessionSampleRate: this._options.sessionSampleRate,
@@ -626,7 +627,7 @@ export class ReplayContainer implements ReplayContainerInterface {
626627
return;
627628
}
628629

629-
const expired = isSessionExpired(this.session, VISIBILITY_CHANGE_TIMEOUT);
630+
const expired = isSessionExpired(this.session, this.timeouts.maxSessionLife, this.timeouts.sessionIdle);
630631

631632
if (breadcrumb && !expired) {
632633
this._createCustomBreadcrumb(breadcrumb);
@@ -646,10 +647,10 @@ export class ReplayContainer implements ReplayContainerInterface {
646647
return;
647648
}
648649

649-
const isSessionActive = this.checkAndHandleExpiredSession(VISIBILITY_CHANGE_TIMEOUT);
650+
const isSessionActive = this.checkAndHandleExpiredSession();
650651

651652
if (!isSessionActive) {
652-
// If the user has come back to the page within VISIBILITY_CHANGE_TIMEOUT
653+
// If the user has come back to the page within SESSION_IDLE_DURATION
653654
// ms, we will re-use the existing session, otherwise create a new
654655
// session
655656
__DEBUG_BUILD__ && logger.log('[Replay] Document has become active, but session has expired');

packages/replay/src/session/getSession.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ interface GetSessionParams extends SessionOptions {
1212
*/
1313
expiry: number;
1414

15+
/** How long a session may max. be. */
16+
maxSessionLife: number;
17+
1518
/**
1619
* The current session (e.g. if stickySession is off)
1720
*/
@@ -23,6 +26,7 @@ interface GetSessionParams extends SessionOptions {
2326
*/
2427
export function getSession({
2528
expiry,
29+
maxSessionLife,
2630
currentSession,
2731
stickySession,
2832
sessionSampleRate,
@@ -35,7 +39,7 @@ export function getSession({
3539
// If there is a session, check if it is valid (e.g. "last activity" time
3640
// should be within the "session idle time", and "session started" time is
3741
// within "max session time").
38-
const isExpired = isSessionExpired(session, expiry);
42+
const isExpired = isSessionExpired(session, maxSessionLife, expiry);
3943

4044
if (!isExpired) {
4145
return { type: 'saved', session };

packages/replay/src/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,10 @@ export interface ReplayContainer {
289289
performanceEvents: AllPerformanceEntry[];
290290
session: Session | undefined;
291291
recordingMode: ReplayRecordingMode;
292+
timeouts: {
293+
sessionIdle: number;
294+
maxSessionLife: number;
295+
};
292296
isEnabled(): boolean;
293297
isPaused(): boolean;
294298
getContext(): InternalEventContext;

packages/replay/src/util/addEvent.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { getCurrentHub } from '@sentry/core';
22
import { logger } from '@sentry/utils';
33

4-
import { SESSION_IDLE_DURATION } from '../constants';
54
import type { AddEventResult, RecordingEvent, ReplayContainer } from '../types';
65

76
/**
@@ -31,7 +30,7 @@ export async function addEvent(
3130
// page has been left open and idle for a long period of time and user
3231
// comes back to trigger a new session. The performance entries rely on
3332
// `performance.timeOrigin`, which is when the page first opened.
34-
if (timestampInMs + SESSION_IDLE_DURATION < new Date().getTime()) {
33+
if (timestampInMs + replay.timeouts.sessionIdle < new Date().getTime()) {
3534
return null;
3635
}
3736

packages/replay/src/util/isSessionExpired.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
1-
import { MAX_SESSION_LIFE } from '../constants';
21
import type { Session } from '../types';
32
import { isExpired } from './isExpired';
43

54
/**
65
* Checks to see if session is expired
76
*/
8-
export function isSessionExpired(session: Session, idleTimeout: number, targetTime: number = +new Date()): boolean {
7+
export function isSessionExpired(
8+
session: Session,
9+
maxSessionLife: number,
10+
idleTimeout: number,
11+
targetTime: number = +new Date(),
12+
): boolean {
913
return (
1014
// First, check that maximum session length has not been exceeded
11-
isExpired(session.started, MAX_SESSION_LIFE, targetTime) ||
15+
isExpired(session.started, maxSessionLife, targetTime) ||
1216
// check that the idle timeout has not been exceeded (i.e. user has
1317
// performed an action within the last `idleTimeout` ms)
1418
isExpired(session.lastActivity, idleTimeout, targetTime)

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
ERROR_CHECKOUT_TIME,
66
MAX_SESSION_LIFE,
77
REPLAY_SESSION_KEY,
8-
VISIBILITY_CHANGE_TIMEOUT,
8+
SESSION_IDLE_DURATION,
99
WINDOW,
1010
} from '../../src/constants';
1111
import type { ReplayContainer } from '../../src/replay';
@@ -154,15 +154,15 @@ describe('Integration | errorSampleRate', () => {
154154
});
155155
});
156156

157-
it('does not send a replay when triggering a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', async () => {
157+
it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', async () => {
158158
Object.defineProperty(document, 'visibilityState', {
159159
configurable: true,
160160
get: function () {
161161
return 'visible';
162162
},
163163
});
164164

165-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
165+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
166166

167167
document.dispatchEvent(new Event('visibilitychange'));
168168

@@ -186,8 +186,8 @@ describe('Integration | errorSampleRate', () => {
186186

187187
expect(replay).not.toHaveLastSentReplay();
188188

189-
// User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
190-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 100);
189+
// User comes back before `SESSION_IDLE_DURATION` elapses
190+
jest.advanceTimersByTime(SESSION_IDLE_DURATION - 100);
191191
Object.defineProperty(document, 'visibilityState', {
192192
configurable: true,
193193
get: function () {

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['_loadAndCheckSession'](0);
43+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](0);
96+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](SESSION_IDLE_DURATION);
98+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](0);
49+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](SESSION_IDLE_DURATION);
60+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](0);
62+
replay['_loadAndCheckSession']();
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['_loadAndCheckSession'](SESSION_IDLE_DURATION);
72+
replay['_loadAndCheckSession']();
7373
});
7474

7575
afterAll(() => {

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

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
DEFAULT_FLUSH_MIN_DELAY,
66
MAX_SESSION_LIFE,
77
REPLAY_SESSION_KEY,
8-
VISIBILITY_CHANGE_TIMEOUT,
8+
SESSION_IDLE_DURATION,
99
WINDOW,
1010
} from '../../src/constants';
1111
import type { ReplayContainer } from '../../src/replay';
@@ -55,7 +55,7 @@ describe('Integration | session', () => {
5555
});
5656
});
5757

58-
it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
58+
it('creates a new session and triggers a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', () => {
5959
Object.defineProperty(document, 'visibilityState', {
6060
configurable: true,
6161
get: function () {
@@ -65,7 +65,7 @@ describe('Integration | session', () => {
6565

6666
const initialSession = replay.session;
6767

68-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
68+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
6969

7070
document.dispatchEvent(new Event('visibilitychange'));
7171

@@ -75,7 +75,7 @@ describe('Integration | session', () => {
7575
expect(replay).not.toHaveSameSession(initialSession);
7676
});
7777

78-
it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => {
78+
it('does not create a new session if user hides the tab and comes back within [SESSION_IDLE_DURATION] seconds', () => {
7979
const initialSession = replay.session;
8080

8181
Object.defineProperty(document, 'visibilityState', {
@@ -88,8 +88,8 @@ describe('Integration | session', () => {
8888
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
8989
expect(replay).toHaveSameSession(initialSession);
9090

91-
// User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
92-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1);
91+
// User comes back before `SESSION_IDLE_DURATION` elapses
92+
jest.advanceTimersByTime(SESSION_IDLE_DURATION - 1);
9393
Object.defineProperty(document, 'visibilityState', {
9494
configurable: true,
9595
get: function () {
@@ -184,7 +184,7 @@ describe('Integration | session', () => {
184184
expect(replay.session).toBe(undefined);
185185
});
186186

187-
it('creates a new session and triggers a full dom snapshot when document becomes visible after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
187+
it('creates a new session and triggers a full dom snapshot when document becomes visible after [SESSION_IDLE_DURATION]ms', () => {
188188
Object.defineProperty(document, 'visibilityState', {
189189
configurable: true,
190190
get: function () {
@@ -194,7 +194,7 @@ describe('Integration | session', () => {
194194

195195
const initialSession = replay.session;
196196

197-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
197+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
198198

199199
document.dispatchEvent(new Event('visibilitychange'));
200200

@@ -204,7 +204,7 @@ describe('Integration | session', () => {
204204
expect(replay).not.toHaveSameSession(initialSession);
205205
});
206206

207-
it('creates a new session and triggers a full dom snapshot when document becomes focused after [VISIBILITY_CHANGE_TIMEOUT]ms', () => {
207+
it('creates a new session and triggers a full dom snapshot when document becomes focused after [SESSION_IDLE_DURATION]ms', () => {
208208
Object.defineProperty(document, 'visibilityState', {
209209
configurable: true,
210210
get: function () {
@@ -214,7 +214,7 @@ describe('Integration | session', () => {
214214

215215
const initialSession = replay.session;
216216

217-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT + 1);
217+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
218218

219219
WINDOW.dispatchEvent(new Event('focus'));
220220

@@ -224,7 +224,7 @@ describe('Integration | session', () => {
224224
expect(replay).not.toHaveSameSession(initialSession);
225225
});
226226

227-
it('does not create a new session if user hides the tab and comes back within [VISIBILITY_CHANGE_TIMEOUT] seconds', () => {
227+
it('does not create a new session if user hides the tab and comes back within [SESSION_IDLE_DURATION] seconds', () => {
228228
const initialSession = replay.session;
229229

230230
Object.defineProperty(document, 'visibilityState', {
@@ -237,8 +237,8 @@ describe('Integration | session', () => {
237237
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
238238
expect(replay).toHaveSameSession(initialSession);
239239

240-
// User comes back before `VISIBILITY_CHANGE_TIMEOUT` elapses
241-
jest.advanceTimersByTime(VISIBILITY_CHANGE_TIMEOUT - 1);
240+
// User comes back before `SESSION_IDLE_DURATION` elapses
241+
jest.advanceTimersByTime(SESSION_IDLE_DURATION - 1);
242242
Object.defineProperty(document, 'visibilityState', {
243243
configurable: true,
244244
get: function () {
@@ -451,7 +451,7 @@ describe('Integration | session', () => {
451451

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

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

0 commit comments

Comments
 (0)