From 5f68d4d13138a3b064ca3ed36d9d72f77500b5f5 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 15 Oct 2024 06:47:27 +0200 Subject: [PATCH] fix(replay): Fix onError sampling when loading an expired buffered session (#13962) This fixes a bug where an older, saved session (that has a `previousSessionId`, i.e. session was recorded and expired) would cause buffered replays to not send when an error happens. This is because the session start timestamp would never update, causing our flush logic to skip sending the replay due to incorrect replay duration calculation. --- .size-limit.js | 2 +- .../src/util/handleRecordingEmit.ts | 20 ++--- .../test/integration/errorSampleRate.test.ts | 84 +++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/.size-limit.js b/.size-limit.js index 8a7670e6d638..a244ccb2a2a3 100644 --- a/.size-limit.js +++ b/.size-limit.js @@ -86,7 +86,7 @@ module.exports = [ path: 'packages/browser/build/npm/esm/index.js', import: createImport('init', 'browserTracingIntegration', 'replayIntegration', 'feedbackIntegration'), gzip: true, - limit: '91 KB', + limit: '95 KB', }, { name: '@sentry/browser (incl. Tracing, Replay, Feedback, metrics)', diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index 0467edefa9a2..4f4637276116 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -71,16 +71,6 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // only be added for checkouts addSettingsEvent(replay, isCheckout); - // If there is a previousSessionId after a full snapshot occurs, then - // the replay session was started due to session expiration. The new session - // is started before triggering a new checkout and contains the id - // of the previous session. Do not immediately flush in this case - // to avoid capturing only the checkout and instead the replay will - // be captured if they perform any follow-up actions. - if (session && session.previousSessionId) { - return true; - } - // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer // this should usually be the timestamp of the checkout event, but to be safe... if (replay.recordingMode === 'buffer' && session && replay.eventBuffer) { @@ -97,6 +87,16 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa } } + // If there is a previousSessionId after a full snapshot occurs, then + // the replay session was started due to session expiration. The new session + // is started before triggering a new checkout and contains the id + // of the previous session. Do not immediately flush in this case + // to avoid capturing only the checkout and instead the replay will + // be captured if they perform any follow-up actions. + if (session && session.previousSessionId) { + return true; + } + if (replay.recordingMode === 'session') { // If the full snapshot is due to an initial load, we will not have // a previous session ID. In this case, we want to buffer events diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index 3763ef83de44..e81d9df1b43d 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -148,6 +148,90 @@ describe('Integration | errorSampleRate', () => { }); }); + it('loads an old session with a previousSessionId set and can send buffered replay', async () => { + vi.mock('../../src/session/fetchSession', () => ({ + fetchSession: () => ({ + id: 'newreplayid', + started: BASE_TIMESTAMP, + lastActivity: BASE_TIMESTAMP, + segmentId: 0, + sampled: 'buffer', + previousSessionId: 'previoussessionid', + }), + })); + + const ADVANCED_TIME = 86400000; + const optionsEvent = createOptionsEvent(replay); + + expect(replay.session.started).toBe(BASE_TIMESTAMP); + + // advance time to make sure replay duration is invalid + vi.advanceTimersByTime(ADVANCED_TIME); + + // full snapshot should update session start time + mockRecord.takeFullSnapshot(true); + expect(replay.session.started).toBe(BASE_TIMESTAMP + ADVANCED_TIME); + expect(replay.recordingMode).toBe('buffer'); + + // advance so we can flush + vi.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + + captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + + // Converts to session mode + expect(replay.recordingMode).toBe('session'); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + recordingData: JSON.stringify([ + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + ADVANCED_TIME, type: 2 }, + { ...optionsEvent, timestamp: BASE_TIMESTAMP + ADVANCED_TIME }, + ]), + }); + + // capture next event + domHandler({ + name: 'click', + event: new Event('click'), + }); + + vi.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); + await vi.advanceTimersToNextTimerAsync(); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + replayEventPayload: expect.objectContaining({ + // We don't change replay_type as it starts in buffer mode and that's + // what we're interested in, even though recordingMode changes to + // 'session' + replay_type: 'buffer', + }), + recordingData: JSON.stringify([ + // There's a new checkout because we convert to session mode + { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + ADVANCED_TIME + DEFAULT_FLUSH_MIN_DELAY, type: 2 }, + { + type: 5, + timestamp: BASE_TIMESTAMP + ADVANCED_TIME + DEFAULT_FLUSH_MIN_DELAY, + data: { + tag: 'breadcrumb', + payload: { + timestamp: (BASE_TIMESTAMP + ADVANCED_TIME + DEFAULT_FLUSH_MIN_DELAY) / 1000, + type: 'default', + category: 'ui.click', + message: '', + data: {}, + }, + }, + }, + ]), + }); + vi.unmock('../../src/session/fetchSession'); + await waitForFlush(); + }); + it('manually flushes replay and does not continue to record', async () => { const TEST_EVENT = getTestEventIncremental({ timestamp: BASE_TIMESTAMP }); mockRecord._emitter(TEST_EVENT);