From fb956d85171b00190c1e87e7ac1288240aeb600c Mon Sep 17 00:00:00 2001 From: Ryan Albrecht Date: Mon, 16 Oct 2023 10:49:27 -0700 Subject: [PATCH] ref(replay): Refactor extractDomNodes & countDomNodes to return an indexed map (#58092) I want to use an indexed map to more easily merge extractedDomNode information with traces inside one tab. This refactor changes the return type for `extractDomNodes` to do that. Also `countDomNodes` to keep things consistent. I went and made a base class for them both because i noticed that `createHiddenPlayer` was copy+pasted, and then got carried away. Related to https://github.com/getsentry/sentry/issues/50065 --- static/app/utils/replays/countDomNodes.tsx | 102 +++------------- static/app/utils/replays/extractDomNodes.tsx | 114 +++--------------- static/app/utils/replays/replayReader.tsx | 17 --- static/app/utils/replays/replayerStepper.tsx | 110 +++++++++++++++++ .../replays/detail/domMutations/index.tsx | 25 ++-- .../views/replays/detail/domNodesChart.tsx | 26 ++-- 6 files changed, 183 insertions(+), 211 deletions(-) create mode 100644 static/app/utils/replays/replayerStepper.tsx diff --git a/static/app/utils/replays/countDomNodes.tsx b/static/app/utils/replays/countDomNodes.tsx index 751627fbe16a06..ada5c24abc2f07 100644 --- a/static/app/utils/replays/countDomNodes.tsx +++ b/static/app/utils/replays/countDomNodes.tsx @@ -1,5 +1,4 @@ -import {Replayer} from '@sentry-internal/rrweb'; - +import replayerStepper from 'sentry/utils/replays/replayerStepper'; import type {RecordingFrame} from 'sentry/utils/replays/types'; export type DomNodeChartDatapoint = { @@ -16,93 +15,30 @@ type Args = { }; export default function countDomNodes({ - frames = [], + frames, rrwebEvents, startTimestampMs, -}: Args): Promise { - return new Promise(resolve => { - const datapoints = new Map(); - const player = createPlayer(rrwebEvents); - - const nextFrame = (function () { - let i = 0; - const len = frames.length; - // how many frames we look at depends on the number of total frames - return () => frames[(i += Math.max(Math.round(len * 0.007), 1))]; - })(); - - const onDone = () => { - resolve(Array.from(datapoints.values())); - }; - - const nextOrDone = () => { - const next = nextFrame(); - if (next) { - matchFrame(next); - } else { - onDone(); - } - }; - - type FrameRef = { - frame: undefined | RecordingFrame; - }; - - const nodeIdRef: FrameRef = { - frame: undefined, - }; - - const handlePause = () => { - const frame = nodeIdRef.frame as RecordingFrame; - const idCount = player.getMirror().getIds().length; // gets number of DOM nodes present - datapoints.set(frame as RecordingFrame, { +}: Args): Promise> { + let frameCount = 0; + const length = frames?.length ?? 0; + const frameStep = Math.max(Math.round(length * 0.007), 1); + + return replayerStepper({ + frames, + rrwebEvents, + startTimestampMs, + shouldVisitFrame: () => { + frameCount++; + return frameCount % frameStep === 0; + }, + onVisitFrame: (frame, collection, replayer) => { + const idCount = replayer.getMirror().getIds().length; // gets number of DOM nodes present + collection.set(frame as RecordingFrame, { count: idCount, timestampMs: frame.timestamp, startTimestampMs: frame.timestamp, endTimestampMs: frame.timestamp, }); - nextOrDone(); - }; - - const matchFrame = frame => { - if (!frame) { - nextOrDone(); - return; - } - nodeIdRef.frame = frame; - - window.setTimeout(() => { - player.pause(frame.timestamp - startTimestampMs); - }, 0); - }; - - player.on('pause', handlePause); - matchFrame(nextFrame()); - }); -} - -function createPlayer(rrwebEvents): Replayer { - const domRoot = document.createElement('div'); - domRoot.className = 'sentry-block'; - const {style} = domRoot; - - style.position = 'fixed'; - style.inset = '0'; - style.width = '0'; - style.height = '0'; - style.overflow = 'hidden'; - - document.body.appendChild(domRoot); - - const replayerRef = new Replayer(rrwebEvents, { - root: domRoot, - loadTimeout: 1, - showWarning: false, - blockClass: 'sentry-block', - speed: 99999, - skipInactive: true, - triggerFocus: false, - mouseTail: false, + }, }); - return replayerRef; } diff --git a/static/app/utils/replays/extractDomNodes.tsx b/static/app/utils/replays/extractDomNodes.tsx index 9b5980eaae3df6..9b706d27f85a9d 100644 --- a/static/app/utils/replays/extractDomNodes.tsx +++ b/static/app/utils/replays/extractDomNodes.tsx @@ -1,6 +1,6 @@ -import {Replayer} from '@sentry-internal/rrweb'; import type {Mirror} from '@sentry-internal/rrweb-snapshot'; +import replayerStepper from 'sentry/utils/replays/replayerStepper'; import type {RecordingFrame, ReplayFrame} from 'sentry/utils/replays/types'; export type Extraction = { @@ -12,111 +12,33 @@ export type Extraction = { type Args = { frames: ReplayFrame[] | undefined; rrwebEvents: RecordingFrame[] | undefined; + startTimestampMs: number; }; export default function extractDomNodes({ - frames = [], + frames, rrwebEvents, -}: Args): Promise { - return new Promise(resolve => { - if (!frames.length) { - resolve([]); - return; - } - - const extractions = new Map(); - - const player = createPlayer(rrwebEvents); - const mirror = player.getMirror(); - - const nextFrame = (function () { - let i = 0; - return () => frames[i++]; - })(); - - const onDone = () => { - resolve(Array.from(extractions.values())); - }; - - const nextOrDone = () => { - const next = nextFrame(); - if (next) { - matchFrame(next); - } else { - onDone(); - } - }; - - type FrameRef = { - frame: undefined | ReplayFrame; - nodeId: undefined | number; - }; - - const nodeIdRef: FrameRef = { - frame: undefined, - nodeId: undefined, - }; - - const handlePause = () => { - if (!nodeIdRef.nodeId && !nodeIdRef.frame) { - return; - } - const frame = nodeIdRef.frame as ReplayFrame; - const nodeId = nodeIdRef.nodeId as number; - + startTimestampMs, +}: Args): Promise> { + return replayerStepper({ + frames, + rrwebEvents, + startTimestampMs, + shouldVisitFrame: frame => { + const nodeId = frame.data && 'nodeId' in frame.data ? frame.data.nodeId : undefined; + return nodeId !== undefined && nodeId !== -1; + }, + onVisitFrame: (frame, collection, replayer) => { + const mirror = replayer.getMirror(); + const nodeId = frame.data && 'nodeId' in frame.data ? frame.data.nodeId : undefined; const html = extractHtml(nodeId as number, mirror); - extractions.set(frame as ReplayFrame, { + collection.set(frame as ReplayFrame, { frame, html, timestamp: frame.timestampMs, }); - nextOrDone(); - }; - - const matchFrame = frame => { - nodeIdRef.frame = frame; - nodeIdRef.nodeId = - frame.data && 'nodeId' in frame.data ? frame.data.nodeId : undefined; - - if (nodeIdRef.nodeId === undefined || nodeIdRef.nodeId === -1) { - nextOrDone(); - return; - } - - window.setTimeout(() => { - player.pause(frame.offsetMs); - }, 0); - }; - - player.on('pause', handlePause); - matchFrame(nextFrame()); - }); -} - -function createPlayer(rrwebEvents): Replayer { - const domRoot = document.createElement('div'); - domRoot.className = 'sentry-block'; - const {style} = domRoot; - - style.position = 'fixed'; - style.inset = '0'; - style.width = '0'; - style.height = '0'; - style.overflow = 'hidden'; - - document.body.appendChild(domRoot); - - const replayerRef = new Replayer(rrwebEvents, { - root: domRoot, - loadTimeout: 1, - showWarning: false, - blockClass: 'sentry-block', - speed: 99999, - skipInactive: true, - triggerFocus: false, - mouseTail: false, + }, }); - return replayerRef; } function extractHtml(nodeId: number, mirror: Mirror): string | null { diff --git a/static/app/utils/replays/replayReader.tsx b/static/app/utils/replays/replayReader.tsx index e05af5456a2a03..d8d273515ae784 100644 --- a/static/app/utils/replays/replayReader.tsx +++ b/static/app/utils/replays/replayReader.tsx @@ -5,8 +5,6 @@ import {duration} from 'moment'; import domId from 'sentry/utils/domId'; import localStorageWrapper from 'sentry/utils/localStorage'; -import countDomNodes from 'sentry/utils/replays/countDomNodes'; -import extractDomNodes from 'sentry/utils/replays/extractDomNodes'; import hydrateBreadcrumbs, { replayInitBreadcrumb, } from 'sentry/utils/replays/hydrateBreadcrumbs'; @@ -259,21 +257,6 @@ export default class ReplayReader { ].sort(sortFrames) ); - countDomNodes = memoize(() => - countDomNodes({ - frames: this.getRRWebMutations(), - rrwebEvents: this.getRRWebFrames(), - startTimestampMs: this._replayRecord.started_at.getTime(), - }) - ); - - getDomNodes = memoize(() => - extractDomNodes({ - frames: this.getDOMFrames(), - rrwebEvents: this.getRRWebFrames(), - }) - ); - getMemoryFrames = memoize(() => this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory') ); diff --git a/static/app/utils/replays/replayerStepper.tsx b/static/app/utils/replays/replayerStepper.tsx new file mode 100644 index 00000000000000..f23daf39431631 --- /dev/null +++ b/static/app/utils/replays/replayerStepper.tsx @@ -0,0 +1,110 @@ +import {Replayer} from '@sentry-internal/rrweb'; + +import type {RecordingFrame, ReplayFrame} from 'sentry/utils/replays/types'; + +interface Args { + frames: Frame[] | undefined; + onVisitFrame: ( + frame: Frame, + collection: Map, + replayer: Replayer + ) => void; + rrwebEvents: RecordingFrame[] | undefined; + shouldVisitFrame: (frame: Frame, replayer: Replayer) => boolean; + startTimestampMs: number; +} + +type FrameRef = { + frame: Frame | undefined; +}; + +export default function replayerStepper< + Frame extends ReplayFrame | RecordingFrame, + CollectionData, +>({ + frames, + onVisitFrame, + rrwebEvents, + shouldVisitFrame, + startTimestampMs, +}: Args): Promise> { + const collection = new Map(); + + return new Promise(resolve => { + if (!frames?.length || !rrwebEvents?.length) { + resolve(new Map()); + return; + } + + const replayer = createHiddenPlayer(rrwebEvents); + + const nextFrame = (function () { + let i = 0; + return () => frames[i++]; + })(); + + const onDone = () => { + resolve(collection); + }; + + const nextOrDone = () => { + const next = nextFrame(); + if (next) { + considerFrame(next); + } else { + onDone(); + } + }; + + const frameRef: FrameRef = { + frame: undefined, + }; + + const considerFrame = (frame: Frame) => { + if (shouldVisitFrame(frame, replayer)) { + frameRef.frame = frame; + window.setTimeout(() => { + const timestamp = + 'offsetMs' in frame ? frame.offsetMs : frame.timestamp - startTimestampMs; + replayer.pause(timestamp); + }, 0); + } else { + frameRef.frame = undefined; + nextOrDone(); + } + }; + + const handlePause = () => { + onVisitFrame(frameRef.frame!, collection, replayer); + nextOrDone(); + }; + + replayer.on('pause', handlePause); + considerFrame(nextFrame()); + }); +} + +function createHiddenPlayer(rrwebEvents: RecordingFrame[]): Replayer { + const domRoot = document.createElement('div'); + domRoot.className = 'sentry-block'; + const {style} = domRoot; + + style.position = 'fixed'; + style.inset = '0'; + style.width = '0'; + style.height = '0'; + style.overflow = 'hidden'; + + document.body.appendChild(domRoot); + + return new Replayer(rrwebEvents, { + root: domRoot, + loadTimeout: 1, + showWarning: false, + blockClass: 'sentry-block', + speed: 99999, + skipInactive: true, + triggerFocus: false, + mouseTail: false, + }); +} diff --git a/static/app/views/replays/detail/domMutations/index.tsx b/static/app/views/replays/detail/domMutations/index.tsx index b1b8d01b54f0d8..82d3fc4a049683 100644 --- a/static/app/views/replays/detail/domMutations/index.tsx +++ b/static/app/views/replays/detail/domMutations/index.tsx @@ -11,6 +11,7 @@ import Placeholder from 'sentry/components/placeholder'; import JumpButtons from 'sentry/components/replays/jumpButtons'; import {useReplayContext} from 'sentry/components/replays/replayContext'; import {t} from 'sentry/locale'; +import extractDomNodes from 'sentry/utils/replays/extractDomNodes'; import useCrumbHandlers from 'sentry/utils/replays/hooks/useCrumbHandlers'; import type ReplayReader from 'sentry/utils/replays/replayReader'; import DomFilters from 'sentry/views/replays/detail/domMutations/domFilters'; @@ -30,21 +31,31 @@ const cellMeasurer = { }; function useExtractedDomNodes({replay}: {replay: null | ReplayReader}) { - return useQuery(['getDomNodes', replay], () => replay?.getDomNodes() ?? [], { - enabled: Boolean(replay), - initialData: [], - cacheTime: Infinity, - }); + return useQuery( + ['getDomNodes', replay], + () => + extractDomNodes({ + frames: replay?.getDOMFrames(), + rrwebEvents: replay?.getRRWebFrames(), + startTimestampMs: replay?.getReplay().started_at.getTime() ?? 0, + }), + {enabled: Boolean(replay), cacheTime: Infinity} + ); } function DomMutations() { const {currentTime, currentHoverTime, replay} = useReplayContext(); - const {data: actions, isFetching} = useExtractedDomNodes({replay}); const {onMouseEnter, onMouseLeave, onClickTimestamp} = useCrumbHandlers(); + const {data: frameToExtraction, isFetching} = useExtractedDomNodes({replay}); + const actions = useMemo( + () => Array.from(frameToExtraction?.values() || []), + [frameToExtraction] + ); + const startTimestampMs = replay?.getReplay()?.started_at?.getTime() ?? 0; - const filterProps = useDomFilters({actions: actions || []}); + const filterProps = useDomFilters({actions}); const {items, setSearchTerm} = filterProps; const clearSearchTerm = () => setSearchTerm(''); diff --git a/static/app/views/replays/detail/domNodesChart.tsx b/static/app/views/replays/detail/domNodesChart.tsx index abe2373d5ebdd5..560b79f839ca73 100644 --- a/static/app/views/replays/detail/domNodesChart.tsx +++ b/static/app/views/replays/detail/domNodesChart.tsx @@ -1,4 +1,4 @@ -import {forwardRef, memo, useEffect, useRef} from 'react'; +import {forwardRef, memo, useEffect, useMemo, useRef} from 'react'; import {useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import moment from 'moment'; @@ -17,7 +17,7 @@ import {ReactEchartsRef, Series} from 'sentry/types/echarts'; import {getFormattedDate} from 'sentry/utils/dates'; import {axisLabelFormatter} from 'sentry/utils/discover/charts'; import {useQuery} from 'sentry/utils/queryClient'; -import {DomNodeChartDatapoint} from 'sentry/utils/replays/countDomNodes'; +import countDomNodes, {DomNodeChartDatapoint} from 'sentry/utils/replays/countDomNodes'; import ReplayReader from 'sentry/utils/replays/replayReader'; import FluidHeight from 'sentry/views/replays/detail/layout/fluidHeight'; @@ -209,11 +209,16 @@ const MemoizedDomNodesChart = memo( ); function useCountDomNodes({replay}: {replay: null | ReplayReader}) { - return useQuery(['countDomNodes', replay], () => replay?.countDomNodes() ?? [], { - enabled: Boolean(replay), - initialData: [], - cacheTime: Infinity, - }); + return useQuery( + ['countDomNodes', replay], + () => + countDomNodes({ + frames: replay?.getRRWebMutations(), + rrwebEvents: replay?.getRRWebFrames(), + startTimestampMs: replay?.getReplay().started_at.getTime() ?? 0, + }), + {enabled: Boolean(replay), cacheTime: Infinity} + ); } function DomNodesChartContainer() { @@ -221,9 +226,14 @@ function DomNodesChartContainer() { useReplayContext(); const chart = useRef(null); const theme = useTheme(); - const {data: datapoints} = useCountDomNodes({replay}); + const {data: frameToCount} = useCountDomNodes({replay}); const startTimestampMs = replay?.getReplay()?.started_at?.getTime() ?? 0; + const datapoints = useMemo( + () => Array.from(frameToCount?.values() || []), + [frameToCount] + ); + useEffect(() => { if (!chart.current) { return;