Skip to content
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
Expand Up @@ -57,10 +57,18 @@ export function handleGlobalEventListener(replay: ReplayContainer): (event: Even
}

if (isFeedbackEvent(event)) {
// The feedback widget links the replay from when it was opened. If the session
// refreshed since then, don't flush or add a breadcrumb to the unlinked new session

@JoshuaKGoldberg JoshuaKGoldberg Sep 21, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Non-Actionable] Fun fact: this is one of the few times recently I've added a comment. Cursor didn't write one originally! But I figured the rest of this area/file is pretty comment-rich.

const sessionId = replay.getSessionId();
const feedbackReplayId = event.contexts.feedback.replay_id;
if (feedbackReplayId && feedbackReplayId !== sessionId) {
return event;
}

// This should never reject
// eslint-disable-next-line @typescript-eslint/no-floating-promises
replay.flush();
event.contexts.feedback.replay_id = replay.getSessionId();
event.contexts.feedback.replay_id = sessionId;
// Add a replay breadcrumb for this piece of feedback
addFeedbackBreadcrumb(replay, event);
return event;
Expand Down
21 changes: 18 additions & 3 deletions packages/replay-internal/src/util/addGlobalListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,35 @@ export function addGlobalListeners(replay: ReplayContainer): void {
replay.lastActiveSpan = span;
});

let replayIdOnFeedbackOpen: string | undefined;

// We want to attach the replay id to the feedback event
client.on('beforeSendFeedback', async (feedbackEvent, options) => {
const feedbackContext = feedbackEvent.contexts?.feedback;
if (!options?.includeReplay || !feedbackContext) {
return;
}

if (feedbackContext.source === 'widget' && replayIdOnFeedbackOpen) {
Comment thread
JoshuaKGoldberg marked this conversation as resolved.
feedbackContext.replay_id = replayIdOnFeedbackOpen;
Comment thread
JoshuaKGoldberg marked this conversation as resolved.
replayIdOnFeedbackOpen = undefined;
return;
Comment thread
JoshuaKGoldberg marked this conversation as resolved.
}

const replayId = replay.getSessionId();
if (options?.includeReplay && replay.isEnabled() && replayId && feedbackEvent.contexts?.feedback) {
if (replay.isEnabled() && replayId) {
// In case the feedback is sent via API and not through our widget, we want to flush replay
if (feedbackEvent.contexts.feedback.source === 'api') {
if (feedbackContext.source === 'api') {
await replay.sendBufferedReplayOrFlush();
}
feedbackEvent.contexts.feedback.replay_id = replayId;
feedbackContext.replay_id = replayId;
}
});

client.on('openFeedbackWidget', async () => {
replayIdOnFeedbackOpen = undefined;
await replay.sendBufferedReplayOrFlush();
replayIdOnFeedbackOpen = replay.isEnabled() ? replay.getSessionId() : undefined;
});
}
}
167 changes: 167 additions & 0 deletions packages/replay-internal/test/integration/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* @vitest-environment jsdom
*/

import '../utils/mock-internal-setTimeout';
import type { Event, FeedbackEvent } from '@sentry/core';
import { captureFeedback, getClient } from '@sentry/core';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_FLUSH_MIN_DELAY, SESSION_IDLE_EXPIRE_DURATION } from '../../src/constants';
import type { ReplayContainer } from '../../src/replay';
import { clearSession } from '../../src/session/clearSession';
import { BASE_TIMESTAMP } from '../index';
import type { RecordMock } from '../index';
import { resetSdkMock } from '../mocks/resetSdkMock';
import type { DomHandler } from '../types';
import { getTestEventIncremental } from '../utils/getTestEvent';

async function advanceTimers(time: number) {
vi.advanceTimersByTime(time);
await new Promise(process.nextTick);
}

function createFeedbackEvent(source: string): FeedbackEvent {
return {
type: 'feedback',
contexts: {
feedback: {
message: 'Something broke',
source,
},
},
};
}

describe('Integration | feedback', () => {
let replay: ReplayContainer;
let mockRecord: RecordMock;
let domHandler: DomHandler;

beforeAll(() => {
vi.useFakeTimers();
});

beforeEach(async () => {
({ mockRecord, domHandler, replay } = await resetSdkMock({
replayOptions: {
stickySession: true,
},
sentryOptions: {
replaysSessionSampleRate: 0.0,
replaysOnErrorSampleRate: 1.0,
},
}));

mockRecord._emitter(getTestEventIncremental({ timestamp: BASE_TIMESTAMP }));
await advanceTimers(10_000);
});

afterEach(() => {
clearSession(replay);
replay.stop();
});

async function openFeedbackWidget() {
getClient()!.emit('openFeedbackWidget');
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
}

async function sendFeedback(source: string): Promise<Event | undefined> {
let sentEvent: Event | undefined;
const unsubscribe = getClient()!.on('beforeSendEvent', event => {
if (event.type === 'feedback') {
sentEvent = event;
}
});

captureFeedback({ message: 'Something broke', source }, { includeReplay: true });
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
unsubscribe();

return sentEvent;
}

async function expireSession() {
await advanceTimers(SESSION_IDLE_EXPIRE_DURATION + 1_000);
domHandler({ name: 'click', event: new Event('click') });
await advanceTimers(DEFAULT_FLUSH_MIN_DELAY);
}

it('sends the buffered replay and continues in session mode when the widget is opened', async () => {
await openFeedbackWidget();

expect(replay).toHaveLastSentReplay();
expect(replay.recordingMode).toBe('session');
});

it('attaches the replay ID from when the widget was opened when the session is refreshed before submission', async () => {
await openFeedbackWidget();
const replayIdOnOpen = replay.getSessionId();
await expireSession();

const sentEvent = await sendFeedback('widget');

expect(replay.getSessionId()).not.toBe(replayIdOnOpen);
expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replayIdOnOpen);
});

it('does not flush the refreshed session when widget feedback is sent after a session refresh', async () => {
await openFeedbackWidget();
await expireSession();
const flushSpy = vi.spyOn(replay, 'flush');

await sendFeedback('widget');

expect(flushSpy).not.toHaveBeenCalled();
});

it('attaches the replay ID from when the widget was opened when replay is stopped before submission', async () => {
await openFeedbackWidget();
const replayIdOnOpen = replay.getSessionId();
replay.stop();

const sentEvent = await sendFeedback('widget');

expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replayIdOnOpen);
});

it('attaches the current replay ID when widget feedback is sent again without reopening the widget', async () => {
await openFeedbackWidget();
await expireSession();
await sendFeedback('widget');

const sentEvent = await sendFeedback('widget');

expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId());
});
Comment thread
JoshuaKGoldberg marked this conversation as resolved.

it('attaches the current replay ID when the widget was opened while replay was disabled', async () => {
replay.stop();
await openFeedbackWidget();
replay.start();

const sentEvent = await sendFeedback('widget');

expect(replay.getSessionId()).toBeDefined();
expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId());
});

it('attaches the current replay ID when feedback is sent via the API after the widget was opened', async () => {
await openFeedbackWidget();
await expireSession();

const sentEvent = await sendFeedback('api');

expect(sentEvent?.contexts?.feedback?.replay_id).toBe(replay.getSessionId());
});

it('does not attach a replay ID when includeReplay is not set', async () => {
await openFeedbackWidget();
const feedbackEvent = createFeedbackEvent('widget');

getClient()!.emit('beforeSendFeedback', feedbackEvent);

expect(feedbackEvent.contexts?.feedback?.replay_id).toBeUndefined();
});
});
Loading