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..c799bbd4a133 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,24 @@ 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: [] })); + const isPaused = await page.evaluate(() => { + // @ts-expect-error - Replay is not typed on window + return window.Replay._replay.isPaused(); + }); + expect(isPaused).toBe(true); - // trigger another click - void page.locator('#something').click(); + await page.locator('#submit').click(); - // Send feedback via API which should trigger another immediate flush - await page.locator('#send').click(); + const replayEvent1 = getReplayEvent(await reqPromise1); + expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] })); - // This should be flushed immediately due to feedback being sent - const replayEvent2 = getReplayEvent(await reqPromise2); - expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] })); + 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); }); 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); + }); });