Skip to content

feat(replay/feedback): Add experimental autoFlushOnFeedback option #15356

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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://[email protected]/1337',
sampleRate: 0,
replaysSessionSampleRate: 1.0,
replaysOnErrorSampleRate: 0.0,

integrations: [window.Replay],
});
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
</head>
<body>
<button id="send">Send feedback</button>
<button id="open">Open feedback</button>
<button id="something">Something</button>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -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: [] }));
});
10 changes: 10 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
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.
Expand Down Expand Up @@ -695,6 +700,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/types-hoist/feedback/sendFeedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface FeedbackContext extends Record<string, unknown> {
replay_id?: string;
url?: string;
associated_event_id?: string;
source?: string;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion packages/feedback/src/modal/integration.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
},
Expand Down
2 changes: 1 addition & 1 deletion packages/replay-internal/src/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/replay-internal/src/types/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export interface ReplayPluginOptions extends ReplayNetworkOptions {
captureExceptions: boolean;
traceInternals: boolean;
continuousCheckout: number;
autoFlushOnFeedback: boolean;
}>;
}

Expand Down
24 changes: 17 additions & 7 deletions packages/replay-internal/src/util/addGlobalListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();
});
}
}
}
Loading