From ab2945bcceafd84fc16303b89cdd186592ba1e4d Mon Sep 17 00:00:00 2001 From: Catherine Lee <55311782+c298lee@users.noreply.github.com> Date: Tue, 3 Sep 2024 17:26:29 -0400 Subject: [PATCH] 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 https://github.com/getsentry/sentry/issues/69881 --------- Co-authored-by: Ryan Albrecht --- .../replays/breadcrumbs/breadcrumbItem.tsx | 185 ++++++++++++++++-- static/app/utils/replays/extractHtml.tsx | 57 +++++- static/app/utils/replays/getFrameDetails.tsx | 12 +- static/app/utils/replays/highlightNode.tsx | 108 +++++----- .../utils/replays/hooks/useCrumbHandlers.tsx | 17 +- .../hooks/useInitialTimeOffsetMs.spec.tsx | 4 +- .../replays/hooks/useInitialTimeOffsetMs.tsx | 2 +- static/app/utils/replays/replayReader.tsx | 34 +++- static/app/utils/replays/types.tsx | 8 +- .../detail/breadcrumbs/breadcrumbRow.tsx | 7 +- .../detail/useVirtualizedInspector.tsx | 8 +- 11 files changed, 326 insertions(+), 116 deletions(-) diff --git a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx index 5a9f00cecf9dee..5b0fb28f1b1916 100644 --- a/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx +++ b/static/app/components/replays/breadcrumbs/breadcrumbItem.tsx @@ -1,9 +1,10 @@ -import type {CSSProperties, MouseEvent} from 'react'; +import type {CSSProperties, ReactNode} from 'react'; import {isValidElement, memo, useCallback} from 'react'; import styled from '@emotion/styled'; import beautify from 'js-beautify'; import ProjectAvatar from 'sentry/components/avatar/projectAvatar'; +import {Button} from 'sentry/components/button'; import {CodeSnippet} from 'sentry/components/codeSnippet'; import {Flex} from 'sentry/components/container/flex'; import ErrorBoundary from 'sentry/components/errorBoundary'; @@ -13,6 +14,7 @@ import PanelItem from 'sentry/components/panels/panelItem'; import {OpenReplayComparisonButton} from 'sentry/components/replays/breadcrumbs/openReplayComparisonButton'; import {useReplayContext} from 'sentry/components/replays/replayContext'; import {useReplayGroupContext} from 'sentry/components/replays/replayGroupContext'; +import StructuredEventData from 'sentry/components/structuredEventData'; import Timeline from 'sentry/components/timeline'; import {useHasNewTimelineUI} from 'sentry/components/timeline/utils'; import {Tooltip} from 'sentry/components/tooltip'; @@ -21,18 +23,22 @@ import {space} from 'sentry/styles/space'; import type {Extraction} from 'sentry/utils/replays/extractHtml'; import {getReplayDiffOffsetsFromFrame} from 'sentry/utils/replays/getDiffTimestamps'; import getFrameDetails from 'sentry/utils/replays/getFrameDetails'; +import useExtractDomNodes from 'sentry/utils/replays/hooks/useExtractDomNodes'; import type ReplayReader from 'sentry/utils/replays/replayReader'; import type { ErrorFrame, FeedbackFrame, HydrationErrorFrame, ReplayFrame, + WebVitalFrame, } from 'sentry/utils/replays/types'; import { isBreadcrumbFrame, isErrorFrame, isFeedbackFrame, isHydrationErrorFrame, + isSpanFrame, + isWebVitalFrame, } from 'sentry/utils/replays/types'; import type {Color} from 'sentry/utils/theme'; import useOrganization from 'sentry/utils/useOrganization'; @@ -40,18 +46,14 @@ import useProjectFromSlug from 'sentry/utils/useProjectFromSlug'; import IconWrapper from 'sentry/views/replays/detail/iconWrapper'; import TimestampButton from 'sentry/views/replays/detail/timestampButton'; -type MouseCallback = (frame: ReplayFrame, e: React.MouseEvent) => void; +type MouseCallback = (frame: ReplayFrame, nodeId?: number) => void; const FRAMES_WITH_BUTTONS = ['replay.hydrate-error']; interface Props { frame: ReplayFrame; onClick: null | MouseCallback; - onInspectorExpanded: ( - path: string, - expandedState: Record, - event: MouseEvent - ) => void; + onInspectorExpanded: (path: string, expandedState: Record) => void; onMouseEnter: MouseCallback; onMouseLeave: MouseCallback; startTimestampMs: number; @@ -105,15 +107,31 @@ function BreadcrumbItem({ ) : null; }, [frame, replay]); - const renderCodeSnippet = useCallback(() => { - return extraction?.html ? ( - - - {beautify.html(extraction?.html, {indent_size: 2})} - - + const renderWebVital = useCallback(() => { + return isSpanFrame(frame) && isWebVitalFrame(frame) ? ( + ) : null; - }, [extraction?.html]); + }, [expandPaths, frame, onInspectorExpanded, onMouseEnter, onMouseLeave, replay]); + + const renderCodeSnippet = useCallback(() => { + return ( + (!isSpanFrame(frame) || !isWebVitalFrame(frame)) && + extraction?.html?.map(html => ( + + + {beautify.html(html, {indent_size: 2})} + + + )) + ); + }, [extraction?.html, frame]); const renderIssueLink = useCallback(() => { return isErrorFrame(frame) || isFeedbackFrame(frame) ? ( @@ -143,13 +161,17 @@ function BreadcrumbItem({ data-is-error-frame={isErrorFrame(frame)} style={style} className={className} - onClick={e => onClick?.(frame, e)} - onMouseEnter={e => onMouseEnter(frame, e)} - onMouseLeave={e => onMouseLeave(frame, e)} + onClick={event => { + event.stopPropagation(); + onClick?.(frame); + }} + onMouseEnter={() => onMouseEnter(frame)} + onMouseLeave={() => onMouseLeave(frame)} > {renderDescription()} {renderComparisonButton()} + {renderWebVital()} {renderCodeSnippet()} {renderIssueLink()} @@ -160,9 +182,12 @@ function BreadcrumbItem({ onClick?.(frame, e)} - onMouseEnter={e => onMouseEnter(frame, e)} - onMouseLeave={e => onMouseLeave(frame, e)} + onClick={event => { + event.stopPropagation(); + onClick?.(frame); + }} + onMouseEnter={() => onMouseEnter(frame)} + onMouseLeave={() => onMouseLeave(frame)} style={style} className={className} > @@ -184,6 +209,7 @@ function BreadcrumbItem({ {renderDescription()} {renderComparisonButton()} + {renderWebVital()} {renderCodeSnippet()} {renderIssueLink()} @@ -192,6 +218,100 @@ function BreadcrumbItem({ ); } +function WebVitalData({ + replay, + frame, + expandPaths, + onInspectorExpanded, + onMouseEnter, + onMouseLeave, +}: { + expandPaths: string[] | undefined; + frame: WebVitalFrame; + onInspectorExpanded: (path: string, expandedState: Record) => void; + onMouseEnter: MouseCallback; + onMouseLeave: MouseCallback; + replay: ReplayReader | null; +}) { + const {data: frameToExtraction} = useExtractDomNodes({replay}); + const selectors = frameToExtraction?.get(frame)?.selectors; + + const webVitalData = {value: frame.data.value}; + if ( + frame.description === 'cumulative-layout-shift' && + frame.data.attributions && + selectors + ) { + const layoutShifts: {[x: string]: ReactNode[]}[] = []; + for (const attr of frame.data.attributions) { + const elements: ReactNode[] = []; + if ('nodeIds' in attr && Array.isArray(attr.nodeIds)) { + attr.nodeIds.forEach(nodeId => { + selectors.get(nodeId) + ? elements.push( + onMouseEnter(frame, nodeId)} + onMouseLeave={() => onMouseLeave(frame, nodeId)} + > + {t('element')} + {': '} + + {selectors.get(nodeId)} + + + ) + : null; + }); + } + // if we can't find the elements associated with the layout shift, we still show the score with element: unknown + if (!elements.length) { + elements.push( + + {t('element')} + {': '} + {t('unknown')} + + ); + } + layoutShifts.push({[`score ${attr.value}`]: elements}); + } + if (layoutShifts.length) { + webVitalData['Layout shifts'] = layoutShifts; + } + } else if (selectors?.size) { + selectors.forEach((key, value) => { + webVitalData[key] = ( + onMouseEnter(frame, value)} + onMouseLeave={() => onMouseLeave(frame, value)} + > + {t('element')} + {': '} + + {key} + + + ); + }); + } + + return ( + { + onInspectorExpanded( + path, + Object.fromEntries(expandedPaths.map(item => [item, true])) + ); + }} + data={webVitalData} + withAnnotatedText + /> + ); +} + function CrumbHydrationButton({ replay, frame, @@ -381,4 +501,27 @@ const CodeContainer = styled('div')` overflow: auto; `; +const ValueObjectKey = styled('span')` + color: var(--prism-keyword); +`; + +const ValueNull = styled('span')` + font-weight: ${p => p.theme.fontWeightBold}; + color: var(--prism-property); +`; + +const SelectorButton = styled(Button)` + background: none; + border: none; + padding: 0 2px; + border-radius: 2px; + font-weight: ${p => p.theme.fontWeightNormal}; + box-shadow: none; + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.subText}; + margin: 0 ${space(0.5)}; + height: auto; + min-height: auto; +`; + export default memo(BreadcrumbItem); diff --git a/static/app/utils/replays/extractHtml.tsx b/static/app/utils/replays/extractHtml.tsx index cb6333177d5396..aedc99f40d6a1a 100644 --- a/static/app/utils/replays/extractHtml.tsx +++ b/static/app/utils/replays/extractHtml.tsx @@ -1,25 +1,72 @@ import type {Mirror} from '@sentry-internal/rrweb-snapshot'; import type {ReplayFrame} from 'sentry/utils/replays/types'; +import constructSelector from 'sentry/views/replays/deadRageClick/constructSelector'; export type Extraction = { frame: ReplayFrame; - html: string | null; + html: string[]; + selectors: Map; timestamp: number; }; -export default function extractHtml(nodeId: number, mirror: Mirror): string | null { - const node = mirror.getNode(nodeId); +export default function extractHtmlAndSelector( + nodeIds: number[], + mirror: Mirror +): {html: string[]; selectors: Map} { + const htmlStrings: string[] = []; + const selectors = new Map(); + for (const nodeId of nodeIds) { + const node = mirror.getNode(nodeId); + if (node) { + const html = extractHtml(node); + if (html) { + htmlStrings.push(html); + } + + const selector = extractSelector(node); + if (selector) { + selectors.set(nodeId, selector); + } + } + } + return {html: htmlStrings, selectors}; +} +function extractHtml(node: Node): string | null { const html = - (node && 'outerHTML' in node ? (node.outerHTML as string) : node?.textContent) || ''; + ('outerHTML' in node ? (node.outerHTML as string) : node.textContent) || ''; // Limit document node depth to 2 let truncated = removeNodesAtLevel(html, 2); // If still very long and/or removeNodesAtLevel failed, truncate if (truncated.length > 1500) { truncated = truncated.substring(0, 1500); } - return truncated ? truncated : null; + if (truncated) { + return truncated; + } + return null; +} + +function extractSelector(node: Node): string | null { + const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; + + if (element) { + return constructSelector({ + alt: element.attributes.getNamedItem('alt')?.nodeValue ?? '', + aria_label: element.attributes.getNamedItem('aria-label')?.nodeValue ?? '', + class: element.attributes.getNamedItem('class')?.nodeValue?.split(' ') ?? [], + component_name: + element.attributes.getNamedItem('data-sentry-component')?.nodeValue ?? '', + id: element.id, + role: element.attributes.getNamedItem('role')?.nodeValue ?? '', + tag: element.tagName.toLowerCase(), + testid: element.attributes.getNamedItem('data-test-id')?.nodeValue ?? '', + title: element.attributes.getNamedItem('title')?.nodeValue ?? '', + }).selector; + } + + return null; } function removeChildLevel(max: number, collection: HTMLCollection, current: number = 0) { diff --git a/static/app/utils/replays/getFrameDetails.tsx b/static/app/utils/replays/getFrameDetails.tsx index a1e409f112a5ee..5720b198c218b3 100644 --- a/static/app/utils/replays/getFrameDetails.tsx +++ b/static/app/utils/replays/getFrameDetails.tsx @@ -286,31 +286,31 @@ const MAPPER_FOR_FRAME: Record Details> = { case 'good': return { color: 'green300', - description: tct('Good [value]ms', { + description: tct('[value]ms (Good)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; case 'needs-improvement': return { color: 'yellow300', - description: tct('Meh [value]ms', { + description: tct('[value]ms (Meh)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; default: return { color: 'red300', - description: tct('Poor [value]ms', { + description: tct('[value]ms (Poor)', { value: frame.data.value.toFixed(2), }), tabKey: TabKey.NETWORK, - title: toTitleCase(explodeSlug(frame.description)), + title: 'Web Vital: ' + toTitleCase(explodeSlug(frame.description)), icon: , }; } diff --git a/static/app/utils/replays/highlightNode.tsx b/static/app/utils/replays/highlightNode.tsx index 40da9a364cf557..630f20ed9d7ea8 100644 --- a/static/app/utils/replays/highlightNode.tsx +++ b/static/app/utils/replays/highlightNode.tsx @@ -2,31 +2,31 @@ import type {Replayer} from '@sentry-internal/rrweb'; const DEFAULT_HIGHLIGHT_COLOR = 'rgba(168, 196, 236, 0.75)'; -const highlightsByNodeId: Map = new Map(); +const highlightsByNodeIds: Map = new Map(); const highlightsBySelector: Map = new Map(); type DrawProps = {annotation: string; color: string; spotlight: boolean}; -interface AddHighlightByNodeIdParams extends Partial { - nodeId: number; +interface AddHighlightByNodeIdsParams extends Partial { + nodeIds: number[]; } interface AddHighlightBySelectorParams extends Partial { selector: string; } -type AddHighlightParams = AddHighlightByNodeIdParams | AddHighlightBySelectorParams; +type AddHighlightParams = AddHighlightByNodeIdsParams | AddHighlightBySelectorParams; type RemoveHighlightParams = | { - nodeId: number; + nodeIds: number[]; } | { selector: string; }; export function clearAllHighlights(replayer: Replayer) { - for (const nodeId of highlightsByNodeId.keys()) { - removeHighlightedNode(replayer, {nodeId}); + for (const nodeId of highlightsByNodeIds.keys()) { + removeHighlightedNode(replayer, {nodeIds: [nodeId]}); } for (const selector of highlightsBySelector.keys()) { removeHighlightedNode(replayer, {selector}); @@ -40,11 +40,13 @@ export function clearAllHighlights(replayer: Replayer) { * are creating a new canvas PER highlight. */ export function removeHighlightedNode(replayer: Replayer, props: RemoveHighlightParams) { - if ('nodeId' in props) { - const highlightObj = highlightsByNodeId.get(props.nodeId); - if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) { - replayer.wrapper.removeChild(highlightObj.canvas); - highlightsByNodeId.delete(props.nodeId); + if ('nodeIds' in props) { + for (const nodeId of props.nodeIds) { + const highlightObj = highlightsByNodeIds.get(nodeId); + if (highlightObj && replayer.wrapper.contains(highlightObj.canvas)) { + replayer.wrapper.removeChild(highlightObj.canvas); + highlightsByNodeIds.delete(nodeId); + } } } else { const highlightObj = highlightsBySelector.get(props.selector); @@ -62,55 +64,53 @@ export function highlightNode(replayer: Replayer, props: AddHighlightParams) { const {wrapper} = replayer; const mirror = replayer.getMirror(); - const node = - 'nodeId' in props - ? mirror.getNode(props.nodeId) - : replayer.iframe.contentDocument?.body.querySelector(props.selector); - - // TODO(replays): There is some sort of race condition here when you "rewind" a replay, - // mirror will be empty and highlight does not get added because node is null - if ( - !node || - !('getBoundingClientRect' in node) || - !replayer.iframe.contentDocument?.body?.contains(node) - ) { - return null; - } - - // Create a new canvas with the same dimensions as the iframe. We may need to - // revisit this strategy as we create a new canvas for every highlight. See - // additional notes in removeHighlight() method. - const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; + const nodes = + 'nodeIds' in props + ? props.nodeIds.map(nodeId => mirror.getNode(nodeId)) + : [replayer.iframe.contentDocument?.body.querySelector(props.selector)]; + + for (const node of nodes) { + // TODO(replays): There is some sort of race condition here when you "rewind" a replay, + // mirror will be empty and highlight does not get added because node is null + if ( + !node || + !('getBoundingClientRect' in node) || + !replayer.iframe.contentDocument?.body?.contains(node) + ) { + continue; + } - if (!element) { - return null; - } + // Create a new canvas with the same dimensions as the iframe. We may need to + // revisit this strategy as we create a new canvas for every highlight. See + // additional notes in removeHighlight() method. + const element = node.nodeType === Node.ELEMENT_NODE ? (node as HTMLElement) : null; - const canvas = document.createElement('canvas'); - canvas.width = Number(replayer.iframe.width); - canvas.height = Number(replayer.iframe.height); - canvas.setAttribute('style', 'position:absolute;'); + if (!element) { + continue; + } - const boundingClientRect = element.getBoundingClientRect(); - const drawProps = { - annotation: props.annotation ?? '', - color: props.color ?? DEFAULT_HIGHLIGHT_COLOR, - spotlight: props.spotlight ?? false, - }; + const canvas = document.createElement('canvas'); + canvas.width = Number(replayer.iframe.width); + canvas.height = Number(replayer.iframe.height); + canvas.setAttribute('style', 'position:absolute;'); - drawCtx(canvas, boundingClientRect, drawProps); + const boundingClientRect = element.getBoundingClientRect(); + const drawProps = { + annotation: props.annotation ?? '', + color: props.color ?? DEFAULT_HIGHLIGHT_COLOR, + spotlight: props.spotlight ?? false, + }; - if ('nodeId' in props) { - highlightsByNodeId.set(props.nodeId, {canvas}); - } else { - highlightsBySelector.set(props.selector, {canvas}); - } + drawCtx(canvas, boundingClientRect, drawProps); - wrapper.insertBefore(canvas, replayer.iframe); + if ('nodeIds' in props) { + highlightsByNodeIds.set(mirror.getId(node), {canvas}); + } else { + highlightsBySelector.set(props.selector, {canvas}); + } - return { - canvas, - }; + wrapper.insertBefore(canvas, replayer.iframe); + } } function drawCtx( diff --git a/static/app/utils/replays/hooks/useCrumbHandlers.tsx b/static/app/utils/replays/hooks/useCrumbHandlers.tsx index 073296eece72e3..825fac72e58b13 100644 --- a/static/app/utils/replays/hooks/useCrumbHandlers.tsx +++ b/static/app/utils/replays/hooks/useCrumbHandlers.tsx @@ -36,7 +36,10 @@ function getNodeIdAndLabel(record: RecordType) { }; } if ('nodeId' in data) { - return {nodeId: data.nodeId, annotation: record.data.label}; + return {nodeIds: [data.nodeId], annotation: record.data.label}; + } + if ('nodeIds' in data) { + return {nodeIds: data.nodeIds, annotation: record.data.label}; } return undefined; } @@ -56,7 +59,7 @@ function useCrumbHandlers() { }); const onMouseEnter = useCallback( - (record: RecordType) => { + (record: RecordType, nodeId?: number) => { // This debounces the mouseEnter callback in unison with mouseLeave. // We ensure the pointer remains over the target element before dispatching // state events in order to minimize unnecessary renders. This helps during @@ -68,7 +71,9 @@ function useCrumbHandlers() { setCurrentHoverTime(record.offsetMs); } - const metadata = getNodeIdAndLabel(record); + const metadata = nodeId + ? {annotations: undefined, nodeIds: [nodeId]} + : getNodeIdAndLabel(record); if (metadata) { // XXX: Kind of hacky, but mouseLeave does not fire if you move from a // crumb to a tooltip @@ -83,7 +88,7 @@ function useCrumbHandlers() { ); const onMouseLeave = useCallback( - (record: RecordType) => { + (record: RecordType, nodeId?: number) => { if (mouseEnterCallback.current.id === record) { // If there is a mouseEnter callback queued and we're leaving the node // just cancel the timeout. @@ -94,7 +99,9 @@ function useCrumbHandlers() { mouseEnterCallback.current.timeoutId = null; } else { setCurrentHoverTime(undefined); - const metadata = getNodeIdAndLabel(record); + const metadata = nodeId + ? {annotations: undefined, nodeIds: [nodeId]} + : getNodeIdAndLabel(record); if (metadata) { removeHighlight(metadata); } diff --git a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx index de4d7c6a257628..049f42b64ca7b2 100644 --- a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx +++ b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.spec.tsx @@ -196,7 +196,7 @@ describe('useInitialTimeOffsetMs', () => { expect(result.current).toStrictEqual({ highlight: { annotation: undefined, - nodeId: 7, + nodeIds: [7], spotlight: true, }, offsetMs: 5 * 60 * 1000, @@ -240,7 +240,7 @@ describe('useInitialTimeOffsetMs', () => { expect(result.current).toStrictEqual({ highlight: { annotation: undefined, - nodeId: 7, + nodeIds: [7], spotlight: true, }, offsetMs: 5 * 60 * 1000, diff --git a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx index 9eeef11ae98e55..6b2c534449cc1e 100644 --- a/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx +++ b/static/app/utils/replays/hooks/useInitialTimeOffsetMs.tsx @@ -143,7 +143,7 @@ async function fromListPageQuery({ return { highlight: { annotation: undefined, - nodeId, + nodeIds: [nodeId], spotlight: true, }, offsetMs: firstTimestmpMs - replayStartTimestampMs, diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index f612447fef2632..318c3f59c2fa9f 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -7,7 +7,7 @@ import {defined} from 'sentry/utils'; import domId from 'sentry/utils/domId'; import localStorageWrapper from 'sentry/utils/localStorage'; import clamp from 'sentry/utils/number/clamp'; -import extractHtml from 'sentry/utils/replays/extractHtml'; +import extractHtmlandSelector from 'sentry/utils/replays/extractHtml'; import hydrateBreadcrumbs, { replayInitBreadcrumb, } from 'sentry/utils/replays/hydrateBreadcrumbs'; @@ -34,11 +34,12 @@ import type { SlowClickFrame, SpanFrame, VideoEvent, + WebVitalFrame, } from 'sentry/utils/replays/types'; import { BreadcrumbCategories, EventType, - getNodeId, + getNodeIds, IncrementalSource, isDeadClick, isDeadRageClick, @@ -144,16 +145,17 @@ function removeDuplicateNavCrumbs( const extractDomNodes = { shouldVisitFrame: frame => { - const nodeId = getNodeId(frame); - return nodeId !== undefined && nodeId !== -1; + const nodeIds = getNodeIds(frame); + return nodeIds.filter(nodeId => nodeId !== -1).length > 0; }, onVisitFrame: (frame, collection, replayer) => { const mirror = replayer.getMirror(); - const nodeId = getNodeId(frame); - const html = extractHtml(nodeId as number, mirror); + const nodeIds = getNodeIds(frame); + const {html, selectors} = extractHtmlandSelector((nodeIds ?? []) as number[], mirror); collection.set(frame as ReplayFrame, { frame, html, + selectors, timestamp: frame.timestampMs, }); }, @@ -576,7 +578,9 @@ export default class ReplayReader { ) ) ), - ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})), + ...this._sortedSpanFrames.filter( + frame => 'nodeId' in (frame.data ?? {}) || 'nodeIds' in (frame.data ?? {}) + ), ].sort(sortFrames) ); @@ -628,7 +632,21 @@ export default class ReplayReader { getWebVitalFrames = memoize(() => { if (this._featureFlags?.includes('session-replay-web-vitals')) { - return this._sortedSpanFrames.filter(isWebVitalFrame); + // sort by largest timestamp first to easily find the last CLS in a burst + const allWebVitals = this._sortedSpanFrames.filter(isWebVitalFrame).reverse(); + let lastTimestamp = 0; + const groupedCls: WebVitalFrame[] = []; + + for (const cls of allWebVitals) { + if (cls.description === 'cumulative-layout-shift') { + if (lastTimestamp === cls.timestampMs) { + groupedCls.push(cls); + } else { + lastTimestamp = cls.timestampMs; + } + } + } + return allWebVitals.filter(frame => !groupedCls.includes(frame)).reverse(); } return []; }); diff --git a/static/app/utils/replays/types.tsx b/static/app/utils/replays/types.tsx index 466a1ef8d5e290..f1af185861be51 100644 --- a/static/app/utils/replays/types.tsx +++ b/static/app/utils/replays/types.tsx @@ -171,10 +171,12 @@ export function getFrameOpOrCategory(frame: ReplayFrame) { return val; } -export function getNodeId(frame: ReplayFrame) { +export function getNodeIds(frame: ReplayFrame) { return 'data' in frame && frame.data && 'nodeId' in frame.data - ? frame.data.nodeId - : undefined; + ? [frame.data.nodeId] + : 'data' in frame && frame.data && 'nodeIds' in frame.data + ? frame.data.nodeIds + : undefined; } export function isConsoleFrame(frame: BreadcrumbFrame): frame is ConsoleFrame { diff --git a/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx b/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx index f0a0a818ef1bcf..f043f8d08e4e05 100644 --- a/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx +++ b/static/app/views/replays/detail/breadcrumbs/breadcrumbRow.tsx @@ -1,4 +1,4 @@ -import type {CSSProperties, MouseEvent} from 'react'; +import type {CSSProperties} from 'react'; import {useCallback} from 'react'; import classNames from 'classnames'; @@ -17,8 +17,7 @@ interface Props { onInspectorExpanded: ( index: number, path: string, - expandedState: Record, - event: MouseEvent + expandedState: Record ) => void; startTimestampMs: number; style: CSSProperties; @@ -42,7 +41,7 @@ export default function BreadcrumbRow({ const {onMouseEnter, onMouseLeave} = useCrumbHandlers(); const handleObjectInspectorExpanded = useCallback( - (path, expandedState, e) => onInspectorExpanded?.(index, path, expandedState, e), + (path, expandedState) => onInspectorExpanded?.(index, path, expandedState), [index, onInspectorExpanded] ); diff --git a/static/app/views/replays/detail/useVirtualizedInspector.tsx b/static/app/views/replays/detail/useVirtualizedInspector.tsx index 564a861c221a77..9c35613ffeca3f 100644 --- a/static/app/views/replays/detail/useVirtualizedInspector.tsx +++ b/static/app/views/replays/detail/useVirtualizedInspector.tsx @@ -23,12 +23,7 @@ export default function useVirtualizedInspector({cache, listRef, expandPathsRef} return { expandPaths: expandPathsRef.current, handleDimensionChange: useCallback( - ( - index: number, - path: string, - expandedState: Record, - event: MouseEvent - ) => { + (index: number, path: string, expandedState: Record) => { const rowState = expandPathsRef.current?.get(index) || new Set(); if (expandedState[path]) { rowState.add(path); @@ -38,7 +33,6 @@ export default function useVirtualizedInspector({cache, listRef, expandPathsRef} } expandPathsRef.current?.set(index, rowState); handleDimensionChange(index); - event.stopPropagation(); }, [expandPathsRef, handleDimensionChange] ),