Skip to content

Commit 2f17ef1

Browse files
authored
feat(replay/feedback): Add experimental autoFlushOnFeedback option (#15356)
This PR adds an experimental flag `autoFlushOnFeedback` to the replay options. It aims to reduce replays that only contain the user interaction within the feedback modal, whereas the we rather want to inspect what happened prior to this. Once enabled, replays are automatically flushed when: - the feedback modal is opened - feedback is captured manually through an api call partly related to #6908
1 parent 2afe732 commit 2f17ef1

File tree

10 files changed

+118
-9
lines changed

10 files changed

+118
-9
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
window.Replay = Sentry.replayIntegration({
5+
flushMinDelay: 200,
6+
flushMaxDelay: 200,
7+
useCompression: false,
8+
_experiments: {
9+
autoFlushOnFeedback: true,
10+
},
11+
});
12+
13+
Sentry.init({
14+
dsn: 'https://[email protected]/1337',
15+
sampleRate: 0,
16+
replaysSessionSampleRate: 1.0,
17+
replaysOnErrorSampleRate: 0.0,
18+
19+
integrations: [window.Replay],
20+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
document.getElementById('open').addEventListener('click', () => {
4+
Sentry.getClient().emit('openFeedbackWidget');
5+
});
6+
7+
document.getElementById('send').addEventListener('click', () => {
8+
Sentry.getClient().emit('beforeSendFeedback');
9+
});
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<!doctype html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
</head>
6+
<body>
7+
<button id="send">Send feedback</button>
8+
<button id="open">Open feedback</button>
9+
<button id="something">Something</button>
10+
</body>
11+
</html>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { expect } from '@playwright/test';
2+
3+
import { sentryTest } from '../../../utils/fixtures';
4+
import { getExpectedReplayEvent } from '../../../utils/replayEventTemplates';
5+
import { getReplayEvent, shouldSkipReplayTest, waitForReplayRequest } from '../../../utils/replayHelpers';
6+
7+
/*
8+
* In this test we want to verify that replay events are automatically flushed when user feedback is submitted via API / opening the widget.
9+
* We emulate this by firing the feedback events directly, which should trigger an immediate flush of any
10+
* buffered replay events, rather than waiting for the normal flush delay.
11+
*/
12+
sentryTest('replay events are flushed automatically on feedback events', async ({ getLocalTestUrl, page }) => {
13+
if (shouldSkipReplayTest()) {
14+
sentryTest.skip();
15+
}
16+
17+
const reqPromise0 = waitForReplayRequest(page, 0);
18+
const reqPromise1 = waitForReplayRequest(page, 1);
19+
const reqPromise2 = waitForReplayRequest(page, 2);
20+
21+
const url = await getLocalTestUrl({ testDir: __dirname });
22+
23+
await page.goto(url);
24+
const replayEvent0 = getReplayEvent(await reqPromise0);
25+
expect(replayEvent0).toEqual(getExpectedReplayEvent());
26+
27+
// Trigger one mouse click
28+
void page.locator('#something').click();
29+
30+
// Open the feedback widget which should trigger an immediate flush
31+
await page.locator('#open').click();
32+
33+
// This should be flushed immediately due to feedback widget being opened
34+
const replayEvent1 = getReplayEvent(await reqPromise1);
35+
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));
36+
37+
// trigger another click
38+
void page.locator('#something').click();
39+
40+
// Send feedback via API which should trigger another immediate flush
41+
await page.locator('#send').click();
42+
43+
// This should be flushed immediately due to feedback being sent
44+
const replayEvent2 = getReplayEvent(await reqPromise2);
45+
expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] }));
46+
});

packages/core/src/client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
561561
callback: (feedback: FeedbackEvent, options?: { includeReplay?: boolean }) => void,
562562
): () => void;
563563

