Skip to content

Commit 8ffde2a

Browse files
authored
fix(replay): Ignore max session life for buffered sessions (#8258)
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 62e7265 commit 8ffde2a

File tree

6 files changed

+236
-37
lines changed

6 files changed

+236
-37
lines changed

packages/replay/src/replay.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,8 @@ export class ReplayContainer implements ReplayContainerInterface {
403403
return this.flushImmediate();
404404
}
405405

406+
const activityTime = Date.now();
407+
406408
// Allow flush to complete before resuming as a session recording, otherwise
407409
// the checkout from `startRecording` may be included in the payload.
408410
// Prefer to keep the error replay as a separate (and smaller) segment
@@ -424,6 +426,18 @@ export class ReplayContainer implements ReplayContainerInterface {
424426
// Once this session ends, we do not want to refresh it
425427
if (this.session) {
426428
this.session.shouldRefresh = false;
429+
430+
// It's possible that the session lifespan is > max session lifespan
431+
// because we have been buffering beyond max session lifespan (we ignore
432+
// expiration given that `shouldRefresh` is true). Since we flip
433+
// `shouldRefresh`, the session could be considered expired due to
434+
// lifespan, which is not what we want. Update session start date to be
435+
// the current timestamp, so that session is not considered to be
436+
// expired. This means that max replay duration can be MAX_SESSION_LIFE +
437+
// (length of buffer), which we are ok with.
438+
this._updateUserActivity(activityTime);
439+
this._updateSessionActivity(activityTime);
440+
this.session.started = activityTime;
427441
this._maybeSaveSession();
428442
}
429443

@@ -689,7 +703,7 @@ export class ReplayContainer implements ReplayContainerInterface {
689703
stickySession: Boolean(this._options.stickySession),
690704
currentSession: this.session,
691705
sessionSampleRate: this._options.sessionSampleRate,
692-
allowBuffering: this._options.errorSampleRate > 0,
706+
allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer',
693707
});
694708

695709
// If session was newly created (i.e. was not loaded from storage), then

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/src/util/handleRecordingEmit.ts

+4
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa
8989
// a previous session ID. In this case, we want to buffer events
9090
// for a set amount of time before flushing. This can help avoid
9191
// capturing replays of users that immediately close the window.
92+
// TODO: We should check `recordingMode` here and do nothing if it's
93+
// buffer, instead of checking inside of timeout, this will make our
94+
// tests a bit cleaner as we will need to wait on the delay in order to
95+
// do nothing.
9296
setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout);
9397

9498
// Cancel any previously debounced flushes to ensure there are no [near]

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

