From df4f4ab8734192cef2f04e2b672c76274da1dfdb Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Mon, 19 Jun 2023 10:49:47 -0400 Subject: [PATCH] feat(replay): Do not capture replays < 5 seconds (GA) (#8277) --- .../suites/replay/bufferMode/test.ts | 19 +- .../suites/replay/fileInput/test.ts | 7 +- .../largeMutations/defaultOptions/test.ts | 4 - .../largeMutations/mutationLimit/test.ts | 3 - .../suites/replay/slowClick/mutation/test.ts | 94 +- .../suites/replay/throttleBreadcrumbs/test.ts | 13 +- .../tests/fixtures/ReplayRecordingData.ts | 22 - packages/replay/src/replay.ts | 7 + packages/replay/src/types/replay.ts | 2 +- .../replay/src/util/handleRecordingEmit.ts | 33 +- .../coreHandlers/handleAfterSendEvent.test.ts | 6 +- .../errorSampleRate-delayFlush.test.ts | 865 ------------------ .../test/integration/errorSampleRate.test.ts | 217 +++-- 13 files changed, 222 insertions(+), 1070 deletions(-) delete mode 100644 packages/replay/test/integration/errorSampleRate-delayFlush.test.ts diff --git a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts index a9a9dbbe86e5..4785c1a5b158 100644 --- a/packages/browser-integration-tests/suites/replay/bufferMode/test.ts +++ b/packages/browser-integration-tests/suites/replay/bufferMode/test.ts @@ -25,7 +25,6 @@ sentryTest( let errorEventId: string | undefined; const reqPromise0 = waitForReplayRequest(page, 0); const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); const reqErrorPromise = waitForErrorRequest(page); await page.route('https://dsn.ingest.sentry.io/**/*', route => { @@ -101,8 +100,7 @@ sentryTest( // Switches to session mode and then goes to background const req1 = await reqPromise1; - const req2 = await reqPromise2; - expect(callsToSentry).toBeGreaterThanOrEqual(5); + expect(callsToSentry).toBeGreaterThanOrEqual(4); const event0 = getReplayEvent(req0); const content0 = getReplayRecordingContent(req0); @@ -110,9 +108,6 @@ sentryTest( const event1 = getReplayEvent(req1); const content1 = getReplayRecordingContent(req1); - const event2 = getReplayEvent(req2); - const content2 = getReplayRecordingContent(req2); - expect(event0).toEqual( getExpectedReplayEvent({ error_ids: [errorEventId!], @@ -157,17 +152,7 @@ sentryTest( // From switching to session mode expect(content1.fullSnapshots).toHaveLength(1); - - expect(event2).toEqual( - getExpectedReplayEvent({ - replay_type: 'buffer', // although we're in session mode, we still send 'buffer' as replay_type - segment_id: 2, - urls: [], - }), - ); - - expect(content2.fullSnapshots).toHaveLength(0); - expect(content2.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); + expect(content1.breadcrumbs).toEqual(expect.arrayContaining([expectedClickBreadcrumb])); }, ); diff --git a/packages/browser-integration-tests/suites/replay/fileInput/test.ts b/packages/browser-integration-tests/suites/replay/fileInput/test.ts index 685c626ec470..e0827538ba56 100644 --- a/packages/browser-integration-tests/suites/replay/fileInput/test.ts +++ b/packages/browser-integration-tests/suites/replay/fileInput/test.ts @@ -25,7 +25,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -39,7 +38,7 @@ sentryTest( await page.goto(url); - await reqPromise0; + const res = await reqPromise0; await page.setInputFiles('#file-input', { name: 'file.csv', @@ -49,9 +48,7 @@ sentryTest( await forceFlushReplay(); - const res1 = await reqPromise1; - - const snapshots = getIncrementalRecordingSnapshots(res1).filter(isInputMutation); + const snapshots = getIncrementalRecordingSnapshots(res).filter(isInputMutation); expect(snapshots).toEqual([]); }, diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts index 29d0f3ada164..c0d8e8234da8 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/defaultOptions/test.ts @@ -11,7 +11,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -24,10 +23,7 @@ sentryTest( const url = await getLocalTestPath({ testDir: __dirname }); await page.goto(url); - await forceFlushReplay(); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); diff --git a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts index 84f0113263d7..b826daafe6b4 100644 --- a/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts +++ b/packages/browser-integration-tests/suites/replay/largeMutations/mutationLimit/test.ts @@ -16,7 +16,6 @@ sentryTest( } const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise0b = waitForReplayRequest(page, 1); await page.route('https://dsn.ingest.sentry.io/**/*', route => { return route.fulfill({ @@ -30,8 +29,6 @@ sentryTest( await page.goto(url); const res0 = await reqPromise0; - await reqPromise0b; - // A second request is sent right after initial snapshot, we want to wait for that to settle before we continue const reqPromise1 = waitForReplayRequest(page); diff --git a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts index deb394ebac2d..bab50e12938c 100644 --- a/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts +++ b/packages/browser-integration-tests/suites/replay/slowClick/mutation/test.ts @@ -3,7 +3,7 @@ import { expect } from '@playwright/test'; import { sentryTest } from '../../../../utils/fixtures'; import { getCustomRecordingEvents, shouldSkipReplayTest, waitForReplayRequest } from '../../../../utils/replayHelpers'; -sentryTest('mutation after threshold results in slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('mutation after threshold results in slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -21,6 +21,7 @@ sentryTest('mutation after threshold results in slow click', async ({ getLocalTe const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { @@ -125,59 +126,63 @@ sentryTest('multiple clicks are counted', async ({ getLocalTestUrl, page }) => { expect(slowClickBreadcrumbs[0]?.data?.timeAfterClickMs).toBeLessThan(3100); }); -sentryTest('immediate mutation does not trigger slow click', async ({ browserName, getLocalTestUrl, page }) => { - // This test seems to only be flakey on firefox - if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { - sentryTest.skip(); - } - - const reqPromise0 = waitForReplayRequest(page, 0); - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), +sentryTest( + 'immediate mutation does not trigger slow click', + async ({ forceFlushReplay, browserName, getLocalTestUrl, page }) => { + // This test seems to only be flakey on firefox + if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { + sentryTest.skip(); + } + + const reqPromise0 = waitForReplayRequest(page, 0); + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); }); - }); - const url = await getLocalTestUrl({ testDir: __dirname }); + const url = await getLocalTestUrl({ testDir: __dirname }); - await page.goto(url); - await reqPromise0; + await page.goto(url); + await forceFlushReplay(); + await reqPromise0; - const reqPromise1 = waitForReplayRequest(page, (event, res) => { - const { breadcrumbs } = getCustomRecordingEvents(res); + const reqPromise1 = waitForReplayRequest(page, (event, res) => { + const { breadcrumbs } = getCustomRecordingEvents(res); - return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); - }); - - await page.click('#mutationButtonImmediately'); - - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + return breadcrumbs.some(breadcrumb => breadcrumb.category === 'ui.click'); + }); - expect(breadcrumbs).toEqual([ - { - category: 'ui.click', - data: { - node: { - attributes: { - id: 'mutationButtonImmediately', + await page.click('#mutationButtonImmediately'); + + const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + + expect(breadcrumbs).toEqual([ + { + category: 'ui.click', + data: { + node: { + attributes: { + id: 'mutationButtonImmediately', + }, + id: expect.any(Number), + tagName: 'button', + textContent: '******* ******** ***********', }, - id: expect.any(Number), - tagName: 'button', - textContent: '******* ******** ***********', + nodeId: expect.any(Number), }, - nodeId: expect.any(Number), + message: 'body > button#mutationButtonImmediately', + timestamp: expect.any(Number), + type: 'default', }, - message: 'body > button#mutationButtonImmediately', - timestamp: expect.any(Number), - type: 'default', - }, - ]); -}); + ]); + }, +); -sentryTest('inline click handler does not trigger slow click', async ({ getLocalTestUrl, page }) => { +sentryTest('inline click handler does not trigger slow click', async ({ forceFlushReplay, getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -195,6 +200,7 @@ sentryTest('inline click handler does not trigger slow click', async ({ getLocal const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); + await forceFlushReplay(); await reqPromise0; const reqPromise1 = waitForReplayRequest(page, (event, res) => { diff --git a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts index 17f4210624a0..e025c90a77e0 100644 --- a/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts +++ b/packages/browser-integration-tests/suites/replay/throttleBreadcrumbs/test.ts @@ -26,16 +26,19 @@ sentryTest( const url = await getLocalTestUrl({ testDir: __dirname }); await page.goto(url); - await reqPromise0; + await forceFlushReplay(); + const res0 = getCustomRecordingEvents(await reqPromise0); await page.click('[data-console]'); await forceFlushReplay(); - const { breadcrumbs } = getCustomRecordingEvents(await reqPromise1); + const res1 = getCustomRecordingEvents(await reqPromise1); - // 1 click breadcrumb + 1 throttled breadcrumb is why console logs are less - // than throttle limit - expect(breadcrumbs.length).toBe(THROTTLE_LIMIT); + const breadcrumbs = [...res0.breadcrumbs, ...res1.breadcrumbs]; + const spans = [...res0.performanceSpans, ...res1.performanceSpans]; expect(breadcrumbs.filter(breadcrumb => breadcrumb.category === 'replay.throttled').length).toBe(1); + // replay.throttled breadcrumb does *not* use the throttledAddEvent as we + // alwants want that breadcrumb to be present in replay + expect(breadcrumbs.length + spans.length).toBe(THROTTLE_LIMIT + 1); }, ); diff --git a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts index a22694a64304..0da2e1b2e327 100644 --- a/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts +++ b/packages/e2e-tests/test-applications/standard-frontend-react/tests/fixtures/ReplayRecordingData.ts @@ -95,26 +95,6 @@ export const ReplayRecordingData = [ }, timestamp: expect.any(Number), }, - { - type: 5, - timestamp: expect.any(Number), - data: { - tag: 'performanceSpan', - payload: { - op: 'memory', - description: 'memory', - startTimestamp: expect.any(Number), - endTimestamp: expect.any(Number), - data: { - memory: { - jsHeapSizeLimit: expect.any(Number), - totalJSHeapSize: expect.any(Number), - usedJSHeapSize: expect.any(Number), - }, - }, - }, - }, - }, { type: 3, data: { @@ -155,8 +135,6 @@ export const ReplayRecordingData = [ data: { source: 5, text: 'Capture Exception', isChecked: false, id: 16 }, timestamp: expect.any(Number), }, - ], - [ { type: 5, timestamp: expect.any(Number), diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index acb2980e608c..8fab410a0c34 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -547,6 +547,13 @@ export class ReplayContainer implements ReplayContainerInterface { return this.flushImmediate(); } + /** + * Flush using debounce flush + */ + public flush(): Promise { + return this._debouncedFlush() as Promise; + } + /** * Always flush via `_debouncedFlush` so that we do not have flushes triggered * from calling both `flush` and `_debouncedFlush`. Otherwise, there could be diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index f058b2c9011a..e2cfddd0f525 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -194,7 +194,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { _experiments: Partial<{ captureExceptions: boolean; traceInternals: boolean; - delayFlushOnCheckout: number; }>; } @@ -438,6 +437,7 @@ export interface ReplayContainer { stopRecording(): boolean; sendBufferedReplayOrFlush(options?: SendBufferedReplayOptions): Promise; conditionalFlush(): Promise; + flush(): Promise; flushImmediate(): Promise; cancelFlush(): void; triggerUserActivity(): void; diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index cc7c87afed48..987f589412ed 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -80,41 +80,12 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa } } - const options = replay.getOptions(); - - // TODO: We want this as an experiment so that we can test - // internally and create metrics before making this the default - if (options._experiments.delayFlushOnCheckout) { + 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 // for a set amount of time before flushing. This can help avoid // capturing replays of users that immediately close the window. - // TODO: We should check `recordingMode` here and do nothing if it's - // buffer, instead of checking inside of timeout, this will make our - // tests a bit cleaner as we will need to wait on the delay in order to - // do nothing. - setTimeout(() => replay.conditionalFlush(), options._experiments.delayFlushOnCheckout); - - // Cancel any previously debounced flushes to ensure there are no [near] - // simultaneous flushes happening. The latter request should be - // insignificant in this case, so wait for additional user interaction to - // trigger a new flush. - // - // This can happen because there's no guarantee that a recording event - // happens first. e.g. a mouse click can happen and trigger a debounced - // flush before the checkout. - replay.cancelFlush(); - - return true; - } - - // Flush immediately so that we do not miss the first segment, otherwise - // it can prevent loading on the UI. This will cause an increase in short - // replays (e.g. opening and closing a tab quickly), but these can be - // filtered on the UI. - if (replay.recordingMode === 'session') { - // We want to ensure the worker is ready, as otherwise we'd always send the first event uncompressed - void replay.flushImmediate(); + void replay.flush(); } return true; diff --git a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts index 1e59a4f7eef0..cd367a2d04f5 100644 --- a/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts +++ b/packages/replay/test/integration/coreHandlers/handleAfterSendEvent.test.ts @@ -148,9 +148,13 @@ describe('Integration | coreHandlers | handleAfterSendEvent', () => { jest.runAllTimers(); await new Promise(process.nextTick); - // Send twice, one for the error & one right after for the session conversion + expect(mockSend).toHaveBeenCalledTimes(1); + + jest.runAllTimers(); + await new Promise(process.nextTick); expect(mockSend).toHaveBeenCalledTimes(2); + // This is removed now, because it has been converted to a "session" session expect(Array.from(replay.getContext().errorIds)).toEqual([]); expect(replay.isEnabled()).toBe(true); diff --git a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts b/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts deleted file mode 100644 index f691d8e953c1..000000000000 --- a/packages/replay/test/integration/errorSampleRate-delayFlush.test.ts +++ /dev/null @@ -1,865 +0,0 @@ -import { captureException, getCurrentHub } from '@sentry/core'; - -import { - BUFFER_CHECKOUT_TIME, - DEFAULT_FLUSH_MIN_DELAY, - MAX_SESSION_LIFE, - REPLAY_SESSION_KEY, - SESSION_IDLE_EXPIRE_DURATION, - WINDOW, -} from '../../src/constants'; -import type { ReplayContainer } from '../../src/replay'; -import { clearSession } from '../../src/session/clearSession'; -import { addEvent } from '../../src/util/addEvent'; -import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; -import { PerformanceEntryResource } from '../fixtures/performanceEntry/resource'; -import type { RecordMock } from '../index'; -import { BASE_TIMESTAMP } from '../index'; -import { resetSdkMock } from '../mocks/resetSdkMock'; -import type { DomHandler } from '../types'; -import { useFakeTimers } from '../utils/use-fake-timers'; - -useFakeTimers(); - -async function advanceTimers(time: number) { - jest.advanceTimersByTime(time); - await new Promise(process.nextTick); -} - -async function waitForBufferFlush() { - await new Promise(process.nextTick); - await new Promise(process.nextTick); -} - -async function waitForFlush() { - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); -} - -describe('Integration | errorSampleRate with delayed flush', () => { - let replay: ReplayContainer; - let mockRecord: RecordMock; - let domHandler: DomHandler; - - beforeEach(async () => { - ({ mockRecord, domHandler, replay } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysSessionSampleRate: 0.0, - replaysOnErrorSampleRate: 1.0, - }, - })); - }); - - afterEach(async () => { - clearSession(replay); - replay.stop(); - }); - - it('uploads a replay when `Sentry.captureException` is called and continues recording', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - await waitForFlush(); - - // This is from when we stop recording and start a session recording - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - // Check that click will get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 80, - data: { - tag: 'breadcrumb', - payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - it('manually flushes replay and does not continue to record', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - // Does not capture on mouse click - domHandler({ - name: 'click', - }); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - - replay.sendBufferedReplayOrFlush({ continueRecording: false }); - - await waitForBufferFlush(); - - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // Check that click will not get captured - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - // This is still the last replay sent since we passed `continueRecording: - // false`. - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - { - type: 5, - timestamp: BASE_TIMESTAMP, - data: { - tag: 'breadcrumb', - payload: { - timestamp: BASE_TIMESTAMP / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - }); - - // This tests a regression where we were calling flush indiscriminantly in `stop()` - it('does not upload a replay event if error is not sampled', async () => { - // We are trying to replicate the case where error rate is 0 and session - // rate is > 0, we can't set them both to 0 otherwise - // `_loadAndCheckSession` is not called when initializing the plugin. - replay.stop(); - replay['_options']['errorSampleRate'] = 0; - replay['_loadAndCheckSession'](); - - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay when triggering a full dom snapshot when document becomes visible after [SESSION_IDLE_EXPIRE_DURATION]ms', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not send a replay if user hides the tab and comes back within 60 seconds', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 100); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event when document becomes hidden', async () => { - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - - // Pretend 5 seconds have passed - const ELAPSED = 5000; - jest.advanceTimersByTime(ELAPSED); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 2 }; - addEvent(replay, TEST_EVENT); - - document.dispatchEvent(new Event('visibilitychange')); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 5 seconds have elapsed since the last replay event occurred', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - // Pretend 5 seconds have passed - const ELAPSED = 5000; - await advanceTimers(ELAPSED); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not upload a replay event if 15 seconds have elapsed since the last replay upload', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - // Fire a new event every 4 seconds, 4 times - [...Array(4)].forEach(() => { - mockRecord._emitter(TEST_EVENT); - jest.advanceTimersByTime(4000); - }); - - // We are at time = +16seconds now (relative to BASE_TIMESTAMP) - // The next event should cause an upload immediately - mockRecord._emitter(TEST_EVENT); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - // There should also not be another attempt at an upload 5 seconds after the last replay event - await waitForFlush(); - expect(replay).not.toHaveLastSentReplay(); - - // Let's make sure it continues to work - mockRecord._emitter(TEST_EVENT); - await waitForFlush(); - jest.runAllTimers(); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - // When the error session records as a normal session, we want to stop - // recording after the session ends. Otherwise, we get into a state where the - // new session is a session type replay (this could conflict with the session - // sample rate of 0.0), or an error session that has no errors. Instead we - // simply stop the session replay completely and wait for a new page load to - // resample. - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])( - 'stops replay if session had an error and exceeds %s and does not start a new session thereafter', - async (_label, waitTime) => { - expect(replay.session?.shouldRefresh).toBe(true); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - await waitForFlush(); - - // segment_id is 1 because it sends twice on error - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - expect(replay.session?.shouldRefresh).toBe(false); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after 15 minutes of inactivity in error mode - - // still no new replay sent - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(false); - - domHandler({ - name: 'click', - }); - - // Remains disabled! - expect(replay.isEnabled()).toBe(false); - }, - ); - - it.each([ - ['MAX_SESSION_LIFE', MAX_SESSION_LIFE], - ['SESSION_IDLE_EXPIRE_DURATION', SESSION_IDLE_EXPIRE_DURATION], - ])('continues buffering replay if session had no error and exceeds %s', async (_label, waitTime) => { - expect(replay).not.toHaveLastSentReplay(); - - // Idle for given time - jest.advanceTimersByTime(waitTime + 1); - await new Promise(process.nextTick); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // still no new replay sent - expect(replay).not.toHaveLastSentReplay(); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - domHandler({ - name: 'click', - }); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - // Should behave the same as above test - it('stops replay if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and does not start a new session thereafter', async () => { - // Idle for 15 minutes - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - // We stop recording after SESSION_IDLE_EXPIRE_DURATION of inactivity in error mode - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('buffer'); - - // should still react to errors later on - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - expect(replay.isEnabled()).toBe(true); - expect(replay.isPaused()).toBe(false); - expect(replay.recordingMode).toBe('session'); - expect(replay.session?.sampled).toBe('buffer'); - expect(replay.session?.shouldRefresh).toBe(false); - }); - - it('has the correct timestamps with deferred root event and last replay update', async () => { - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - - expect(replay).toHaveSentReplay({ - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - replayEventPayload: expect.objectContaining({ - replay_start_timestamp: BASE_TIMESTAMP / 1000, - // the exception happens roughly 10 seconds after BASE_TIMESTAMP - // (advance timers + waiting for flush after the checkout) and - // extra time is likely due to async of `addMemoryEntry()` - - timestamp: (BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + DEFAULT_FLUSH_MIN_DELAY + 40) / 1000, - error_ids: [expect.any(String)], - trace_ids: [], - urls: ['http://localhost/'], - replay_id: expect.any(String), - }), - recordingPayloadHeader: { segment_id: 0 }, - }); - }); - - it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { - const ELAPSED = BUFFER_CHECKOUT_TIME; - const TICK = 20; - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - // add a mock performance event - replay.performanceEvents.push(PerformanceEntryResource()); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.advanceTimersByTime(ELAPSED); - - // in production, this happens at a time interval - // session started time should be updated to this current timestamp - mockRecord.takeFullSnapshot(true); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - // See comments in `handleRecordingEmit.ts`, we perform a setTimeout into a - // noop when it can be skipped altogether - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + DEFAULT_FLUSH_MIN_DELAY + TICK + TICK); - - // Does not capture mouse click - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, - }), - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + TICK, - type: 2, - }, - optionsEvent, - ]), - }); - }); - - it('stops replay when user goes idle', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - // Go idle - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); - - it('stops replay when session exceeds max length after latest captured error', async () => { - const sessionId = replay.session?.id; - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - jest.advanceTimersByTime(2 * MAX_SESSION_LIFE); - - captureException(new Error('testing')); - - // Flush due to exception - await new Promise(process.nextTick); - await waitForFlush(); - - expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - }); - - // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` - await waitForFlush(); - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { - data: { - isCheckout: true, - }, - timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - jest.advanceTimersByTime(MAX_SESSION_LIFE); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - - // Once the session is stopped after capturing a replay already - // (buffer-mode), another error will not trigger a new replay - captureException(new Error('testing')); - - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); - expect(replay).not.toHaveLastSentReplay(); - }); - - it('does not stop replay based on earliest event in buffer', async () => { - jest.setSystemTime(BASE_TIMESTAMP); - - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - - jest.runAllTimers(); - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - captureException(new Error('testing')); - - await waitForBufferFlush(); - - expect(replay).toHaveLastSentReplay(); - - // Flush from calling `stopRecording` - await waitForFlush(); - - // Now wait after session expires - should stop recording - mockRecord.takeFullSnapshot.mockClear(); - (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); - - expect(replay).not.toHaveLastSentReplay(); - - const TICKS = 80; - - // We advance time so that we are on the border of expiring, taking into - // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The - // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has - // happened, and for the next two that will happen. The first following - // `waitForFlush` does not expire session, but the following one will. - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); - await new Promise(process.nextTick); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(true); - - // It's hard to test, but if we advance the below time less 1 ms, it should - // be enabled, but we can't trigger a session check via flush without - // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. - jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); - mockRecord._emitter(TEST_EVENT); - expect(replay).not.toHaveLastSentReplay(); - await waitForFlush(); - - expect(replay).not.toHaveLastSentReplay(); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); - expect(replay.isEnabled()).toBe(false); - }); -}); - -/** - * This is testing a case that should only happen with error-only sessions. - * Previously we had assumed that loading a session from session storage meant - * that the session was not new. However, this is not the case with error-only - * sampling since we can load a saved session that did not have an error (and - * thus no replay was created). - */ -it('sends a replay after loading the session from storage', async () => { - // Pretend that a session is already saved before loading replay - WINDOW.sessionStorage.setItem( - REPLAY_SESSION_KEY, - `{"segmentId":0,"id":"fd09adfc4117477abc8de643e5a5798a","sampled":"buffer","started":${BASE_TIMESTAMP},"lastActivity":${BASE_TIMESTAMP}}`, - ); - const { mockRecord, replay, integration } = await resetSdkMock({ - replayOptions: { - stickySession: true, - _experiments: { - delayFlushOnCheckout: DEFAULT_FLUSH_MIN_DELAY, - }, - }, - sentryOptions: { - replaysOnErrorSampleRate: 1.0, - }, - autoStart: false, - }); - integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); - - jest.runAllTimers(); - - await new Promise(process.nextTick); - const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; - mockRecord._emitter(TEST_EVENT); - - expect(replay).not.toHaveLastSentReplay(); - - captureException(new Error('testing')); - - // 2 ticks to send replay from an error - await waitForBufferFlush(); - - // Buffered events before error - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP, type: 2 }, - optionsEvent, - TEST_EVENT, - ]), - }); - - // `startRecording()` after switching to session mode to continue recording - await waitForFlush(); - - // Latest checkout when we call `startRecording` again after uploading segment - // after an error occurs (e.g. when we switch to session replay recording) - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), - }); -}); diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index ea1825dd8429..fe3049f9704f 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -26,6 +26,15 @@ async function advanceTimers(time: number) { await new Promise(process.nextTick); } +async function waitForBufferFlush() { + await new Promise(process.nextTick); + await new Promise(process.nextTick); +} + +async function waitForFlush() { + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); +} + describe('Integration | errorSampleRate', () => { let replay: ReplayContainer; let mockRecord: RecordMock; @@ -66,11 +75,9 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay).toHaveSentReplay({ + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', @@ -96,48 +103,35 @@ describe('Integration | errorSampleRate', () => { ]), }); + await waitForFlush(); + // This is from when we stop recording and start a session recording expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, type: 2 }, - ]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - // New checkout when we call `startRecording` again after uploading segment - // after an error occurs - expect(replay).toHaveLastSentReplay({ - recordingData: JSON.stringify([ - { - data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + DEFAULT_FLUSH_MIN_DELAY + 40, - type: 2, - }, - ]), - }); - // Check that click will get captured domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).toHaveLastSentReplay({ recordingData: JSON.stringify([ { type: 5, - timestamp: BASE_TIMESTAMP + 10000 + 60, + timestamp: BASE_TIMESTAMP + 10000 + 80, data: { tag: 'breadcrumb', payload: { - timestamp: (BASE_TIMESTAMP + 10000 + 60) / 1000, + timestamp: (BASE_TIMESTAMP + 10000 + 80) / 1000, type: 'default', category: 'ui.click', message: '', @@ -167,9 +161,7 @@ describe('Integration | errorSampleRate', () => { replay.sendBufferedReplayOrFlush({ continueRecording: false }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, @@ -202,8 +194,8 @@ describe('Integration | errorSampleRate', () => { domHandler({ name: 'click', }); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + + await waitForFlush(); // This is still the last replay sent since we passed `continueRecording: // false`. @@ -353,12 +345,12 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); // There should also not be another attempt at an upload 5 seconds after the last replay event - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); // Let's make sure it continues to work mockRecord._emitter(TEST_EVENT); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); jest.runAllTimers(); await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); @@ -380,9 +372,16 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); + + await waitForFlush(); // segment_id is 1 because it sends twice on error expect(replay).toHaveLastSentReplay({ @@ -462,9 +461,7 @@ describe('Integration | errorSampleRate', () => { name: 'click', }); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(replay.isEnabled()).toBe(true); @@ -474,23 +471,12 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay.session?.id).toBe(oldSessionId); - // Flush of buffered events - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); - - // Checkout from `startRecording` expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -546,7 +532,7 @@ describe('Integration | errorSampleRate', () => { // `startRecording` full checkout expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 1 }, + recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', }), @@ -602,6 +588,7 @@ describe('Integration | errorSampleRate', () => { it('has correct timestamps when error occurs much later than initial pageload/checkout', async () => { const ELAPSED = BUFFER_CHECKOUT_TIME; + const TICK = 20; const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -624,25 +611,25 @@ describe('Integration | errorSampleRate', () => { jest.runAllTimers(); await new Promise(process.nextTick); + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.runAllTimers(); - await new Promise(process.nextTick); + await waitForBufferFlush(); - expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + 40); + expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED + TICK + TICK); // Does not capture mouse click expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ // Make sure the old performance event is thrown out - replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + 20) / 1000, + replay_start_timestamp: (BASE_TIMESTAMP + ELAPSED + TICK) / 1000, }), recordingData: JSON.stringify([ { data: { isCheckout: true }, - timestamp: BASE_TIMESTAMP + ELAPSED + 20, + timestamp: BASE_TIMESTAMP + ELAPSED + TICK, type: 2, }, optionsEvent, @@ -664,12 +651,13 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForBufferFlush(); expect(replay).toHaveLastSentReplay(); + // Flush from calling `stopRecording` + await waitForFlush(); + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -684,8 +672,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); expect(replay).not.toHaveLastSentReplay(); expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); @@ -711,12 +698,29 @@ describe('Integration | errorSampleRate', () => { // Flush due to exception await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + await waitForFlush(); + expect(replay.session?.id).toBe(sessionId); - expect(replay).toHaveLastSentReplay(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + }); + + // This comes from `startRecording()` in `sendBufferedReplayOrFlush()` + await waitForFlush(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 1 }, + recordingData: JSON.stringify([ + { + data: { + isCheckout: true, + }, + timestamp: BASE_TIMESTAMP + 2 * MAX_SESSION_LIFE + DEFAULT_FLUSH_MIN_DELAY + 40, + type: 2, + }, + ]), + }); - // Now wait after session expires - should re-start into buffering mode + // Now wait after session expires - should stop recording mockRecord.takeFullSnapshot.mockClear(); (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); @@ -732,7 +736,7 @@ describe('Integration | errorSampleRate', () => { expect(replay.isEnabled()).toBe(false); // Once the session is stopped after capturing a replay already - // (buffer-mode), another error should trigger a new replay + // (buffer-mode), another error will not trigger a new replay captureException(new Error('testing')); await new Promise(process.nextTick); @@ -740,6 +744,73 @@ describe('Integration | errorSampleRate', () => { await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); }); + + it('does not stop replay based on earliest event in buffer', async () => { + jest.setSystemTime(BASE_TIMESTAMP); + + const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP - 60000, type: 3 }; + mockRecord._emitter(TEST_EVENT); + + expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); + expect(replay).not.toHaveLastSentReplay(); + + jest.runAllTimers(); + await new Promise(process.nextTick); + + expect(replay).not.toHaveLastSentReplay(); + captureException(new Error('testing')); + + await waitForBufferFlush(); + + expect(replay).toHaveLastSentReplay(); + + // Flush from calling `stopRecording` + await waitForFlush(); + + // Now wait after session expires - should stop recording + mockRecord.takeFullSnapshot.mockClear(); + (getCurrentHub().getClient()!.getTransport()!.send as unknown as jest.SpyInstance).mockClear(); + + expect(replay).not.toHaveLastSentReplay(); + + const TICKS = 80; + + // We advance time so that we are on the border of expiring, taking into + // account that TEST_EVENT timestamp is 60000 ms before BASE_TIMESTAMP. The + // 3 DEFAULT_FLUSH_MIN_DELAY is to account for the `waitForFlush` that has + // happened, and for the next two that will happen. The first following + // `waitForFlush` does not expire session, but the following one will. + jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 60000 - 3 * DEFAULT_FLUSH_MIN_DELAY - TICKS); + await new Promise(process.nextTick); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(true); + + // It's hard to test, but if we advance the below time less 1 ms, it should + // be enabled, but we can't trigger a session check via flush without + // incurring another DEFAULT_FLUSH_MIN_DELAY timeout. + jest.advanceTimersByTime(60000 - DEFAULT_FLUSH_MIN_DELAY); + mockRecord._emitter(TEST_EVENT); + expect(replay).not.toHaveLastSentReplay(); + await waitForFlush(); + + expect(replay).not.toHaveLastSentReplay(); + expect(mockRecord.takeFullSnapshot).toHaveBeenCalledTimes(0); + expect(replay.isEnabled()).toBe(false); + }); }); /** @@ -749,7 +820,7 @@ describe('Integration | errorSampleRate', () => { * sampling since we can load a saved session that did not have an error (and * thus no replay was created). */ -it('sends a replay after loading the session multiple times', async () => { +it('sends a replay after loading the session from storage', async () => { // Pretend that a session is already saved before loading replay WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, @@ -765,7 +836,6 @@ it('sends a replay after loading the session multiple times', async () => { autoStart: false, }); integration['_initialize'](); - const optionsEvent = createOptionsEvent(replay); jest.runAllTimers(); @@ -778,10 +848,10 @@ it('sends a replay after loading the session multiple times', async () => { captureException(new Error('testing')); - await new Promise(process.nextTick); - jest.advanceTimersByTime(DEFAULT_FLUSH_MIN_DELAY); - await new Promise(process.nextTick); + // 2 ticks to send replay from an error + await waitForBufferFlush(); + // Buffered events before error expect(replay).toHaveSentReplay({ recordingPayloadHeader: { segment_id: 0 }, recordingData: JSON.stringify([ @@ -791,10 +861,13 @@ it('sends a replay after loading the session multiple times', async () => { ]), }); + // `startRecording()` after switching to session mode to continue recording + await waitForFlush(); + // Latest checkout when we call `startRecording` again after uploading segment // after an error occurs (e.g. when we switch to session replay recording) expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 1 }, - recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 5040, type: 2 }]), + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + 40, type: 2 }]), }); });