Skip to content

Commit 68bfd03

Browse files
committed
fix(replay): Ignore max session life for buffered sessions
Theres an edge case where a buffered session becomes expired, an error comes in, the link from error to replay is lost because a new session is created due to the session being expired. We should either ignore the max session life for a buffered session, or possibly check/refresh session when an error comes in.
1 parent e95e574 commit 68bfd03

File tree

5 files changed

+163
-38
lines changed

5 files changed

+163
-38
lines changed

packages/replay/src/replay.ts

+11-1
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,15 @@ export class ReplayContainer implements ReplayContainerInterface {
417417
// Once this session ends, we do not want to refresh it
418418
if (this.session) {
419419
this.session.shouldRefresh = false;
420+
421+
// It's possible that the session lifespan is > max session lifespan
422+
// because we have been buffering (which ignores expiration given that
423+
// `shouldRefresh` is true). Since we flip `shouldRefresh`, the session
424+
// could be considered expired due to lifespan. Update session start date
425+
// to the earliest event in buffer, or current timestamp.
426+
this._updateUserActivity();
427+
this._updateSessionActivity();
428+
this.session.started = Date.now();
420429
this._maybeSaveSession();
421430
}
422431

@@ -657,9 +666,10 @@ export class ReplayContainer implements ReplayContainerInterface {
657666
stickySession: Boolean(this._options.stickySession),
658667
currentSession: this.session,
659668
sessionSampleRate: this._options.sessionSampleRate,
660-
allowBuffering: this._options.errorSampleRate > 0,
669+
allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
661670
});
662671