+104-13
Original file line numberDiff line numberDiff line change
@@ -573,6 +573,7 @@ describe('Integration | errorSampleRate with delayed flush', () => {
573573

574574
it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => {
575575
const ELAPSED = BUFFER_CHECKOUT_TIME;
576+
const TICK = 20;
576577
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
577578
mockRecord._emitter(TEST_EVENT);
578579

@@ -593,26 +594,29 @@ describe('Integration | errorSampleRate with delayed flush', () => {
593594
const optionsEvent = createOptionsEvent(replay);
594595

595596
jest.runAllTimers();
596-
jest.advanceTimersByTime(20);
597597
await new Promise(process.nextTick);
598598

599+
expect(replay).not.toHaveLastSentReplay();
600+
599601
captureException(new Error('testing'));
600602

601603
await waitForBufferFlush();
602604

603-
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 20);
605+
// See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a
606+
// noop when it can be skipped altogether
607+
expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK);
604608

605609
// Does not capture mouse click
606610
expect(replay).toHaveSentReplay({
607611
recordingPayloadHeader: { segment_id: 0 },
608612
replayEventPayload: expect.objectContaining({
609613
// Make sure the old performance event is thrown out
610-
replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000,
614+
replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000,
611615
}),
612616
recordingData: JSON.stringify([
613617
{
614618
data: { isCheckout: true },
615-
timestamp: BASE_TIMESTAMP + ELAPSED + 20,
619+
timestamp: BASE_TIMESTAMP + ELAPSED + TICK,
616620
type: 2,
617621
},
618622
optionsEvent,
@@ -662,7 +666,8 @@ describe('Integration | errorSampleRate with delayed flush', () => {
662666
expect(replay.isEnabled()).toBe(false);
663667
});
664668

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

668673
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 };
@@ -674,34 +679,120 @@ describe('Integration | errorSampleRate with delayed flush', () => {
674679
jest.runAllTimers();
675680
await new Promise(process.nextTick);
676681

682+
jest.advanceTimersByTime(2 * MAX_SESSION_LIFE);
683+
677684
captureException(new Error('testing'));
678685

679-
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
686+
// Flush due to exception
687+
await new Promise(process.nextTick);
688+
await waitForFlush();
689+
690+
expect(replay.session?.id).toBe(sessionId);
691+
expect(replay).toHaveLastSentReplay({
692+
recordingPayloadHeader: { segment_id: 0 },
693+
});
694+
695+
// This comes from `startRecording()` in `sendBufferedReplayOrFlush()`
696+
await waitForFlush();
697+
expect(replay).toHaveLastSentReplay({
698+
recordingPayloadHeader: { segment_id: 1 },
699+
recordingData: JSON.stringify([
700+
{
701+
data: {
702+
isCheckout: true,
703+
},
704+
timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40,
705+
type: 2,
706+
},
707+
]),
708+
});
709+
710+
// Now wait after session expires - should stop recording
711+
mockRecord.takeFullSnapshot.mockClear();
712+
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
713+
714+
jest.advanceTimersByTime(MAX_SESSION_LIFE);
715+
await new Promise(process.nextTick);
716+
717+
mockRecord._emitter(TEST_EVENT);
718+
jest.runAllTimers();
680719
await new Promise(process.nextTick);
681720

682721
expect(replay).not.toHaveLastSentReplay();
722+
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
723+
expect(replay.isEnabled()).toBe(false);
724+
725+
// Once the session is stopped after capturing a replay already
726+
// (buffer-mode), another error will not trigger a new replay
727+
captureException(new Error('testing'));
683728

684-
// Wait a bit, shortly before session expires
685-
jest.advanceTimersByTime(MAX_SESSION_LIFE - 1000);
686729
await new Promise(process.nextTick);
730+
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
731+
await new Promise(process.nextTick);
732+
expect(replay).not.toHaveLastSentReplay();
733+
});
687734

735+
it('does not stop replay based on earliest event in buffer', async () => {
736+
jest.setSystemTime(BASE_TIMESTAMP);
737+
738+
const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 };
688739
mockRecord._emitter(TEST_EVENT);
689-
replay.triggerUserActivity();
740+
741+
expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled();
742+
expect(replay).not.toHaveLastSentReplay();
743+
744+
jest.runAllTimers();
745+
await new Promise(process.nextTick);
746+
747+
expect(replay).not.toHaveLastSentReplay();
748+
captureException(new Error('testing'));
749+
750+
await waitForBufferFlush();
690751

691752
expect(replay).toHaveLastSentReplay();
692753

754+
// Flush from calling `stopRecording`
755+
await waitForFlush();
756+
693757
// Now wait after session expires - should stop recording
694758
mockRecord.takeFullSnapshot.mockClear();
695759
(getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance<any>).mockClear();
696760

697-
jest.advanceTimersByTime(10_000);
761+
expect(replay).not.toHaveLastSentReplay();
762+
763+
const TICKS = 80;
764+
765+
// We advance time so that we are on the border of expiring, taking into
766+
// account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The
767+
// 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has
768+
// happened, and for the next two that will happen. The first following
769+
// `waitForFlush` does not expire session, but the following one will.
770+
jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS);
698771
await new Promise(process.nextTick);
699772

700773
mockRecord._emitter(TEST_EVENT);
701-
replay.triggerUserActivity();
774+
expect(replay).not.toHaveLastSentReplay();
775+
await waitForFlush();
702776

703-
jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY);
704-
await new Promise(process.nextTick);
777+
expect(replay).not.toHaveLastSentReplay();
778+
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
779+
expect(replay.isEnabled()).toBe(true);
780+
781+
mockRecord._emitter(TEST_EVENT);
782+
expect(replay).not.toHaveLastSentReplay();
783+
await waitForFlush();
784+
785+
expect(replay).not.toHaveLastSentReplay();
786+
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);
787+
expect(replay.isEnabled()).toBe(true);
788+
789+
// It's hard to test, but if we advance the below time less 1 ms, it should
790+
// be enabled, but we can't trigger a session check via flush without
791+
// incurring another DEFAULT_FLUSH_MIN_DELAY timeout.
792+
jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY);
793+
mockRecord._emitter(TEST_EVENT);
794+
expect(replay).not.toHaveLastSentReplay();
795+
await waitForFlush();
705796

706797
expect(replay).not.toHaveLastSentReplay();
707798
expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0);

0 commit comments

Comments
 (0)