From b3207a0200be7b1b964a9f9409f4130ee5d24efc Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 15 Nov 2024 14:19:58 +0800 Subject: [PATCH 1/3] feat(hydration error): Create a tree-view that highlights dom mutations directly --- .../replays/diff/replayDiffChooser.tsx | 12 + .../replays/diff/replayMutationTree.tsx | 53 +++++ static/app/utils/replays/extractHtml.tsx | 24 +- .../replays/hooks/useExtractDiffMutations.tsx | 222 ++++++++++++++++++ .../replays/hooks/useExtractDomNodes.tsx | 4 +- static/app/utils/replays/replayReader.tsx | 22 +- 6 files changed, 311 insertions(+), 26 deletions(-) create mode 100644 static/app/components/replays/diff/replayMutationTree.tsx create mode 100644 static/app/utils/replays/hooks/useExtractDiffMutations.tsx diff --git a/static/app/components/replays/diff/replayDiffChooser.tsx b/static/app/components/replays/diff/replayDiffChooser.tsx index f1dd87e6f6309e..63627a02d9cb0c 100644 --- a/static/app/components/replays/diff/replayDiffChooser.tsx +++ b/static/app/components/replays/diff/replayDiffChooser.tsx @@ -1,6 +1,7 @@ import styled from '@emotion/styled'; import FeatureBadge from 'sentry/components/badge/featureBadge'; +import {ReplayMutationTree} from 'sentry/components/replays/diff/replayMutationTree'; import {ReplaySideBySideImageDiff} from 'sentry/components/replays/diff/replaySideBySideImageDiff'; import {ReplaySliderDiff} from 'sentry/components/replays/diff/replaySliderDiff'; import {ReplayTextDiff} from 'sentry/components/replays/diff/replayTextDiff'; @@ -22,6 +23,7 @@ export const enum DiffType { HTML = 'html', SLIDER = 'slider', VISUAL = 'visual', + MUTATIONS = 'mutations', } export default function ReplayDiffChooser({ @@ -41,6 +43,9 @@ export default function ReplayDiffChooser({ {t('Slider Diff')} {t('Side By Side Diff')} + + {t('Mutations')} + {t('HTML Diff')} @@ -68,6 +73,13 @@ export default function ReplayDiffChooser({ rightOffsetMs={rightOffsetMs} /> + + + diff --git a/static/app/components/replays/diff/replayMutationTree.tsx b/static/app/components/replays/diff/replayMutationTree.tsx new file mode 100644 index 00000000000000..7372dd389fa3d4 --- /dev/null +++ b/static/app/components/replays/diff/replayMutationTree.tsx @@ -0,0 +1,53 @@ +import {css} from '@emotion/react'; +import styled from '@emotion/styled'; + +import StructuredEventData from 'sentry/components/structuredEventData'; +import useExtractDiffMutations from 'sentry/utils/replays/hooks/useExtractDiffMutations'; +import type ReplayReader from 'sentry/utils/replays/replayReader'; + +interface Props { + leftOffsetMs: number; + replay: ReplayReader; + rightOffsetMs: number; +} + +export function ReplayMutationTree({replay, leftOffsetMs, rightOffsetMs}: Props) { + const {data} = useExtractDiffMutations({ + leftOffsetMs, + replay, + rightOffsetMs, + }); + + const timeIndexedMutations = Array.from(data?.values() ?? []).reduce( + (mutation, acc) => { + for (const timestamp in Object.keys(mutation)) { + acc[timestamp] = mutation[timestamp]; + } + return acc; + }, + {} + ); + + return ( + + pre { + margin: 0; + } + `} + /> + + ); +} + +const ScrollWrapper = styled('div')` + overflow: auto; + height: 0; + display: flex; + flex-grow: 1; +`; diff --git a/static/app/utils/replays/extractHtml.tsx b/static/app/utils/replays/extractHtml.tsx index aedc99f40d6a1a..041d6c123ed57e 100644 --- a/static/app/utils/replays/extractHtml.tsx +++ b/static/app/utils/replays/extractHtml.tsx @@ -1,6 +1,6 @@ import type {Mirror} from '@sentry-internal/rrweb-snapshot'; -import type {ReplayFrame} from 'sentry/utils/replays/types'; +import {getNodeIds, type ReplayFrame} from 'sentry/utils/replays/types'; import constructSelector from 'sentry/views/replays/deadRageClick/constructSelector'; export type Extraction = { @@ -10,7 +10,27 @@ export type Extraction = { timestamp: number; }; -export default function extractHtmlAndSelector( +const extractDomNodes = { + shouldVisitFrame: frame => { + const nodeIds = getNodeIds(frame); + return nodeIds.filter(nodeId => nodeId !== -1).length > 0; + }, + onVisitFrame: (frame, collection, replayer) => { + const mirror = replayer.getMirror(); + const nodeIds = getNodeIds(frame); + const {html, selectors} = extractHtmlAndSelector((nodeIds ?? []) as number[], mirror); + collection.set(frame as ReplayFrame, { + frame, + html, + selectors, + timestamp: frame.timestampMs, + }); + }, +}; + +export default extractDomNodes; + +function extractHtmlAndSelector( nodeIds: number[], mirror: Mirror ): {html: string[]; selectors: Map} { diff --git a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx new file mode 100644 index 00000000000000..baf3496c062f8f --- /dev/null +++ b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx @@ -0,0 +1,222 @@ +import {useQuery, type UseQueryResult} from 'sentry/utils/queryClient'; +import replayerStepper from 'sentry/utils/replays/replayerStepper'; +import type ReplayReader from 'sentry/utils/replays/replayReader'; +import { + EventType, + IncrementalSource, + type RecordingFrame, + type ReplayFrame, +} from 'sentry/utils/replays/types'; + +type DiffMutation = Record< + number, + { + adds: Record; + attributes: Record; + offset: number; + removes: Record; + } +>; + +type Args = { + rangeEndTimestampMs: number; + rangeStartTimestampMs: number; + replay: ReplayReader; +}; + +async function extractDiffMutations({ + rangeEndTimestampMs, + rangeStartTimestampMs, + replay, +}: Args): Promise> { + let hasStartedVisiting = false; + let hasFinishedVisiting = false; + let lastFrame: null | RecordingFrame = null; + + const startTimestampMs = replay.getReplay().started_at.getTime() ?? 0; + + const results = await replayerStepper({ + frames: replay.getRRWebFrames(), + rrwebEvents: replay.getRRWebFrames(), + startTimestampMs, + shouldVisitFrame: (frame, _replayer) => { + const isWithinRange = + rangeStartTimestampMs < frame.timestamp && frame.timestamp <= rangeEndTimestampMs; + + // within the range, so we visit. + if (isWithinRange) { + hasStartedVisiting = true; + return ( + frame.type === EventType.IncrementalSnapshot && + 'source' in frame.data && + frame.data.source === IncrementalSource.Mutation + ); + } + // if we started, but didn't record that visiting is finished, then this + // is the first frame outside of the range. we'll visit it, so we can + // consume the lastFrame, but afterwards no more visits will happen. + if (hasStartedVisiting && !hasFinishedVisiting) { + hasFinishedVisiting = true; + return true; + } + // we either haven't started, or we already finished, not need to visit. + return false; + }, + onVisitFrame: (frame, collection, replayer) => { + const mirror = replayer.getMirror(); + + if ( + lastFrame && + lastFrame.type === EventType.IncrementalSnapshot && + 'source' in lastFrame.data && + lastFrame.data.source === IncrementalSource.Mutation + ) { + const adds = {}; + for (const add of lastFrame.data.adds) { + const node = mirror.getNode(add.node.id) as HTMLElement | null; + if (!node || !node.outerHTML) { + continue; + } + const selector = getSelectorForElem(node); + const rootIsAdded = Object.keys(adds).some(key => selector.startsWith(key)); + if (rootIsAdded) { + continue; + } + + adds[selector] = { + html: node.outerHTML, + }; + } + + const attributes = {}; + for (const attr of lastFrame.data.attributes) { + const node = mirror.getNode(attr.id) as HTMLElement | null; + if (!node || !node.outerHTML) { + continue; + } + attributes[getSelectorForElem(node)] = { + tag: node.outerHTML.replace(node.innerHTML, '...'), + changed: attr.attributes, + }; + } + + const item = collection.get(lastFrame); + if (item) { + item[lastFrame.timestamp].adds = adds; + item[lastFrame.timestamp].attributes = attributes; + } + } + + if ( + frame.type === EventType.IncrementalSnapshot && + 'source' in frame.data && + frame.data.source === IncrementalSource.Mutation + ) { + // fill the cache + lastFrame = frame; + + const removes = {}; + for (const removal of frame.data.removes) { + const node = mirror.getNode(removal.id) as HTMLElement | null; + if (!node || !node.outerHTML) { + continue; + } + const selector = getSelectorForElem(node); + const rootIsRemoved = Object.keys(removes).some(key => + selector.startsWith(key) + ); + if (rootIsRemoved) { + continue; + } + + removes[selector] = { + html: node.outerHTML, + }; + } + + collection.set(frame, { + [frame.timestamp]: { + adds: {}, + attributes: {}, + removes, + offset: frame.timestamp - startTimestampMs, + }, + }); + } + }, + }); + return results; +} + +function getNameForElem(element: HTMLElement) { + if (element.id) { + return `#${element.id}`; + } + const parts = [ + element.tagName.toLowerCase(), + element.className && typeof element.className === 'string' + ? `.${element.className.split(' ').filter(Boolean).join('.')}` + : '', + ]; + const attrs = [ + 'data-sentry-element', + 'data-sentry-source-file', + 'data-test-id', + 'data-testid', + ]; + for (const attr of attrs) { + const value = element.getAttribute(attr); + if (value) { + parts.push(`[${attr}=${value}]`); + } + } + return parts.join(''); +} + +// Copy Selector => `#blk_router > div.tsqd-parent-container > div > div` +// Copy JS Path => `document.querySelector("#blk_router > div.tsqd-parent-container > div > div")` +// Copy XPath => `//*[@id="blk_router"]/div[2]/div/div` +// Copy Full XPath => `/html/body/div[1]/div[2]/div/div` +function getSelectorForElem(element: HTMLElement): string { + const parts: string[] = []; + let elem: HTMLElement | null = + element.nodeType !== Node.ELEMENT_NODE ? element.parentElement : element; + + while (elem) { + parts.unshift(getNameForElem(elem)); + if (elem.id !== '' || (elem.getAttribute('data-sentry-element') ?? '') !== '') { + break; + } + elem = elem.parentElement; + } + return parts.join(' > '); +} + +interface Props { + leftOffsetMs: number; + replay: ReplayReader; + rightOffsetMs: number; +} + +export default function useExtractDiffMutations({ + leftOffsetMs, + replay, + rightOffsetMs, +}: Props): UseQueryResult> { + const startTimestampMs = replay.getReplay().started_at.getTime(); + const rangeStartTimestampMs = startTimestampMs + leftOffsetMs; + const rangeEndTimestampMs = startTimestampMs + rightOffsetMs; + + return useQuery({ + queryKey: [ + 'extractDiffMutations', + replay, + rangeStartTimestampMs, + rangeEndTimestampMs, + ], + queryFn: () => + extractDiffMutations({replay, rangeStartTimestampMs, rangeEndTimestampMs}), + enabled: true, + gcTime: 0, // Infinity, + }); +} diff --git a/static/app/utils/replays/hooks/useExtractDomNodes.tsx b/static/app/utils/replays/hooks/useExtractDomNodes.tsx index 2d0dfbcc8e13ac..d9a4fe65872061 100644 --- a/static/app/utils/replays/hooks/useExtractDomNodes.tsx +++ b/static/app/utils/replays/hooks/useExtractDomNodes.tsx @@ -10,9 +10,7 @@ export default function useExtractDomNodes({ }): UseQueryResult> { return useQuery({ queryKey: ['getDomNodes', replay], - queryFn: () => { - return replay?.getExtractDomNodes(); - }, + queryFn: () => replay?.getExtractDomNodes(), enabled: Boolean(replay), gcTime: Infinity, }); diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index fa08f69d7a14bd..e2fda40bd1c5a9 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -6,7 +6,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 extractHtmlandSelector from 'sentry/utils/replays/extractHtml'; +import extractDomNodes from 'sentry/utils/replays/extractHtml'; import hydrateBreadcrumbs, { replayInitBreadcrumb, } from 'sentry/utils/replays/hydrateBreadcrumbs'; @@ -28,7 +28,6 @@ import type { MemoryFrame, OptionFrame, RecordingFrame, - ReplayFrame, serializedNodeWithId, SlowClickFrame, SpanFrame, @@ -38,7 +37,6 @@ import type { import { BreadcrumbCategories, EventType, - getNodeIds, IncrementalSource, isCLSFrame, isConsoleFrame, @@ -149,24 +147,6 @@ function removeDuplicateNavCrumbs( return otherBreadcrumbFrames.concat(uniqueNavCrumbs); } -const extractDomNodes = { - shouldVisitFrame: frame => { - const nodeIds = getNodeIds(frame); - return nodeIds.filter(nodeId => nodeId !== -1).length > 0; - }, - onVisitFrame: (frame, collection, replayer) => { - const mirror = replayer.getMirror(); - const nodeIds = getNodeIds(frame); - const {html, selectors} = extractHtmlandSelector((nodeIds ?? []) as number[], mirror); - collection.set(frame as ReplayFrame, { - frame, - html, - selectors, - timestamp: frame.timestampMs, - }); - }, -}; - export default class ReplayReader { static factory({ attachments, From a8a0c441fd52c9a77901b88106b691c2ff733e03 Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Fri, 15 Nov 2024 14:36:31 +0800 Subject: [PATCH 2/3] move this change into #80810 --- static/app/utils/replays/extractHtml.tsx | 24 ++----------------- .../replays/hooks/useExtractDomNodes.tsx | 4 +++- static/app/utils/replays/replayReader.tsx | 22 ++++++++++++++++- 3 files changed, 26 insertions(+), 24 deletions(-) diff --git a/static/app/utils/replays/extractHtml.tsx b/static/app/utils/replays/extractHtml.tsx index 041d6c123ed57e..aedc99f40d6a1a 100644 --- a/static/app/utils/replays/extractHtml.tsx +++ b/static/app/utils/replays/extractHtml.tsx @@ -1,6 +1,6 @@ import type {Mirror} from '@sentry-internal/rrweb-snapshot'; -import {getNodeIds, type ReplayFrame} from 'sentry/utils/replays/types'; +import type {ReplayFrame} from 'sentry/utils/replays/types'; import constructSelector from 'sentry/views/replays/deadRageClick/constructSelector'; export type Extraction = { @@ -10,27 +10,7 @@ export type Extraction = { timestamp: number; }; -const extractDomNodes = { - shouldVisitFrame: frame => { - const nodeIds = getNodeIds(frame); - return nodeIds.filter(nodeId => nodeId !== -1).length > 0; - }, - onVisitFrame: (frame, collection, replayer) => { - const mirror = replayer.getMirror(); - const nodeIds = getNodeIds(frame); - const {html, selectors} = extractHtmlAndSelector((nodeIds ?? []) as number[], mirror); - collection.set(frame as ReplayFrame, { - frame, - html, - selectors, - timestamp: frame.timestampMs, - }); - }, -}; - -export default extractDomNodes; - -function extractHtmlAndSelector( +export default function extractHtmlAndSelector( nodeIds: number[], mirror: Mirror ): {html: string[]; selectors: Map} { diff --git a/static/app/utils/replays/hooks/useExtractDomNodes.tsx b/static/app/utils/replays/hooks/useExtractDomNodes.tsx index d9a4fe65872061..2d0dfbcc8e13ac 100644 --- a/static/app/utils/replays/hooks/useExtractDomNodes.tsx +++ b/static/app/utils/replays/hooks/useExtractDomNodes.tsx @@ -10,7 +10,9 @@ export default function useExtractDomNodes({ }): UseQueryResult> { return useQuery({ queryKey: ['getDomNodes', replay], - queryFn: () => replay?.getExtractDomNodes(), + queryFn: () => { + return replay?.getExtractDomNodes(); + }, enabled: Boolean(replay), gcTime: Infinity, }); diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index e2fda40bd1c5a9..fa08f69d7a14bd 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -6,7 +6,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 extractDomNodes from 'sentry/utils/replays/extractHtml'; +import extractHtmlandSelector from 'sentry/utils/replays/extractHtml'; import hydrateBreadcrumbs, { replayInitBreadcrumb, } from 'sentry/utils/replays/hydrateBreadcrumbs'; @@ -28,6 +28,7 @@ import type { MemoryFrame, OptionFrame, RecordingFrame, + ReplayFrame, serializedNodeWithId, SlowClickFrame, SpanFrame, @@ -37,6 +38,7 @@ import type { import { BreadcrumbCategories, EventType, + getNodeIds, IncrementalSource, isCLSFrame, isConsoleFrame, @@ -147,6 +149,24 @@ function removeDuplicateNavCrumbs( return otherBreadcrumbFrames.concat(uniqueNavCrumbs); } +const extractDomNodes = { + shouldVisitFrame: frame => { + const nodeIds = getNodeIds(frame); + return nodeIds.filter(nodeId => nodeId !== -1).length > 0; + }, + onVisitFrame: (frame, collection, replayer) => { + const mirror = replayer.getMirror(); + const nodeIds = getNodeIds(frame); + const {html, selectors} = extractHtmlandSelector((nodeIds ?? []) as number[], mirror); + collection.set(frame as ReplayFrame, { + frame, + html, + selectors, + timestamp: frame.timestampMs, + }); + }, +}; + export default class ReplayReader { static factory({ attachments, From 1840a73005e99049517302fe11036ee465f9415a Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Sat, 16 Nov 2024 14:46:21 +0800 Subject: [PATCH 3/3] fix reducer and gcTime --- static/app/components/replays/diff/replayMutationTree.tsx | 4 ++-- static/app/utils/replays/hooks/useExtractDiffMutations.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/static/app/components/replays/diff/replayMutationTree.tsx b/static/app/components/replays/diff/replayMutationTree.tsx index 7372dd389fa3d4..a40b5770883e33 100644 --- a/static/app/components/replays/diff/replayMutationTree.tsx +++ b/static/app/components/replays/diff/replayMutationTree.tsx @@ -19,8 +19,8 @@ export function ReplayMutationTree({replay, leftOffsetMs, rightOffsetMs}: Props) }); const timeIndexedMutations = Array.from(data?.values() ?? []).reduce( - (mutation, acc) => { - for (const timestamp in Object.keys(mutation)) { + (acc, mutation) => { + for (const timestamp of Object.keys(mutation)) { acc[timestamp] = mutation[timestamp]; } return acc; diff --git a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx index baf3496c062f8f..6cf8e68caa527e 100644 --- a/static/app/utils/replays/hooks/useExtractDiffMutations.tsx +++ b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx @@ -217,6 +217,6 @@ export default function useExtractDiffMutations({ queryFn: () => extractDiffMutations({replay, rangeStartTimestampMs, rangeEndTimestampMs}), enabled: true, - gcTime: 0, // Infinity, + gcTime: Infinity, }); }