Skip to content

Commit ab2945b

Browse files
c298leeryan953
andauthored
feat(replay): Web Vital Breadcrumb Design (#76320)
Implements the newest web vital breadcrumbs design: https://www.figma.com/design/bZIT1O93bdxTNbNWDUvCyQ/Specs%3A-Breadcrumbs-%26-Web-Vitals?node-id=277-336&node-type=CANVAS&t=ccSNvPkjFTZWWpDY-0 For web vitals, `nodeId` was changed to `nodeIds`, which means there could be multiple elements associated with a breadcrumb item. Highlighting and code snippet extraction were updated to accept multiple `nodeIds`. When hovering over a web vital breadcrumb, all the associated elements will be highlighted, and when hovering over a selector, only that associated element would be highlighted. Since the SDK changes have not been merged and released yet, the CLS web vital is currently using web vitals to show what they would look like. https://github.com/user-attachments/assets/df881120-ddff-4b74-a9e7-6fb1c17ae04e Closes #69881 --------- Co-authored-by: Ryan Albrecht <[email protected]>
1 parent abf397d commit ab2945b

File tree

11 files changed

+326
-116
lines changed

11 files changed

+326
-116
lines changed

static/app/components/replays/breadcrumbs/breadcrumbItem.tsx

+164-21
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type {CSSProperties, MouseEvent} from 'react';
1+
import type {CSSProperties, ReactNode} from 'react';
22
import {isValidElement, memo, useCallback} from 'react';
33
import styled from '@emotion/styled';
44
import beautify from 'js-beautify';
55

66
import ProjectAvatar from 'sentry/components/avatar/projectAvatar';
7+
import {Button} from 'sentry/components/button';
78
import {CodeSnippet} from 'sentry/components/codeSnippet';
89
import {Flex} from 'sentry/components/container/flex';
910
import ErrorBoundary from 'sentry/components/errorBoundary';
@@ -13,6 +14,7 @@ import PanelItem from 'sentry/components/panels/panelItem';
1314
import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton';
1415
import {useReplayContext} from 'sentry/components/replays/replayContext';
1516
import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext';
17+
import StructuredEventData from 'sentry/components/structuredEventData';
1618
import Timeline from 'sentry/components/timeline';
1719
import {useHasNewTimelineUI} from 'sentry/components/timeline/utils';
1820
import {Tooltip} from 'sentry/components/tooltip';
@@ -21,37 +23,37 @@ import {space} from 'sentry/styles/space';
2123
import type {Extraction} from 'sentry/utils/replays/extractHtml';
2224
import {getReplayDiffOffsetsFromFrame} from 'sentry/utils/replays/getDiffTimestamps';
2325
import getFrameDetails from 'sentry/utils/replays/getFrameDetails';
26+
import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes';
2427
import type ReplayReader from 'sentry/utils/replays/replayReader';
2528
import type {
2629
ErrorFrame,
2730
FeedbackFrame,
2831
HydrationErrorFrame,
2932
ReplayFrame,
33+
WebVitalFrame,
3034
} from 'sentry/utils/replays/types';
3135
import {
3236
isBreadcrumbFrame,
3337
isErrorFrame,
3438
isFeedbackFrame,
3539
isHydrationErrorFrame,
40+
isSpanFrame,
41+
isWebVitalFrame,
3642
} from 'sentry/utils/replays/types';
3743
import type {Color} from 'sentry/utils/theme';
3844
import useOrganization from 'sentry/utils/useOrganization';
3945
import useProjectFromSlug from 'sentry/utils/useProjectFromSlug';
4046
import IconWrapper from 'sentry/views/replays/detail/iconWrapper';
4147
import TimestampButton from 'sentry/views/replays/detail/timestampButton';
4248

43-
type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent<HTMLElement>) => void;
49+
type MouseCallback = (frame: ReplayFrame, nodeId?: number) => void;
4450

4551
const FRAMES_WITH_BUTTONS = ['replay.hydrate-error'];
4652

4753
interface Props {
4854
frame: ReplayFrame;
4955
onClick: null | MouseCallback;
50-
onInspectorExpanded: (
51-
path: string,
52-
expandedState: Record<string, boolean>,
53-
event: MouseEvent<HTMLDivElement>
54-
) => void;
56+
onInspectorExpanded: (path: string, expandedState: Record<string, boolean>) => void;
5557
onMouseEnter: MouseCallback;
5658
onMouseLeave: MouseCallback;
5759
startTimestampMs: number;
@@ -105,15 +107,31 @@ function BreadcrumbItem({
105107
) : null;
106108
}, [frame, replay]);
107109

