diff --git a/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts b/packages/replay-internal/src/coreHandlers/handleGlobalEvent.ts index 3a7ea6a87fc6..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 ac4d228f8976..69fede798cbf 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -56,20 +56,35 @@ 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; + replayIdOnFeedbackOpen = undefined; + 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..7688074671b1 --- /dev/null +++ b/packages/replay-internal/test/integration/feedback.test.ts @@ -0,0 +1,167 @@ +/** + * @vitest-environment jsdom + */ + +import '../utils/mock-internal-setTimeout'; +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'; +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 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') }); + 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 sentEvent = await sendFeedback('widget'); + + expect(replay.getSessionId()).not.toBe(replayIdOnOpen); + 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(); + replay.start(); + + const sentEvent = await sendFeedback('widget'); + + expect(replay.getSessionId()).toBeDefined(); + 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 sentEvent = await sendFeedback('api'); + + expect(sentEvent?.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(); + }); +});