Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9006fae
fix(replay): Attach Session Replay buffer to user feedback
antonis Oct 12, 2026
dcb704b
Merge remote-tracking branch 'origin/main' into fix/feedback-form-rep…
antonis Oct 12, 2026
86bbbf5
chore: Add PR number to CHANGELOG entry
antonis Oct 12, 2026
2c229cc
fix(replay): Harden feedback replay id state against widget race, can…
antonis Oct 12, 2026
3bee85e
fix(replay): Link the uploaded feedback replay by flush result, handl…
antonis Oct 12, 2026
a052c5f
Merge remote-tracking branch 'origin/main' into fix/feedback-form-rep…
antonis Oct 12, 2026
c6f5bd8
fix(replay): Link feedback by pre-capture buffer id and stop stale ca…
antonis Oct 12, 2026
dc6b973
Merge remote-tracking branch 'origin/main' into fix/feedback-form-rep…
antonis Oct 12, 2026
549b28f
fix(replay): Invalidate the replay-id cache after a feedback widget f…
antonis Oct 12, 2026
00c3ca1
fix(replay): Avoid floating promise in feedback widget flush
antonis Oct 12, 2026
353ec57
fix(replay): Invalidate the replay-id cache after an API feedback flu…
antonis Oct 12, 2026
cca76cf
Merge remote-tracking branch 'origin/main' into fix/feedback-form-rep…
antonis Oct 12, 2026
4bdd11f
fix(replay): Isolate feedback replay hooks and preserve carried repla…
antonis Oct 12, 2026
6bb871e
fix(replay): Reuse the in-flight feedback flush on form remount
antonis Oct 12, 2026
489e028
Merge branch 'main' into fix/feedback-form-replay-buffer
antonis Oct 13, 2026
106275c
Merge branch 'main' into fix/feedback-form-replay-buffer
antonis Oct 13, 2026
6f659d7
fix(replay): Do not link a dismissed form's replay to a later feedback
antonis Oct 13, 2026
661681b
fix(replay): Keep a draft's uploaded replay across a feedback form re…
antonis Oct 13, 2026
d612e05
fix(replay): Prefer the carried replay id over the current buffer on …
antonis Oct 13, 2026
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

### Fixes

- Attach the on-error Session Replay buffer to feedback submitted from the feedback form ([#6870](https://github.com/getsentry/sentry-react-native/pull/6870))
- Do not mark `sendDefaultPii` as deprecated in the React Native options ([#6864](https://github.com/getsentry/sentry-react-native/pull/6864))

### Dependencies
Expand Down
9 changes: 8 additions & 1 deletion packages/core/src/js/feedback/FeedbackForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
name: trimmedName,
email: trimmedEmail,
associatedEventId: eventId,
source: 'widget',
};

this._isSubmitting = true;
Expand Down Expand Up @@ -189,7 +190,9 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
this.setState({ isVisible: false });
}

captureFeedback(userFeedback, attachments ? { attachments } : undefined);
// Always request the replay: it links it when Session Replay is on, and is a no-op when off.
const hint = { includeReplay: true, ...(attachments ? { attachments } : {}) };
captureFeedback(userFeedback, hint);
} catch (error) {
const errorString = `Feedback form submission failed: ${error}`;
debug.error(errorString);
Expand Down Expand Up @@ -314,6 +317,10 @@ export class FeedbackForm extends React.Component<FeedbackFormProps, FeedbackFor
this._themeListener = Appearance.addChangeListener(() => {
this.forceUpdate();
});

// Signals the Session Replay integration to upload the buffered replay so it's ready to attach
// when the form is submitted.
getClient()?.emit('openFeedbackWidget');
}

/**
Expand Down
111 changes: 111 additions & 0 deletions packages/core/src/js/replay/mobilereplay.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* oxlint-disable eslint(max-lines) */
import type {
Breadcrumb,
BreadcrumbHint,
Expand All @@ -6,6 +7,7 @@ import type {
ErrorEvent,
Event,
EventHint,
FeedbackEvent,
Comment thread
sentry-warden[bot] marked this conversation as resolved.
Comment thread
antonis marked this conversation as resolved.
Metric,
} from '@sentry/core';

Expand Down Expand Up @@ -381,6 +383,13 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
// This will be updated when we know the replay ID changes (e.g., after captureReplay)
let cachedReplayId: string | null = null;

// The replay upload started when the feedback widget opened. `openId` is the buffer id read
// before the flush (what it uploads, and the fallback for a submit before it resolves); `id` is
// the uploaded replay id once resolved (`null` on a sampling miss). A fresh object per open means
// a superseded or already-consumed flush can't affect a later feedback — its late resolution
// mutates an object no one reads any more.
let feedbackReplayFlush: { openId: string | null; id: string | null; resolved: boolean } | null = null;
Comment thread
sentry-warden[bot] marked this conversation as resolved.

function updateCachedReplayId(replayId: string | null): void {
cachedReplayId = replayId;
}
Expand Down Expand Up @@ -514,6 +523,104 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
}
}

