From b51a7a99f234de3e7dc29a6ab8f61b8494e059b7 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Thu, 2 Oct 2025 12:31:51 -0400 Subject: [PATCH 01/14] fix(replay): Linked errors not resetting session id This PR fixes a case where we [correctly] tag an error event w/ replay id, but something occurs where the replay event does not end up being flushed. This means the existing session is still in a buffered state, and will keep its session id until a new error event is sampled and a replay is created. When this does happen, we can have a replay with a super long duration (e.g. the time between the two error replays). We now update the session immediately when we tag an error event w/ replay id so that if the replay event does not successfully flush, the session will respect its expiration date. --- .../replay/bufferStalledRequests/init.js | 18 + .../replay/bufferStalledRequests/subject.js | 11 + .../bufferStalledRequests/template.html | 11 + .../replay/bufferStalledRequests/test.ts | 322 ++++++++++++++++++ .../src/coreHandlers/handleGlobalEvent.ts | 14 + 5 files changed, 376 insertions(+) create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html create mode 100644 dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js new file mode 100644 index 000000000000..f9dccbffb530 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/init.js @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + minReplayDuration: 0, + stickySession: true, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 1, + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js new file mode 100644 index 000000000000..1c9b22455261 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/subject.js @@ -0,0 +1,11 @@ +document.getElementById('error1').addEventListener('click', () => { + throw new Error('First Error'); +}); + +document.getElementById('error2').addEventListener('click', () => { + throw new Error('Second Error'); +}); + +document.getElementById('click').addEventListener('click', () => { + // Just a click for interaction +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html new file mode 100644 index 000000000000..1beb4b281b28 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts new file mode 100644 index 000000000000..623cb96068c3 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -0,0 +1,322 @@ +import { expect } from '@playwright/test'; +import { sentryTest } from '../../../utils/fixtures'; +import { envelopeRequestParser, waitForErrorRequest } from '../../../utils/helpers'; +import { + getReplaySnapshot, + isReplayEvent, + shouldSkipReplayTest, + waitForReplayRunning, +} from '../../../utils/replayHelpers'; + +sentryTest( + 'buffer mode turns into session mode after interrupting error event ingest, and resumes session on reload', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + + if (errorCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + waitForErrorRequest(page); + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(0); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('session'); + expect(secondSession.recordingMode).toBe('session'); // this turns to "session" because of `sampled` value being "session" + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); + +sentryTest( + 'buffer mode turns into session mode after interrupting replay flush, and resumes session on reload', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('session'); + expect(secondSession.recordingMode).toBe('session'); // this turns to "session" because of `sampled` value being "session" + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(1); + }, +); + +sentryTest( + 'starts a new session after interrupting replay flush and session "expires"', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); + } + } + + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); + } + } + + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + // Trigger first error - this should change session sampled to "session" + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + // Now expire the session by manipulating session storage + // Simulate session expiry by setting lastActivity to a time in the past + await page.evaluate(() => { + const replayIntegration = (window as any).Replay; + const replay = replayIntegration['_replay']; + + // Set session as expired (15 minutes ago) + if (replay.session) { + const fifteenMinutesAgo = Date.now() - 15 * 60 * 1000; + replay.session.lastActivity = fifteenMinutesAgo; + replay.session.started = fifteenMinutesAgo; + + // Also update session storage if sticky sessions are enabled + const sessionKey = 'sentryReplaySession'; + const sessionData = sessionStorage.getItem(sessionKey); + if (sessionData) { + const session = JSON.parse(sessionData); + session.lastActivity = fifteenMinutesAgo; + session.started = fifteenMinutesAgo; + sessionStorage.setItem(sessionKey, JSON.stringify(session)); + } + } + }); + + await page.reload(); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.recordingMode).toBe('buffer'); + expect(secondSession.session?.id).not.toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(0); + }, +); + +sentryTest( + '[buffer-mode] marks session as sampled immediately when error is sampled', + async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + await page.route('https://dsn.ingest.sentry.io/**/*', route => { + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), + }); + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + await page.goto(url); + + // Wait for replay to initialize + await new Promise(resolve => setTimeout(resolve, 100)); + + // Verify initial state - buffer mode, not sampled + const initialSession = await getReplaySnapshot(page); + expect(initialSession.recordingMode).toBe('buffer'); + expect(initialSession.session?.sampled).toBe('buffer'); + expect(initialSession.session?.segmentId).toBe(0); + + // Trigger error which should immediately mark session as sampled + const reqErrorPromise = waitForErrorRequest(page); + await page.locator('#error1').click(); + + // Check session state BEFORE waiting for error to be sent + // The session should already be marked as 'session' synchronously + const duringErrorProcessing = await getReplaySnapshot(page); + expect(duringErrorProcessing.session?.sampled).toBe('session'); + expect(duringErrorProcessing.recordingMode).toBe('buffer'); // Still in buffer recording mode + + await reqErrorPromise; + + // After error is sent, verify state is still correct + const afterError = await getReplaySnapshot(page); + expect(afterError.session?.sampled).toBe('session'); + expect(afterError.recordingMode).toBe('session'); + + // Verify the session was persisted to sessionStorage (if sticky sessions enabled) + const sessionData = await page.evaluate(() => { + const sessionKey = 'sentryReplaySession'; + const data = sessionStorage.getItem(sessionKey); + return data ? JSON.parse(data) : null; + }); + + expect(sessionData).toBeDefined(); + expect(sessionData.sampled).toBe('session'); + }, +); diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 55559c0d4c01..a56c145f5ddf 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -1,5 +1,6 @@ import type { Event, EventHint } from '@sentry/core'; import { DEBUG_BUILD } from '../debug-build'; +import { saveSession } from '../session/saveSession'; import type { ReplayContainer } from '../types'; import { isErrorEvent, isFeedbackEvent, isReplayEvent, isTransactionEvent } from '../util/eventUtils'; import { isRrwebError } from '../util/isRrwebError'; @@ -69,6 +70,19 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even event.tags = { ...event.tags, replayId: replay.getSessionId() }; } + // If we sampled this error in buffer mode, immediately mark the session as "sampled" + // by changing the sampled state from 'buffer' to 'session'. Otherwise, if the application is interrupted before `afterSendEvent` occurs, then the session would remain as "buffer" but we have an error event that is tagged with a replay id. This could end up creating replays w/ excessive durations because of the linked error. + if (isErrorEventSampled && replay.recordingMode === 'buffer') { + const session = replay.session; + if (session?.sampled === 'buffer') { + session.sampled = 'session'; + // Save the session if sticky sessions are enabled to persist the state change + if (replay.getOptions().stickySession) { + saveSession(session); + } + } + } + return event; }, { id: 'Replay' }, From 0d035532bb5220b5f84625b98c99b8a1729282fd Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Fri, 3 Oct 2025 11:25:35 -0400 Subject: [PATCH 02/14] add a new "dirty" flag to session --- .../src/coreHandlers/handleGlobalEvent.ts | 17 +++++++++-------- packages/replay-internal/src/types/replay.ts | 7 +++++++ .../src/util/handleRecordingEmit.ts | 2 +- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index a56c145f5ddf..b28d4547265e 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -71,15 +71,16 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even } // If we sampled this error in buffer mode, immediately mark the session as "sampled" - // by changing the sampled state from 'buffer' to 'session'. Otherwise, if the application is interrupted before `afterSendEvent` occurs, then the session would remain as "buffer" but we have an error event that is tagged with a replay id. This could end up creating replays w/ excessive durations because of the linked error. - if (isErrorEventSampled && replay.recordingMode === 'buffer') { + // by changing the sampled state from 'buffer' to 'session'. Otherwise, if the application is interrupte + // before `afterSendEvent` occurs, then the session would remain as "buffer" but we have an error event + // that is tagged with a replay id. This could end up creating replays w/ excessive durations because + // of the linked error. + if (isErrorEventSampled && replay.recordingMode === 'buffer' && replay.session?.sampled === 'buffer') { const session = replay.session; - if (session?.sampled === 'buffer') { - session.sampled = 'session'; - // Save the session if sticky sessions are enabled to persist the state change - if (replay.getOptions().stickySession) { - saveSession(session); - } + session.dirty = true; + // Save the session if sticky sessions are enabled to persist the state change + if (replay.getOptions().stickySession) { + saveSession(session); } } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 1e7891a84e76..a2c84d6c4bbe 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -383,6 +383,13 @@ export interface Session { * Is the session sampled? `false` if not sampled, otherwise, `session` or `buffer` */ sampled: Sampled; + + /** + * Session is dirty when its id has been linked to an event (e.g. error event). + * This is helpful when a session is mistakenly stuck in "buffer" mode (e.g. network issues preventing it from being converted to "session" mode). + * The dirty flag is used to prevent updating the session start time to the earliest event in the buffer so that it can be refreshed if it's been expired. + */ + dirty?: boolean; } export type EventBufferType = 'sync' | 'worker'; diff --git a/packages/replay-internal/src/util/handleRecordingEmit.ts b/packages/replay-internal/src/util/handleRecordingEmit.ts index 0ae87601637b..aeb49f0cd259 100644 --- a/packages/replay-internal/src/util/handleRecordingEmit.ts +++ b/packages/replay-internal/src/util/handleRecordingEmit.ts @@ -72,7 +72,7 @@ export function getHandleRecordingEmit(replay: ReplayContainer): RecordingEmitCa // When in buffer mode, make sure we adjust the session started date to the current earliest event of the buffer // this should usually be the timestamp of the checkout event, but to be safe... - if (replay.recordingMode === 'buffer' && session && replay.eventBuffer) { + if (replay.recordingMode === 'buffer' && session && replay.eventBuffer && !session.dirty) { const earliestEvent = replay.eventBuffer.getEarliestTimestamp(); if (earliestEvent) { DEBUG_BUILD && From 60e298acfa84e8344311ffea67fc536d692267bb Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 7 Oct 2025 14:41:14 -0400 Subject: [PATCH 03/14] add dirty flag to session --- packages/replay-internal/src/replay.ts | 1 + packages/replay-internal/src/session/Session.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index ae3aa9589cab..581983310b42 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -606,6 +606,7 @@ export class ReplayContainer implements ReplayContainerInterface { // Once this session ends, we do not want to refresh it if (this.session) { + this.session.dirty = false; this._updateUserActivity(activityTime); this._updateSessionActivity(activityTime); this._maybeSaveSession(); diff --git a/packages/replay-internal/src/session/Session.ts b/packages/replay-internal/src/session/Session.ts index 554f625cc8e9..59e6b09ed43c 100644 --- a/packages/replay-internal/src/session/Session.ts +++ b/packages/replay-internal/src/session/Session.ts @@ -13,6 +13,7 @@ export function makeSession(session: Partial & { sampled: Sampled }): S const segmentId = session.segmentId || 0; const sampled = session.sampled; const previousSessionId = session.previousSessionId; + const dirty = session.dirty || false; return { id, @@ -21,5 +22,6 @@ export function makeSession(session: Partial & { sampled: Sampled }): S segmentId, sampled, previousSessionId, + dirty, }; } From d9f802291d537b1ebf9f4281c981ca4ec42c9966 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 7 Oct 2025 14:41:21 -0400 Subject: [PATCH 04/14] update tests --- .../replay/bufferStalledRequests/test.ts | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 623cb96068c3..48173b3cac2a 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -9,7 +9,7 @@ import { } from '../../../utils/replayHelpers'; sentryTest( - 'buffer mode turns into session mode after interrupting error event ingest, and resumes session on reload', + 'buffer mode remains after interrupting error event ingest', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipReplayTest() || browserName === 'webkit') { sentryTest.skip(); @@ -63,32 +63,34 @@ sentryTest( // Wait for replay to initialize await waitForReplayRunning(page); - // Trigger first error - this should change session sampled to "session" waitForErrorRequest(page); await page.locator('#error1').click(); + + // This resolves, but the route doesn't get fulfilled as we want the reload to "interrupt" this flow await firstReplayEventPromise; expect(errorCount).toBe(1); expect(replayCount).toBe(0); expect(replayIds).toHaveLength(1); - // Get the first session info const firstSession = await getReplaySnapshot(page); const firstSessionId = firstSession.session?.id; expect(firstSessionId).toBeDefined(); - expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' - expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); await page.reload(); const secondSession = await getReplaySnapshot(page); - expect(secondSession.session?.sampled).toBe('session'); - expect(secondSession.recordingMode).toBe('session'); // this turns to "session" because of `sampled` value being "session" + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.recordingMode).toBe('buffer'); expect(secondSession.session?.id).toBe(firstSessionId); expect(secondSession.session?.segmentId).toBe(0); }, ); -sentryTest( - 'buffer mode turns into session mode after interrupting replay flush, and resumes session on reload', +sentryTest.only( + 'buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipReplayTest() || browserName === 'webkit') { sentryTest.skip(); @@ -141,7 +143,6 @@ sentryTest( // Wait for replay to initialize await waitForReplayRunning(page); - // Trigger first error - this should change session sampled to "session" await page.locator('#error1').click(); await firstReplayEventPromise; expect(errorCount).toBe(1); @@ -152,15 +153,20 @@ sentryTest( const firstSession = await getReplaySnapshot(page); const firstSessionId = firstSession.session?.id; expect(firstSessionId).toBeDefined(); - expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode await page.reload(); + await waitForReplayRunning(page); const secondSession = await getReplaySnapshot(page); - expect(secondSession.session?.sampled).toBe('session'); - expect(secondSession.recordingMode).toBe('session'); // this turns to "session" because of `sampled` value being "session" + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); expect(secondSession.session?.id).toBe(firstSessionId); expect(secondSession.session?.segmentId).toBe(1); + // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, + // so we resume in session mode + expect(secondSession.recordingMode).toBe('session'); }, ); @@ -229,7 +235,8 @@ sentryTest( const firstSession = await getReplaySnapshot(page); const firstSessionId = firstSession.session?.id; expect(firstSessionId).toBeDefined(); - expect(firstSession.session?.sampled).toBe('session'); // Should be marked as 'session' + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode // Now expire the session by manipulating session storage @@ -266,7 +273,7 @@ sentryTest( ); sentryTest( - '[buffer-mode] marks session as sampled immediately when error is sampled', + 'marks session as dirty immediately when error is sampled in buffer mode', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipReplayTest() || browserName === 'webkit') { sentryTest.skip(); @@ -296,18 +303,18 @@ sentryTest( const reqErrorPromise = waitForErrorRequest(page); await page.locator('#error1').click(); - // Check session state BEFORE waiting for error to be sent - // The session should already be marked as 'session' synchronously const duringErrorProcessing = await getReplaySnapshot(page); - expect(duringErrorProcessing.session?.sampled).toBe('session'); + expect(duringErrorProcessing.session?.sampled).toBe('buffer'); + expect(duringErrorProcessing.session?.dirty).toBe(true); expect(duringErrorProcessing.recordingMode).toBe('buffer'); // Still in buffer recording mode await reqErrorPromise; // After error is sent, verify state is still correct const afterError = await getReplaySnapshot(page); - expect(afterError.session?.sampled).toBe('session'); + expect(afterError.session?.sampled).toBe('buffer'); expect(afterError.recordingMode).toBe('session'); + expect(afterError.session?.dirty).toBe(false); // Verify the session was persisted to sessionStorage (if sticky sessions enabled) const sessionData = await page.evaluate(() => { @@ -317,6 +324,7 @@ sentryTest( }); expect(sessionData).toBeDefined(); - expect(sessionData.sampled).toBe('session'); + expect(sessionData.sampled).toBe('buffer'); + expect(sessionData.dirty).toBe(false); }, ); From 2ab84b69c4b4cb86d4a1819dc294d58a5c04a13b Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 7 Oct 2025 17:28:23 -0400 Subject: [PATCH 05/14] fix tests --- .../test/unit/session/fetchSession.test.ts | 4 +++- .../test/unit/session/loadOrCreateSession.test.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/replay-internal/test/unit/session/fetchSession.test.ts b/packages/replay-internal/test/unit/session/fetchSession.test.ts index 46f0f05f5c9a..9dee5cb5cee0 100644 --- a/packages/replay-internal/test/unit/session/fetchSession.test.ts +++ b/packages/replay-internal/test/unit/session/fetchSession.test.ts @@ -28,6 +28,7 @@ describe('Unit | session | fetchSession', () => { ); expect(fetchSession()).toEqual({ + dirty: false, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, @@ -39,10 +40,11 @@ describe('Unit | session | fetchSession', () => { it('fetches an unsampled session', function () { WINDOW.sessionStorage.setItem( REPLAY_SESSION_KEY, - '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658}', + '{"id":"fd09adfc4117477abc8de643e5a5798a","sampled": false,"started":1648827162630,"lastActivity":1648827162658,"dirty":true}', ); expect(fetchSession()).toEqual({ + dirty: true, id: 'fd09adfc4117477abc8de643e5a5798a', lastActivity: 1648827162658, segmentId: 0, diff --git a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts index 273d401a7afc..dee44638344b 100644 --- a/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts +++ b/packages/replay-internal/test/unit/session/loadOrCreateSession.test.ts @@ -77,6 +77,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -104,6 +105,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }); // Should not have anything in storage @@ -129,10 +131,10 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }); }); }); - describe('stickySession: true', () => { it('creates new session if none exists', function () { const session = loadOrCreateSession( @@ -151,6 +153,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: expect.any(Number), sampled: 'session', started: expect.any(Number), + dirty: false, }; expect(session).toEqual(expectedSession); @@ -181,6 +184,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'test_old_session_uuid', + dirty: false, }; expect(session).toEqual(expectedSession); expect(session.lastActivity).toBeGreaterThanOrEqual(now); @@ -209,6 +213,7 @@ describe('Unit | session | loadOrCreateSession', () => { lastActivity: date, sampled: 'session', started: date, + dirty: false, }); }); @@ -250,6 +255,7 @@ describe('Unit | session | loadOrCreateSession', () => { sampled: 'session', started: expect.any(Number), previousSessionId: 'previous_session_id', + dirty: false, }; expect(session).toEqual(expectedSession); @@ -347,6 +353,7 @@ describe('Unit | session | loadOrCreateSession', () => { segmentId: 0, lastActivity: expect.any(Number), sampled: false, + dirty: false, started: expect.any(Number), }; expect(session).toEqual(expectedSession); From 46cbaf246f76cc4ef7ebd65c429729bfb93f3c5c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 7 Oct 2025 18:09:56 -0400 Subject: [PATCH 06/14] fix tests --- .../test/integration/errorSampleRate.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index f79e393df7e3..b0f1dec8f0e0 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -80,6 +80,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay).toHaveLastSentReplay({ @@ -158,6 +160,7 @@ describe('Integration | errorSampleRate', () => { segmentId: 0, sampled: 'buffer', previousSessionId: 'previoussessionid', + dirty: false, }), })); @@ -179,6 +182,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // Converts to session mode expect(replay.recordingMode).toBe('session'); @@ -508,6 +513,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay).toHaveLastSentReplay({ @@ -604,6 +611,8 @@ describe('Integration | errorSampleRate', () => { // should still react to errors later on captureException(new Error('testing')); + await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); expect(replay.session?.id).toBe(oldSessionId); @@ -739,7 +748,8 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); await vi.advanceTimersToNextTimerAsync(); - // await vi.advanceTimersToNextTimerAsync(); + // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents + await vi.advanceTimersToNextTimerAsync(); // This is still the timestamp from the full snapshot we took earlier expect(replay.session?.started).toBe(BASE_TIMESTAMP + ELAPSED); From 7630eddd3006cccad3682f6b8c4341435bc4207e Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 13:01:26 -0400 Subject: [PATCH 07/14] Apply suggestion from @billyvg --- .../suites/replay/bufferStalledRequests/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 48173b3cac2a..9a2200862c2b 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -89,7 +89,7 @@ sentryTest( }, ); -sentryTest.only( +sentryTest( 'buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { if (shouldSkipReplayTest() || browserName === 'webkit') { From d464260e70cee6b5b6cdb9518434a6ba3fc5f40d Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 15:25:08 -0400 Subject: [PATCH 08/14] formatting --- .../replay/bufferStalledRequests/test.ts | 147 +++++++++--------- 1 file changed, 72 insertions(+), 75 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 9a2200862c2b..ac01bdeedabc 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -89,86 +89,83 @@ sentryTest( }, ); -sentryTest( - 'buffer mode remains after interrupting replay flush', - async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipReplayTest() || browserName === 'webkit') { - sentryTest.skip(); - } - - let errorCount = 0; - let replayCount = 0; - const errorEventIds: string[] = []; - const replayIds: string[] = []; - let firstReplayEventResolved: (value?: unknown) => void = () => {}; - // Need TS 5.7 for withResolvers - const firstReplayEventPromise = new Promise(resolve => { - firstReplayEventResolved = resolve; - }); - - const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); - - await page.route('https://dsn.ingest.sentry.io/**/*', async route => { - const event = envelopeRequestParser(route.request()); - - // Track error events - if (event && !event.type && event.event_id) { - errorCount++; - errorEventIds.push(event.event_id); - if (event.tags?.replayId) { - replayIds.push(event.tags.replayId as string); - } +sentryTest('buffer mode remains after interrupting replay flush', async ({ getLocalTestUrl, page, browserName }) => { + if (shouldSkipReplayTest() || browserName === 'webkit') { + sentryTest.skip(); + } + + let errorCount = 0; + let replayCount = 0; + const errorEventIds: string[] = []; + const replayIds: string[] = []; + let firstReplayEventResolved: (value?: unknown) => void = () => {}; + // Need TS 5.7 for withResolvers + const firstReplayEventPromise = new Promise(resolve => { + firstReplayEventResolved = resolve; + }); + + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); + + await page.route('https://dsn.ingest.sentry.io/**/*', async route => { + const event = envelopeRequestParser(route.request()); + + // Track error events + if (event && !event.type && event.event_id) { + errorCount++; + errorEventIds.push(event.event_id); + if (event.tags?.replayId) { + replayIds.push(event.tags.replayId as string); } + } - // Track replay events and simulate failure for the first replay - if (event && isReplayEvent(event)) { - replayCount++; - if (replayCount === 1) { - firstReplayEventResolved(); - // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow - await new Promise(resolve => setTimeout(resolve, 100000)); - } + // Track replay events and simulate failure for the first replay + if (event && isReplayEvent(event)) { + replayCount++; + if (replayCount === 1) { + firstReplayEventResolved(); + // intentional so that it never resolves, we'll force a reload instead to interrupt the normal flow + await new Promise(resolve => setTimeout(resolve, 100000)); } + } - // Success for other requests - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); + // Success for other requests + return route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ id: 'test-id' }), }); - - await page.goto(url); - - // Wait for replay to initialize - await waitForReplayRunning(page); - - await page.locator('#error1').click(); - await firstReplayEventPromise; - expect(errorCount).toBe(1); - expect(replayCount).toBe(1); - expect(replayIds).toHaveLength(1); - - // Get the first session info - const firstSession = await getReplaySnapshot(page); - const firstSessionId = firstSession.session?.id; - expect(firstSessionId).toBeDefined(); - expect(firstSession.session?.sampled).toBe('buffer'); - expect(firstSession.session?.dirty).toBe(true); - expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode - - await page.reload(); - await waitForReplayRunning(page); - const secondSession = await getReplaySnapshot(page); - expect(secondSession.session?.sampled).toBe('buffer'); - expect(secondSession.session?.dirty).toBe(true); - expect(secondSession.session?.id).toBe(firstSessionId); - expect(secondSession.session?.segmentId).toBe(1); - // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, - // so we resume in session mode - expect(secondSession.recordingMode).toBe('session'); - }, -); + }); + + await page.goto(url); + + // Wait for replay to initialize + await waitForReplayRunning(page); + + await page.locator('#error1').click(); + await firstReplayEventPromise; + expect(errorCount).toBe(1); + expect(replayCount).toBe(1); + expect(replayIds).toHaveLength(1); + + // Get the first session info + const firstSession = await getReplaySnapshot(page); + const firstSessionId = firstSession.session?.id; + expect(firstSessionId).toBeDefined(); + expect(firstSession.session?.sampled).toBe('buffer'); + expect(firstSession.session?.dirty).toBe(true); + expect(firstSession.recordingMode).toBe('buffer'); // But still in buffer mode + + await page.reload(); + await waitForReplayRunning(page); + const secondSession = await getReplaySnapshot(page); + expect(secondSession.session?.sampled).toBe('buffer'); + expect(secondSession.session?.dirty).toBe(true); + expect(secondSession.session?.id).toBe(firstSessionId); + expect(secondSession.session?.segmentId).toBe(1); + // Because a flush attempt was made and not allowed to complete, segmentId increased from 0, + // so we resume in session mode + expect(secondSession.recordingMode).toBe('session'); +}); sentryTest( 'starts a new session after interrupting replay flush and session "expires"', From f4f016f5ee006cf2372c2cae23fd96ca4bb5bfe2 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 15:54:34 -0400 Subject: [PATCH 09/14] fix flake --- .../suites/replay/bufferStalledRequests/test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index ac01bdeedabc..e39a8fef155f 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -5,6 +5,7 @@ import { getReplaySnapshot, isReplayEvent, shouldSkipReplayTest, + waitForReplayRequest, waitForReplayRunning, } from '../../../utils/replayHelpers'; @@ -284,6 +285,8 @@ sentryTest( }); }); + const replayRequestPromise = waitForReplayRequest(page, 0); + const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); @@ -310,7 +313,6 @@ sentryTest( // After error is sent, verify state is still correct const afterError = await getReplaySnapshot(page); expect(afterError.session?.sampled).toBe('buffer'); - expect(afterError.recordingMode).toBe('session'); expect(afterError.session?.dirty).toBe(false); // Verify the session was persisted to sessionStorage (if sticky sessions enabled) @@ -323,5 +325,11 @@ sentryTest( expect(sessionData).toBeDefined(); expect(sessionData.sampled).toBe('buffer'); expect(sessionData.dirty).toBe(false); + + // Need to wait for replay request before checking `recordingMode`, otherwise it will be flakey + await replayRequestPromise; + const afterReplay = await getReplaySnapshot(page); + expect(afterReplay.recordingMode).toBe('session'); + }, ); From a47855b53a82daa15f68e8604134c7766005f8d1 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 16:31:19 -0400 Subject: [PATCH 10/14] wait for 2nd replay segment --- .../suites/replay/bufferStalledRequests/test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index e39a8fef155f..65ae20a0d7b0 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -285,7 +285,7 @@ sentryTest( }); }); - const replayRequestPromise = waitForReplayRequest(page, 0); + const replayRequestPromise = waitForReplayRequest(page, 1); const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); From 2bed2d3b33acbbeea18cdb014ac744b9635543ff Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 16:43:59 -0400 Subject: [PATCH 11/14] formatting --- .../suites/replay/bufferStalledRequests/test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 65ae20a0d7b0..f8d2a0a95870 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -330,6 +330,5 @@ sentryTest( await replayRequestPromise; const afterReplay = await getReplaySnapshot(page); expect(afterReplay.recordingMode).toBe('session'); - }, ); From 682005dcd766004280858d32b2855f3429313dce Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 17:32:09 -0400 Subject: [PATCH 12/14] fix timing issues --- .../replay/bufferStalledRequests/test.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index f8d2a0a95870..04ccb5dd561d 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -285,7 +285,7 @@ sentryTest( }); }); - const replayRequestPromise = waitForReplayRequest(page, 1); + const replayRequestPromise = waitForReplayRequest(page, 0); const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); await page.goto(url); @@ -303,17 +303,24 @@ sentryTest( const reqErrorPromise = waitForErrorRequest(page); await page.locator('#error1').click(); + // await this later otherwise we have a timing issue w/ this and `getReplaySnapshot` + const sessionDataOnErrorPromise = page.evaluate(() => { + const sessionKey = 'sentryReplaySession'; + const data = sessionStorage.getItem(sessionKey); + return data ? JSON.parse(data) : null; + }); + const duringErrorProcessing = await getReplaySnapshot(page); expect(duringErrorProcessing.session?.sampled).toBe('buffer'); expect(duringErrorProcessing.session?.dirty).toBe(true); expect(duringErrorProcessing.recordingMode).toBe('buffer'); // Still in buffer recording mode - await reqErrorPromise; + const sessionDataOnError = await sessionDataOnErrorPromise; + expect(sessionDataOnError).toBeDefined(); + expect(sessionDataOnError.sampled).toBe('buffer'); + expect(sessionDataOnError.dirty).toBe(true); - // After error is sent, verify state is still correct - const afterError = await getReplaySnapshot(page); - expect(afterError.session?.sampled).toBe('buffer'); - expect(afterError.session?.dirty).toBe(false); + await reqErrorPromise; // Verify the session was persisted to sessionStorage (if sticky sessions enabled) const sessionData = await page.evaluate(() => { @@ -326,9 +333,11 @@ sentryTest( expect(sessionData.sampled).toBe('buffer'); expect(sessionData.dirty).toBe(false); - // Need to wait for replay request before checking `recordingMode`, otherwise it will be flakey + // Need to wait for replay request before checking `recordingMode` and `dirty`, + // since they update after replay gets flushed await replayRequestPromise; const afterReplay = await getReplaySnapshot(page); expect(afterReplay.recordingMode).toBe('session'); + expect(afterReplay.session?.dirty).toBe(false); }, ); From 852d46407422371014d73740c22c34d8c129260c Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 14 Oct 2025 18:15:46 -0400 Subject: [PATCH 13/14] remove flakey browser test and move test to integration test --- .../replay/bufferStalledRequests/test.ts | 72 ------------------- .../test/integration/errorSampleRate.test.ts | 11 +++ 2 files changed, 11 insertions(+), 72 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 04ccb5dd561d..055c322355fb 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -269,75 +269,3 @@ sentryTest( expect(secondSession.session?.segmentId).toBe(0); }, ); - -sentryTest( - 'marks session as dirty immediately when error is sampled in buffer mode', - async ({ getLocalTestUrl, page, browserName }) => { - if (shouldSkipReplayTest() || browserName === 'webkit') { - sentryTest.skip(); - } - - await page.route('https://dsn.ingest.sentry.io/**/*', route => { - return route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ id: 'test-id' }), - }); - }); - - const replayRequestPromise = waitForReplayRequest(page, 0); - - const url = await getLocalTestUrl({ testDir: __dirname, skipDsnRouteHandler: true }); - await page.goto(url); - - // Wait for replay to initialize - await new Promise(resolve => setTimeout(resolve, 100)); - - // Verify initial state - buffer mode, not sampled - const initialSession = await getReplaySnapshot(page); - expect(initialSession.recordingMode).toBe('buffer'); - expect(initialSession.session?.sampled).toBe('buffer'); - expect(initialSession.session?.segmentId).toBe(0); - - // Trigger error which should immediately mark session as sampled - const reqErrorPromise = waitForErrorRequest(page); - await page.locator('#error1').click(); - - // await this later otherwise we have a timing issue w/ this and `getReplaySnapshot` - const sessionDataOnErrorPromise = page.evaluate(() => { - const sessionKey = 'sentryReplaySession'; - const data = sessionStorage.getItem(sessionKey); - return data ? JSON.parse(data) : null; - }); - - const duringErrorProcessing = await getReplaySnapshot(page); - expect(duringErrorProcessing.session?.sampled).toBe('buffer'); - expect(duringErrorProcessing.session?.dirty).toBe(true); - expect(duringErrorProcessing.recordingMode).toBe('buffer'); // Still in buffer recording mode - - const sessionDataOnError = await sessionDataOnErrorPromise; - expect(sessionDataOnError).toBeDefined(); - expect(sessionDataOnError.sampled).toBe('buffer'); - expect(sessionDataOnError.dirty).toBe(true); - - await reqErrorPromise; - - // Verify the session was persisted to sessionStorage (if sticky sessions enabled) - const sessionData = await page.evaluate(() => { - const sessionKey = 'sentryReplaySession'; - const data = sessionStorage.getItem(sessionKey); - return data ? JSON.parse(data) : null; - }); - - expect(sessionData).toBeDefined(); - expect(sessionData.sampled).toBe('buffer'); - expect(sessionData.dirty).toBe(false); - - // Need to wait for replay request before checking `recordingMode` and `dirty`, - // since they update after replay gets flushed - await replayRequestPromise; - const afterReplay = await getReplaySnapshot(page); - expect(afterReplay.recordingMode).toBe('session'); - expect(afterReplay.session?.dirty).toBe(false); - }, -); diff --git a/packages/replay-internal/test/integration/errorSampleRate.test.ts b/packages/replay-internal/test/integration/errorSampleRate.test.ts index b0f1dec8f0e0..b49882b72034 100644 --- a/packages/replay-internal/test/integration/errorSampleRate.test.ts +++ b/packages/replay-internal/test/integration/errorSampleRate.test.ts @@ -80,10 +80,21 @@ describe('Integration | errorSampleRate', () => { captureException(new Error('testing')); + // session gets immediately marked as dirty since error will + // be linked to current session (replay) id. there's a possibility + // that replay never gets flushed so we must mark as dirty so we + // know to refresh session in the future. + expect(replay.recordingMode).toBe('buffer'); + expect(replay.session?.dirty).toBe(true); + await vi.advanceTimersToNextTimerAsync(); // need 2nd tick to wait for `saveSession` to complete in `handleGlobalEvents await vi.advanceTimersToNextTimerAsync(); + // dirty gets reset after replay is flushed + expect(replay.recordingMode).toBe('session'); + expect(replay.session?.dirty).toBe(false); + expect(replay).toHaveLastSentReplay({ recordingPayloadHeader: { segment_id: 0 }, replayEventPayload: expect.objectContaining({ From 1a0bc8a3eb8506592bfc058a4f4a4c707701ee95 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Wed, 15 Oct 2025 10:14:37 -0400 Subject: [PATCH 14/14] remove unused --- .../suites/replay/bufferStalledRequests/test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts index 055c322355fb..11154caaaa8b 100644 --- a/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/bufferStalledRequests/test.ts @@ -5,7 +5,6 @@ import { getReplaySnapshot, isReplayEvent, shouldSkipReplayTest, - waitForReplayRequest, waitForReplayRunning, } from '../../../utils/replayHelpers';