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
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
107 changes: 107 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,100 @@ 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 remount (e.g. the
// screenshot flow closes and reopens the form) reuses a flush that is still in flight, and
// otherwise carries forward an id a previous open already uploaded, so neither a pending nor a
// completed upload is discarded. Wrapped: a synchronous bridge throw must not propagate out of
// the widget's `componentDidMount`.
function flushReplayForFeedbackWidget(): void {
try {
// A flush from an earlier open in the same flow is still uploading: keep it rather than
// starting another, so its pending upload isn't discarded.
if (feedbackReplayFlush && !feedbackReplayFlush.resolved) {
return;
}
const carried = feedbackReplayFlush?.resolved ? feedbackReplayFlush.id : null;
const flush: { openId: string | null; id: string | null; resolved: boolean } = {
openId: NATIVE.getCurrentReplayId() ?? carried,
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 open uploaded nothing, so it isn't discarded.
if (uploadedId) {
flush.id = uploadedId;
}
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);
}
}

// 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 +729,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