Skip to content

Commit 89af26d

Browse files
feat(replay): add feedback message to breadcrumb (#95513)
closes https://linear.app/getsentry/issue/REPLAY-508/show-feedback-message-in-feedback-breadcrumb <img width="677" height="341" alt="SCR-20250714-pqtd" src="https://github.com/user-attachments/assets/81e36a38-b8de-414e-b325-d34419d76a77" /> with multiple feedbacks in 1 replay: <img width="591" height="486" alt="SCR-20250715-jmwj" src="https://github.com/user-attachments/assets/c2f52596-4c1e-4980-9936-6460801ba67c" />
1 parent d3fc534 commit 89af26d

File tree

7 files changed

+112
-20
lines changed

7 files changed

+112
-20
lines changed

static/app/utils/replays/getFrameDetails.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const MAPPER_FOR_FRAME: Record<string, (frame: any) => Details> = {
9797
}),
9898
feedback: (frame: FeedbackFrame) => ({
9999
colorGraphicsToken: 'promotion',
100-
description: frame.data.projectSlug,
100+
description: frame.message,
101101
tabKey: TabKey.BREADCRUMBS,
102102
title: defaultTitle(frame),
103103
icon: <IconMegaphone size="xs" />,
@@ -473,7 +473,7 @@ export function defaultTitle(frame: ReplayFrame | RawBreadcrumbFrame) {
473473
if (
474474
'message' in frame &&
475475
typeof frame.message === 'string' &&
476-
frame.message.includes('User Feedback')
476+
frame.category === 'feedback'
477477
) {
478478
return t('User Feedback');
479479
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type {FeedbackEvent} from 'sentry/utils/feedback/types';
2+
import {useApiQueries} from 'sentry/utils/queryClient';
3+
import useOrganization from 'sentry/utils/useOrganization';
4+
5+
export default function useFeedbackEvents({
6+
feedbackEventIds,
7+
projectId,
8+
}: {
9+
feedbackEventIds: string[];
10+
projectId: string | undefined | null;
11+
}) {
12+
const organization = useOrganization();
13+
14+
const feedbackEventQuery = useApiQueries<FeedbackEvent>(
15+
feedbackEventIds.map((feedbackEventId: string) => [
16+
`/projects/${organization.slug}/${projectId}/events/${feedbackEventId}/`,
17+
]),
18+
{
19+
staleTime: Infinity,
20+
enabled: Boolean(feedbackEventIds.length > 0 && projectId),
21+
}
22+
);
23+
24+
const feedbackEvents = feedbackEventQuery
25+
.map(query => query.data)
26+
.filter(e => e !== undefined);
27+
const isPending = feedbackEventQuery.some(query => query.isPending);
28+
const isError = feedbackEventQuery.some(query => query.isError);
29+
30+
return {
31+
feedbackEvents,
32+
isPending,
33+
isError,
34+
};
35+
}

static/app/utils/replays/hooks/useLoadReplayReader.tsx

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,19 @@ export default function useLoadReplayReader({
2929
}: Props): ReplayReaderResult {
3030
const replayId = parseReplayId(replaySlug);
3131

32-
const {attachments, errors, replayRecord, status, isError, isPending, ...replayData} =
33-
useReplayData({
34-
orgSlug,
35-
replayId,
36-
});
32+
const {
33+
attachments,
34+
errors,
35+
feedbackEvents,
36+
replayRecord,
37+
status,
38+
isError,
39+
isPending,
40+
...replayData
41+
} = useReplayData({
42+
orgSlug,
43+
replayId,
44+
});
3745

3846
// get first error matching our group
3947
const firstMatchingError = useMemo(
@@ -63,6 +71,7 @@ export default function useLoadReplayReader({
6371
attachments,
6472
clipWindow: memoizedClipWindow,
6573
errors,
74+
feedbackEvents,
6675
fetching: isPending,
6776
replayRecord,
6877
eventTimestampMs,
@@ -71,6 +80,7 @@ export default function useLoadReplayReader({
7180
attachments,
7281
memoizedClipWindow,
7382
errors,
83+
feedbackEvents,
7484
isPending,
7585
replayRecord,
7686
eventTimestampMs,
@@ -80,6 +90,7 @@ export default function useLoadReplayReader({
8090
...replayData,
8191
attachments,
8292
errors,
93+
feedbackEvents,
8394
isError,
8495
isPending,
8596
replay,

static/app/utils/replays/hooks/useReplayData.spec.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import ProjectsStore from 'sentry/stores/projectsStore';
1616
import {DiscoverDatasets} from 'sentry/utils/discover/types';
1717
import {QueryClientProvider} from 'sentry/utils/queryClient';
1818
import useReplayData from 'sentry/utils/replays/hooks/useReplayData';
19+
import {OrganizationContext} from 'sentry/views/organizationContext';
1920
import type {HydratedReplayRecord} from 'sentry/views/replays/types';
2021

2122
const {organization, project} = initializeOrg();
@@ -29,7 +30,11 @@ function wrapper({children}: {children?: ReactNode}) {
2930

3031
queryClient.invalidateQueries = mockInvalidateQueries;
3132

32-
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
33+
return (
34+
<QueryClientProvider client={queryClient}>
35+
<OrganizationContext value={organization}>{children}</OrganizationContext>
36+
</QueryClientProvider>
37+
);
3338
}
3439

3540
function getMockReplayRecord(replayRecord?: Partial<HydratedReplayRecord>) {
@@ -96,6 +101,7 @@ describe('useReplayData', () => {
96101
expect(result.current).toEqual({
97102
attachments: expect.any(Array),
98103
errors: expect.any(Array),
104+
feedbackEvents: expect.any(Array),
99105
fetchError: undefined,
100106
isError: false,
101107
isPending: false,
@@ -455,6 +461,7 @@ describe('useReplayData', () => {
455461
const expectedReplayData = {
456462
attachments: [],
457463
errors: [],
464+
feedbackEvents: [],
458465
fetchError: undefined,
459466
isError: true,
460467
isPending: true,

static/app/utils/replays/hooks/useReplayData.tsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
44
import useFetchParallelPages from 'sentry/utils/api/useFetchParallelPages';
55
import useFetchSequentialPages from 'sentry/utils/api/useFetchSequentialPages';
66
import {DiscoverDatasets} from 'sentry/utils/discover/types';
7+
import type {FeedbackEvent} from 'sentry/utils/feedback/types';
78
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
89
import type {ApiQueryKey} from 'sentry/utils/queryClient';
910
import {useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
11+
import useFeedbackEvents from 'sentry/utils/replays/hooks/useFeedbackEvents';
1012
import {useReplayProjectSlug} from 'sentry/utils/replays/hooks/useReplayProjectSlug';
1113
import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
1214
import type RequestError from 'sentry/utils/requestError/requestError';
@@ -47,6 +49,7 @@ interface Result {
4749
projectSlug: string | null;
4850
replayRecord: ReplayRecord | undefined;
4951
status: 'pending' | 'error' | 'success';
52+
feedbackEvents?: FeedbackEvent[];
5053
}
5154

5255
/**
@@ -241,6 +244,28 @@ function useReplayData({
241244
});
242245
}, [orgSlug, replayId, projectSlug, queryClient]);
243246

247+
const {allErrors, feedbackEventIds} = useMemo(() => {
248+
const errors = errorPages
249+
.concat(extraErrorPages)
250+
.concat(platformErrorPages)
251+
.flatMap(page => page.data);
252+
253+
const feedbackIds = errors
254+
?.filter(error => error?.title.includes('User Feedback'))
255+
.map(error => error.id);
256+
257+
return {allErrors: errors, feedbackEventIds: feedbackIds};
258+
}, [errorPages, extraErrorPages, platformErrorPages]);
259+
260+
const {
261+
feedbackEvents,
262+
isPending: feedbackEventsPending,
263+
isError: feedbackEventsError,
264+
} = useFeedbackEvents({
265+
feedbackEventIds: feedbackEventIds ?? [],
266+
projectId: replayRecord?.project_id,
267+
});
268+
244269
const allStatuses = [
245270
enableReplayRecord ? fetchReplayStatus : undefined,
246271
enableAttachments ? fetchAttachmentsStatus : undefined,
@@ -249,20 +274,17 @@ function useReplayData({
249274
fetchPlatformErrorsStatus,
250275
];
251276

252-
const isError = allStatuses.includes('error');
253-
const isPending = allStatuses.includes('pending');
277+
const isError = allStatuses.includes('error') || feedbackEventsError;
278+
const isPending = allStatuses.includes('pending') || feedbackEventsPending;
254279
const status = isError ? 'error' : isPending ? 'pending' : 'success';
255280

256281
return useMemo(() => {
257-
const allErrors = errorPages
258-
.concat(extraErrorPages)
259-
.concat(platformErrorPages)
260-
.flatMap(page => page.data);
261282
return {
262283
attachments: attachmentPages.flat(2),
263284
errors: allErrors,
264285
fetchError: fetchReplayError ?? undefined,
265286
attachmentError: fetchAttachmentsError ?? undefined,
287+
feedbackEvents,
266288
isError,
267289
isPending,
268290
status,
@@ -271,18 +293,17 @@ function useReplayData({
271293
replayRecord,
272294
};
273295
}, [
274-
errorPages,
275-
extraErrorPages,
276-
platformErrorPages,
277296
attachmentPages,
278297
fetchReplayError,
279298
fetchAttachmentsError,
299+
feedbackEvents,
280300
isError,
281301
isPending,
282302
status,
283303
clearQueryCache,
284304
projectSlug,
285305
replayRecord,
306+
allErrors,
286307
]);
287308
}
288309

static/app/utils/replays/hydrateErrors.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import invariant from 'invariant';
44
import {defined} from 'sentry/utils';
55
import toArray from 'sentry/utils/array/toArray';
66
import isValidDate from 'sentry/utils/date/isValidDate';
7+
import type {FeedbackEvent} from 'sentry/utils/feedback/types';
78
import type {
89
BreadcrumbFrame,
910
ErrorFrame,
@@ -13,7 +14,8 @@ import type {HydratedReplayRecord} from 'sentry/views/replays/types';
1314

1415
export default function hydrateErrors(
1516
replayRecord: HydratedReplayRecord,
16-
errors: RawReplayError[]
17+
errors: RawReplayError[],
18+
feedbackEvents?: FeedbackEvent[]
1719
): {errorFrames: ErrorFrame[]; feedbackFrames: BreadcrumbFrame[]} {
1820
const startTimestampMs = replayRecord.started_at.getTime();
1921

@@ -39,7 +41,9 @@ export default function hydrateErrors(
3941
labels: toArray(e['error.type']).filter(Boolean),
4042
projectSlug: e['project.name'],
4143
},
42-
message: e.title,
44+
message:
45+
feedbackEvents?.find(event => event.id === e.id)?.contexts.feedback
46+
?.message ?? e.title,
4347
offsetMs: Math.abs(time.getTime() - startTimestampMs),
4448
timestamp: time,
4549
timestampMs: time.getTime(),

static/app/utils/replays/replayReader.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {type Duration, duration} from 'moment-timezone';
55

66
import {defined} from 'sentry/utils';
77
import {domId} from 'sentry/utils/domId';
8+
import type {FeedbackEvent} from 'sentry/utils/feedback/types';
89
import localStorageWrapper from 'sentry/utils/localStorage';
910
import clamp from 'sentry/utils/number/clamp';
1011
import type {Extraction} from 'sentry/utils/replays/extractDomNodes';
@@ -86,11 +87,16 @@ interface ReplayReaderParams {
8687
* If provided, the replay will be clipped to this window.
8788
*/
8889
clipWindow?: ClipWindow;
90+
8991
/**
9092
* Relates to the setting of the clip window. If the event timestamp is before the replay started,
9193
* the clip window will be set to the start of the replay.
9294
*/
9395
eventTimestampMs?: number;
96+
/**
97+
* Feedbacks in this replay
98+
*/
99+
feedbackEvents?: FeedbackEvent[];
94100
}
95101

96102
type RequiredNotNull<T> = {
@@ -159,6 +165,7 @@ export default class ReplayReader {
159165
static factory({
160166
attachments,
161167
errors,
168+
feedbackEvents,
162169
replayRecord,
163170
clipWindow,
164171
fetching,
@@ -172,6 +179,7 @@ export default class ReplayReader {
172179
return new ReplayReader({
173180
attachments,
174181
errors,
182+
feedbackEvents,
175183
replayRecord,
176184
fetching,
177185
clipWindow,
@@ -187,6 +195,7 @@ export default class ReplayReader {
187195
return new ReplayReader({
188196
attachments: [],
189197
errors: [],
198+
feedbackEvents,
190199
fetching,
191200
replayRecord,
192201
clipWindow,
@@ -198,6 +207,7 @@ export default class ReplayReader {
198207
private constructor({
199208
attachments,
200209
errors,
210+
feedbackEvents,
201211
fetching,
202212
replayRecord,
203213
clipWindow,
@@ -248,7 +258,11 @@ export default class ReplayReader {
248258
this._replayRecord = replayRecord;
249259
// Errors don't need to be sorted here, they will be merged with breadcrumbs
250260
// and spans in the getter and then sorted together.
251-
const {errorFrames, feedbackFrames} = hydrateErrors(replayRecord, errors);
261+
const {errorFrames, feedbackFrames} = hydrateErrors(
262+
replayRecord,
263+
errors,
264+
feedbackEvents
265+
);
252266
this._errors = errorFrames.sort(sortFrames);
253267
// RRWeb Events are not sorted here, they are fetched in sorted order.
254268
this._sortedRRWebEvents = rrwebFrames;

0 commit comments

Comments
 (0)