From 70e439337fd2b6715f5e01d9cd9f49413c86a1b1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:01:21 +0000 Subject: [PATCH 1/3] feat(replay): Freeze replay while feedback is open Co-Authored-By: James Keane --- packages/core/src/client.ts | 10 ++++ packages/feedback/src/modal/integration.tsx | 1 + .../src/util/addGlobalListeners.ts | 24 ++++++-- .../test/integration/lifecycleHooks.test.ts | 58 ++++++++++++++++++- 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 12d02b348bb2..23c69a2dd909 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -810,6 +810,11 @@ export abstract class Client { */ public on(hook: 'openFeedbackWidget', callback: () => void): () => void; + /** + * Register a callback when the feedback widget is closed in a user's browser + */ + public on(hook: 'closeFeedbackWidget', callback: () => void): () => void; + /** * A hook that is called when a replay session starts recording (either session or buffer mode). * @returns {() => void} A function that, when executed, removes the registered callback. @@ -1133,6 +1138,11 @@ export abstract class Client { */ public emit(hook: 'openFeedbackWidget'): void; + /** + * Fire a hook event for when the feedback widget is closed in a user's browser + */ + public emit(hook: 'closeFeedbackWidget'): void; + /** * Fire a hook event when a replay session starts recording. */ diff --git a/packages/feedback/src/modal/integration.tsx b/packages/feedback/src/modal/integration.tsx index ab47c87bfc1d..86ce06cf517f 100644 --- a/packages/feedback/src/modal/integration.tsx +++ b/packages/feedback/src/modal/integration.tsx @@ -56,6 +56,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => { }, close() { renderContent(false); + getClient()?.emit('closeFeedbackWidget'); DOCUMENT.body.style.overflow = originalOverflow; }, }; diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index ac4d228f8976..0a8481515c19 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 replayPausedByFeedback = false; + // We want to attach the replay id to the feedback event client.on('beforeSendFeedback', async (feedbackEvent, options) => { const replayId = replay.getSessionId(); if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) { - // In case the feedback is sent via API and not through our widget, we want to flush replay - if (feedbackEvent.contexts.feedback.source === 'api') { + feedbackEvent.contexts.feedback.replay_id = replayId; + const feedbackSource = feedbackEvent.contexts.feedback.source; + if (feedbackSource === 'api') { await replay.sendBufferedReplayOrFlush(); + } else if (feedbackSource === 'widget' && replayPausedByFeedback) { + await replay.sendBufferedReplayOrFlush({ continueRecording: false }); } - feedbackEvent.contexts.feedback.replay_id = replayId; } }); - client.on('openFeedbackWidget', async () => { - await replay.sendBufferedReplayOrFlush(); + client.on('openFeedbackWidget', () => { + if (replay.isEnabled() && !replay.isPaused()) { + replay.pause(); + replayPausedByFeedback = true; + } + }); + + client.on('closeFeedbackWidget', () => { + if (replayPausedByFeedback) { + replayPausedByFeedback = false; + replay.resume(); + } }); } } diff --git a/packages/replay-internal/test/integration/lifecycleHooks.test.ts b/packages/replay-internal/test/integration/lifecycleHooks.test.ts index 814e50491bfb..bbf6d628e599 100644 --- a/packages/replay-internal/test/integration/lifecycleHooks.test.ts +++ b/packages/replay-internal/test/integration/lifecycleHooks.test.ts @@ -3,7 +3,7 @@ */ import '../utils/mock-internal-setTimeout'; -import type { ReplayEndEvent, ReplayStartEvent } from '@sentry/core'; +import type { FeedbackEvent, ReplayEndEvent, ReplayStartEvent } from '@sentry/core'; import { getClient } from '@sentry/core'; import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Replay } from '../../src/integration'; @@ -106,4 +106,60 @@ describe('Integration | lifecycle hooks', () => { expect(startEvents).toHaveLength(0); }); + + it('freezes replay while the feedback widget is open and sends the frozen buffer on submit', () => { + integration.startBuffering(); + const client = getClient()!; + const pauseSpy = vi.spyOn(replay, 'pause'); + const resumeSpy = vi.spyOn(replay, 'resume'); + const sendBufferedReplaySpy = vi.spyOn(replay, 'sendBufferedReplayOrFlush').mockResolvedValue(); + const feedbackEvent: FeedbackEvent = { + type: 'feedback', + contexts: { feedback: { message: 'test', source: 'widget' } }, + }; + + client.emit('openFeedbackWidget'); + client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + + expect(pauseSpy).toHaveBeenCalledOnce(); + expect(sendBufferedReplaySpy).toHaveBeenCalledWith({ continueRecording: false }); + expect(feedbackEvent.contexts.feedback.replay_id).toBe(replay.getSessionId()); + expect(replay.recordingMode).toBe('buffer'); + + client.emit('closeFeedbackWidget'); + + expect(resumeSpy).toHaveBeenCalledOnce(); + expect(replay.recordingMode).toBe('buffer'); + }); + + it('keeps session recording mode after feedback submission', () => { + integration.start(); + const client = getClient()!; + const sendBufferedReplaySpy = vi.spyOn(replay, 'sendBufferedReplayOrFlush').mockResolvedValue(); + const feedbackEvent: FeedbackEvent = { + type: 'feedback', + contexts: { feedback: { message: 'test', source: 'widget' } }, + }; + + client.emit('openFeedbackWidget'); + client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true }); + client.emit('closeFeedbackWidget'); + + expect(sendBufferedReplaySpy).toHaveBeenCalledWith({ continueRecording: false }); + expect(replay.recordingMode).toBe('session'); + expect(replay.isPaused()).toBe(false); + }); + + it('does not resume replay when it was already paused before the feedback widget opened', () => { + integration.startBuffering(); + replay.pause(); + const client = getClient()!; + const resumeSpy = vi.spyOn(replay, 'resume'); + + client.emit('openFeedbackWidget'); + client.emit('closeFeedbackWidget'); + + expect(resumeSpy).not.toHaveBeenCalled(); + expect(replay.isPaused()).toBe(true); + }); }); From 905b3102d0c840999587b4f15deb6d21378334f1 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:16:07 +0000 Subject: [PATCH 2/3] test(replay): Update feedback lifecycle coverage --- .../replay/autoFlushOnFeedback/subject.js | 12 ++++- .../replay/autoFlushOnFeedback/template.html | 3 +- .../suites/replay/autoFlushOnFeedback/test.ts | 46 +++++++++++-------- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js index 06fee7313b72..4a7578f0925d 100644 --- a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js @@ -4,6 +4,14 @@ document.getElementById('open').addEventListener('click', () => { Sentry.getClient().emit('openFeedbackWidget'); }); -document.getElementById('send').addEventListener('click', () => { - Sentry.getClient().emit('beforeSendFeedback'); +document.getElementById('submit').addEventListener('click', () => { + Sentry.getClient().emit( + 'beforeSendFeedback', + { contexts: { feedback: { message: 'test', source: 'widget' } }, type: 'feedback' }, + { includeReplay: true }, + ); +}); + +document.getElementById('close').addEventListener('click', () => { + Sentry.getClient().emit('closeFeedbackWidget'); }); diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html index 2218082097dc..6f7b5e483bd6 100644 --- a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html @@ -4,8 +4,9 @@ - + + diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts index 98650cc67ce7..5968bda038ca 100644 --- a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts @@ -4,18 +4,16 @@ import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates'; import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers'; /* - * In this test we want to verify that replay events are automatically flushed when user feedback is submitted via API / opening the widget. - * We emulate this by firing the feedback events directly, which should trigger an immediate flush of any - * buffered replay events, rather than waiting for the normal flush delay. + * In this test we want to verify that replay is frozen when the feedback widget opens and flushed on submission. + * We emulate this by firing the feedback lifecycle events directly. */ -sentryTest('replay events are flushed automatically on feedback events', async ({ getLocalTestUrl, page }) => { +sentryTest('replay is frozen on feedback open and flushed on submit', async ({ getLocalTestUrl, page }) => { if (shouldSkipReplayTest()) { sentryTest.skip(); } const reqPromise0 = waitForReplayRequest(page, 0); const reqPromise1 = waitForReplayRequest(page, 1); - const reqPromise2 = waitForReplayRequest(page, 2); const url = await getLocalTestUrl({ testDir: __dirname }); @@ -23,23 +21,31 @@ sentryTest('replay events are flushed automatically on feedback events', async ( const replayEvent0 = getReplayEvent(await reqPromise0); expect(replayEvent0).toEqual(getExpectedReplayEvent()); - // Trigger one mouse click - void page.locator('#something').click(); - - // Open the feedback widget which should trigger an immediate flush + await page.locator('#something').click(); await page.locator('#open').click(); - // This should be flushed immediately due to feedback widget being opened - const replayEvent1 = getReplayEvent(await reqPromise1); - expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); - - // trigger another click - void page.locator('#something').click(); + const isPaused = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed on window + return window.Replay._replay.isPaused(); + }); + expect(isPaused).toBe(true); - // Send feedback via API which should trigger another immediate flush - await page.locator('#send').click(); + await page.locator('#submit').click(); - // This should be flushed immediately due to feedback being sent - const replayEvent2 = getReplayEvent(await reqPromise2); - expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] })); + const replayEvent1 = getReplayEvent(await reqPromise1); + const { breadcrumbs, ...replayEventWithoutBreadcrumbs } = replayEvent1; + expect(replayEventWithoutBreadcrumbs).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); + expect(breadcrumbs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ category: 'ui.click', message: 'body > button#something' }), + expect.objectContaining({ category: 'ui.click', message: 'body > button#open' }), + ]), + ); + + await page.locator('#close').click(); + const isResumed = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed on window + return !window.Replay._replay.isPaused(); + }); + expect(isResumed).toBe(true); }); From afa50f9ec4f9e446732bf15a2cb6fb926ab8422a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:27:39 +0000 Subject: [PATCH 3/3] test(replay): Avoid unstable breadcrumb assertion --- .../suites/replay/autoFlushOnFeedback/test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts index 5968bda038ca..c799bbd4a133 100644 --- a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts @@ -33,14 +33,7 @@ sentryTest('replay is frozen on feedback open and flushed on submit', async ({ g await page.locator('#submit').click(); const replayEvent1 = getReplayEvent(await reqPromise1); - const { breadcrumbs, ...replayEventWithoutBreadcrumbs } = replayEvent1; - expect(replayEventWithoutBreadcrumbs).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); - expect(breadcrumbs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ category: 'ui.click', message: 'body > button#something' }), - expect.objectContaining({ category: 'ui.click', message: 'body > button#open' }), - ]), - ); + expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); await page.locator('#close').click(); const isResumed = await page.evaluate(() => {