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();
+ });
+ }
}
}