diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js new file mode 100644 index 000000000000..ecbfac30016e --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/init.js @@ -0,0 +1,20 @@ +import * as Sentry from '@sentry/browser'; + +window.Sentry = Sentry; +window.Replay = Sentry.replayIntegration({ + flushMinDelay: 200, + flushMaxDelay: 200, + useCompression: false, + _experiments: { + autoFlushOnFeedback: true, + }, +}); + +Sentry.init({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + sampleRate: 0, + replaysSessionSampleRate: 1.0, + replaysOnErrorSampleRate: 0.0, + + integrations: [window.Replay], +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js new file mode 100644 index 000000000000..06fee7313b72 --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/subject.js @@ -0,0 +1,9 @@ +import * as Sentry from '@sentry/browser'; + +document.getElementById('open').addEventListener('click', () => { + Sentry.getClient().emit('openFeedbackWidget'); +}); + +document.getElementById('send').addEventListener('click', () => { + Sentry.getClient().emit('beforeSendFeedback'); +}); diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html new file mode 100644 index 000000000000..2218082097dc --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/template.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts new file mode 100644 index 000000000000..41e94eef690b --- /dev/null +++ b/dev-packages/browser-integration-tests/suites/replay/autoFlushOnFeedback/test.ts @@ -0,0 +1,46 @@ +import { expect } from '@playwright/test'; + +import { sentryTest } from '../../../utils/fixtures'; +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. + */ +sentryTest('replay events are flushed automatically on feedback events', 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 }); + + await page.goto(url); + 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('#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(); + + // Send feedback via API which should trigger another immediate flush + await page.locator('#send').click(); + + // This should be flushed immediately due to feedback being sent + const replayEvent2 = getReplayEvent(await reqPromise2); + expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] })); +}); diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 805596a8a855..0c74625e31a4 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -561,6 +561,11 @@ export abstract class Client { callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void, ): () => void; + /** + * Register a callback when the feedback widget is opened in a user's browser + */ + public on(hook: 'openFeedbackWidget', callback: () => void): () => void; + /** * A hook for the browser tracing integrations to trigger a span start for a page load. * @returns {() => void} A function that, when executed, removes the registered callback. @@ -695,6 +700,11 @@ export abstract class Client { */ public emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void; + /** + * Fire a hook event for when the feedback widget is opened in a user's browser + */ + public emit(hook: 'openFeedbackWidget'): void; + /** * Emit a hook event for browser tracing integrations to trigger a span start for a page load. */ diff --git a/packages/core/src/types-hoist/feedback/sendFeedback.ts b/packages/core/src/types-hoist/feedback/sendFeedback.ts index 8f865b57038d..63d63b402b50 100644 --- a/packages/core/src/types-hoist/feedback/sendFeedback.ts +++ b/packages/core/src/types-hoist/feedback/sendFeedback.ts @@ -19,6 +19,7 @@ interface FeedbackContext extends Record { replay_id?: string; url?: string; associated_event_id?: string; + source?: string; } /** diff --git a/packages/feedback/src/modal/integration.tsx b/packages/feedback/src/modal/integration.tsx index bd8b9b84f148..f4b228d814b6 100644 --- a/packages/feedback/src/modal/integration.tsx +++ b/packages/feedback/src/modal/integration.tsx @@ -1,4 +1,4 @@ -import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; +import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core'; import type { FeedbackFormData, FeedbackModalIntegration, IntegrationFn, User } from '@sentry/core'; import { h, render } from 'preact'; import * as hooks from 'preact/hooks'; @@ -51,6 +51,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => { open() { renderContent(true); options.onFormOpen?.(); + getClient()?.emit('openFeedbackWidget'); originalOverflow = DOCUMENT.body.style.overflow; DOCUMENT.body.style.overflow = 'hidden'; }, diff --git a/packages/replay-internal/src/replay.ts b/packages/replay-internal/src/replay.ts index 7a19f1ea7070..89df655050e5 100644 --- a/packages/replay-internal/src/replay.ts +++ b/packages/replay-internal/src/replay.ts @@ -933,7 +933,7 @@ export class ReplayContainer implements ReplayContainerInterface { // There is no way to remove these listeners, so ensure they are only added once if (!this._hasInitializedCoreListeners) { - addGlobalListeners(this); + addGlobalListeners(this, { autoFlushOnFeedback: this._options._experiments.autoFlushOnFeedback }); this._hasInitializedCoreListeners = true; } diff --git a/packages/replay-internal/src/types/replay.ts b/packages/replay-internal/src/types/replay.ts index 7cd4c78a21c5..6ac77d7b672f 100644 --- a/packages/replay-internal/src/types/replay.ts +++ b/packages/replay-internal/src/types/replay.ts @@ -239,6 +239,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions { captureExceptions: boolean; traceInternals: boolean; continuousCheckout: number; + autoFlushOnFeedback: boolean; }>; } diff --git a/packages/replay-internal/src/util/addGlobalListeners.ts b/packages/replay-internal/src/util/addGlobalListeners.ts index f29ed4087d95..afa005669a46 100644 --- a/packages/replay-internal/src/util/addGlobalListeners.ts +++ b/packages/replay-internal/src/util/addGlobalListeners.ts @@ -17,7 +17,10 @@ import type { ReplayContainer } from '../types'; /** * Add global listeners that cannot be removed. */ -export function addGlobalListeners(replay: ReplayContainer): void { +export function addGlobalListeners( + replay: ReplayContainer, + { autoFlushOnFeedback }: { autoFlushOnFeedback?: boolean }, +): void { // Listeners from core SDK // const client = getClient(); @@ -57,15 +60,22 @@ export function addGlobalListeners(replay: ReplayContainer): void { replay.lastActiveSpan = span; }); - // We want to flush replay - client.on('beforeSendFeedback', (feedbackEvent, options) => { + // 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) { - // This should never reject - if (feedbackEvent.contexts?.feedback) { - feedbackEvent.contexts.feedback.replay_id = replayId; + 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' && autoFlushOnFeedback) { + await replay.flush(); } + feedbackEvent.contexts.feedback.replay_id = replayId; } }); + + if (autoFlushOnFeedback) { + client.on('openFeedbackWidget', async () => { + await replay.flush(); + }); + } } }