diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js b/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js deleted file mode 100644 index 46af904118a6..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/init.js +++ /dev/null @@ -1,23 +0,0 @@ -import * as Sentry from '@sentry/browser'; - -window.Sentry = Sentry; -window.Replay = new Sentry.Replay({ - flushMinDelay: 500, - flushMaxDelay: 500, -}); - -Sentry.init({ - dsn: 'https://public@dsn.ingest.sentry.io/1337', - sampleRate: 0, - replaysSessionSampleRate: 1.0, - replaysOnErrorSampleRate: 0.0, - debug: true, - - integrations: [window.Replay], -}); - -window.Replay._replay.timeouts = { - sessionIdlePause: 1000, // this is usually 5min, but we want to test this with shorter times - sessionIdleExpire: 2000, // this is usually 15min, but we want to test this with shorter times - maxSessionLife: 3600000, // default: 60min -}; diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts b/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts deleted file mode 100644 index bd303c9e68c3..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { expect } from '@playwright/test'; - -import { sentryTest } from '../../../utils/fixtures'; -import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; -import { - getFullRecordingSnapshots, - getReplayEvent, - getReplaySnapshot, - normalize, - shouldSkipReplayTest, - waitForReplayRequest, -} from '../../../utils/replayHelpers'; - -// Session should expire after 2s - keep in sync with init.js -const SESSION_TIMEOUT = 2000; - -sentryTest('handles an expired session', async ({ browserName, getLocalTestPath, page }) => { - // This test seems to only be flakey on firefox - if (shouldSkipReplayTest() || ['firefox'].includes(browserName)) { - sentryTest.skip(); - } - - const reqPromise0 = waitForReplayRequest(page, 0); - const reqPromise1 = waitForReplayRequest(page, 1); - - 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 getLocalTestPath({ testDir: __dirname }); - - await page.goto(url); - const req0 = await reqPromise0; - - const replayEvent0 = getReplayEvent(req0); - expect(replayEvent0).toEqual(getExpectedReplayEvent({})); - - const fullSnapshots0 = getFullRecordingSnapshots(req0); - expect(fullSnapshots0.length).toEqual(1); - const stringifiedSnapshot = normalize(fullSnapshots0[0]); - expect(stringifiedSnapshot).toMatchSnapshot('snapshot-0.json'); - - // We wait for another segment 0 - const reqPromise2 = waitForReplayRequest(page, 0); - - await page.click('#button1'); - const req1 = await reqPromise1; - - const replayEvent1 = getReplayEvent(req1); - expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); - - const replay = await getReplaySnapshot(page); - const oldSessionId = replay.session?.id; - - await new Promise(resolve => setTimeout(resolve, SESSION_TIMEOUT)); - - await page.click('#button2'); - const req2 = await reqPromise2; - - const replay2 = await getReplaySnapshot(page); - - expect(replay2.session?.id).not.toEqual(oldSessionId); - - const replayEvent2 = getReplayEvent(req2); - expect(replayEvent2).toEqual(getExpectedReplayEvent({})); - - const fullSnapshots2 = getFullRecordingSnapshots(req2); - expect(fullSnapshots2.length).toEqual(1); - const stringifiedSnapshot2 = normalize(fullSnapshots2[0]); - expect(stringifiedSnapshot2).toMatchSnapshot('snapshot-2.json'); -}); diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-chromium.json b/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-chromium.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-chromium.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-firefox.json b/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-firefox.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-firefox.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-webkit.json b/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-webkit.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2-webkit.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2.json b/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-2.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/init.js b/packages/browser-integration-tests/suites/replay/sessionIdle/init.js similarity index 91% rename from packages/browser-integration-tests/suites/replay/sessionInactive/init.js rename to packages/browser-integration-tests/suites/replay/sessionIdle/init.js index 4c641d160d79..4a8d05c16b32 100644 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/init.js +++ b/packages/browser-integration-tests/suites/replay/sessionIdle/init.js @@ -18,6 +18,5 @@ Sentry.init({ window.Replay._replay.timeouts = { sessionIdlePause: 1000, // this is usually 5min, but we want to test this with shorter times - sessionIdleExpire: 900000, // defayult: 15min maxSessionLife: 3600000, // default: 60min }; diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/template.html b/packages/browser-integration-tests/suites/replay/sessionIdle/template.html similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionExpiry/template.html rename to packages/browser-integration-tests/suites/replay/sessionIdle/template.html diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts similarity index 96% rename from packages/browser-integration-tests/suites/replay/sessionInactive/test.ts rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts index ef2b841cbea3..0e9f1e51cd6a 100644 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts +++ b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts @@ -14,7 +14,7 @@ import { // Session should be paused after 2s - keep in sync with init.js const SESSION_PAUSED = 2000; -sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => { +sentryTest('pauses an idle session', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } @@ -66,6 +66,7 @@ sentryTest('handles an inactive session', async ({ getLocalTestPath, page }) => // Trigger an action, should resume the recording await page.click('#button2'); + const req1 = await reqPromise1; const replay3 = await getReplaySnapshot(page); diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-chromium.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-chromium.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-chromium.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-chromium.json diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-firefox.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-firefox.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-firefox.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-firefox.json diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-webkit.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-webkit.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0-webkit.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0-webkit.json diff --git a/packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionExpiry/test.ts-snapshots/snapshot-0.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-0.json diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-chromium.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-chromium.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-chromium.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-chromium.json diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-firefox.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-firefox.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-firefox.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-firefox.json diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-webkit.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-webkit.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1-webkit.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1-webkit.json diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1.json b/packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1.json similarity index 100% rename from packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-1.json rename to packages/browser-integration-tests/suites/replay/sessionIdle/test.ts-snapshots/snapshot-1.json diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/template.html b/packages/browser-integration-tests/suites/replay/sessionInactive/template.html deleted file mode 100644 index 7223a20f82ba..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/template.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-chromium.json b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-chromium.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-chromium.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-firefox.json b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-firefox.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-firefox.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-webkit.json b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-webkit.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0-webkit.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0.json b/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0.json deleted file mode 100644 index d510b410a343..000000000000 --- a/packages/browser-integration-tests/suites/replay/sessionInactive/test.ts-snapshots/snapshot-0.json +++ /dev/null @@ -1,113 +0,0 @@ -{ - "type": 2, - "data": { - "node": { - "type": 0, - "childNodes": [ - { - "type": 1, - "name": "html", - "publicId": "", - "systemId": "", - "id": 2 - }, - { - "type": 2, - "tagName": "html", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "head", - "attributes": {}, - "childNodes": [ - { - "type": 2, - "tagName": "meta", - "attributes": { - "charset": "utf-8" - }, - "childNodes": [], - "id": 5 - } - ], - "id": 4 - }, - { - "type": 3, - "textContent": "\n ", - "id": 6 - }, - { - "type": 2, - "tagName": "body", - "attributes": {}, - "childNodes": [ - { - "type": 3, - "textContent": "\n ", - "id": 8 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 1')", - "id": "button1" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 10 - } - ], - "id": 9 - }, - { - "type": 3, - "textContent": "\n ", - "id": 11 - }, - { - "type": 2, - "tagName": "button", - "attributes": { - "onclick": "console.log('Test log 2')", - "id": "button2" - }, - "childNodes": [ - { - "type": 3, - "textContent": "***** **", - "id": 13 - } - ], - "id": 12 - }, - { - "type": 3, - "textContent": "\n ", - "id": 14 - }, - { - "type": 3, - "textContent": "\n\n", - "id": 15 - } - ], - "id": 7 - } - ], - "id": 3 - } - ], - "id": 1 - }, - "initialOffset": { - "left": 0, - "top": 0 - } - }, - "timestamp": [timestamp] -} \ No newline at end of file diff --git a/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js b/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js index 0c16dc6ca3a1..9ab8bbe47797 100644 --- a/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js +++ b/packages/browser-integration-tests/suites/replay/sessionMaxAge/init.js @@ -18,6 +18,5 @@ Sentry.init({ window.Replay._replay.timeouts = { sessionIdlePause: 300000, // default: 5min - sessionIdleExpire: 900000, // default: 15min - maxSessionLife: 4000, // this is usually 60min, but we want to test this with shorter times + maxSessionLife: 2000, // this is usually 60min, but we want to test this with shorter times }; diff --git a/packages/browser-integration-tests/suites/replay/sessionMaxAge/test.ts b/packages/browser-integration-tests/suites/replay/sessionMaxAge/test.ts index ca50c5a62203..4e7a8b123eef 100644 --- a/packages/browser-integration-tests/suites/replay/sessionMaxAge/test.ts +++ b/packages/browser-integration-tests/suites/replay/sessionMaxAge/test.ts @@ -11,13 +11,8 @@ import { waitForReplayRequest, } from '../../../utils/replayHelpers'; -// Session should be max. 4s long -const SESSION_MAX_AGE = 4000; +const SESSION_MAX_AGE = 2000; -/* - The main difference between this and sessionExpiry test, is that here we wait for the overall time (4s) - in multiple steps (2s, 2s) instead of waiting for the whole time at once (4s). -*/ sentryTest('handles session that exceeds max age', async ({ getLocalTestPath, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); diff --git a/packages/replay/jest.setup.ts b/packages/replay/jest.setup.ts index 665d29e2386a..7e20018cef3a 100644 --- a/packages/replay/jest.setup.ts +++ b/packages/replay/jest.setup.ts @@ -129,21 +129,23 @@ function checkCallForSentReplay( } /** -* Only want calls that send replay events, i.e. ignore error events -*/ + * Only want calls that send replay events, i.e. ignore error events + */ function getReplayCalls(calls: any[][][]): any[][][] { - return calls.map(call => { - const arg = call[0]; + return calls + .map(call => { + const arg = call[0]; if (arg.length !== 2) { return []; } - if (!arg[1][0].find(({type}: {type: string}) => ['replay_event', 'replay_recording'].includes(type))) { + if (!arg[1][0].find(({ type }: { type: string }) => ['replay_event', 'replay_recording'].includes(type))) { return []; } - return [ arg ]; - }).filter(Boolean); + return [arg]; + }) + .filter(Boolean); } /** @@ -159,9 +161,11 @@ const toHaveSentReplay = function ( let result: CheckCallForSentReplayResult; - const expectedKeysLength = expected ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length : 0; + const expectedKeysLength = expected + ? ('sample' in expected ? Object.keys(expected.sample) : Object.keys(expected)).length + : 0; - const replayCalls = getReplayCalls(calls) + const replayCalls = getReplayCalls(calls); for (const currentCall of replayCalls) { result = checkCallForSentReplay.call(this, currentCall[0], expected); @@ -213,7 +217,7 @@ const toHaveLastSentReplay = function ( expected?: SentReplayExpected | { sample: SentReplayExpected; inverse: boolean }, ) { const { calls } = (getCurrentHub().getClient()?.getTransport()?.send as MockTransport).mock; - const replayCalls = getReplayCalls(calls) + const replayCalls = getReplayCalls(calls); const lastCall = replayCalls[calls.length - 1]?.[0]; diff --git a/packages/replay/src/constants.ts b/packages/replay/src/constants.ts index 86dc228c50bf..a09738dc594e 100644 --- a/packages/replay/src/constants.ts +++ b/packages/replay/src/constants.ts @@ -14,9 +14,6 @@ export const UNABLE_TO_SEND_REPLAY = 'Unable to send Replay'; // The idle limit for a session after which recording is paused. export const SESSION_IDLE_PAUSE_DURATION = 300_000; // 5 minutes in ms -// The idle limit for a session after which the session expires. -export const SESSION_IDLE_EXPIRE_DURATION = 900_000; // 15 minutes in ms - // The maximum length of a session export const MAX_SESSION_LIFE = 3_600_000; // 60 minutes in ms diff --git a/packages/replay/src/coreHandlers/handleHistory.ts b/packages/replay/src/coreHandlers/handleHistory.ts index a72618a2203f..6a10b2c07076 100644 --- a/packages/replay/src/coreHandlers/handleHistory.ts +++ b/packages/replay/src/coreHandlers/handleHistory.ts @@ -39,12 +39,14 @@ export function handleHistorySpanListener(replay: ReplayContainer): (handlerData // Need to collect visited URLs replay.getContext().urls.push(result.name); - replay.triggerUserActivity(); - - replay.addUpdate(() => { - createPerformanceSpans(replay, [result]); - // Returning false to flush - return false; + replay.updateUserActivity(); + + replay.checkSessionState(() => { + replay.addUpdate(() => { + createPerformanceSpans(replay, [result]); + // Returning false to flush + return false; + }); }); }; } diff --git a/packages/replay/src/coreHandlers/util/addBreadcrumbEvent.ts b/packages/replay/src/coreHandlers/util/addBreadcrumbEvent.ts index 947fb12f1ae4..090f0d259a8e 100644 --- a/packages/replay/src/coreHandlers/util/addBreadcrumbEvent.ts +++ b/packages/replay/src/coreHandlers/util/addBreadcrumbEvent.ts @@ -13,25 +13,25 @@ export function addBreadcrumbEvent(replay: ReplayContainer, breadcrumb: Breadcru } if (['ui.click', 'ui.input'].includes(breadcrumb.category as string)) { - replay.triggerUserActivity(); - } else { - replay.checkAndHandleExpiredSession(); + replay.updateUserActivity(); } - replay.addUpdate(() => { - void replay.throttledAddEvent({ - type: EventType.Custom, - // TODO: We were converting from ms to seconds for breadcrumbs, spans, - // but maybe we should just keep them as milliseconds - timestamp: (breadcrumb.timestamp || 0) * 1000, - data: { - tag: 'breadcrumb', - // normalize to max. 10 depth and 1_000 properties per object - payload: normalize(breadcrumb, 10, 1_000), - }, - }); + replay.checkSessionState(() => { + replay.addUpdate(() => { + void replay.throttledAddEvent({ + type: EventType.Custom, + // TODO: We were converting from ms to seconds for breadcrumbs, spans, + // but maybe we should just keep them as milliseconds + timestamp: (breadcrumb.timestamp || 0) * 1000, + data: { + tag: 'breadcrumb', + // normalize to max. 10 depth and 1_000 properties per object + payload: normalize(breadcrumb, 10, 1_000), + }, + }); - // Do not flush after console log messages - return breadcrumb.category === 'console'; + // Do not flush after console log messages + return breadcrumb.category === 'console'; + }); }); } diff --git a/packages/replay/src/replay.ts b/packages/replay/src/replay.ts index c72306239533..43d4f97cb1f5 100644 --- a/packages/replay/src/replay.ts +++ b/packages/replay/src/replay.ts @@ -7,7 +7,6 @@ import { logger } from '@sentry/utils'; import { BUFFER_CHECKOUT_TIME, MAX_SESSION_LIFE, - SESSION_IDLE_EXPIRE_DURATION, SESSION_IDLE_PAUSE_DURATION, SLOW_CLICK_SCROLL_TIMEOUT, SLOW_CLICK_THRESHOLD, @@ -17,8 +16,9 @@ import { ClickDetector } from './coreHandlers/handleClick'; import { handleKeyboardEvent } from './coreHandlers/handleKeyboardEvent'; import { setupPerformanceObserver } from './coreHandlers/performanceObserver'; import { createEventBuffer } from './eventBuffer'; +import { checkSessionState } from './session/checkSessionState'; import { clearSession } from './session/clearSession'; -import { getSession } from './session/getSession'; +import { restoreOrCreateSession } from './session/restoreOrCreateSession'; import { saveSession } from './session/saveSession'; import type { AddEventResult, @@ -32,6 +32,7 @@ import type { RecordingOptions, ReplayContainer as ReplayContainerInterface, ReplayPluginOptions, + Sampled, SendBufferedReplayOptions, Session, SlowClickConfig, @@ -45,8 +46,7 @@ import { createPerformanceEntries } from './util/createPerformanceEntries'; import { createPerformanceSpans } from './util/createPerformanceSpans'; import { debounce } from './util/debounce'; import { getHandleRecordingEmit } from './util/handleRecordingEmit'; -import { isExpired } from './util/isExpired'; -import { isSessionExpired } from './util/isSessionExpired'; +import { sampleSession } from './util/sampleSession'; import { sendReplay } from './util/sendReplay'; import type { SKIPPED } from './util/throttle'; import { throttle, THROTTLED } from './util/throttle'; @@ -87,7 +87,6 @@ export class ReplayContainer implements ReplayContainerInterface { */ public readonly timeouts: Timeouts = { sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, maxSessionLife: MAX_SESSION_LIFE, } as const; @@ -108,11 +107,6 @@ export class ReplayContainer implements ReplayContainerInterface { private _debouncedFlush: ReturnType; private _flushLock: Promise | null = null; - /** - * Timestamp of the last user activity. This lives across sessions. - */ - private _lastActivity: number = Date.now(); - /** * Is the integration currently active? */ @@ -208,37 +202,13 @@ export class ReplayContainer implements ReplayContainerInterface { */ public initializeSampling(): void { const { errorSampleRate, sessionSampleRate } = this._options; + const sampled = sampleSession({ errorSampleRate, sessionSampleRate }); - // If neither sample rate is > 0, then do nothing - user will need to call one of - // `start()` or `startBuffering` themselves. - if (errorSampleRate <= 0 && sessionSampleRate <= 0) { - return; - } - - // Otherwise if there is _any_ sample rate set, try to load an existing - // session, or create a new one. - const isSessionSampled = this._loadAndCheckSession(); - - if (!isSessionSampled) { - // This should only occur if `errorSampleRate` is 0 and was unsampled for - // session-based replay. In this case there is nothing to do. - return; - } - - if (!this.session) { - // This should not happen, something wrong has occurred - this._handleException(new Error('Unable to initialize and create session')); + if (!sampled) { return; } - if (this.session.sampled && this.session.sampled !== 'session') { - // If not sampled as session-based, then recording mode will be `buffer` - // Note that we don't explicitly check if `sampled === 'buffer'` because we - // could have sessions from Session storage that are still `error` from - // prior SDK version. - this.recordingMode = 'buffer'; - } - + this._initializeSession(sampled); this._initializeRecording(); } @@ -258,20 +228,7 @@ export class ReplayContainer implements ReplayContainerInterface { throw new Error('Replay buffering is in progress, call `flush()` to save the replay'); } - const previousSessionId = this.session && this.session.id; - - const { session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - // This is intentional: create a new session-based replay when calling `start()` - sessionSampleRate: 1, - allowBuffering: false, - }); - - session.previousSessionId = previousSessionId; - this.session = session; - + this._initializeSession('session', { forceSampled: true }); this._initializeRecording(); } @@ -284,20 +241,7 @@ export class ReplayContainer implements ReplayContainerInterface { throw new Error('Replay recording is already in progress'); } - const previousSessionId = this.session && this.session.id; - - const { session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - sessionSampleRate: 0, - allowBuffering: true, - }); - - session.previousSessionId = previousSessionId; - this.session = session; - - this.recordingMode = 'buffer'; + this._initializeSession('buffer', { forceSampled: true }); this._initializeRecording(); } @@ -379,8 +323,7 @@ export class ReplayContainer implements ReplayContainerInterface { this.eventBuffer && this.eventBuffer.destroy(); this.eventBuffer = null; - // Clear session from session storage, note this means if a new session - // is started after, it will not have `previousSessionId` + // Clear session from session storage clearSession(this); } catch (err) { this._handleException(err); @@ -393,6 +336,10 @@ export class ReplayContainer implements ReplayContainerInterface { * not as thorough of a shutdown as `stop()`. */ public pause(): void { + if (this.isPaused()) { + return; + } + this._isPaused = true; this.stopRecording(); } @@ -404,12 +351,14 @@ export class ReplayContainer implements ReplayContainerInterface { * new DOM checkout.` */ public resume(): void { - if (!this._loadAndCheckSession()) { + if (!this.isPaused()) { return; } this._isPaused = false; - this.startRecording(); + this.checkSessionState(() => { + this.startRecording(); + }); } /** @@ -444,20 +393,8 @@ export class ReplayContainer implements ReplayContainerInterface { // starting a new recording this.recordingMode = 'session'; - // Once this session ends, we do not want to refresh it if (this.session) { - this.session.shouldRefresh = false; - - // It's possible that the session lifespan is > max session lifespan - // because we have been buffering beyond max session lifespan (we ignore - // expiration given that `shouldRefresh` is true). Since we flip - // `shouldRefresh`, the session could be considered expired due to - // lifespan, which is not what we want. Update session start date to be - // the current timestamp, so that session is not considered to be - // expired. This means that max replay duration can be MAX_SESSION_LIFE + - // (length of buffer), which we are ok with. - this._updateUserActivity(activityTime); - this._updateSessionActivity(activityTime); + this.session.lastActivity = activityTime; this.session.started = activityTime; this._maybeSaveSession(); } @@ -495,42 +432,9 @@ export class ReplayContainer implements ReplayContainerInterface { } /** - * Updates the user activity timestamp and resumes recording. This should be - * called in an event handler for a user action that we consider as the user - * being "active" (e.g. a mouse click). - */ - public triggerUserActivity(): void { - this._updateUserActivity(); - - // This case means that recording was once stopped due to inactivity. - // Ensure that recording is resumed. - if (!this._stopRecording) { - // Create a new session, otherwise when the user action is flushed, it - // will get rejected due to an expired session. - if (!this._loadAndCheckSession()) { - return; - } - - // Note: This will cause a new DOM checkout - this.resume(); - return; - } - - // Otherwise... recording was never suspended, continue as normalish - this.checkAndHandleExpiredSession(); - - this._updateSessionActivity(); - } - - /** - * Updates the user activity timestamp *without* resuming - * recording. Some user events (e.g. keydown) can be create - * low-value replays that only contain the keypress as a - * breadcrumb. Instead this would require other events to - * create a new replay after a session has expired. + * Updates the user activity timestamp. */ public updateUserActivity(): void { - this._updateUserActivity(); this._updateSessionActivity(); } @@ -575,54 +479,6 @@ export class ReplayContainer implements ReplayContainerInterface { return this.session && this.session.id; } - /** - * Checks if recording should be stopped due to user inactivity. Otherwise - * check if session is expired and create a new session if so. Triggers a new - * full snapshot on new session. - * - * Returns true if session is not expired, false otherwise. - * @hidden - */ - public checkAndHandleExpiredSession(): boolean | void { - const oldSessionId = this.getSessionId(); - - // Prevent starting a new session if the last user activity is older than - // SESSION_IDLE_PAUSE_DURATION. Otherwise non-user activity can trigger a new - // session+recording. This creates noisy replays that do not have much - // content in them. - if ( - this._lastActivity && - isExpired(this._lastActivity, this.timeouts.sessionIdlePause) && - this.session && - this.session.sampled === 'session' - ) { - // Pause recording only for session-based replays. Otherwise, resuming - // will create a new replay and will conflict with users who only choose - // to record error-based replays only. (e.g. the resumed replay will not - // contain a reference to an error) - this.pause(); - return; - } - - // --- There is recent user activity --- // - // This will create a new session if expired, based on expiry length - if (!this._loadAndCheckSession()) { - return; - } - - // Session was expired if session ids do not match - const expired = oldSessionId !== this.getSessionId(); - - if (!expired) { - return true; - } - - // Session is expired, trigger a full snapshot (which will create a new session) - this._triggerFullSnapshot(); - - return false; - } - /** * Capture some initial state that can change throughout the lifespan of the * replay. This is required because otherwise they would be captured at the @@ -675,6 +531,23 @@ export class ReplayContainer implements ReplayContainerInterface { return res; } + /** + * Check the state/expiration of the session. + * The callback is called when the session is neither paused nor expired. + */ + public checkSessionState(onContinue: () => void): void { + if (!this.session || !this.session.sampled) { + return; + } + + checkSessionState(this.session, this.recordingMode, this.timeouts, { + onPause: () => this.pause(), + ensureResumed: () => this.resume(), + onEnd: () => this._refreshSession(), + onContinue, + }); + } + /** * This will get the parametrized route name of the current page. * This is only available if performance is enabled, and if an instrumented router is used. @@ -688,6 +561,31 @@ export class ReplayContainer implements ReplayContainerInterface { return lastTransaction.name; } + /** + * Initialize a new session. + */ + private _initializeSession(sampled: Sampled, { forceSampled = false }: { forceSampled?: boolean } = {}): void { + const { stickySession } = this._options; + + // If neither sample rate is > 0, then do nothing - user will need to call one of + // `start()` or `startBuffering` themselves. + if (!sampled) { + return; + } + + const session = restoreOrCreateSession({ stickySession, sampled, forceSampled }); + + // This shouldn't be possible really, but just in case... + if (!session.sampled) { + return; + } + + this.session = session; + this.recordingMode = this.session.sampled === 'buffer' ? 'buffer' : 'session'; + + return; + } + /** * Initialize and start all listeners to varying events (DOM, * Performance Observer, Recording, Sentry SDK, etc) @@ -697,7 +595,7 @@ export class ReplayContainer implements ReplayContainerInterface { // this method is generally called on page load or manually - in both cases // we should treat it as an activity - this._updateSessionActivity(); + this.updateUserActivity(); this.eventBuffer = createEventBuffer({ useCompression: this._options.useCompression, @@ -708,6 +606,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Need to set as enabled before we start recording, as `record()` can trigger a flush with a new checkout this._isEnabled = true; + this._isPaused = false; this.startRecording(); } @@ -722,37 +621,31 @@ export class ReplayContainer implements ReplayContainerInterface { } /** - * Loads (or refreshes) the current session. - * Returns false if session is not recorded. + * Refresh a session that has ended, either when it exceeded the max. age or when it was inactive for too long. + * This means there was a sampled & sent session before - this will never be called while the session is buffering. */ - private _loadAndCheckSession(): boolean { - const { type, session } = getSession({ - timeouts: this.timeouts, - stickySession: Boolean(this._options.stickySession), - currentSession: this.session, - sessionSampleRate: this._options.sessionSampleRate, - allowBuffering: this._options.errorSampleRate > 0 || this.recordingMode === 'buffer', - }); - - // If session was newly created (i.e. was not loaded from storage), then - // enable flag to create the root replay - if (type === 'new') { - this.setInitialState(); - } - - const currentSessionId = this.getSessionId(); - if (session.id !== currentSessionId) { - session.previousSessionId = currentSessionId; + private _refreshSession(): void { + // To avoid firing this multiple times, we check if we are even recording + if (!this.isEnabled()) { + return; } - this.session = session; + const { errorSampleRate, sessionSampleRate } = this._options; + const sampled = sampleSession({ errorSampleRate, sessionSampleRate }); - if (!this.session.sampled) { - void this.stop('session unsampled'); - return false; + if (!sampled || !this.session) { + void this.stop('session expired without new session'); + return; } - return true; + void this.stop('session expired with refreshing').then(() => { + if (sampled === 'session') { + return this.start(); + } + if (sampled === 'buffer') { + return this.startBuffering(); + } + }); } /** @@ -860,71 +753,35 @@ export class ReplayContainer implements ReplayContainerInterface { * Tasks to run when we consider a page to be hidden (via blurring and/or visibility) */ private _doChangeToBackgroundTasks(breadcrumb?: BreadcrumbFrame): void { - if (!this.session) { - return; - } - - const expired = isSessionExpired(this.session, this.timeouts); - - if (breadcrumb && !expired) { - this._createCustomBreadcrumb(breadcrumb); - } + this.checkSessionState(() => { + if (breadcrumb) { + this._createCustomBreadcrumb(breadcrumb); + } - // Send replay when the page/tab becomes hidden. There is no reason to send - // replay if it becomes visible, since no actions we care about were done - // while it was hidden - void this.conditionalFlush(); + // Send replay when the page/tab becomes hidden. There is no reason to send + // replay if it becomes visible, since no actions we care about were done + // while it was hidden + void this.conditionalFlush(); + }); } /** * Tasks to run when we consider a page to be visible (via focus and/or visibility) */ private _doChangeToForegroundTasks(breadcrumb?: BreadcrumbFrame): void { - if (!this.session) { - return; - } - - const isSessionActive = this.checkAndHandleExpiredSession(); - - if (!isSessionActive) { - // If the user has come back to the page within SESSION_IDLE_PAUSE_DURATION - // ms, we will re-use the existing session, otherwise create a new - // session - __DEBUG_BUILD__ && logger.log('[Replay] Document has become active, but session has expired'); - return; - } - - if (breadcrumb) { - this._createCustomBreadcrumb(breadcrumb); - } - } - - /** - * Trigger rrweb to take a full snapshot which will cause this plugin to - * create a new Replay event. - */ - private _triggerFullSnapshot(checkout = true): void { - try { - __DEBUG_BUILD__ && logger.log('[Replay] Taking full rrweb snapshot'); - record.takeFullSnapshot(checkout); - } catch (err) { - this._handleException(err); - } - } - - /** - * Update user activity (across session lifespans) - */ - private _updateUserActivity(_lastActivity: number = Date.now()): void { - this._lastActivity = _lastActivity; + this.checkSessionState(() => { + if (breadcrumb) { + this._createCustomBreadcrumb(breadcrumb); + } + }); } /** * Updates the session's last activity timestamp */ - private _updateSessionActivity(_lastActivity: number = Date.now()): void { + private _updateSessionActivity(lastActivity: number = Date.now()): void { if (this.session) { - this.session.lastActivity = _lastActivity; + this.session.lastActivity = lastActivity; this._maybeSaveSession(); } } @@ -1090,42 +947,58 @@ export class ReplayContainer implements ReplayContainerInterface { return; } - if (!this.checkAndHandleExpiredSession()) { - __DEBUG_BUILD__ && logger.error('[Replay] Attempting to finish replay event after session expired.'); - return; - } + return new Promise(resolve => { + if (!this.session) { + resolve(); + return; + } + checkSessionState(this.session, this.recordingMode, this.timeouts, { + onPause: () => { + this.pause(); + resolve(); + }, + ensureResumed: () => this.resume(), + onEnd: () => { + __DEBUG_BUILD__ && logger.error('[Replay] Attempting to finish replay event after session expired.'); + this._refreshSession(); + resolve(); + }, + onContinue: () => { + void this._flushLocked().then(resolve); + }, + }); + }); + }; - if (!this.session) { - __DEBUG_BUILD__ && logger.error('[Replay] No session found to flush.'); - return; - } + /** + * Flush, while locking so any future flush attemps while this is ongoing are queued. + */ + private async _flushLocked(): Promise { + try { + // A flush is about to happen, cancel any queued flushes + this._debouncedFlush.cancel(); - // A flush is about to happen, cancel any queued flushes - this._debouncedFlush.cancel(); + // this._flushLock acts as a lock so that future calls to `_flush()` + // will be blocked until this promise resolves + if (!this._flushLock) { + this._flushLock = this._runFlush(); + await this._flushLock; + this._flushLock = null; + return; + } - // this._flushLock acts as a lock so that future calls to `_flush()` - // will be blocked until this promise resolves - if (!this._flushLock) { - this._flushLock = this._runFlush(); + // Wait for previous flush to finish, then call the debounced `_flush()`. + // It's possible there are other flush requests queued and waiting for it + // to resolve. We want to reduce all outstanding requests (as well as any + // new flush requests that occur within a second of the locked flush + // completing) into a single flush. await this._flushLock; - this._flushLock = null; - return; + } catch (error) { + __DEBUG_BUILD__ && logger.error(error); } - // Wait for previous flush to finish, then call the debounced `_flush()`. - // It's possible there are other flush requests queued and waiting for it - // to resolve. We want to reduce all outstanding requests (as well as any - // new flush requests that occur within a second of the locked flush - // completing) into a single flush. - - try { - await this._flushLock; - } catch (err) { - __DEBUG_BUILD__ && logger.error(err); - } finally { - this._debouncedFlush(); - } - }; + this._debouncedFlush(); + } /** Save the session, if it is sticky */ private _maybeSaveSession(): void { diff --git a/packages/replay/src/session/Session.ts b/packages/replay/src/session/Session.ts index b5ecddcbdb84..95ca32ba3cda 100644 --- a/packages/replay/src/session/Session.ts +++ b/packages/replay/src/session/Session.ts @@ -20,6 +20,5 @@ export function makeSession(session: Partial & { sampled: Sampled }): S lastActivity, segmentId, sampled, - shouldRefresh: true, }; } diff --git a/packages/replay/src/session/checkSessionState.ts b/packages/replay/src/session/checkSessionState.ts new file mode 100644 index 000000000000..bc1df40f65f8 --- /dev/null +++ b/packages/replay/src/session/checkSessionState.ts @@ -0,0 +1,45 @@ +import type { ReplayRecordingMode } from '@sentry/types'; + +import type { Session, Timeouts } from '../types'; +import { isExpired } from '../util/isExpired'; + +/** Check the state of the session. */ +export function checkSessionState( + session: Session, + recordingMode: ReplayRecordingMode, + timeouts: Timeouts, + callbacks: { + onPause: () => void; + ensureResumed: () => void; + onEnd: () => void; + onContinue: () => void; + }, +): void { + const _isIdle = (): boolean => { + return isExpired(session.lastActivity, timeouts.sessionIdlePause); + }; + + const _exceedsMaxLength = (): boolean => { + return isExpired(session.started, timeouts.maxSessionLife); + }; + + // When buffering, we never want to expire/end/pause/restart the recording + if (recordingMode === 'buffer') { + callbacks.onContinue(); + return; + } + + if (_exceedsMaxLength()) { + callbacks.onEnd(); + return; + } + + if (_isIdle()) { + callbacks.onPause(); + return; + } + + callbacks.ensureResumed(); + + callbacks.onContinue(); +} diff --git a/packages/replay/src/session/createSession.ts b/packages/replay/src/session/createSession.ts index f5f2d120b92b..841db948c93e 100644 --- a/packages/replay/src/session/createSession.ts +++ b/packages/replay/src/session/createSession.ts @@ -1,6 +1,6 @@ import { logger } from '@sentry/utils'; -import type { Sampled, Session, SessionOptions } from '../types'; +import type { Sampled, Session } from '../types'; import { isSampled } from '../util/isSampled'; import { saveSession } from './saveSession'; import { makeSession } from './Session'; @@ -17,8 +17,13 @@ export function getSessionSampleType(sessionSampleRate: number, allowBuffering: * that all replays will be saved to as attachments. Currently, we only expect * one of these Sentry events per "replay session". */ -export function createSession({ sessionSampleRate, allowBuffering, stickySession = false }: SessionOptions): Session { - const sampled = getSessionSampleType(sessionSampleRate, allowBuffering); +export function createSession({ + sampled, + stickySession = false, +}: { + sampled: Sampled; + stickySession: boolean; +}): Session { const session = makeSession({ sampled, }); diff --git a/packages/replay/src/session/getSession.ts b/packages/replay/src/session/getSession.ts deleted file mode 100644 index 73554a8860de..000000000000 --- a/packages/replay/src/session/getSession.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { logger } from '@sentry/utils'; - -import type { Session, SessionOptions, Timeouts } from '../types'; -import { isSessionExpired } from '../util/isSessionExpired'; -import { createSession } from './createSession'; -import { fetchSession } from './fetchSession'; -import { makeSession } from './Session'; - -interface GetSessionParams extends SessionOptions { - timeouts: Timeouts; - - /** - * The current session (e.g. if stickySession is off) - */ - currentSession?: Session; -} - -/** - * Get or create a session - */ -export function getSession({ - timeouts, - currentSession, - stickySession, - sessionSampleRate, - allowBuffering, -}: GetSessionParams): { type: 'new' | 'saved'; session: Session } { - // If session exists and is passed, use it instead of always hitting session storage - const session = currentSession || (stickySession && fetchSession()); - - if (session) { - // If there is a session, check if it is valid (e.g. "last activity" time - // should be within the "session idle time", and "session started" time is - // within "max session time"). - const isExpired = isSessionExpired(session, timeouts); - - if (!isExpired || (allowBuffering && session.shouldRefresh)) { - return { type: 'saved', session }; - } else if (!session.shouldRefresh) { - // This is the case if we have an error session that is completed - // (=triggered an error). Session will continue as session-based replay, - // and when this session is expired, it will not be renewed until user - // reloads. - const discardedSession = makeSession({ sampled: false }); - return { type: 'new', session: discardedSession }; - } else { - __DEBUG_BUILD__ && logger.log('[Replay] Session has expired'); - } - // Otherwise continue to create a new session - } - - const newSession = createSession({ - stickySession, - sessionSampleRate, - allowBuffering, - }); - - return { type: 'new', session: newSession }; -} diff --git a/packages/replay/src/session/restoreOrCreateSession.ts b/packages/replay/src/session/restoreOrCreateSession.ts new file mode 100644 index 000000000000..7fffcd91bb53 --- /dev/null +++ b/packages/replay/src/session/restoreOrCreateSession.ts @@ -0,0 +1,34 @@ +import { logger } from '@sentry/utils'; + +import type { Sampled, Session } from '../types'; +import { createSession } from './createSession'; +import { fetchSession } from './fetchSession'; + +/** Either restore a session from sessionStorage, or create a new one. */ +export function restoreOrCreateSession({ + stickySession, + sampled, + forceSampled, +}: { + stickySession: boolean; + sampled: Sampled; + forceSampled: boolean; +}): Session { + const currentSession = stickySession && fetchSession(); + + if (currentSession && currentSession.sampled !== sampled && forceSampled) { + // If for whatever reason the session from sessionStorage has a different sampling, we force to the new sampling + __DEBUG_BUILD__ && + logger.log(`[Replay] Tried to restore session with different sampling: ${currentSession.sampled} !== ${sampled}`); + } else if (currentSession) { + __DEBUG_BUILD__ && logger.log(`[Replay] Loaded session from sessionStorage: id=${currentSession.id}`); + return currentSession; + } + + const newSession = createSession({ + stickySession, + sampled, + }); + + return newSession; +} diff --git a/packages/replay/src/types/replay.ts b/packages/replay/src/types/replay.ts index 00cda5e3c6e7..0cda7108413e 100644 --- a/packages/replay/src/types/replay.ts +++ b/packages/replay/src/types/replay.ts @@ -30,7 +30,6 @@ export interface SendReplayData { export interface Timeouts { sessionIdlePause: number; - sessionIdleExpire: number; maxSessionLife: number; } @@ -206,18 +205,6 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { }>; } -/** - * Session options that are configurable by the integration configuration - */ -export interface SessionOptions extends Pick { - /** - * Should buffer recordings to be saved later either by error sampling, or by - * manually calling `flush()`. This is only a factor if not sampled for a - * session-based replay. - */ - allowBuffering: boolean; -} - export interface ReplayIntegrationPrivacyOptions { /** * Mask text content for elements that match the CSS selectors in the list. @@ -351,22 +338,10 @@ export interface Session { */ segmentId: number; - /** - * The ID of the previous session. - * If this is empty, there was no previous session. - */ - previousSessionId?: string; - /** * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer` */ sampled: Sampled; - - /** - * If this is false, the session should not be refreshed when it was inactive. - * This can be the case if you had a buffered session which is now recording because an error happened. - */ - shouldRefresh: boolean; } export type EventBufferType = 'sync' | 'worker'; @@ -449,12 +424,11 @@ export interface ReplayContainer { flush(): Promise; flushImmediate(): Promise; cancelFlush(): void; - triggerUserActivity(): void; updateUserActivity(): void; addUpdate(cb: AddUpdateCallback): void; getOptions(): ReplayPluginOptions; getSessionId(): string | undefined; - checkAndHandleExpiredSession(): boolean | void; + checkSessionState(onContinue: () => void): void; setInitialState(): void; getCurrentRoute(): string | undefined; } diff --git a/packages/replay/src/util/handleRecordingEmit.ts b/packages/replay/src/util/handleRecordingEmit.ts index 987f589412ed..42e0b10581d2 100644 --- a/packages/replay/src/util/handleRecordingEmit.ts +++ b/packages/replay/src/util/handleRecordingEmit.ts @@ -1,5 +1,4 @@ import { EventType } from '@sentry-internal/rrweb'; -import { logger } from '@sentry/utils'; import { saveSession } from '../session/saveSession'; import type { AddEventResult, OptionFrameEvent, RecordingEvent, ReplayContainer } from '../types'; @@ -16,79 +15,64 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa let hadFirstEvent = false; return (event: RecordingEvent, _isCheckout?: boolean) => { - // If this is false, it means session is expired, create and a new session and wait for checkout - if (!replay.checkAndHandleExpiredSession()) { - __DEBUG_BUILD__ && logger.warn('[Replay] Received replay event after session expired.'); - - return; - } - - // `_isCheckout` is only set when the checkout is due to `checkoutEveryNms` - // We also want to treat the first event as a checkout, so we handle this specifically here - const isCheckout = _isCheckout || !hadFirstEvent; - hadFirstEvent = true; - - // The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush. - replay.addUpdate(() => { - // The session is always started immediately on pageload/init, but for - // error-only replays, it should reflect the most recent checkout - // when an error occurs. Clear any state that happens before this current - // checkout. This needs to happen before `addEvent()` which updates state - // dependent on this reset. - if (replay.recordingMode === 'buffer' && isCheckout) { - replay.setInitialState(); - } - - // We need to clear existing events on a checkout, otherwise they are - // incremental event updates and should be appended - void addEvent(replay, event, isCheckout); - - // Different behavior for full snapshots (type=2), ignore other event types - // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 - if (!isCheckout) { - return false; - } - - // Additionally, create a meta event that will capture certain SDK settings. - // In order to handle buffer mode, this needs to either be done when we - // receive checkout events or at flush time. - // - // `isCheckout` is always true, but want to be explicit that it should - // only be added for checkouts - void addSettingsEvent(replay, isCheckout); + replay.checkSessionState(() => { + // `_isCheckout` is only set when the checkout is due to `checkoutEveryNms` + // We also want to treat the first event as a checkout, so we handle this specifically here + const isCheckout = _isCheckout || !hadFirstEvent; + hadFirstEvent = true; + + // The handler returns `true` if we do not want to trigger debounced flush, `false` if we want to debounce flush. + replay.addUpdate(() => { + // The session is always started immediately on pageload/init, but for + // error-only replays, it should reflect the most recent checkout + // when an error occurs. Clear any state that happens before this current + // checkout. This needs to happen before `addEvent()` which updates state + // dependent on this reset. + if (replay.recordingMode === 'buffer' && isCheckout) { + replay.setInitialState(); + } - // 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 (replay.session && replay.session.previousSessionId) { - return true; - } + // We need to clear existing events on a checkout, otherwise they are + // incremental event updates and should be appended + void addEvent(replay, event, isCheckout); - // 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' && replay.session && replay.eventBuffer) { - const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); - if (earliestEvent) { - replay.session.started = earliestEvent; + // Different behavior for full snapshots (type=2), ignore other event types + // See https://github.com/rrweb-io/rrweb/blob/d8f9290ca496712aa1e7d472549480c4e7876594/packages/rrweb/src/types.ts#L16 + if (!isCheckout) { + return false; + } - if (replay.getOptions().stickySession) { - saveSession(replay.session); + // Additionally, create a meta event that will capture certain SDK settings. + // In order to handle buffer mode, this needs to either be done when we + // receive checkout events or at flush time. + // + // `isCheckout` is always true, but want to be explicit that it should + // only be added for checkouts + void addSettingsEvent(replay, isCheckout); + + // 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' && replay.session && replay.eventBuffer) { + const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); + if (earliestEvent) { + replay.session.started = earliestEvent; + + if (replay.getOptions().stickySession) { + saveSession(replay.session); + } } } - } - 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. - void replay.flush(); - } + 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. + void replay.flush(); + } - return true; + return true; + }); }); }; } diff --git a/packages/replay/src/util/isSessionExpired.ts b/packages/replay/src/util/isSessionExpired.ts deleted file mode 100644 index a51104fd5a47..000000000000 --- a/packages/replay/src/util/isSessionExpired.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Session, Timeouts } from '../types'; -import { isExpired } from './isExpired'; - -/** - * Checks to see if session is expired - */ -export function isSessionExpired(session: Session, timeouts: Timeouts, targetTime: number = +new Date()): boolean { - return ( - // First, check that maximum session length has not been exceeded - isExpired(session.started, timeouts.maxSessionLife, targetTime) || - // check that the idle timeout has not been exceeded (i.e. user has - // performed an action within the last `sessionIdleExpire` ms) - isExpired(session.lastActivity, timeouts.sessionIdleExpire, targetTime) - ); -} diff --git a/packages/replay/src/util/sampleSession.ts b/packages/replay/src/util/sampleSession.ts new file mode 100644 index 000000000000..4a9223574e02 --- /dev/null +++ b/packages/replay/src/util/sampleSession.ts @@ -0,0 +1,23 @@ +import type { Sampled } from '../types'; +import { isSampled } from './isSampled'; + +/** + * Sample a session based on the provided sample rates. + */ +export function sampleSession({ + errorSampleRate, + sessionSampleRate, +}: { + errorSampleRate: number; + sessionSampleRate: number; +}): Sampled { + if (errorSampleRate <= 0 && sessionSampleRate <= 0) { + return false; + } + + if (isSampled(sessionSampleRate)) { + return 'session'; + } + + return errorSampleRate > 0 ? 'buffer' : false; +} diff --git a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts index 6bf33f182e66..b26f0e1b3586 100644 --- a/packages/replay/test/integration/beforeAddRecordingEvent.test.ts +++ b/packages/replay/test/integration/beforeAddRecordingEvent.test.ts @@ -84,7 +84,7 @@ describe('Integration | beforeAddRecordingEvent', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); mockSendReplayRequest.mockClear(); }); @@ -94,7 +94,6 @@ describe('Integration | beforeAddRecordingEvent', () => { await new Promise(process.nextTick); jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/errorSampleRate.test.ts b/packages/replay/test/integration/errorSampleRate.test.ts index fe3049f9704f..0a07ba502e79 100644 --- a/packages/replay/test/integration/errorSampleRate.test.ts +++ b/packages/replay/test/integration/errorSampleRate.test.ts @@ -1,11 +1,10 @@ -import { captureException, getCurrentHub } from '@sentry/core'; +import { captureException } 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'; @@ -229,11 +228,9 @@ describe('Integration | errorSampleRate', () => { // 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. + // rate is > 0 replay.stop(); - replay['_options']['errorSampleRate'] = 0; - replay['_loadAndCheckSession'](); + replay['_initializeSession']('buffer'); jest.runAllTimers(); await new Promise(process.nextTick); @@ -241,7 +238,7 @@ describe('Integration | errorSampleRate', () => { 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 () => { + it('does not send a replay when triggering a full dom snapshot when document becomes visible after 60s', async () => { Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -249,7 +246,7 @@ describe('Integration | errorSampleRate', () => { }, }); - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); + jest.advanceTimersByTime(60 * 1000); document.dispatchEvent(new Event('visibilitychange')); @@ -273,8 +270,7 @@ describe('Integration | errorSampleRate', () => { expect(replay).not.toHaveLastSentReplay(); - // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 100); + jest.advanceTimersByTime(60 * 1000); Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -356,92 +352,36 @@ describe('Integration | errorSampleRate', () => { 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', - }), - }); + it('stops & restarts replay if session had an error and exceeds MAX_SESSION_LIFE', async () => { + captureException(new Error('testing')); - expect(replay.isEnabled()).toBe(false); + await waitForBufferFlush(); - domHandler({ - name: 'click', - }); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + replayEventPayload: expect.objectContaining({ + replay_type: 'buffer', + }), + }); - // Remains disabled! - expect(replay.isEnabled()).toBe(false); - }, - ); + await waitForFlush(); - 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) => { - const oldSessionId = replay.session?.id; - expect(oldSessionId).toBeDefined(); + // 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).not.toHaveLastSentReplay(); + const sessionId1 = replay.getSessionId(); // Idle for given time - jest.advanceTimersByTime(waitTime + 1); + jest.advanceTimersByTime(MAX_SESSION_LIFE + 1); await new Promise(process.nextTick); const TEST_EVENT = { - data: { name: 'lost event' }, + data: { name: 'new session event' }, timestamp: BASE_TIMESTAMP, type: 3, }; @@ -450,52 +390,36 @@ describe('Integration | errorSampleRate', () => { 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); + const sessionId2 = replay.getSessionId(); + // Session has changed + expect(sessionId2).not.toBe(sessionId1); 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.session?.id).toBe(oldSessionId); - + // sent a new session expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ replay_type: 'buffer', + replay_id: sessionId2, }), }); 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 () => { + it('continues buffering replay if session had no error and exceeds MAX_SESSION_LIFE', async () => { const oldSessionId = replay.session?.id; expect(oldSessionId).toBeDefined(); - // Idle for 15 minutes - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); + expect(replay).not.toHaveLastSentReplay(); + + // Idle for given time + jest.advanceTimersByTime(MAX_SESSION_LIFE + 1); + await new Promise(process.nextTick); const TEST_EVENT = { data: { name: 'lost event' }, @@ -503,12 +427,23 @@ describe('Integration | errorSampleRate', () => { 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 + // 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); @@ -517,20 +452,10 @@ 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); - expect(replay.session?.id).toBe(oldSessionId); + await waitForBufferFlush(); - // buffered events - expect(replay).toHaveSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - replayEventPayload: expect.objectContaining({ - replay_type: 'buffer', - }), - }); + expect(replay.session?.id).toBe(oldSessionId); - // `startRecording` full checkout expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ @@ -542,7 +467,6 @@ describe('Integration | errorSampleRate', () => { 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 () => { @@ -637,51 +561,15 @@ describe('Integration | errorSampleRate', () => { }); }); - it('stops replay when user goes idle', async () => { + it('does not pause/expire session while buffering', 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(); + // Wait for session to expire + jest.advanceTimersByTime(MAX_SESSION_LIFE + 1); 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); + expect(replay.isPaused()).toBe(false); + expect(replay.isEnabled()).toBe(true); const TEST_EVENT = { data: {}, timestamp: BASE_TIMESTAMP, type: 3 }; mockRecord._emitter(TEST_EVENT); @@ -692,124 +580,11 @@ describe('Integration | errorSampleRate', () => { 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); }); }); @@ -820,7 +595,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 from storage', async () => { +it('Integration | errorSampleRate | 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, @@ -831,6 +606,7 @@ it('sends a replay after loading the session from storage', async () => { stickySession: true, }, sentryOptions: { + replaysSessionSampleRate: 0, replaysOnErrorSampleRate: 1.0, }, autoStart: false, diff --git a/packages/replay/test/integration/events.test.ts b/packages/replay/test/integration/events.test.ts index b95faffa59da..4d105df9a2bc 100644 --- a/packages/replay/test/integration/events.test.ts +++ b/packages/replay/test/integration/events.test.ts @@ -40,7 +40,8 @@ describe('Integration | events', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); + replay.setInitialState(); mockTransportSend.mockClear(); }); @@ -93,7 +94,7 @@ describe('Integration | events', () => { it('has correct timestamps when there are events earlier than initial timestamp', async function () { clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); mockTransportSend.mockClear(); Object.defineProperty(document, 'visibilityState', { configurable: true, diff --git a/packages/replay/test/integration/flush.test.ts b/packages/replay/test/integration/flush.test.ts index cf142ae8c45c..959a1931e4b9 100644 --- a/packages/replay/test/integration/flush.test.ts +++ b/packages/replay/test/integration/flush.test.ts @@ -95,7 +95,7 @@ describe('Integration | flush', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); mockRecord.takeFullSnapshot.mockClear(); Object.defineProperty(WINDOW, 'location', { value: prevLocation, diff --git a/packages/replay/test/integration/rateLimiting.test.ts b/packages/replay/test/integration/rateLimiting.test.ts index 723dc682d100..705ae0e86729 100644 --- a/packages/replay/test/integration/rateLimiting.test.ts +++ b/packages/replay/test/integration/rateLimiting.test.ts @@ -46,7 +46,7 @@ describe('Integration | rate-limiting behaviour', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); mockSendReplayRequest.mockClear(); }); @@ -57,7 +57,6 @@ describe('Integration | rate-limiting behaviour', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); jest.clearAllMocks(); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/sendReplayEvent.test.ts b/packages/replay/test/integration/sendReplayEvent.test.ts index d7a9974bcaa9..8c164cf146c2 100644 --- a/packages/replay/test/integration/sendReplayEvent.test.ts +++ b/packages/replay/test/integration/sendReplayEvent.test.ts @@ -59,7 +59,8 @@ describe('Integration | sendReplayEvent', () => { // Create a new session and clear mocks because a segment (from initial // checkout) will have already been uploaded by the time the tests run clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); + replay.setInitialState(); mockSendReplayRequest.mockClear(); }); @@ -69,7 +70,6 @@ describe('Integration | sendReplayEvent', () => { await new Promise(process.nextTick); jest.setSystemTime(new Date(BASE_TIMESTAMP)); clearSession(replay); - replay['_loadAndCheckSession'](); }); afterAll(() => { diff --git a/packages/replay/test/integration/session.test.ts b/packages/replay/test/integration/session.test.ts index 304059659078..4c65e36b2371 100644 --- a/packages/replay/test/integration/session.test.ts +++ b/packages/replay/test/integration/session.test.ts @@ -5,7 +5,6 @@ import { DEFAULT_FLUSH_MIN_DELAY, MAX_SESSION_LIFE, REPLAY_SESSION_KEY, - SESSION_IDLE_EXPIRE_DURATION, SESSION_IDLE_PAUSE_DURATION, WINDOW, } from '../../src/constants'; @@ -13,7 +12,6 @@ import type { ReplayContainer } from '../../src/replay'; import { clearSession } from '../../src/session/clearSession'; import type { Session } from '../../src/types'; import { addEvent } from '../../src/util/addEvent'; -import { createPerformanceSpans } from '../../src/util/createPerformanceSpans'; import { createOptionsEvent } from '../../src/util/handleRecordingEmit'; import { BASE_TIMESTAMP } from '../index'; import type { RecordMock } from '../mocks/mockRrweb'; @@ -60,7 +58,7 @@ describe('Integration | session', () => { // Require a "user interaction" to start a new session, visibility is not enough. This can be noisy // (e.g. rapidly switching tabs/window focus) and leads to empty sessions. - it('does not create a new session when document becomes visible after [SESSION_IDLE_EXPIRE_DURATION]ms', () => { + it('does not create a new session when document becomes visible after [MAX_SESSION_LIFE]ms', () => { Object.defineProperty(document, 'visibilityState', { configurable: true, get: function () { @@ -70,7 +68,7 @@ describe('Integration | session', () => { const initialSession = { ...replay.session } as Session; - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); + jest.advanceTimersByTime(MAX_SESSION_LIFE + 1); document.dispatchEvent(new Event('visibilitychange')); @@ -78,167 +76,7 @@ describe('Integration | session', () => { expect(replay).toHaveSameSession(initialSession); }); - it('does not create a new session when document becomes focused after [SESSION_IDLE_EXPIRE_DURATION]ms', () => { - const initialSession = { ...replay.session } as Session; - - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION + 1); - - WINDOW.dispatchEvent(new Event('focus')); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveSameSession(initialSession); - }); - - it('does not create a new session if user hides the tab and comes back within [SESSION_IDLE_EXPIRE_DURATION] seconds', () => { - const initialSession = { ...replay.session } as Session; - - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'hidden'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveSameSession(initialSession); - - // User comes back before `SESSION_IDLE_EXPIRE_DURATION` elapses - jest.advanceTimersByTime(SESSION_IDLE_EXPIRE_DURATION - 1); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - get: function () { - return 'visible'; - }, - }); - document.dispatchEvent(new Event('visibilitychange')); - - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - // Should NOT have created a new session - expect(replay).toHaveSameSession(initialSession); - }); - - it('creates a new session if user has been idle for more than SESSION_IDLE_EXPIRE_DURATION and comes back to click their mouse', async () => { - const initialSession = { ...replay.session } as Session; - - expect(mockRecord).toHaveBeenCalledTimes(1); - expect(initialSession?.id).toBeDefined(); - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://localhost/', - initialTimestamp: BASE_TIMESTAMP, - }), - ); - - const url = 'http://dummy/'; - Object.defineProperty(WINDOW, 'location', { - value: new URL(url), - }); - - const ELAPSED = SESSION_IDLE_EXPIRE_DURATION + 1; - jest.advanceTimersByTime(ELAPSED); - - // Session has become in an idle state - // - // This event will put the Replay SDK into a paused state - const TEST_EVENT = { - data: { name: 'lost event' }, - timestamp: BASE_TIMESTAMP, - type: 3, - }; - mockRecord._emitter(TEST_EVENT); - - // performance events can still be collected while recording is stopped - // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive - replay.addUpdate(() => { - createPerformanceSpans(replay, [ - { - type: 'navigation.navigate' as const, - name: 'foo', - start: BASE_TIMESTAMP + ELAPSED, - end: BASE_TIMESTAMP + ELAPSED + 100, - data: { - decodedBodySize: 1, - encodedBodySize: 2, - duration: 0, - domInteractive: 0, - domContentLoadedEventEnd: 0, - domContentLoadedEventStart: 0, - loadEventStart: 0, - loadEventEnd: 0, - domComplete: 0, - redirectCount: 0, - size: 0, - }, - }, - ]); - return true; - }); - - await new Promise(process.nextTick); - - expect(replay).not.toHaveLastSentReplay(); - expect(replay.isPaused()).toBe(true); - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(replay).toHaveSameSession(initialSession); - expect(mockRecord).toHaveBeenCalledTimes(1); - - // Now do a click which will create a new session and start recording again - domHandler({ - name: 'click', - }); - - // This is not called because we have to start recording - expect(mockRecord.takeFullSnapshot).not.toHaveBeenCalled(); - expect(mockRecord).toHaveBeenCalledTimes(2); - - // Should be a new session - expect(replay).not.toHaveSameSession(initialSession); - - // Replay does not send immediately because checkout was due to expired session - expect(replay).not.toHaveLastSentReplay(); - - const optionsEvent = createOptionsEvent(replay); - - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); - - const newTimestamp = BASE_TIMESTAMP + ELAPSED + 20; - - expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, - recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, - optionsEvent, - { - type: 5, - timestamp: newTimestamp, - data: { - tag: 'breadcrumb', - payload: { - timestamp: newTimestamp / 1000, - type: 'default', - category: 'ui.click', - message: '', - data: {}, - }, - }, - }, - ]), - }); - - // Earliest event is reset - expect(replay.eventBuffer?.getEarliestTimestamp()).toBeNull(); - - // `_context` should be reset when a new session is created - expect(replay.getContext()).toEqual({ - initialUrl: 'http://dummy/', - initialTimestamp: newTimestamp, - urls: [], - errorIds: new Set(), - traceIds: new Set(), - }); - }); - - it('pauses and resumes a session if user has been idle for more than SESSION_IDLE_PASUE_DURATION and comes back to click their mouse', async () => { + it('pauses and resumes a session if user has been idle for more than SESSION_IDLE_PAUSE_DURATION and comes back to click their mouse', async () => { const initialSession = { ...replay.session } as Session; expect(initialSession?.id).toBeDefined(); @@ -267,33 +105,6 @@ describe('Integration | session', () => { }; mockRecord._emitter(TEST_EVENT); - // performance events can still be collected while recording is stopped - // TODO: we may want to prevent `addEvent` from adding to buffer when user is inactive - replay.addUpdate(() => { - createPerformanceSpans(replay, [ - { - type: 'navigation.navigate' as const, - name: 'foo', - start: BASE_TIMESTAMP + ELAPSED, - end: BASE_TIMESTAMP + ELAPSED + 100, - data: { - decodedBodySize: 1, - encodedBodySize: 2, - duration: 0, - domInteractive: 0, - domContentLoadedEventEnd: 0, - domContentLoadedEventStart: 0, - loadEventStart: 0, - loadEventEnd: 0, - domComplete: 0, - redirectCount: 0, - size: 0, - }, - }, - ]); - return true; - }); - await new Promise(process.nextTick); expect(replay).not.toHaveLastSentReplay(); @@ -355,7 +166,6 @@ describe('Integration | session', () => { const ELAPSED = MAX_SESSION_LIFE + 1; jest.advanceTimersByTime(ELAPSED); // Update activity so as to not consider session to be idling - replay['_updateUserActivity'](); replay['_updateSessionActivity'](); // This should trigger a new session @@ -366,42 +176,53 @@ describe('Integration | session', () => { }; mockRecord._emitter(TEST_EVENT); + await new Promise(process.nextTick); + await waitForFlush(); + + const newTimestamp = BASE_TIMESTAMP + ELAPSED; + const optionsEvent = createOptionsEvent(replay); + optionsEvent.timestamp = newTimestamp; + expect(replay).not.toHaveSameSession(initialSession); - expect(mockRecord.takeFullSnapshot).toHaveBeenCalled(); - expect(replay).not.toHaveLastSentReplay(); - // @ts-ignore private - expect(replay._stopRecording).toBeDefined(); + expect(replay).toHaveLastSentReplay({ + recordingPayloadHeader: { segment_id: 0 }, + recordingData: JSON.stringify([{ data: { isCheckout: true }, timestamp: newTimestamp, type: 2 }, optionsEvent]), + }); + expect(replay.isEnabled()).toBe(true); + // `_context` should be reset when a new session is created + expect(replay.getContext()).toEqual( + expect.objectContaining({ + initialUrl: 'http://dummy/', + initialTimestamp: newTimestamp, + }), + ); // Now do a click domHandler({ name: 'click', }); - const newTimestamp = BASE_TIMESTAMP + ELAPSED; + const postFlushTimestamp = newTimestamp + DEFAULT_FLUSH_MIN_DELAY + 40; const NEW_TEST_EVENT = { data: { name: 'test' }, - timestamp: newTimestamp + DEFAULT_FLUSH_MIN_DELAY + 20, + timestamp: postFlushTimestamp, type: 3, }; mockRecord._emitter(NEW_TEST_EVENT); - const optionsEvent = createOptionsEvent(replay); - jest.runAllTimers(); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await waitForFlush(); expect(replay).toHaveLastSentReplay({ - recordingPayloadHeader: { segment_id: 0 }, + recordingPayloadHeader: { segment_id: 1 }, recordingData: JSON.stringify([ - { data: { isCheckout: true }, timestamp: BASE_TIMESTAMP + ELAPSED, type: 2 }, - optionsEvent, { type: 5, - timestamp: newTimestamp, + timestamp: postFlushTimestamp, data: { tag: 'breadcrumb', payload: { - timestamp: newTimestamp / 1000, + timestamp: postFlushTimestamp / 1000, type: 'default', category: 'ui.click', message: '', @@ -412,19 +233,11 @@ describe('Integration | session', () => { NEW_TEST_EVENT, ]), }); - - // `_context` should be reset when a new session is created - expect(replay.getContext()).toEqual( - expect.objectContaining({ - initialUrl: 'http://dummy/', - initialTimestamp: newTimestamp, - }), - ); }); it('increases segment id after each event', async () => { clearSession(replay); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -457,3 +270,7 @@ describe('Integration | session', () => { }); }); }); + +async function waitForFlush() { + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); +} diff --git a/packages/replay/test/integration/stop.test.ts b/packages/replay/test/integration/stop.test.ts index cc0e28195244..31b4a4b9bce0 100644 --- a/packages/replay/test/integration/stop.test.ts +++ b/packages/replay/test/integration/stop.test.ts @@ -44,6 +44,7 @@ describe('Integration | stop', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); replay.eventBuffer?.destroy(); jest.clearAllMocks(); + replay['_initializeSession']('session'); }); afterEach(async () => { @@ -52,7 +53,6 @@ describe('Integration | stop', () => { jest.setSystemTime(new Date(BASE_TIMESTAMP)); sessionStorage.clear(); clearSession(replay); - replay['_loadAndCheckSession'](); mockRecord.takeFullSnapshot.mockClear(); mockAddInstrumentationHandler.mockClear(); Object.defineProperty(WINDOW, 'location', { diff --git a/packages/replay/test/unit/session/checkSessionState.test.ts b/packages/replay/test/unit/session/checkSessionState.test.ts new file mode 100644 index 000000000000..f4ac41c67f99 --- /dev/null +++ b/packages/replay/test/unit/session/checkSessionState.test.ts @@ -0,0 +1,148 @@ +import { checkSessionState } from '../../../src/session/checkSessionState'; +import { makeSession } from '../../../src/session/Session'; +import type { Session, Timeouts } from '../../../src/types'; + +describe('Unit | session | checkSessionState', () => { + const timeouts: Timeouts = { + sessionIdlePause: 10_000, + maxSessionLife: 10_000, + }; + + it('works for a regular session', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'session' }); + + checkSessionState(session, 'session', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).not.toHaveBeenCalled(); + expect(ensureResumed).toHaveBeenCalledTimes(1); + expect(onEnd).not.toHaveBeenCalled(); + expect(onContinue).toHaveBeenCalledTimes(1); + }); + + it('pauses an idle session', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'session', lastActivity: Date.now() - 20_000 }); + + const timeouts: Timeouts = { + sessionIdlePause: 10_000, + maxSessionLife: 100_000, + }; + + checkSessionState(session, 'session', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).toHaveBeenCalledTimes(1); + expect(ensureResumed).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + expect(onContinue).not.toHaveBeenCalled(); + }); + + it('does not pause an idle buffer session', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'buffer', lastActivity: Date.now() - 20_000 }); + + const timeouts: Timeouts = { + sessionIdlePause: 10_000, + maxSessionLife: 100_000, + }; + + checkSessionState(session, 'buffer', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).not.toHaveBeenCalled(); + expect(ensureResumed).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + expect(onContinue).toHaveBeenCalled(); + }); + + it('ends a too long session', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'session', started: Date.now() - 20_000 }); + + checkSessionState(session, 'session', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).not.toHaveBeenCalled(); + expect(ensureResumed).not.toHaveBeenCalled(); + expect(onEnd).toHaveBeenCalledTimes(1); + expect(onContinue).not.toHaveBeenCalled(); + }); + + it('does not end a too long buffer session', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'buffer', started: Date.now() - 20_000 }); + + checkSessionState(session, 'buffer', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).not.toHaveBeenCalled(); + expect(ensureResumed).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + expect(onContinue).toHaveBeenCalled(); + }); + + it('uses recordingMode over session.sampled', () => { + const onPause = jest.fn(); + const ensureResumed = jest.fn(); + const onEnd = jest.fn(); + const onContinue = jest.fn(); + + const session: Session = makeSession({ sampled: 'buffer', started: Date.now() - 20_000 }); + + // A session with sampled=buffer can be in `session` mode after an error has been captured + // In this case, we need to use the recordingMode `session` for decisions, not the session.sampled + checkSessionState(session, 'session', timeouts, { + onPause, + ensureResumed, + onEnd, + onContinue, + }); + + expect(onPause).not.toHaveBeenCalled(); + expect(ensureResumed).not.toHaveBeenCalled(); + expect(onEnd).toHaveBeenCalledTimes(1); + expect(onContinue).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/replay/test/unit/session/createSession.test.ts b/packages/replay/test/unit/session/createSession.test.ts index 891cc012edb6..03c99d3a82d0 100644 --- a/packages/replay/test/unit/session/createSession.test.ts +++ b/packages/replay/test/unit/session/createSession.test.ts @@ -33,8 +33,7 @@ describe('Unit | session | createSession', () => { it('creates a new session with no sticky sessions', function () { const newSession = createSession({ stickySession: false, - sessionSampleRate: 1.0, - allowBuffering: false, + sampled: 'session', }); expect(captureEventMock).not.toHaveBeenCalled(); @@ -48,8 +47,7 @@ describe('Unit | session | createSession', () => { it('creates a new session with sticky sessions', function () { const newSession = createSession({ stickySession: true, - sessionSampleRate: 1.0, - allowBuffering: false, + sampled: 'session', }); expect(captureEventMock).not.toHaveBeenCalled(); diff --git a/packages/replay/test/unit/session/fetchSession.test.ts b/packages/replay/test/unit/session/fetchSession.test.ts index cf1856e53356..526c9c7969d1 100644 --- a/packages/replay/test/unit/session/fetchSession.test.ts +++ b/packages/replay/test/unit/session/fetchSession.test.ts @@ -28,7 +28,6 @@ describe('Unit | session | fetchSession', () => { segmentId: 0, sampled: 'session', started: 1648827162630, - shouldRefresh: true, }); }); @@ -44,7 +43,6 @@ describe('Unit | session | fetchSession', () => { segmentId: 0, sampled: false, started: 1648827162630, - shouldRefresh: true, }); }); diff --git a/packages/replay/test/unit/session/getSession.test.ts b/packages/replay/test/unit/session/getSession.test.ts deleted file mode 100644 index aa3110d114f2..000000000000 --- a/packages/replay/test/unit/session/getSession.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { - MAX_SESSION_LIFE, - SESSION_IDLE_EXPIRE_DURATION, - SESSION_IDLE_PAUSE_DURATION, - WINDOW, -} from '../../../src/constants'; -import * as CreateSession from '../../../src/session/createSession'; -import * as FetchSession from '../../../src/session/fetchSession'; -import { getSession } from '../../../src/session/getSession'; -import { saveSession } from '../../../src/session/saveSession'; -import { makeSession } from '../../../src/session/Session'; - -jest.mock('@sentry/utils', () => { - return { - ...(jest.requireActual('@sentry/utils') as { string: unknown }), - uuid4: jest.fn(() => 'test_session_uuid'), - }; -}); - -const SAMPLE_OPTIONS = { - sessionSampleRate: 1.0, - allowBuffering: false, -}; - -function createMockSession(when: number = Date.now()) { - return makeSession({ - id: 'test_session_id', - segmentId: 0, - lastActivity: when, - started: when, - sampled: 'session', - shouldRefresh: true, - }); -} - -describe('Unit | session | getSession', () => { - beforeAll(() => { - jest.spyOn(CreateSession, 'createSession'); - jest.spyOn(FetchSession, 'fetchSession'); - WINDOW.sessionStorage.clear(); - }); - - afterEach(() => { - WINDOW.sessionStorage.clear(); - (CreateSession.createSession as jest.MockedFunction).mockClear(); - (FetchSession.fetchSession as jest.MockedFunction).mockClear(); - }); - - it('creates a non-sticky session when one does not exist', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - - // Should not have anything in storage - expect(FetchSession.fetchSession()).toBe(null); - }); - - it('creates a non-sticky session, regardless of session existing in sessionStorage', function () { - saveSession(createMockSession(Date.now() - 10000)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toBeDefined(); - }); - - it('creates a non-sticky session, when one is expired', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'old_session_id', - lastActivity: Date.now() - 1001, - started: Date.now() - 1001, - segmentId: 0, - sampled: 'session', - }), - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toBeDefined(); - expect(session.id).not.toBe('old_session_id'); - }); - - it('creates a sticky session when one does not exist', function () { - expect(FetchSession.fetchSession()).toBe(null); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: SESSION_IDLE_EXPIRE_DURATION, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - sessionSampleRate: 1.0, - allowBuffering: false, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - - // Should not have anything in storage - expect(FetchSession.fetchSession()).toEqual({ - id: 'test_session_uuid', - segmentId: 0, - lastActivity: expect.any(Number), - sampled: 'session', - started: expect.any(Number), - shouldRefresh: true, - }); - }); - - it('fetches an existing sticky session', function () { - const now = Date.now(); - saveSession(createMockSession(now)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - sessionSampleRate: 1.0, - allowBuffering: false, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(session).toEqual({ - id: 'test_session_id', - segmentId: 0, - lastActivity: now, - sampled: 'session', - started: now, - shouldRefresh: true, - }); - }); - - it('fetches an expired sticky session', function () { - const now = Date.now(); - saveSession(createMockSession(Date.now() - 2000)); - - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: true, - ...SAMPLE_OPTIONS, - }); - - expect(FetchSession.fetchSession).toHaveBeenCalled(); - expect(CreateSession.createSession).toHaveBeenCalled(); - - expect(session.id).toBe('test_session_uuid'); - expect(session.lastActivity).toBeGreaterThanOrEqual(now); - expect(session.started).toBeGreaterThanOrEqual(now); - expect(session.segmentId).toBe(0); - }); - - it('fetches a non-expired non-sticky session', function () { - const { session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - 500, - started: +new Date() - 500, - segmentId: 0, - sampled: 'session', - }), - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(session.id).toBe('test_session_uuid_2'); - expect(session.segmentId).toBe(0); - }); - - it('re-uses the same "buffer" session if it is expired and has never sent a buffered replay', function () { - const { type, session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession: makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - MAX_SESSION_LIFE - 1, - started: +new Date() - MAX_SESSION_LIFE - 1, - segmentId: 0, - sampled: 'buffer', - }), - allowBuffering: true, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(type).toBe('saved'); - expect(session.id).toBe('test_session_uuid_2'); - expect(session.sampled).toBe('buffer'); - expect(session.segmentId).toBe(0); - }); - - it('creates a new session if it is expired and it was a "buffer" session that has sent a replay', function () { - const currentSession = makeSession({ - id: 'test_session_uuid_2', - lastActivity: +new Date() - MAX_SESSION_LIFE - 1, - started: +new Date() - MAX_SESSION_LIFE - 1, - segmentId: 0, - sampled: 'buffer', - }); - currentSession.shouldRefresh = false; - - const { type, session } = getSession({ - timeouts: { - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1000, - maxSessionLife: MAX_SESSION_LIFE, - }, - stickySession: false, - ...SAMPLE_OPTIONS, - currentSession, - allowBuffering: true, - }); - - expect(FetchSession.fetchSession).not.toHaveBeenCalled(); - expect(CreateSession.createSession).not.toHaveBeenCalled(); - - expect(type).toBe('new'); - expect(session.id).not.toBe('test_session_uuid_2'); - expect(session.sampled).toBe(false); - expect(session.segmentId).toBe(0); - }); -}); diff --git a/packages/replay/test/unit/util/isSessionExpired.test.ts b/packages/replay/test/unit/util/isSessionExpired.test.ts deleted file mode 100644 index 38b24056d36f..000000000000 --- a/packages/replay/test/unit/util/isSessionExpired.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { MAX_SESSION_LIFE, SESSION_IDLE_PAUSE_DURATION } from '../../../src/constants'; -import { makeSession } from '../../../src/session/Session'; -import { isSessionExpired } from '../../../src/util/isSessionExpired'; - -function createSession(extra?: Record) { - return makeSession({ - // Setting started/lastActivity to 0 makes it use the default, which is `Date.now()` - started: 1, - lastActivity: 1, - segmentId: 0, - sampled: 'session', - ...extra, - }); -} - -describe('Unit | util | isSessionExpired', () => { - it('session last activity is older than expiry time', function () { - expect( - isSessionExpired( - createSession(), - { - maxSessionLife: MAX_SESSION_LIFE, - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 100, - }, - 200, - ), - ).toBe(true); // Session expired at ts = 100 - }); - - it('session last activity is not older than expiry time', function () { - expect( - isSessionExpired( - createSession({ lastActivity: 100 }), - { - maxSessionLife: MAX_SESSION_LIFE, - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 150, - }, - 200, - ), - ).toBe(false); // Session expires at ts >= 250 - }); - - it('session age is not older than max session life', function () { - expect( - isSessionExpired( - createSession(), - { - maxSessionLife: MAX_SESSION_LIFE, - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1_800_000, - }, - 50_000, - ), - ).toBe(false); - }); - - it('session age is older than max session life', function () { - expect( - isSessionExpired( - createSession(), - { - maxSessionLife: MAX_SESSION_LIFE, - sessionIdlePause: SESSION_IDLE_PAUSE_DURATION, - sessionIdleExpire: 1_800_000, - }, - 1_800_001, - ), - ).toBe(true); // Session expires at ts >= 1_800_000 - }); -}); diff --git a/packages/replay/test/unit/util/sampleSession.test.ts b/packages/replay/test/unit/util/sampleSession.test.ts new file mode 100644 index 000000000000..321632fac0d0 --- /dev/null +++ b/packages/replay/test/unit/util/sampleSession.test.ts @@ -0,0 +1,28 @@ +import type { Sampled } from '../../../src/types'; +import { sampleSession } from '../../../src/util/sampleSession'; + +// Note: We "fix" Math.random() to always return 0.2 +const cases: [number, number, Sampled][] = [ + [0, 0, false], + [-1, -1, false], + [1, 0, 'session'], + [0, 1, 'buffer'], + [0.1, 0.1, 'buffer'], + [0.1, 0, false], + [0.3, 0.1, 'session'], + [0.3, 0, 'session'], +]; + +describe('Unit | util | sampleSession', () => { + const mockRandom = jest.spyOn(Math, 'random'); + + test.each(cases)( + 'given sessionSampleRate=%p and errorSampleRate=%p, result should be %p', + (sessionSampleRate: number, errorSampleRate: number, expectedResult: Sampled) => { + mockRandom.mockImplementationOnce(() => 0.2); + + const result = sampleSession({ sessionSampleRate, errorSampleRate }); + expect(result).toEqual(expectedResult); + }, + ); +}); diff --git a/packages/replay/test/utils/setupReplayContainer.ts b/packages/replay/test/utils/setupReplayContainer.ts index e2a49052a799..dbef83f4a8d0 100644 --- a/packages/replay/test/utils/setupReplayContainer.ts +++ b/packages/replay/test/utils/setupReplayContainer.ts @@ -41,9 +41,9 @@ export function setupReplayContainer({ }); clearSession(replay); - replay.setInitialState(); - replay['_loadAndCheckSession'](); + replay['_initializeSession']('session'); replay['_isEnabled'] = true; + replay.setInitialState(); replay.eventBuffer = createEventBuffer({ useCompression: options?.useCompression || false, });