Skip to content
Draft
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
Expand Up @@ -4,6 +4,14 @@ document.getElementById('open').addEventListener('click', () => {
Sentry.getClient().emit('openFeedbackWidget');
});

document.getElementById('send').addEventListener('click', () => {
Sentry.getClient().emit('beforeSendFeedback');
document.getElementById('submit').addEventListener('click', () => {
Sentry.getClient().emit(
'beforeSendFeedback',
{ contexts: { feedback: { message: 'test', source: 'widget' } }, type: 'feedback' },
{ includeReplay: true },
);
});

document.getElementById('close').addEventListener('click', () => {
Sentry.getClient().emit('closeFeedbackWidget');
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
<meta charset="utf-8" />
</head>
<body>
<button id="send">Send feedback</button>
<button id="open">Open feedback</button>
<button id="submit">Submit feedback widget</button>
<button id="close">Close feedback</button>
<button id="something">Something</button>
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,41 @@ 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.
* In this test we want to verify that replay is frozen when the feedback widget opens and flushed on submission.
* We emulate this by firing the feedback lifecycle events directly.
*/
sentryTest('replay events are flushed automatically on feedback events', async ({ getLocalTestUrl, page }) => {
sentryTest('replay is frozen on feedback open and flushed on submit', 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('#something').click();
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: [] }));
const isPaused = await page.evaluate(() => {
// @ts-expect-error - Replay is not typed on window
return window.Replay._replay.isPaused();
});
expect(isPaused).toBe(true);

// trigger another click
void page.locator('#something').click();
await page.locator('#submit').click();

// Send feedback via API which should trigger another immediate flush
await page.locator('#send').click();
const replayEvent1 = getReplayEvent(await reqPromise1);
expect(replayEvent1).toEqual(getExpectedReplayEvent({ segment_id: 1, urls: [] }));

// This should be flushed immediately due to feedback being sent
const replayEvent2 = getReplayEvent(await reqPromise2);
expect(replayEvent2).toEqual(getExpectedReplayEvent({ segment_id: 2, urls: [] }));
await page.locator('#close').click();
const isResumed = await page.evaluate(() => {
// @ts-expect-error - Replay is not typed on window
return !window.Replay._replay.isPaused();
});
expect(isResumed).toBe(true);
});
10 changes: 10 additions & 0 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public on(hook: 'openFeedbackWidget', callback: () => void): () => void;

/**
* Register a callback when the feedback widget is closed in a user's browser
*/
public on(hook: 'closeFeedbackWidget', callback: () => void): () => void;

/**
* A hook that is called when a replay session starts recording (either session or buffer mode).
* @returns {() => void} A function that, when executed, removes the registered callback.
Expand Down Expand Up @@ -1133,6 +1138,11 @@ export abstract class Client<O extends ClientOptions = ClientOptions> {
*/
public emit(hook: 'openFeedbackWidget'): void;

/**
* Fire a hook event for when the feedback widget is closed in a user's browser
*/
public emit(hook: 'closeFeedbackWidget'): void;

/**
* Fire a hook event when a replay session starts recording.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/feedback/src/modal/integration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export const feedbackModalIntegration = ((): FeedbackModalIntegration => {
},
close() {
renderContent(false);
getClient()?.emit('closeFeedbackWidget');
DOCUMENT.body.style.overflow = originalOverflow;
},
};
Expand Down
24 changes: 19 additions & 5 deletions packages/replay-internal/src/util/addGlobalListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,34 @@ export function addGlobalListeners(replay: ReplayContainer): void {
replay.lastActiveSpan = span;
});

let replayPausedByFeedback = false;

// 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 && 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') {
feedbackEvent.contexts.feedback.replay_id = replayId;
const feedbackSource = feedbackEvent.contexts.feedback.source;
if (feedbackSource === 'api') {
await replay.sendBufferedReplayOrFlush();
} else if (feedbackSource === 'widget' && replayPausedByFeedback) {
await replay.sendBufferedReplayOrFlush({ continueRecording: false });
}
feedbackEvent.contexts.feedback.replay_id = replayId;
}
});

client.on('openFeedbackWidget', async () => {
await replay.sendBufferedReplayOrFlush();
client.on('openFeedbackWidget', () => {
if (replay.isEnabled() && !replay.isPaused()) {
replay.pause();
replayPausedByFeedback = true;
}
});

client.on('closeFeedbackWidget', () => {
if (replayPausedByFeedback) {
replayPausedByFeedback = false;
replay.resume();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import '../utils/mock-internal-setTimeout';
import type { ReplayEndEvent, ReplayStartEvent } from '@sentry/core';
import type { FeedbackEvent, ReplayEndEvent, ReplayStartEvent } from '@sentry/core';
import { getClient } from '@sentry/core';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Replay } from '../../src/integration';
Expand Down Expand Up @@ -106,4 +106,60 @@ describe('Integration | lifecycle hooks', () => {

expect(startEvents).toHaveLength(0);
});

it('freezes replay while the feedback widget is open and sends the frozen buffer on submit', () => {
integration.startBuffering();
const client = getClient()!;
const pauseSpy = vi.spyOn(replay, 'pause');
const resumeSpy = vi.spyOn(replay, 'resume');
const sendBufferedReplaySpy = vi.spyOn(replay, 'sendBufferedReplayOrFlush').mockResolvedValue();
const feedbackEvent: FeedbackEvent = {
type: 'feedback',
contexts: { feedback: { message: 'test', source: 'widget' } },
};

client.emit('openFeedbackWidget');
client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true });

expect(pauseSpy).toHaveBeenCalledOnce();
expect(sendBufferedReplaySpy).toHaveBeenCalledWith({ continueRecording: false });
expect(feedbackEvent.contexts.feedback.replay_id).toBe(replay.getSessionId());
expect(replay.recordingMode).toBe('buffer');

client.emit('closeFeedbackWidget');

expect(resumeSpy).toHaveBeenCalledOnce();
expect(replay.recordingMode).toBe('buffer');
});

it('keeps session recording mode after feedback submission', () => {
integration.start();
const client = getClient()!;
const sendBufferedReplaySpy = vi.spyOn(replay, 'sendBufferedReplayOrFlush').mockResolvedValue();
const feedbackEvent: FeedbackEvent = {
type: 'feedback',
contexts: { feedback: { message: 'test', source: 'widget' } },
};

client.emit('openFeedbackWidget');
client.emit('beforeSendFeedback', feedbackEvent, { includeReplay: true });
client.emit('closeFeedbackWidget');

expect(sendBufferedReplaySpy).toHaveBeenCalledWith({ continueRecording: false });
expect(replay.recordingMode).toBe('session');
expect(replay.isPaused()).toBe(false);
});

it('does not resume replay when it was already paused before the feedback widget opened', () => {
integration.startBuffering();
replay.pause();
const client = getClient()!;
const resumeSpy = vi.spyOn(replay, 'resume');

client.emit('openFeedbackWidget');
client.emit('closeFeedbackWidget');

expect(resumeSpy).not.toHaveBeenCalled();
expect(replay.isPaused()).toBe(true);
});
});
Loading