108-
const renderCodeSnippet = useCallback(() => {
109-
return extraction?.html ? (
110-
<CodeContainer>
111-
<CodeSnippet language="html" hideCopyButton>
112-
{beautify.html(extraction?.html, {indent_size: 2})}
113-
</CodeSnippet>
114-
</CodeContainer>
110+
const renderWebVital = useCallback(() => {
111+
return isSpanFrame(frame) && isWebVitalFrame(frame) ? (
112+
<WebVitalData
113+
replay={replay}
114+
frame={frame}
115+
expandPaths={expandPaths}
116+
onInspectorExpanded={onInspectorExpanded}
117+
onMouseEnter={onMouseEnter}
118+
onMouseLeave={onMouseLeave}
119+
/>
115120
) : null;
116-
}, [extraction?.html]);
121+
}, [expandPaths, frame, onInspectorExpanded, onMouseEnter, onMouseLeave, replay]);
122+
123+
const renderCodeSnippet = useCallback(() => {
124+
return (
125+
(!isSpanFrame(frame) || !isWebVitalFrame(frame)) &&
126+
extraction?.html?.map(html => (
127+
<CodeContainer key={html}>
128+
<CodeSnippet language="html" hideCopyButton>
129+
{beautify.html(html, {indent_size: 2})}
130+
</CodeSnippet>
131+
</CodeContainer>
132+
))
133+
);
134+
}, [extraction?.html, frame]);
117135

118136
const renderIssueLink = useCallback(() => {
119137
return isErrorFrame(frame) || isFeedbackFrame(frame) ? (
@@ -143,13 +161,17 @@ function BreadcrumbItem({
143161
data-is-error-frame={isErrorFrame(frame)}
144162
style={style}
145163
className={className}
146-
onClick={e => onClick?.(frame, e)}
147-
onMouseEnter={e => onMouseEnter(frame, e)}
148-
onMouseLeave={e => onMouseLeave(frame, e)}
164+
onClick={event => {
165+
event.stopPropagation();
166+
onClick?.(frame);
167+
}}
168+
onMouseEnter={() => onMouseEnter(frame)}
169+
onMouseLeave={() => onMouseLeave(frame)}
149170
>
150171
<ErrorBoundary mini>
151172
{renderDescription()}
152173
{renderComparisonButton()}
174+
{renderWebVital()}
153175
{renderCodeSnippet()}
154176
{renderIssueLink()}
155177
</ErrorBoundary>
@@ -160,9 +182,12 @@ function BreadcrumbItem({
160182
<CrumbItem
161183
data-is-error-frame={isErrorFrame(frame)}
162184
as={onClick && !forceSpan ? 'button' : 'span'}
163-
onClick={e => onClick?.(frame, e)}
164-
onMouseEnter={e => onMouseEnter(frame, e)}
165-
onMouseLeave={e => onMouseLeave(frame, e)}
185+
onClick={event => {
186+
event.stopPropagation();
187+
onClick?.(frame);
188+
}}
189+
onMouseEnter={() => onMouseEnter(frame)}
190+
onMouseLeave={() => onMouseLeave(frame)}
166191
style={style}
167192
className={className}
168193
>
@@ -184,6 +209,7 @@ function BreadcrumbItem({
184209
{renderDescription()}
185210
</Flex>
186211
{renderComparisonButton()}
212+
{renderWebVital()}
187213
{renderCodeSnippet()}
188214
{renderIssueLink()}
189215
</CrumbDetails>
@@ -192,6 +218,100 @@ function BreadcrumbItem({
192218
);
193219
}
194220

221+
function WebVitalData({
222+
replay,
223+
frame,
224+
expandPaths,
225+
onInspectorExpanded,
226+
onMouseEnter,
227+
onMouseLeave,
228+
}: {
229+
expandPaths: string[] | undefined;
230+
frame: WebVitalFrame;
231+
onInspectorExpanded: (path: string, expandedState: Record<string, boolean>) => void;
232+
onMouseEnter: MouseCallback;
233+
onMouseLeave: MouseCallback;
234+
replay: ReplayReader | null;
235+
}) {
236+
const {data: frameToExtraction} = useExtractDomNodes({replay});
237+
const selectors = frameToExtraction?.get(frame)?.selectors;
238+
239+
const webVitalData = {value: frame.data.value};
240+
if (
241+
frame.description === 'cumulative-layout-shift' &&
242+
frame.data.attributions &&
243+
selectors
244+
) {
245+
const layoutShifts: {[x: string]: ReactNode[]}[] = [];
246+
for (const attr of frame.data.attributions) {
247+
const elements: ReactNode[] = [];
248+
if ('nodeIds' in attr && Array.isArray(attr.nodeIds)) {
249+
attr.nodeIds.forEach(nodeId => {
250+
selectors.get(nodeId)
251+
? elements.push(
252+
<span
253+
key={nodeId}
254+
onMouseEnter={() => onMouseEnter(frame, nodeId)}
255+
onMouseLeave={() => onMouseLeave(frame, nodeId)}
256+
>
257+
<ValueObjectKey>{t('element')}</ValueObjectKey>
258+
<span>{': '}</span>
259+
<span>
260+
<SelectorButton>{selectors.get(nodeId)}</SelectorButton>
261+
</span>
262+
</span>
263+
)
264+
: null;
265+
});
266+
}
267+
// if we can't find the elements associated with the layout shift, we still show the score with element: unknown
268+
if (!elements.length) {
269+
elements.push(
270+
<span>
271+
<ValueObjectKey>{t('element')}</ValueObjectKey>
272+
<span>{': '}</span>
273+
<ValueNull>{t('unknown')}</ValueNull>
274+
</span>
275+
);
276+
}
277+
layoutShifts.push({[`score ${attr.value}`]: elements});
278+
}
279+
if (layoutShifts.length) {
280+
webVitalData['Layout shifts'] = layoutShifts;
281+
}
282+
} else if (selectors?.size) {
283+
selectors.forEach((key, value) => {
284+
webVitalData[key] = (
285+
<span
286+
key={key}
287+
onMouseEnter={() => onMouseEnter(frame, value)}
288+
onMouseLeave={() => onMouseLeave(frame, value)}
289+
>
290+
<ValueObjectKey>{t('element')}</ValueObjectKey>
291+
<span>{': '}</span>
292+
<SelectorButton size="zero" borderless>
293+
{key}
294+
</SelectorButton>
295+
</span>
296+
);
297+
});
298+
}
299+
300+
return (
301+
<StructuredEventData
302+
initialExpandedPaths={expandPaths ?? []}
303+
onToggleExpand={(expandedPaths, path) => {
304+
onInspectorExpanded(
305+
path,
306+
Object.fromEntries(expandedPaths.map(item => [item, true]))
307+
);
308+
}}
309+
data={webVitalData}
310+
withAnnotatedText
311+
/>
312+
);
313+
}
314+
195315
function CrumbHydrationButton({
196316
replay,
197317
frame,
@@ -381,4 +501,27 @@ const CodeContainer = styled('div')`
381501
overflow: auto;
382502
`;
383503

504+
const ValueObjectKey = styled('span')`
505+
color: var(--prism-keyword);
506+
`;
507+
508+
const ValueNull = styled('span')`
509+
font-weight: ${p => p.theme.fontWeightBold};
510+
color: var(--prism-property);
511+
`;
512+
513+
const SelectorButton = styled(Button)`
514+
background: none;
515+
border: none;
516+
padding: 0 2px;
517+
border-radius: 2px;
518+
font-weight: ${p => p.theme.fontWeightNormal};
519+
box-shadow: none;
520+
font-size: ${p => p.theme.fontSizeSmall};
521+
color: ${p => p.theme.subText};
522+
margin: 0 ${space(0.5)};
523+
height: auto;
524+
min-height: auto;
525+
`;
526+
384527
export default memo(BreadcrumbItem);

static/app/utils/replays/extractHtml.tsx

+52-5
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,72 @@
11
import type {Mirror} from '@sentry-internal/rrweb-snapshot';
22

33
import type {ReplayFrame} from 'sentry/utils/replays/types';
4+
import constructSelector from 'sentry/views/replays/deadRageClick/constructSelector';
45

56
export type Extraction = {
67
frame: ReplayFrame;
7-
html: string | null;
8+
html: string[];
9+
selectors: Map<number, string>;
810
timestamp: number;
911
};
1012

11-
export default function extractHtml(nodeId: number, mirror: Mirror): string | null {
12-
const node = mirror.getNode(nodeId);
13+
export default function extractHtmlAndSelector(
14+
nodeIds: number[],
15+
mirror: Mirror
16+
): {html: string[]; selectors: Map<number, string>} {
17+
const htmlStrings: string[] = [];
18+
const selectors = new Map<number, string>();
19+
for (const nodeId of nodeIds) {
20+
const node = mirror.getNode(nodeId);
21+
if (node) {
22+
const html = extractHtml(node);
23+
if (html) {
24+
htmlStrings.push(html);
25+
}
26+
27+
const selector = extractSelector(node);
28+
if (selector) {
29+
selectors.set(nodeId, selector);
30+
}
31+
}
32+
}
33+
return {html: htmlStrings, selectors};
34+
}
1335

36+
function extractHtml(node: Node): string | null {
1437
const html =
15-
(node && 'outerHTML' in node ? (node.outerHTML as string) : node?.textContent) || '';
38+
('outerHTML' in node ? (node.outerHTML as string) : node.textContent) || '';
1639
// Limit document node depth to 2
1740
let truncated = removeNodesAtLevel(html, 2);
1841
// If still very long and/or removeNodesAtLevel failed, truncate
1942
if (truncated.length > 1500) {
2043
truncated = truncated.substring(0, 1500);
2144
}
22-
return truncated ? truncated : null;
45+
if (truncated) {
46+
return truncated;
47+
}
48+
return null;
49+
}
50+
51+
function extractSelector(node: Node): string | null {
52+
const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null;
53+
54+
if (element) {
55+
return constructSelector({
56+
alt: element.attributes.getNamedItem('alt')?.nodeValue ?? '',
57+
aria_label: element.attributes.getNamedItem('aria-label')?.nodeValue ?? '',
58+
class: element.attributes.getNamedItem('class')?.nodeValue?.split(' ') ?? [],
59+
component_name:
60+
element.attributes.getNamedItem('data-sentry-component')?.nodeValue ?? '',
61+
id: element.id,
62+
role: element.attributes.getNamedItem('role')?.nodeValue ?? '',
63+
tag: element.tagName.toLowerCase(),
64+
testid: element.attributes.getNamedItem('data-test-id')?.nodeValue ?? '',
65+
title: element.attributes.getNamedItem('title')?.nodeValue ?? '',
66+
}).selector;
67+
}
68+
69+
return null;
2370
}
2471

2572
function removeChildLevel(max: number, collection: HTMLCollection, current: number = 0) {

static/app/utils/replays/getFrameDetails.tsx

+6-6
Original file line numberDiff line numberDiff line change
@@ -286,31 +286,31 @@ const MAPPER_FOR_FRAME: Record<string, (frame) => Details> = {
286286
case 'good':
287287
return {
288288
color: 'green300',
289-
description: tct('Good [value]ms', {
289+
description: tct('[value]ms (Good)', {
290290
value: frame.data.value.toFixed(2),
291291
}),
292292
tabKey: TabKey.NETWORK,
293-
title: toTitleCase(explodeSlug(frame.description)),
293+
title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)),
294294
icon: <IconHappy size="xs" />,
295295
};
296296
case 'needs-improvement':
297297
return {
298298
color: 'yellow300',
299-
description: tct('Meh [value]ms', {
299+
description: tct('[value]ms (Meh)', {
300300
value: frame.data.value.toFixed(2),
301301
}),
302302
tabKey: TabKey.NETWORK,
303-
title: toTitleCase(explodeSlug(frame.description)),
303+
title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)),
304304
icon: <IconMeh size="xs" />,
305305
};
306306
default:
307307
return {
308308
color: 'red300',
309-
description: tct('Poor [value]ms', {
309+
description: tct('[value]ms (Poor)', {
310310
value: frame.data.value.toFixed(2),
311311
}),
312312
tabKey: TabKey.NETWORK,
313-
title: toTitleCase(explodeSlug(frame.description)),
313+
title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)),
314314
icon: <IconSad size="xs" />,
315315
};
316316
}

0 commit comments

Comments
 (0)