replayReader.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493
  1. import * as Sentry from '@sentry/react';
  2. import type {incrementalSnapshotEvent} from '@sentry-internal/rrweb';
  3. import {IncrementalSource} from '@sentry-internal/rrweb';
  4. import memoize from 'lodash/memoize';
  5. import {type Duration, duration} from 'moment';
  6. import {defined} from 'sentry/utils';
  7. import domId from 'sentry/utils/domId';
  8. import localStorageWrapper from 'sentry/utils/localStorage';
  9. import clamp from 'sentry/utils/number/clamp';
  10. import hydrateBreadcrumbs, {
  11. replayInitBreadcrumb,
  12. } from 'sentry/utils/replays/hydrateBreadcrumbs';
  13. import hydrateErrors from 'sentry/utils/replays/hydrateErrors';
  14. import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
  15. import {
  16. clipEndFrame,
  17. recordingEndFrame,
  18. recordingStartFrame,
  19. } from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
  20. import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
  21. import {replayTimestamps} from 'sentry/utils/replays/replayDataUtils';
  22. import type {
  23. BreadcrumbFrame,
  24. ErrorFrame,
  25. fullSnapshotEvent,
  26. MemoryFrame,
  27. OptionFrame,
  28. RecordingFrame,
  29. serializedNodeWithId,
  30. SlowClickFrame,
  31. SpanFrame,
  32. VideoEvent,
  33. } from 'sentry/utils/replays/types';
  34. import {
  35. BreadcrumbCategories,
  36. EventType,
  37. isDeadClick,
  38. isDeadRageClick,
  39. isLCPFrame,
  40. isPaintFrame,
  41. } from 'sentry/utils/replays/types';
  42. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  43. interface ClipWindow {
  44. endTimestampMs: number;
  45. startTimestampMs: number;
  46. }
  47. interface ReplayReaderParams {
  48. /**
  49. * Loaded segment data
  50. *
  51. * This is a mix of rrweb data, breadcrumbs and spans/transactions sorted by time
  52. * All three types are mixed together.
  53. */
  54. attachments: unknown[] | undefined;
  55. /**
  56. * Error objects related to this replay
  57. *
  58. * Error instances could be frontend, backend, or come from the error platform
  59. * like performance-errors or replay-errors
  60. */
  61. errors: ReplayError[] | undefined;
  62. /**
  63. * The root Replay event, created at the start of the browser session.
  64. */
  65. replayRecord: ReplayRecord | undefined;
  66. /**
  67. * If provided, the replay will be clipped to this window.
  68. */
  69. clipWindow?: ClipWindow;
  70. }
  71. type RequiredNotNull<T> = {
  72. [P in keyof T]: NonNullable<T[P]>;
  73. };
  74. const sortFrames = (a, b) => a.timestampMs - b.timestampMs;
  75. function removeDuplicateClicks(frames: BreadcrumbFrame[]) {
  76. const slowClickFrames = frames.filter(
  77. frame => frame.category === 'ui.slowClickDetected'
  78. );
  79. const clickFrames = frames.filter(frame => frame.category === 'ui.click');
  80. const otherFrames = frames.filter(
  81. frame => !(slowClickFrames.includes(frame) || clickFrames.includes(frame))
  82. );
  83. const uniqueClickFrames: BreadcrumbFrame[] = clickFrames.filter(clickFrame => {
  84. return !slowClickFrames.some(
  85. slowClickFrame =>
  86. slowClickFrame.data &&
  87. 'nodeId' in slowClickFrame.data &&
  88. clickFrame.data &&
  89. 'nodeId' in clickFrame.data &&
  90. slowClickFrame.data.nodeId === clickFrame.data.nodeId &&
  91. slowClickFrame.timestampMs === clickFrame.timestampMs
  92. );
  93. });
  94. return uniqueClickFrames.concat(otherFrames).concat(slowClickFrames);
  95. }
  96. export default class ReplayReader {
  97. static factory({attachments, errors, replayRecord, clipWindow}: ReplayReaderParams) {
  98. if (!attachments || !replayRecord || !errors) {
  99. return null;
  100. }
  101. try {
  102. return new ReplayReader({attachments, errors, replayRecord, clipWindow});
  103. } catch (err) {
  104. Sentry.captureException(err);
  105. // If something happens then we don't really know if it's the attachments
  106. // array or errors array to blame (it's probably attachments though).
  107. // Either way we can use the replayRecord to show some metadata, and then
  108. // put an error message below it.
  109. return new ReplayReader({
  110. attachments: [],
  111. errors: [],
  112. replayRecord,
  113. clipWindow,
  114. });
  115. }
  116. }
  117. private constructor({
  118. attachments,
  119. errors,
  120. replayRecord,
  121. clipWindow,
  122. }: RequiredNotNull<ReplayReaderParams>) {
  123. this._cacheKey = domId('replayReader-');
  124. if (replayRecord.is_archived) {
  125. this._replayRecord = replayRecord;
  126. const archivedReader = new Proxy(this, {
  127. get(_target, prop, _receiver) {
  128. if (prop === '_replayRecord') {
  129. return replayRecord;
  130. }
  131. return () => {};
  132. },
  133. });
  134. return archivedReader;
  135. }
  136. const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames, videoFrames} =
  137. hydrateFrames(attachments);
  138. if (localStorageWrapper.getItem('REPLAY-BACKEND-TIMESTAMPS') !== '1') {
  139. // TODO(replays): We should get correct timestamps from the backend instead
  140. // of having to fix them up here.
  141. const {startTimestampMs, endTimestampMs} = replayTimestamps(
  142. replayRecord,
  143. rrwebFrames,
  144. breadcrumbFrames,
  145. spanFrames
  146. );
  147. this.timestampDeltas = {
  148. startedAtDelta: startTimestampMs - replayRecord.started_at.getTime(),
  149. finishedAtDelta: endTimestampMs - replayRecord.finished_at.getTime(),
  150. };
  151. replayRecord.started_at = new Date(startTimestampMs);
  152. replayRecord.finished_at = new Date(endTimestampMs);
  153. replayRecord.duration = duration(
  154. replayRecord.finished_at.getTime() - replayRecord.started_at.getTime()
  155. );
  156. }
  157. // Hydrate the data we were given
  158. this._replayRecord = replayRecord;
  159. // Errors don't need to be sorted here, they will be merged with breadcrumbs
  160. // and spans in the getter and then sorted together.
  161. const {errorFrames, feedbackFrames} = hydrateErrors(replayRecord, errors);
  162. this._errors = errorFrames.sort(sortFrames);
  163. // RRWeb Events are not sorted here, they are fetched in sorted order.
  164. this._sortedRRWebEvents = rrwebFrames;
  165. this._videoEvents = videoFrames;
  166. // Breadcrumbs must be sorted. Crumbs like `slowClick` and `multiClick` will
  167. // have the same timestamp as the click breadcrumb, but will be emitted a
  168. // few seconds later.
  169. this._sortedBreadcrumbFrames = hydrateBreadcrumbs(
  170. replayRecord,
  171. breadcrumbFrames,
  172. this._sortedRRWebEvents
  173. )
  174. .concat(feedbackFrames)
  175. .sort(sortFrames);
  176. // Spans must be sorted so components like the Timeline and Network Chart
  177. // can have an easier time to render.
  178. this._sortedSpanFrames = hydrateSpans(replayRecord, spanFrames).sort(sortFrames);
  179. this._optionFrame = optionFrame;
  180. // Insert extra records to satisfy minimum requirements for the UI
  181. this._sortedBreadcrumbFrames.push(replayInitBreadcrumb(replayRecord));
  182. this._sortedRRWebEvents.unshift(recordingStartFrame(replayRecord));
  183. this._sortedRRWebEvents.push(recordingEndFrame(replayRecord));
  184. this._duration = replayRecord.duration;
  185. if (clipWindow) {
  186. this._applyClipWindow(clipWindow);
  187. }
  188. }
  189. public timestampDeltas = {startedAtDelta: 0, finishedAtDelta: 0};
  190. private _cacheKey: string;
  191. private _duration: Duration = duration(0);
  192. private _errors: ErrorFrame[] = [];
  193. private _optionFrame: undefined | OptionFrame;
  194. private _replayRecord: ReplayRecord;
  195. private _sortedBreadcrumbFrames: BreadcrumbFrame[] = [];
  196. private _sortedRRWebEvents: RecordingFrame[] = [];
  197. private _sortedSpanFrames: SpanFrame[] = [];
  198. private _startOffsetMs = 0;
  199. private _videoEvents: VideoEvent[] = [];
  200. private _applyClipWindow = (clipWindow: ClipWindow) => {
  201. const clipStartTimestampMs = clamp(
  202. clipWindow.startTimestampMs,
  203. this._replayRecord.started_at.getTime(),
  204. this._replayRecord.finished_at.getTime()
  205. );
  206. const clipEndTimestampMs = clamp(
  207. clipWindow.endTimestampMs,
  208. clipStartTimestampMs,
  209. this._replayRecord.finished_at.getTime()
  210. );
  211. // For RRWeb frames we only trim from the end because playback will
  212. // not work otherwise. The start offset is used to begin playback at
  213. // the correct time.
  214. this._sortedRRWebEvents = this._sortedRRWebEvents.filter(
  215. frame => frame.timestamp <= clipEndTimestampMs
  216. );
  217. this._sortedRRWebEvents.push(clipEndFrame(clipEndTimestampMs));
  218. this._startOffsetMs = clipStartTimestampMs - this._replayRecord.started_at.getTime();
  219. this._duration = duration(clipEndTimestampMs - clipStartTimestampMs);
  220. // We also only trim from the back for breadcrumbs/spans to keep
  221. // historical information about the replay, such as the current URL.
  222. this._sortedBreadcrumbFrames = this._updateFrameOffsets(
  223. this._trimFramesToClipWindow(
  224. this._sortedBreadcrumbFrames,
  225. this._replayRecord.started_at.getTime(),
  226. clipEndTimestampMs
  227. )
  228. );
  229. this._sortedSpanFrames = this._updateFrameOffsets(
  230. this._trimFramesToClipWindow(
  231. this._sortedSpanFrames,
  232. this._replayRecord.started_at.getTime(),
  233. clipEndTimestampMs
  234. )
  235. );
  236. this._errors = this._updateFrameOffsets(
  237. this._trimFramesToClipWindow(this._errors, clipStartTimestampMs, clipEndTimestampMs)
  238. );
  239. };
  240. /**
  241. * Filters out frames that are outside of the supplied window
  242. */
  243. _trimFramesToClipWindow = <T extends {timestampMs: number}>(
  244. frames: Array<T>,
  245. startTimestampMs: number,
  246. endTimestampMs: number
  247. ) => {
  248. return frames.filter(
  249. frame =>
  250. frame.timestampMs >= startTimestampMs && frame.timestampMs <= endTimestampMs
  251. );
  252. };
  253. /**
  254. * Updates the offsetMs of all frames to be relative to the start of the clip window
  255. */
  256. _updateFrameOffsets = <T extends {offsetMs: number}>(frames: Array<T>) => {
  257. return frames.map(frame => ({
  258. ...frame,
  259. offsetMs: frame.offsetMs - this.getStartOffsetMs(),
  260. }));
  261. };
  262. toJSON = () => this._cacheKey;
  263. processingErrors = memoize(() => {
  264. return [
  265. this.getRRWebFrames().length < 2
  266. ? `Replay has ${this.getRRWebFrames().length} frames`
  267. : null,
  268. !this.getRRWebFrames().some(frame => frame.type === EventType.Meta)
  269. ? 'Missing Meta Frame'
  270. : null,
  271. ].filter(defined);
  272. });
  273. hasProcessingErrors = () => {
  274. return this.processingErrors().length;
  275. };
  276. /**
  277. * @returns Duration of Replay (milliseonds)
  278. */
  279. getDurationMs = () => {
  280. return this._duration.asMilliseconds();
  281. };
  282. getStartOffsetMs = () => this._startOffsetMs;
  283. getStartTimestampMs = () =>
  284. this._replayRecord.started_at.getTime() + this._startOffsetMs;
  285. getReplay = () => {
  286. return this._replayRecord;
  287. };
  288. getRRWebFrames = () => this._sortedRRWebEvents;
  289. getRRWebMutations = () =>
  290. this._sortedRRWebEvents.filter(
  291. event =>
  292. [EventType.IncrementalSnapshot].includes(event.type) &&
  293. [IncrementalSource.Mutation].includes(
  294. (event as incrementalSnapshotEvent).data.source
  295. ) // filter only for mutation events
  296. );
  297. getErrorFrames = () => this._errors;
  298. getConsoleFrames = memoize(() =>
  299. this._sortedBreadcrumbFrames.filter(
  300. frame =>
  301. frame.category === 'console' || !BreadcrumbCategories.includes(frame.category)
  302. )
  303. );
  304. getNavigationFrames = memoize(() =>
  305. [
  306. ...this._sortedBreadcrumbFrames.filter(frame => frame.category === 'replay.init'),
  307. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  308. ].sort(sortFrames)
  309. );
  310. getNetworkFrames = memoize(() =>
  311. this._sortedSpanFrames.filter(
  312. frame => frame.op.startsWith('navigation.') || frame.op.startsWith('resource.')
  313. )
  314. );
  315. getDOMFrames = memoize(() =>
  316. [
  317. ...removeDuplicateClicks(
  318. this._sortedBreadcrumbFrames
  319. .filter(frame => 'nodeId' in (frame.data ?? {}))
  320. .filter(
  321. frame =>
  322. !(
  323. (frame.category === 'ui.slowClickDetected' &&
  324. !isDeadClick(frame as SlowClickFrame)) ||
  325. frame.category === 'ui.multiClick'
  326. )
  327. )
  328. ),
  329. ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
  330. ].sort(sortFrames)
  331. );
  332. getMemoryFrames = memoize(() =>
  333. this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory')
  334. );
  335. getChapterFrames = memoize(() =>
  336. this._trimFramesToClipWindow(
  337. [
  338. ...this.getPerfFrames(),
  339. ...this._sortedBreadcrumbFrames.filter(frame =>
  340. [
  341. 'replay.hydrate-error',
  342. 'replay.init',
  343. 'replay.mutations',
  344. 'feedback',
  345. ].includes(frame.category)
  346. ),
  347. ...this._errors,
  348. ].sort(sortFrames),
  349. this.getStartTimestampMs(),
  350. this.getStartTimestampMs() + this.getDurationMs()
  351. )
  352. );
  353. getPerfFrames = memoize(() =>
  354. [
  355. ...removeDuplicateClicks(
  356. this._sortedBreadcrumbFrames.filter(
  357. frame =>
  358. ['navigation', 'ui.click'].includes(frame.category) ||
  359. (frame.category === 'ui.slowClickDetected' &&
  360. (isDeadClick(frame as SlowClickFrame) ||
  361. isDeadRageClick(frame as SlowClickFrame)))
  362. )
  363. ),
  364. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  365. ].sort(sortFrames)
  366. );
  367. getLPCFrames = memoize(() => this._sortedSpanFrames.filter(isLCPFrame));
  368. getVideoEvents = () => this._videoEvents;
  369. getPaintFrames = memoize(() => this._sortedSpanFrames.filter(isPaintFrame));
  370. getSDKOptions = () => this._optionFrame;
  371. /**
  372. * Checks the replay to see if user has any canvas elements in their
  373. * application. Needed to inform them that we now support canvas in replays.
  374. */
  375. hasCanvasElementInReplay = memoize(() => {
  376. return Boolean(this._sortedRRWebEvents.filter(findCanvas).length);
  377. });
  378. isVideoReplay = memoize(() => this.getVideoEvents().length > 0);
  379. isNetworkDetailsSetup = memoize(() => {
  380. const sdkOptions = this.getSDKOptions();
  381. if (sdkOptions) {
  382. return sdkOptions.networkDetailHasUrls;
  383. }
  384. // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1
  385. // So even if we don't have the config object, we should still fallback and
  386. // look for spans with network data, as that means things are setup!
  387. return this.getNetworkFrames().some(
  388. frame =>
  389. // We'd need to `filter()` before calling `some()` in order for TS to be happy
  390. // @ts-expect-error
  391. Object.keys(frame?.data?.request?.headers ?? {}).length ||
  392. // @ts-expect-error
  393. Object.keys(frame?.data?.response?.headers ?? {}).length
  394. );
  395. });
  396. }
  397. function findCanvas(event: RecordingFrame) {
  398. if (event.type === EventType.FullSnapshot) {
  399. return findCanvasInSnapshot(event);
  400. }
  401. if (event.type === EventType.IncrementalSnapshot) {
  402. return findCanvasInMutation(event);
  403. }
  404. return false;
  405. }
  406. function findCanvasInMutation(event: incrementalSnapshotEvent) {
  407. if (event.data.source !== IncrementalSource.Mutation) {
  408. return false;
  409. }
  410. return event.data.adds.find(
  411. add => add.node && add.node.type === 2 && add.node.tagName === 'canvas'
  412. );
  413. }
  414. function findCanvasInChildNodes(nodes: serializedNodeWithId[]) {
  415. return nodes.find(
  416. node =>
  417. node.type === 2 &&
  418. (node.tagName === 'canvas' || findCanvasInChildNodes(node.childNodes || []))
  419. );
  420. }
  421. function findCanvasInSnapshot(event: fullSnapshotEvent) {
  422. if (event.data.node.type !== 0) {
  423. return false;
  424. }
  425. return findCanvasInChildNodes(event.data.node.childNodes);
  426. }