From f3d43af0dbfdffe50e4adf11fabbf08086464dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 21 Sep 2026 11:35:13 -0400 Subject: [PATCH 1/4] fix(replay): Attach replay ID from when the feedback widget opened The widget flushes the replay when it opens, but the replay ID was read at submission. If the session refreshed while the form was open, feedback linked to a new, unsent buffered replay instead of the one that was flushed. Keep the replay ID captured on open for widget submissions. Refs REPLAY-1000 Co-Authored-By: Claude Opus 5 --- .../src/util/addGlobalListeners.ts | 20 ++- .../test/integration/feedback.test.ts | 114 ++++++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 packages/replay-internal/test/integration/feedback.test.ts diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index ac4d228f8976..477822242b0e 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -56,20 +56,34 @@ export function addGlobalListeners(replay: ReplayContainer): void { replay.lastActiveSpan = span; }); + let replayIdOnFeedbackOpen: string | undefined; + // We want to attach the replay id to the feedback event client.on('beforeSendFeedback', async (feedbackEvent, options) => { + const feedbackContext = feedbackEvent.contexts?.feedback; + if (!options?.includeReplay || !feedbackContext) { + return; + } + + if (feedbackContext.source === 'widget' && replayIdOnFeedbackOpen) { + feedbackContext.replay_id = replayIdOnFeedbackOpen; + return; + } + const replayId = replay.getSessionId(); - if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) { + if (replay.isEnabled() && replayId) { // In case the feedback is sent via API and not through our widget, we want to flush replay - if (feedbackEvent.contexts.feedback.source === 'api') { + if (feedbackContext.source === 'api') { await replay.sendBufferedReplayOrFlush(); } - feedbackEvent.contexts.feedback.replay_id = replayId; + feedbackContext.replay_id = replayId; } }); client.on('openFeedbackWidget', async () => { + replayIdOnFeedbackOpen = undefined; await replay.sendBufferedReplayOrFlush(); + replayIdOnFeedbackOpen = replay.isEnabled() ? replay.getSessionId() : undefined; }); } } diff --git a/packages/replay-internal/test/integration/feedback.test.ts b/packages/replay-internal/test/integration/feedback.test.ts new file mode 100644 index 000000000000..07ec197872aa --- /dev/null +++ b/packages/replay-internal/test/integration/feedback.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +import type { FeedbackEvent } from '@sentry/core'; +import { getClient } from '@sentry/core'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_EXPIRE_DURATION } from '../../src/constants'; +import type { ReplayContainer } from '../../src/replay'; +import { clearSession } from '../../src/session/clearSession'; +import { BASE_TIMESTAMP } from '../index'; +import type { RecordMock } from '../index'; +import { resetSdkMock } from '../mocks/resetSdkMock'; +import type { DomHandler } from '../types'; +import { getTestEventIncremental } from '../utils/getTestEvent'; + +async function advanceTimers(time: number) { + vi.advanceTimersByTime(time); + await new Promise(process.nextTick); +} + +function createFeedbackEvent(source: string): FeedbackEvent { + return { + type: 'feedback', + contexts: { + feedback: { + message: 'Something broke', + source, + }, + }, + }; +} + +describe('Integration | feedback', () => { + let replay: ReplayContainer; + let mockRecord: RecordMock; + let domHandler: DomHandler; + + beforeAll(() => { + vi.useFakeTimers(); + }); + + beforeEach(async () => { + ({ mockRecord, domHandler, replay } = await resetSdkMock({ + replayOptions: { + stickySession: true, + }, + sentryOptions: { + replaysSessionSampleRate: 0.0, + replaysOnErrorSampleRate: 1.0, + }, + })); + + mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP })); + await advanceTimers(10_000); + }); + + afterEach(() => { + clearSession(replay); + replay.stop(); + }); + + async function openFeedbackWidget() { + getClient()!.emit('openFeedbackWidget'); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + } + + async function expireSession() { + await advanceTimers(SESSION_IDLE_EXPIRE_DURATION + 1_000); + domHandler({ name: 'click', event: new Event('click') }); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + } + + it('sends the buffered replay and continues in session mode when the widget is opened', async () => { + await openFeedbackWidget(); + + expect(replay).toHaveLastSentReplay(); + expect(replay.recordingMode).toBe('session'); + }); + + it('attaches the replay ID from when the widget was opened when the session is refreshed before submission', async () => { + await openFeedbackWidget(); + const replayIdOnOpen = replay.getSessionId(); + await expireSession(); + const feedbackEvent = createFeedbackEvent('widget'); + + getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + + expect(replay.getSessionId()).not.toBe(replayIdOnOpen); + expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); + }); + + it('attaches the current replay ID when feedback is sent via the API after the widget was opened', async () => { + await openFeedbackWidget(); + await expireSession(); + const feedbackEvent = createFeedbackEvent('api'); + + getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + + expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); + }); + + it('does not attach a replay ID when includeReplay is not set', async () => { + await openFeedbackWidget(); + const feedbackEvent = createFeedbackEvent('widget'); + + getClient()!.emit('beforeSendFeedback', feedbackEvent); + + expect(feedbackEvent.contexts?.feedback?.replay_id).toBeUndefined(); + }); +}); From ffa138a1ae8f02ec7838d24740c1d88766658fe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 21 Sep 2026 11:41:28 -0400 Subject: [PATCH 2/4] test(replay): Cover feedback widget opened while replay is disabled Co-Authored-By: Claude Opus 5 (1M context) --- .../test/integration/feedback.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/replay-internal/test/integration/feedback.test.ts b/packages/replay-internal/test/integration/feedback.test.ts index 07ec197872aa..28095194565f 100644 --- a/packages/replay-internal/test/integration/feedback.test.ts +++ b/packages/replay-internal/test/integration/feedback.test.ts @@ -92,6 +92,18 @@ describe('Integration | feedback', () => { expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); }); + it('attaches the current replay ID when the widget was opened while replay was disabled', async () => { + replay.stop(); + await openFeedbackWidget(); + replay.start(); + const feedbackEvent = createFeedbackEvent('widget'); + + getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + + expect(replay.getSessionId()).toBeDefined(); + expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); + }); + it('attaches the current replay ID when feedback is sent via the API after the widget was opened', async () => { await openFeedbackWidget(); await expireSession(); From f05dd9a2b4a607fcf7e7b4885927ee8be5130f02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 21 Sep 2026 11:57:25 -0400 Subject: [PATCH 3/4] fix(replay): Keep feedback replay ID captured when the widget opened Co-Authored-By: Claude Opus 5 (1M context) --- .../src/coreHandlers/handleGlobalEvent.ts | 2 +- .../test/integration/feedback.test.ts | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 3a7ea6a87fc6..bf25f572d7d2 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -60,7 +60,7 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.flush(); - event.contexts.feedback.replay_id = replay.getSessionId(); + event.contexts.feedback.replay_id ??= replay.getSessionId(); // Add a replay breadcrumb for this piece of feedback addFeedbackBreadcrumb(replay, event); return event; diff --git a/packages/replay-internal/test/integration/feedback.test.ts b/packages/replay-internal/test/integration/feedback.test.ts index 28095194565f..ff1aea6889c5 100644 --- a/packages/replay-internal/test/integration/feedback.test.ts +++ b/packages/replay-internal/test/integration/feedback.test.ts @@ -3,8 +3,8 @@ */ import '../utils/mock-internal-setTimeout'; -import type { FeedbackEvent } from '@sentry/core'; -import { getClient } from '@sentry/core'; +import type { Event, FeedbackEvent } from '@sentry/core'; +import { captureFeedback, getClient } from '@sentry/core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_EXPIRE_DURATION } from '../../src/constants'; import type { ReplayContainer } from '../../src/replay'; @@ -67,6 +67,21 @@ describe('Integration | feedback', () => { await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); } + async function sendFeedback(source: string): Promise { + let sentEvent: Event | undefined; + const unsubscribe = getClient()!.on('beforeSendEvent', event => { + if (event.type === 'feedback') { + sentEvent = event; + } + }); + + captureFeedback({ message: 'Something broke', source }, { includeReplay: true }); + await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + unsubscribe(); + + return sentEvent; + } + async function expireSession() { await advanceTimers(SESSION_IDLE_EXPIRE_DURATION + 1_000); domHandler({ name: 'click', event: new Event('click') }); @@ -84,35 +99,31 @@ describe('Integration | feedback', () => { await openFeedbackWidget(); const replayIdOnOpen = replay.getSessionId(); await expireSession(); - const feedbackEvent = createFeedbackEvent('widget'); - getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + const sentEvent = await sendFeedback('widget'); expect(replay.getSessionId()).not.toBe(replayIdOnOpen); - expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); + expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); }); it('attaches the current replay ID when the widget was opened while replay was disabled', async () => { replay.stop(); await openFeedbackWidget(); replay.start(); - const feedbackEvent = createFeedbackEvent('widget'); - getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + const sentEvent = await sendFeedback('widget'); expect(replay.getSessionId()).toBeDefined(); - expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); + expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); }); it('attaches the current replay ID when feedback is sent via the API after the widget was opened', async () => { await openFeedbackWidget(); await expireSession(); - const feedbackEvent = createFeedbackEvent('api'); - getClient()!.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); - await advanceTimers(DEFAULT_FLUSH_MIN_DELAY); + const sentEvent = await sendFeedback('api'); - expect(feedbackEvent.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); + expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); }); it('does not attach a replay ID when includeReplay is not set', async () => { From 8b282ad1b63d7e6a1ac32efe3d3e5d0a5a3f827a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josh=20Goldberg=20=E2=9C=A8?= Date: Mon, 21 Sep 2026 14:00:00 -0400 Subject: [PATCH 4/4] fix(replay): Skip flushing a refreshed session for widget feedback Also clear the replay ID captured on widget open once it's used, and cover feedback sent after replay is stopped. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/coreHandlers/handleGlobalEvent.ts | 10 ++++++- .../src/util/addGlobalListeners.ts | 1 + .../test/integration/feedback.test.ts | 30 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index bf25f572d7d2..0d9a54960f2f 100644 --- a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts +++ b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts @@ -57,10 +57,18 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even } if (isFeedbackEvent(event)) { + // The feedback widget links the replay from when it was opened. If the session + // refreshed since then, don't flush or add a breadcrumb to the unlinked new session + const sessionId = replay.getSessionId(); + const feedbackReplayId = event.contexts.feedback.replay_id; + if (feedbackReplayId && feedbackReplayId !== sessionId) { + return event; + } + // This should never reject // eslint-disable-next-line @typescript-eslint/no-floating-promises replay.flush(); - event.contexts.feedback.replay_id ??= replay.getSessionId(); + event.contexts.feedback.replay_id = sessionId; // Add a replay breadcrumb for this piece of feedback addFeedbackBreadcrumb(replay, event); return event; diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index 477822242b0e..69fede798cbf 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -67,6 +67,7 @@ export function addGlobalListeners(replay: ReplayContainer): void { if (feedbackContext.source === 'widget' && replayIdOnFeedbackOpen) { feedbackContext.replay_id = replayIdOnFeedbackOpen; + replayIdOnFeedbackOpen = undefined; return; } diff --git a/packages/replay-internal/test/integration/feedback.test.ts b/packages/replay-internal/test/integration/feedback.test.ts index ff1aea6889c5..7688074671b1 100644 --- a/packages/replay-internal/test/integration/feedback.test.ts +++ b/packages/replay-internal/test/integration/feedback.test.ts @@ -106,6 +106,36 @@ describe('Integration | feedback', () => { expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); }); + it('does not flush the refreshed session when widget feedback is sent after a session refresh', async () => { + await openFeedbackWidget(); + await expireSession(); + const flushSpy = vi.spyOn(replay, 'flush'); + + await sendFeedback('widget'); + + expect(flushSpy).not.toHaveBeenCalled(); + }); + + it('attaches the replay ID from when the widget was opened when replay is stopped before submission', async () => { + await openFeedbackWidget(); + const replayIdOnOpen = replay.getSessionId(); + replay.stop(); + + const sentEvent = await sendFeedback('widget'); + + expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replayIdOnOpen); + }); + + it('attaches the current replay ID when widget feedback is sent again without reopening the widget', async () => { + await openFeedbackWidget(); + await expireSession(); + await sendFeedback('widget'); + + const sentEvent = await sendFeedback('widget'); + + expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId()); + }); + it('attaches the current replay ID when the widget was opened while replay was disabled', async () => { replay.stop(); await openFeedbackWidget();