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..a40b5770883e33 --- /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( + (acc, mutation) => { + for (const timestamp of 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/hooks/useExtractDiffMutations.tsx b/static/app/utils/replays/hooks/useExtractDiffMutations.tsx new file mode 100644 index 00000000000000..6cf8e68caa527e --- /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: Infinity, + }); +}