// Start uploading the buffer when the widget opens. Read the buffer id first (before capture may
// restart buffering) so the id we link matches what this flush uploads. A reopen is the same draft
// resumed (the screenshot flow closes and reopens the form, restoring the draft; only a submit
// discards it), so it continues the earlier flush: while its upload is still in flight the reopen
// reuses it, and once it has settled the reopen carries its uploaded id forward (upgrading to its
// own if that capture uploads a fresher replay). That keeps the replay already sent for this draft
// instead of dropping it on a reopen that uploads nothing (e.g. an on-error sampling miss). The
// flush is cleared on submit, so an unrelated later feedback never inherits it. Wrapped: a
// synchronous bridge throw must not propagate out of the widget's `componentDidMount`.
function flushReplayForFeedbackWidget(): void {
try {
if (feedbackReplayFlush && !feedbackReplayFlush.resolved) {
return;
}
const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null;
const flush: { openId: string | null; id: string | null; resolved: boolean } = {
// Prefer a carried id as the pre-resolve fallback: it is a replay already sent for this
// draft, so a submit while this reopen's capture is still in flight must not fall back to
// the current (unconfirmed, maybe-never-sent) buffer id instead.
openId: carried ?? NATIVE.getCurrentReplayId(),
id: carried,
resolved: false,
};
feedbackReplayFlush = flush;
// On settle, mark resolved and drop the cached id: capture may change the native replay
// identity, so the next DSC/metric re-reads the current id (as the runtime controls do) rather
// than caching the uploaded one.
const settle = (): void => {
flush.resolved = true;
invalidateCachedReplayId();
};
NATIVE.captureReplay(false)
.then(uploadedId => {
// Keep the carried id when this reopen uploaded nothing, so it isn't dropped.
if (uploadedId) {
flush.id = uploadedId;
}
Comment thread
cursor[bot] marked this conversation as resolved.
settle();
})
.then(undefined, (error: unknown) => {
settle();
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback widget`, error);
});
} catch (error) {
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to start replay flush for feedback widget`, error);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

// Emitted synchronously before the event is sent, so resolve the id synchronously. Widget
// feedback links the id the open flush uploaded — `null` on a sampling miss means no replay was
// sent, so nothing is linked; before it resolves, fall back to the id read at open. Other
// feedback (direct `captureFeedback`) ignores the widget flush and links the current recording,
// flushing it now. Wrapped: a synchronous bridge throw must not abort feedback capture.
function linkReplayToFeedback(feedback: FeedbackEvent, options?: { includeReplay?: boolean }): void {
if (!options?.includeReplay) {
return;
}

let replayId: string | null = null;
try {
if (feedback.contexts?.feedback?.source === 'widget') {
const flush = feedbackReplayFlush;
feedbackReplayFlush = null;
replayId = flush?.resolved ? flush.id : (flush?.openId ?? null);
} else {
replayId = NATIVE.getCurrentReplayId();
if (replayId) {
// Flush the current buffer and, like the widget flush, invalidate the cached id on settle:
// capture may change the native replay identity, so the next DSC/metric re-reads it.
NATIVE.captureReplay(false).then(
() => {
invalidateCachedReplayId();
},
(error: unknown) => {
invalidateCachedReplayId();
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to flush replay for feedback`, error);
},
);
}
}
} catch (error) {
debug.error(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} Failed to resolve replay for feedback`, error);
return;
}
Comment thread
cursor[bot] marked this conversation as resolved.

if (!replayId) {
debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} no active recording to link to feedback.`);
return;
}

feedback.contexts = feedback.contexts || {};
feedback.contexts.feedback = {
...feedback.contexts.feedback,
replay_id: replayId,
};
debug.log(`[Sentry] ${MOBILE_REPLAY_INTEGRATION_NAME} linked replay ${replayId} to feedback ${feedback.event_id}.`);
}

function setup(client: Client): void {
if (!hasHooks(client)) {
Comment thread
sentry-warden[bot] marked this conversation as resolved.
return;
Expand Down Expand Up @@ -626,6 +733,10 @@ export const mobileReplayIntegration = (initOptions: MobileReplayOptions = defau
// errors are logged inside flushReplayForSentEvent
flushReplayForSentEvent(event).then(undefined, () => {});
});

// Attach the Session Replay to user feedback: upload on open, link on submit.
client.on('openFeedbackWidget', flushReplayForFeedbackWidget);
client.on('beforeSendFeedback', linkReplayToFeedback);
Comment thread
sentry-warden[bot] marked this conversation as resolved.
}

function getReplayId(): string | null {
Expand Down
12 changes: 11 additions & 1 deletion packages/core/test/feedback/FeedbackForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,22 @@ describe('FeedbackForm', () => {
message: 'This is a feedback message.',
name: 'John Doe',
email: 'john.doe@example.com',
source: 'widget',
},
undefined,
{ includeReplay: true },
);
});
});

it('emits openFeedbackWidget on mount so the replay buffer can be primed', () => {
const client = getClient()!;
const emitSpy = jest.spyOn(client, 'emit');

render(<FeedbackForm {...defaultProps} />);

expect(emitSpy).toHaveBeenCalledWith('openFeedbackWidget');
Comment thread
sentry-warden[bot] marked this conversation as resolved.
});

it('shows success message when the form is submitted successfully', async () => {
const { getByPlaceholderText, getByText } = render(<FeedbackForm {...defaultProps} />);

Expand Down
Loading
Loading