|
| 1 | +import {useQuery, type UseQueryResult} from 'sentry/utils/queryClient'; |
| 2 | +import replayerStepper from 'sentry/utils/replays/replayerStepper'; |
| 3 | +import type ReplayReader from 'sentry/utils/replays/replayReader'; |
| 4 | +import { |
| 5 | + EventType, |
| 6 | + IncrementalSource, |
| 7 | + type RecordingFrame, |
| 8 | + type ReplayFrame, |
| 9 | +} from 'sentry/utils/replays/types'; |
| 10 | + |
| 11 | +type DiffMutation = Record< |
| 12 | + number, |
| 13 | + { |
| 14 | + adds: Record<string, unknown>; |
| 15 | + attributes: Record<string, unknown>; |
| 16 | + offset: number; |
| 17 | + removes: Record<string, unknown>; |
| 18 | + } |
| 19 | +>; |
| 20 | + |
| 21 | +type Args = { |
| 22 | + rangeEndTimestampMs: number; |
| 23 | + rangeStartTimestampMs: number; |
| 24 | + replay: ReplayReader; |
| 25 | +}; |
| 26 | + |
| 27 | +async function extractDiffMutations({ |
| 28 | + rangeEndTimestampMs, |
| 29 | + rangeStartTimestampMs, |
| 30 | + replay, |
| 31 | +}: Args): Promise<Map<RecordingFrame, DiffMutation>> { |
| 32 | + let hasStartedVisiting = false; |
| 33 | + let hasFinishedVisiting = false; |
| 34 | + let lastFrame: null | RecordingFrame = null; |
| 35 | + |
| 36 | + const startTimestampMs = replay.getReplay().started_at.getTime() ?? 0; |
| 37 | + |
| 38 | + const results = await replayerStepper<RecordingFrame, DiffMutation>({ |
| 39 | + frames: replay.getRRWebFrames(), |
| 40 | + rrwebEvents: replay.getRRWebFrames(), |
| 41 | + startTimestampMs, |
| 42 | + shouldVisitFrame: (frame, _replayer) => { |
| 43 | + const isWithinRange = |
| 44 | + rangeStartTimestampMs < frame.timestamp && frame.timestamp <= rangeEndTimestampMs; |
| 45 | + |
| 46 | + // within the range, so we visit. |
| 47 | + if (isWithinRange) { |
| 48 | + hasStartedVisiting = true; |
| 49 | + return ( |
| 50 | + frame.type === EventType.IncrementalSnapshot && |
| 51 | + 'source' in frame.data && |
| 52 | + frame.data.source === IncrementalSource.Mutation |
| 53 | + ); |
| 54 | + } |
| 55 | + // if we started, but didn't record that visiting is finished, then this |
| 56 | + // is the first frame outside of the range. we'll visit it, so we can |
| 57 | + // consume the lastFrame, but afterwards no more visits will happen. |
| 58 | + if (hasStartedVisiting && !hasFinishedVisiting) { |
| 59 | + hasFinishedVisiting = true; |
| 60 | + return true; |
| 61 | + } |
| 62 | + // we either haven't started, or we already finished, not need to visit. |
| 63 | + return false; |
| 64 | + }, |
| 65 | + onVisitFrame: (frame, collection, replayer) => { |
| 66 | + const mirror = replayer.getMirror(); |
| 67 | + |
| 68 | + if ( |
| 69 | + lastFrame && |
| 70 | + lastFrame.type === EventType.IncrementalSnapshot && |
| 71 | + 'source' in lastFrame.data && |
| 72 | + lastFrame.data.source === IncrementalSource.Mutation |
| 73 | + ) { |
| 74 | + const adds = {}; |
| 75 | + for (const add of lastFrame.data.adds) { |
| 76 | + const node = mirror.getNode(add.node.id) as HTMLElement | null; |
| 77 | + if (!node || !node.outerHTML) { |
| 78 | + continue; |
| 79 | + } |
| 80 | + const selector = getSelectorForElem(node); |
| 81 | + const rootIsAdded = Object.keys(adds).some(key => selector.startsWith(key)); |
| 82 | + if (rootIsAdded) { |
| 83 | + continue; |
| 84 | + } |
| 85 | + |
| 86 | + adds[selector] = { |
| 87 | + html: node.outerHTML, |
| 88 | + }; |
| 89 | + } |
| 90 | + |
| 91 | + const attributes = {}; |
| 92 | + for (const attr of lastFrame.data.attributes) { |
| 93 | + const node = mirror.getNode(attr.id) as HTMLElement | null; |
| 94 | + if (!node || !node.outerHTML) { |
| 95 | + continue; |
| 96 | + } |
| 97 | + attributes[getSelectorForElem(node)] = { |
| 98 | + tag: node.outerHTML.replace(node.innerHTML, '...'), |
| 99 | + changed: attr.attributes, |
| 100 | + }; |
| 101 | + } |
| 102 | + |
| 103 | + const item = collection.get(lastFrame); |
| 104 | + if (item) { |
| 105 | + item[lastFrame.timestamp].adds = adds; |
| 106 | + item[lastFrame.timestamp].attributes = attributes; |
| 107 | + } |
| 108 | + } |
| 109 | + |
| 110 | + if ( |
| 111 | + frame.type === EventType.IncrementalSnapshot && |
| 112 | + 'source' in frame.data && |
| 113 | + frame.data.source === IncrementalSource.Mutation |
| 114 | + ) { |
| 115 | + // fill the cache |
| 116 | + lastFrame = frame; |
| 117 | + |
| 118 | + const removes = {}; |
| 119 | + for (const removal of frame.data.removes) { |
| 120 | + const node = mirror.getNode(removal.id) as HTMLElement | null; |
| 121 | + if (!node || !node.outerHTML) { |
| 122 | + continue; |
| 123 | + } |
| 124 | + const selector = getSelectorForElem(node); |
| 125 | + const rootIsRemoved = Object.keys(removes).some(key => |
| 126 | + selector.startsWith(key) |
| 127 | + ); |
| 128 | + if (rootIsRemoved) { |
| 129 | + continue; |
| 130 | + } |
| 131 | + |
| 132 | + removes[selector] = { |
| 133 | + html: node.outerHTML, |
| 134 | + }; |
| 135 | + } |
| 136 | + |
| 137 | + collection.set(frame, { |
| 138 | + [frame.timestamp]: { |
| 139 | + adds: {}, |
| 140 | + attributes: {}, |
| 141 | + removes, |
| 142 | + offset: frame.timestamp - startTimestampMs, |
| 143 | + }, |
| 144 | + }); |
| 145 | + } |
| 146 | + }, |
| 147 | + }); |
| 148 | + return results; |
| 149 | +} |
| 150 | + |
| 151 | +function getNameForElem(element: HTMLElement) { |
| 152 | + if (element.id) { |
| 153 | + return `#${element.id}`; |
| 154 | + } |
| 155 | + const parts = [ |
| 156 | + element.tagName.toLowerCase(), |
| 157 | + element.className && typeof element.className === 'string' |
| 158 | + ? `.${element.className.split(' ').filter(Boolean).join('.')}` |
| 159 | + : '', |
| 160 | + ]; |
| 161 | + const attrs = [ |
| 162 | + 'data-sentry-element', |
| 163 | + 'data-sentry-source-file', |
| 164 | + 'data-test-id', |
| 165 | + 'data-testid', |
| 166 | + ]; |
| 167 | + for (const attr of attrs) { |
| 168 | + const value = element.getAttribute(attr); |
| 169 | + if (value) { |
| 170 | + parts.push(`[${attr}=${value}]`); |
| 171 | + } |
| 172 | + } |
| 173 | + return parts.join(''); |
| 174 | +} |
| 175 | + |
| 176 | +// Copy Selector => `#blk_router > div.tsqd-parent-container > div > div` |
| 177 | +// Copy JS Path => `document.querySelector("#blk_router > div.tsqd-parent-container > div > div")` |
| 178 | +// Copy XPath => `//*[@id="blk_router"]/div[2]/div/div` |
| 179 | +// Copy Full XPath => `/html/body/div[1]/div[2]/div/div` |
| 180 | +function getSelectorForElem(element: HTMLElement): string { |
| 181 | + const parts: string[] = []; |
| 182 | + let elem: HTMLElement | null = |
| 183 | + element.nodeType !== Node.ELEMENT_NODE ? element.parentElement : element; |
| 184 | + |
| 185 | + while (elem) { |
| 186 | + parts.unshift(getNameForElem(elem)); |
| 187 | + if (elem.id !== '' || (elem.getAttribute('data-sentry-element') ?? '') !== '') { |
| 188 | + break; |
| 189 | + } |
| 190 | + elem = elem.parentElement; |
| 191 | + } |
| 192 | + return parts.join(' > '); |
| 193 | +} |
| 194 | + |
| 195 | +interface Props { |
| 196 | + leftOffsetMs: number; |
| 197 | + replay: ReplayReader; |
| 198 | + rightOffsetMs: number; |
| 199 | +} |
| 200 | + |
| 201 | +export default function useExtractDiffMutations({ |
| 202 | + leftOffsetMs, |
| 203 | + replay, |
| 204 | + rightOffsetMs, |
| 205 | +}: Props): UseQueryResult<Map<ReplayFrame, DiffMutation>> { |
| 206 | + const startTimestampMs = replay.getReplay().started_at.getTime(); |
| 207 | + const rangeStartTimestampMs = startTimestampMs + leftOffsetMs; |
| 208 | + const rangeEndTimestampMs = startTimestampMs + rightOffsetMs; |
| 209 | + |
| 210 | + return useQuery({ |
| 211 | + queryKey: [ |
| 212 | + 'extractDiffMutations', |
| 213 | + replay, |
| 214 | + rangeStartTimestampMs, |
| 215 | + rangeEndTimestampMs, |
| 216 | + ], |
| 217 | + queryFn: () => |
| 218 | + extractDiffMutations({replay, rangeStartTimestampMs, rangeEndTimestampMs}), |
| 219 | + enabled: true, |
| 220 | + gcTime: Infinity, |
| 221 | + }); |
| 222 | +} |
0 commit comments