672+
663673
// If session was newly created (i.e. was not loaded from storage), then
664674
// enable flag to create the root replay
665675
if (type === 'new') {

packages/replay/src/session/getSession.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,13 @@ export function getSession({
3434
// within "max session time").
3535
const isExpired = isSessionExpired(session, timeouts);
3636

37-
if (!isExpired) {
37+
if (!isExpired || (allowBuffering && session.shouldRefresh)) {
3838
return { type: 'saved', session };
3939
} else if (!session.shouldRefresh) {
40-
// In this case, stop
41-
// This is the case if we have an error session that is completed (=triggered an error)
40+
// This is the case if we have an error session that is completed
41+
// (=triggered an error). Session will continue as session-based replay,
42+
// and when this session is expired, it will not be renewed until user
43+
// reloads.
4244
const discardedSession = makeSession({ sampled: false });
4345
return { type: 'new', session: discardedSession };
4446
} else {

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

+39-16
Original file line numberDiff line numberDiff line change
@@ -598,9 +598,12 @@ describe('Integration | errorSampleRate with delayed flush', () => {
598598

599599
captureException(new Error('testing'));
600600

601-
await waitForBufferFlush();
601+
await new Promise(process.nextTick);
602+
jest.runAllTimers();
603+
jest.advanceTimersByTime(20);
604+
await new Promise(process.nextTick);
602605

603-
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
606+
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + 100);
604607

605608
// Does not capture mouse click
606609
expect(replay).toHaveSentReplay({
@@ -662,7 +665,8 @@ describe('Integration | errorSampleRate with delayed flush', () => {
662665
expect(replay.isEnabled()).toBe(false);
663666
});
664667

665-
it('stops replay when session exceeds max length', async () => {
668+
it('stops replay when session exceeds max length after latest captured error', async () => {
669+
const sessionId = replay.session?.id;
666670
jest.setSystemTime(BASE_TIMESTAMP);
667671

668672
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
@@ -674,38 +678,57 @@ describe('Integration | errorSampleRate with delayed flush', () => {
674678
jest.runAllTimers();
675679
await new Promise(process.nextTick);
676680

681+
jest.advanceTimersByTime(2 * MAX_SESSION_LIFE);
682+
677683
captureException(new Error('testing'));
678684

685+
// Flush due to exception
686+
await new Promise(process.nextTick);
679687
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
680688
await new Promise(process.nextTick);
689+
expect(replay.session?.id).toBe(sessionId);
690+
expect(replay).toHaveLastSentReplay({
691+
recordingPayloadHeader: { segment_id: 0 },
692+
});
681693

682-
expect(replay).not.toHaveLastSentReplay();
683-
684-
// Wait a bit, shortly before session expires
685-
jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
694+
// This comes from `startRecording()` in `sendBufferedReplayOrFlush()`
695+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
686696
await new Promise(process.nextTick);
687-
688-
mockRecord._emitter(TEST_EVENT);
689-
replay.triggerUserActivity();
690-
691-
expect(replay).toHaveLastSentReplay();
697+
expect(replay).toHaveLastSentReplay({
698+
recordingPayloadHeader: { segment_id: 1 },
699+
recordingData: JSON.stringify([
700+
{data: {
701+
isCheckout: true,
702+
},
703+
timestamp: BASE_TIMESTAMP + (2* MAX_SESSION_LIFE) + DEFAULT_FLUSH_MIN_DELAY + 40,
704+
type: 2,
705+
}
706+
]),
707+
});
692708

693709
// Now wait after session expires - should stop recording
694710
mockRecord.takeFullSnapshot.mockClear();
695711
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
696712

697-
jest.advanceTimersByTime(10_000);
713+
jest.advanceTimersByTime(MAX_SESSION_LIFE);
698714
await new Promise(process.nextTick);
699715

700716
mockRecord._emitter(TEST_EVENT);
701-
replay.triggerUserActivity();
702-
703-
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
717+
jest.runAllTimers();
704718
await new Promise(process.nextTick);
705719

706720
expect(replay).not.toHaveLastSentReplay();
707721
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
708722
expect(replay.isEnabled()).toBe(false);
723+
724+
// Once the session is stopped after capturing a replay already
725+
// (buffer-mode), another error will not trigger a new replay
726+
captureException(new Error('testing'));
727+
728+
await new Promise(process.nextTick);
729+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
730+
await new Promise(process.nextTick);
731+
expect(replay).not.toHaveLastSentReplay();
709732
});
710733
});
711734

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

+49-18
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,9 @@ describe('Integration | errorSampleRate', () => {
432432
['MAX_SESSION_LIFE', MAX_SESSION_LIFE],
433433
['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION],
434434
])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => {
435+
const oldSessionId = replay.session?.id;
436+
expect(oldSessionId).toBeDefined();
437+
435438
expect(replay).not.toHaveLastSentReplay();
436439

437440
// Idle for given time
@@ -475,13 +478,24 @@ describe('Integration | errorSampleRate', () => {
475478
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
476479
await new Promise(process.nextTick);
477480

478-
expect(replay).toHaveLastSentReplay({
481+
expect(replay.session?.id).toBe(oldSessionId)
482+
483+
// Flush of buffered events
484+
expect(replay).toHaveSentReplay({
479485
recordingPayloadHeader: { segment_id: 0 },
480486
replayEventPayload: expect.objectContaining({
481487
replay_type: 'buffer',
482488
}),
483489
});
484490

491+
// Checkout from `startRecording`
492+
expect(replay).toHaveLastSentReplay({
493+
recordingPayloadHeader: { segment_id: 1 },
494+
replayEventPayload: expect.objectContaining({
495+
replay_type: 'buffer',
496+
}),
497+
});
498+
485499
expect(replay.isEnabled()).toBe(true);
486500
expect(replay.isPaused()).toBe(false);
487501
expect(replay.recordingMode).toBe('session');
@@ -491,6 +505,9 @@ describe('Integration | errorSampleRate', () => {
491505

492506
// Should behave the same as above test
493507
it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => {
508+
const oldSessionId = replay.session?.id;
509+
expect(oldSessionId).toBeDefined();
510+
494511
// Idle for 15 minutes
495512
jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1);
496513

@@ -517,14 +534,24 @@ describe('Integration | errorSampleRate', () => {
517534
await new Promise(process.nextTick);
518535
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
519536
await new Promise(process.nextTick);
537+
expect(replay.session?.id).toBe(oldSessionId);
520538

521-
expect(replay).toHaveLastSentReplay({
539+
// buffered events
540+
expect(replay).toHaveSentReplay({
522541
recordingPayloadHeader: { segment_id: 0 },
523542
replayEventPayload: expect.objectContaining({
524543
replay_type: 'buffer',
525544
}),
526545
});
527546

547+
// `startRecording` full checkout
548+
expect(replay).toHaveLastSentReplay({
549+
recordingPayloadHeader: { segment_id: 1 },
550+
replayEventPayload: expect.objectContaining({
551+
replay_type: 'buffer',
552+
}),
553+
});
554+
528555
expect(replay.isEnabled()).toBe(true);
529556
expect(replay.isPaused()).toBe(false);
530557
expect(replay.recordingMode).toBe('session');
@@ -605,7 +632,7 @@ describe('Integration | errorSampleRate', () => {
605632
jest.advanceTimersByTime(20);
606633
await new Promise(process.nextTick);
607634

608-
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
635+
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 100);
609636

610637
// Does not capture mouse click
611638
expect(replay).toHaveSentReplay({
@@ -667,7 +694,8 @@ describe('Integration | errorSampleRate', () => {
667694
expect(replay.isEnabled()).toBe(false);
668695
});
669696

670-
it('stops replay when session exceeds max length', async () => {
697+
it('stops replay when session exceeds max length after latest captured error', async () => {
698+
const sessionId = replay.session?.id;
671699
jest.setSystemTime(BASE_TIMESTAMP);
672700

673701
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
@@ -679,37 +707,40 @@ describe('Integration | errorSampleRate', () => {
679707
jest.runAllTimers();
680708
await new Promise(process.nextTick);
681709

710+
jest.advanceTimersByTime(2 * MAX_SESSION_LIFE);
711+
682712
captureException(new Error('testing'));
683713

684-
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
714+
// Flush due to exception
685715
await new Promise(process.nextTick);
686-
expect(replay).not.toHaveLastSentReplay();
687-
688-
// Wait a bit, shortly before session expires
689-
jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
716+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
690717
await new Promise(process.nextTick);
691-
692-
mockRecord._emitter(TEST_EVENT);
693-
replay.triggerUserActivity();
694-
718+
expect(replay.session?.id).toBe(sessionId);
695719
expect(replay).toHaveLastSentReplay();
696720

697-
// Now wait after session expires - should stop recording
721+
// Now wait after session expires - should re-start into buffering mode
698722
mockRecord.takeFullSnapshot.mockClear();
699723
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
700724

701-
jest.advanceTimersByTime(10_000);
725+
jest.advanceTimersByTime(MAX_SESSION_LIFE);
702726
await new Promise(process.nextTick);
703727

704728
mockRecord._emitter(TEST_EVENT);
705-
replay.triggerUserActivity();
706-
707-
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
729+
jest.runAllTimers();
708730
await new Promise(process.nextTick);
709731

710732
expect(replay).not.toHaveLastSentReplay();
711733
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
712734
expect(replay.isEnabled()).toBe(false);
735+
736+
// Once the session is stopped after capturing a replay already
737+
// (buffer-mode), another error should trigger a new replay
738+
captureException(new Error('testing'));
739+
740+
await new Promise(process.nextTick);
741+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
742+
await new Promise(process.nextTick);
743+
expect(replay).not.toHaveLastSentReplay();
713744
});
714745
});
715746

packages/replay/test/unit/session/getSession.test.ts

+59
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,63 @@ describe('Unit | session | getSession', () => {
229229
expect(session.id).toBe('test_session_uuid_2');
230230
expect(session.segmentId).toBe(0);
231231
});
232+
233+
it('re-uses the same "buffer" session if it is expired and has never sent a buffered replay', function () {
234+
const { type, session } = getSession({
235+
timeouts: {
236+
sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
237+
sessionIdleExpire: 1000,
238+
maxSessionLife: MAX_SESSION_LIFE,
239+
},
240+
stickySession: false,
241+
...SAMPLE_OPTIONS,
242+
currentSession: makeSession({
243+
id: 'test_session_uuid_2',
244+
lastActivity: +new Date() - MAX_SESSION_LIFE - 1,
245+
started: +new Date() - MAX_SESSION_LIFE - 1,
246+
segmentId: 0,
247+
sampled: 'buffer',
248+
}),
249+
allowBuffering: true,
250+
});
251+
252+
expect(FetchSession.fetchSession).not.toHaveBeenCalled();
253+
expect(CreateSession.createSession).not.toHaveBeenCalled();
254+
255+
expect(type).toBe('saved');
256+
expect(session.id).toBe('test_session_uuid_2');
257+
expect(session.sampled).toBe('buffer');
258+
expect(session.segmentId).toBe(0);
259+
});
260+
261+
it('creates a new session if it is expired and it was a "buffer" session that has sent a replay', function() {
262+
const currentSession = makeSession({
263+
id: 'test_session_uuid_2',
264+
lastActivity: +new Date() - MAX_SESSION_LIFE - 1,
265+
started: +new Date() - MAX_SESSION_LIFE - 1,
266+
segmentId: 0,
267+
sampled: 'buffer',
268+
});
269+
currentSession.shouldRefresh = false;
270+
271+
const { type, session } = getSession({
272+
timeouts: {
273+
sessionIdlePause: SESSION_IDLE_PAUSE_DURATION,
274+
sessionIdleExpire: 1000,
275+
maxSessionLife: MAX_SESSION_LIFE,
276+
},
277+
stickySession: false,
278+
...SAMPLE_OPTIONS,
279+
currentSession,
280+
allowBuffering: true,
281+
});
282+
283+
expect(FetchSession.fetchSession).not.toHaveBeenCalled();
284+
expect(CreateSession.createSession).not.toHaveBeenCalled();
285+
286+
expect(type).toBe('new');
287+
expect(session.id).not.toBe('test_session_uuid_2');
288+
expect(session.sampled).toBe(false);
289+
expect(session.segmentId).toBe(0);
290+
});
232291
});

0 commit comments

Comments
 (0)