564+
/**
565+
* Register a callback when the feedback widget is opened in a user's browser
566+
*/
567+
public on(hook: 'openFeedbackWidget', callback: () => void): () => void;
568+
564569
/**
565570
* A hook for the browser tracing integrations to trigger a span start for a page load.
566571
* @returns {() => void} A function that, when executed, removes the registered callback.
@@ -695,6 +700,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
695700
*/
696701
public emit(hook: 'beforeSendFeedback', feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void;
697702

703+
/**
704+
* Fire a hook event for when the feedback widget is opened in a user's browser
705+
*/
706+
public emit(hook: 'openFeedbackWidget'): void;
707+
698708
/**
699709
* Emit a hook event for browser tracing integrations to trigger a span start for a page load.
700710
*/

packages/core/src/types-hoist/feedback/sendFeedback.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ interface FeedbackContext extends Record<string, unknown> {
1919
replay_id?: string;
2020
url?: string;
2121
associated_event_id?: string;
22+
source?: string;
2223
}
2324

2425
/**

packages/feedback/src/modal/integration.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
1+
import { getClient, getCurrentScope, getGlobalScope, getIsolationScope } from '@sentry/core';
22
import type { FeedbackFormData, FeedbackModalIntegration, IntegrationFn, User } from '@sentry/core';
33
import { h, render } from 'preact';
44
import * as hooks from 'preact/hooks';
@@ -51,6 +51,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => {
5151
open() {
5252
renderContent(true);
5353
options.onFormOpen?.();
54+
getClient()?.emit('openFeedbackWidget');
5455
originalOverflow = DOCUMENT.body.style.overflow;
5556
DOCUMENT.body.style.overflow = 'hidden';
5657
},

packages/replay-internal/src/replay.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -933,7 +933,7 @@ export class ReplayContainer implements ReplayContainerInterface {
933933

934934
// There is no way to remove these listeners, so ensure they are only added once
935935
if (!this._hasInitializedCoreListeners) {
936-
addGlobalListeners(this);
936+
addGlobalListeners(this, { autoFlushOnFeedback: this._options._experiments.autoFlushOnFeedback });
937937

938938
this._hasInitializedCoreListeners = true;
939939
}

packages/replay-internal/src/types/replay.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
239239
captureExceptions: boolean;
240240
traceInternals: boolean;
241241
continuousCheckout: number;
242+
autoFlushOnFeedback: boolean;
242243
}>;
243244
}
244245

packages/replay-internal/src/util/addGlobalListeners.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import type { ReplayContainer } from '../types';
1717
/**
1818
* Add global listeners that cannot be removed.
1919
*/
20-
export function addGlobalListeners(replay: ReplayContainer): void {
20+
export function addGlobalListeners(
21+
replay: ReplayContainer,
22+
{ autoFlushOnFeedback }: { autoFlushOnFeedback?: boolean },
23+
): void {
2124
// Listeners from core SDK //
2225
const client = getClient();
2326

@@ -57,15 +60,22 @@ export function addGlobalListeners(replay: ReplayContainer): void {
5760
replay.lastActiveSpan = span;
5861
});
5962

60-
// We want to flush replay
61-
client.on('beforeSendFeedback', (feedbackEvent, options) => {
63+
// We want to attach the replay id to the feedback event
64+
client.on('beforeSendFeedback', async (feedbackEvent, options) => {
6265
const replayId = replay.getSessionId();
63-
if (options?.includeReplay && replay.isEnabled() && replayId) {
64-
// This should never reject
65-
if (feedbackEvent.contexts?.feedback) {
66-
feedbackEvent.contexts.feedback.replay_id = replayId;
66+
if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) {
67+
// In case the feedback is sent via API and not through our widget, we want to flush replay
68+
if (feedbackEvent.contexts.feedback.source === 'api' && autoFlushOnFeedback) {
69+
await replay.flush();
6770
}
71+
feedbackEvent.contexts.feedback.replay_id = replayId;
6872
}
6973
});
74+
75+
if (autoFlushOnFeedback) {
76+
client.on('openFeedbackWidget', async () => {
77+
await replay.flush();
78+
});
79+
}
7080
}
7181
}

0 commit comments

Comments
 (0)