Skip to content

Commit a70376e

Browse files
authored
feat(replay): Reduce time limit before pausing a recording (#7356)
Reduce the time limit before pausing a recording from `MAX_SESSION_LIFE` (1 hour) to `SESSION_IDLE_DURATION` (5 minutes). This reduces the amount of empty replays that are caused by a DOM mutation on a timer. An example of this is on sentry.io where there is a time component that updates every x seconds. As a reminder, there are three events that will break the idle timeout: * mouse click * user input in a form field * a navigation Closes #7352
1 parent f8ca00d commit a70376e

File tree

4 files changed

+186
-227
lines changed

4 files changed

+186
-227
lines changed

packages/integration-tests/suites/replay/sessionInactive/init.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,6 @@ Sentry.init({
1717
});
1818

1919
window.Replay._replay.timeouts = {
20-
sessionIdle: 300000, // default: 5min
20+
sessionIdle: 1000, // default: 5min
2121
maxSessionLife: 2000, // this is usually 60min, but we want to test this with shorter times
2222
};

packages/replay/src/replay.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,11 +392,19 @@ export class ReplayContainer implements ReplayContainerInterface {
392392
const oldSessionId = this.getSessionId();
393393

394394
// Prevent starting a new session if the last user activity is older than
395-
// MAX_SESSION_LIFE. Otherwise non-user activity can trigger a new
395+
// SESSION_IDLE_DURATION. Otherwise non-user activity can trigger a new
396396
// session+recording. This creates noisy replays that do not have much
397397
// content in them.
398-
if (this._lastActivity && isExpired(this._lastActivity, this.timeouts.maxSessionLife)) {
399-
// Pause recording
398+
if (
399+
this._lastActivity &&
400+
isExpired(this._lastActivity, this.timeouts.sessionIdle) &&
401+
this.session &&
402+
this.session.sampled === 'session'
403+
) {
404+
// Pause recording only for session-based replays. Otherwise, resuming
405+
// will create a new replay and will conflict with users who only choose
406+
// to record error-based replays only. (e.g. the resumed replay will not
407+
// contain a reference to an error)
400408
this.pause();
401409
return;
402410
}

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

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -269,9 +269,15 @@ describe('Integration | errorSampleRate', () => {
269269
expect(replay).not.toHaveLastSentReplay();
270270
});
271271

272-
it('stops replay if user has been idle for more than 15 minutes and comes back to move their mouse', async () => {
272+
// When the error session records as a normal session, we want to stop
273+
// recording after the session ends. Otherwise, we get into a state where the
274+
// new session is a session type replay (this could conflict with the session
275+
// sample rate of 0.0), or an error session that has no errors. Instead we
276+
// simply stop the session replay completely and wait for a new page load to
277+
// resample.
278+
it('stops replay if session exceeds MAX_SESSION_LIFE and does not start a new session thereafter', async () => {
273279
// Idle for 15 minutes
274-
jest.advanceTimersByTime(15 * 60000);
280+
jest.advanceTimersByTime(MAX_SESSION_LIFE + 1);
275281

276282
const TEST_EVENT = {
277283
data: { name: 'lost event' },
@@ -289,6 +295,42 @@ describe('Integration | errorSampleRate', () => {
289295
expect(replay).not.toHaveLastSentReplay();
290296
expect(replay.isEnabled()).toBe(false);
291297
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
298+
299+
domHandler({
300+
name: 'click',
301+
});
302+
303+
// Remains disabled!
304+
expect(replay.isEnabled()).toBe(false);
305+
});
306+
307+
// Should behave the same as above test
308+
it('stops replay if user has been idle for more than SESSION_IDLE_DURATION and does not start a new session thereafter', async () => {
309+
// Idle for 15 minutes
310+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
311+
312+
const TEST_EVENT = {
313+
data: { name: 'lost event' },
314+
timestamp: BASE_TIMESTAMP,
315+
type: 3,
316+
};
317+
mockRecord._emitter(TEST_EVENT);
318+
expect(replay).not.toHaveLastSentReplay();
319+
320+
jest.runAllTimers();
321+
await new Promise(process.nextTick);
322+
323+
// We stop recording after SESSION_IDLE_DURATION of inactivity in error mode
324+
expect(replay).not.toHaveLastSentReplay();
325+
expect(replay.isEnabled()).toBe(false);
326+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
327+
328+
domHandler({
329+
name: 'click',
330+
});
331+
332+
// Remains disabled!
333+
expect(replay.isEnabled()).toBe(false);
292334
});
293335

294336
it('has the correct timestamps with deferred root event and last replay update', async () => {
@@ -375,7 +417,46 @@ describe('Integration | errorSampleRate', () => {
375417
});
376418
});
377419

378-
it('stops replay when session expires', async () => {
420+
it('stops replay when user goes idle', async () => {
421+
jest.setSystemTime(BASE_TIMESTAMP);
422+
423+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
424+
mockRecord._emitter(TEST_EVENT);
425+
426+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
427+
expect(replay).not.toHaveLastSentReplay();
428+
429+
jest.runAllTimers();
430+
await new Promise(process.nextTick);
431+
432+
captureException(new Error('testing'));
433+
434+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
435+
await new Promise(process.nextTick);
436+
437+
expect(replay).toHaveLastSentReplay();
438+
439+
// Now wait after session expires - should stop recording
440+
mockRecord.takeFullSnapshot.mockClear();
441+
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
442+
443+
// Go idle
444+
jest.advanceTimersByTime(SESSION_IDLE_DURATION + 1);
445+
await new Promise(process.nextTick);
446+
447+
mockRecord._emitter(TEST_EVENT);
448+
449+
expect(replay).not.toHaveLastSentReplay();
450+
451+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
452+
await new Promise(process.nextTick);
453+
454+
expect(replay).not.toHaveLastSentReplay();
455+
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
456+
expect(replay.isEnabled()).toBe(false);
457+
});
458+
459+
it('stops replay when session exceeds max length', async () => {
379460
jest.setSystemTime(BASE_TIMESTAMP);
380461

381462
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };

0 commit comments

Comments
 (0)