replayReader.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import * as Sentry from '@sentry/react';
  2. import {incrementalSnapshotEvent, IncrementalSource} from '@sentry-internal/rrweb';
  3. import memoize from 'lodash/memoize';
  4. import {duration} from 'moment';
  5. import domId from 'sentry/utils/domId';
  6. import localStorageWrapper from 'sentry/utils/localStorage';
  7. import countDomNodes from 'sentry/utils/replays/countDomNodes';
  8. import extractDomNodes from 'sentry/utils/replays/extractDomNodes';
  9. import hydrateBreadcrumbs, {
  10. replayInitBreadcrumb,
  11. } from 'sentry/utils/replays/hydrateBreadcrumbs';
  12. import hydrateErrors from 'sentry/utils/replays/hydrateErrors';
  13. import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
  14. import {
  15. recordingEndFrame,
  16. recordingStartFrame,
  17. } from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
  18. import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
  19. import {replayTimestamps} from 'sentry/utils/replays/replayDataUtils';
  20. import type {
  21. BreadcrumbFrame,
  22. ErrorFrame,
  23. MemoryFrame,
  24. OptionFrame,
  25. RecordingFrame,
  26. SlowClickFrame,
  27. SpanFrame,
  28. } from 'sentry/utils/replays/types';
  29. import {
  30. BreadcrumbCategories,
  31. EventType,
  32. isDeadClick,
  33. isDeadRageClick,
  34. isLCPFrame,
  35. isPaintFrame,
  36. } from 'sentry/utils/replays/types';
  37. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  38. interface ReplayReaderParams {
  39. /**
  40. * Loaded segment data
  41. *
  42. * This is a mix of rrweb data, breadcrumbs and spans/transactions sorted by time
  43. * All three types are mixed together.
  44. */
  45. attachments: unknown[] | undefined;
  46. /**
  47. * Error objects related to this replay
  48. *
  49. * Error instances could be frontend, backend, or come from the error platform
  50. * like performance-errors or replay-errors
  51. */
  52. errors: ReplayError[] | undefined;
  53. /**
  54. * The root Replay event, created at the start of the browser session.
  55. */
  56. replayRecord: ReplayRecord | undefined;
  57. }
  58. type RequiredNotNull<T> = {
  59. [P in keyof T]: NonNullable<T[P]>;
  60. };
  61. const sortFrames = (a, b) => a.timestampMs - b.timestampMs;
  62. function removeDuplicateClicks(frames: BreadcrumbFrame[]) {
  63. const slowClickFrames = frames.filter(
  64. frame => frame.category === 'ui.slowClickDetected'
  65. );
  66. const clickFrames = frames.filter(frame => frame.category === 'ui.click');
  67. const otherFrames = frames.filter(
  68. frame => !(slowClickFrames.includes(frame) || clickFrames.includes(frame))
  69. );
  70. const uniqueClickFrames: BreadcrumbFrame[] = clickFrames.filter(clickFrame => {
  71. return !slowClickFrames.some(
  72. slowClickFrame =>
  73. slowClickFrame.data &&
  74. 'nodeId' in slowClickFrame.data &&
  75. clickFrame.data &&
  76. 'nodeId' in clickFrame.data &&
  77. slowClickFrame.data.nodeId === clickFrame.data.nodeId &&
  78. slowClickFrame.timestampMs === clickFrame.timestampMs
  79. );
  80. });
  81. return uniqueClickFrames.concat(otherFrames).concat(slowClickFrames);
  82. }
  83. export default class ReplayReader {
  84. static factory({attachments, errors, replayRecord}: ReplayReaderParams) {
  85. if (!attachments || !replayRecord || !errors) {
  86. return null;
  87. }
  88. try {
  89. return new ReplayReader({attachments, errors, replayRecord});
  90. } catch (err) {
  91. Sentry.captureException(err);
  92. // If something happens then we don't really know if it's the attachments
  93. // array or errors array to blame (it's probably attachments though).
  94. // Either way we can use the replayRecord to show some metadata, and then
  95. // put an error message below it.
  96. return new ReplayReader({
  97. attachments: [],
  98. errors: [],
  99. replayRecord,
  100. });
  101. }
  102. }
  103. private constructor({
  104. attachments,
  105. errors,
  106. replayRecord,
  107. }: RequiredNotNull<ReplayReaderParams>) {
  108. this._cacheKey = domId('replayReader-');
  109. const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames} =
  110. hydrateFrames(attachments);
  111. if (localStorageWrapper.getItem('REPLAY-BACKEND-TIMESTAMPS') !== '1') {
  112. // TODO(replays): We should get correct timestamps from the backend instead
  113. // of having to fix them up here.
  114. const {startTimestampMs, endTimestampMs} = replayTimestamps(
  115. replayRecord,
  116. rrwebFrames,
  117. breadcrumbFrames,
  118. spanFrames
  119. );
  120. this.timestampDeltas = {
  121. startedAtDelta: startTimestampMs - replayRecord.started_at.getTime(),
  122. finishedAtDelta: endTimestampMs - replayRecord.finished_at.getTime(),
  123. };
  124. replayRecord.started_at = new Date(startTimestampMs);
  125. replayRecord.finished_at = new Date(endTimestampMs);
  126. replayRecord.duration = duration(
  127. replayRecord.finished_at.getTime() - replayRecord.started_at.getTime()
  128. );
  129. }
  130. // Hydrate the data we were given
  131. this._replayRecord = replayRecord;
  132. // Errors don't need to be sorted here, they will be merged with breadcrumbs
  133. // and spans in the getter and then sorted together.
  134. this._errors = hydrateErrors(replayRecord, errors).sort(sortFrames);
  135. // RRWeb Events are not sorted here, they are fetched in sorted order.
  136. this._sortedRRWebEvents = rrwebFrames;
  137. // Breadcrumbs must be sorted. Crumbs like `slowClick` and `multiClick` will
  138. // have the same timestamp as the click breadcrumb, but will be emitted a
  139. // few seconds later.
  140. this._sortedBreadcrumbFrames = hydrateBreadcrumbs(
  141. replayRecord,
  142. breadcrumbFrames
  143. ).sort(sortFrames);
  144. // Spans must be sorted so components like the Timeline and Network Chart
  145. // can have an easier time to render.
  146. this._sortedSpanFrames = hydrateSpans(replayRecord, spanFrames).sort(sortFrames);
  147. this._optionFrame = optionFrame;
  148. // Insert extra records to satisfy minimum requirements for the UI
  149. this._sortedBreadcrumbFrames.push(replayInitBreadcrumb(replayRecord));
  150. this._sortedRRWebEvents.unshift(recordingStartFrame(replayRecord));
  151. this._sortedRRWebEvents.push(recordingEndFrame(replayRecord));
  152. }
  153. public timestampDeltas = {startedAtDelta: 0, finishedAtDelta: 0};
  154. private _cacheKey: string;
  155. private _errors: ErrorFrame[];
  156. private _optionFrame: undefined | OptionFrame;
  157. private _replayRecord: ReplayRecord;
  158. private _sortedBreadcrumbFrames: BreadcrumbFrame[];
  159. private _sortedRRWebEvents: RecordingFrame[];
  160. private _sortedSpanFrames: SpanFrame[];
  161. toJSON = () => this._cacheKey;
  162. /**
  163. * @returns Duration of Replay (milliseonds)
  164. */
  165. getDurationMs = () => {
  166. return this._replayRecord.duration.asMilliseconds();
  167. };
  168. getReplay = () => {
  169. return this._replayRecord;
  170. };
  171. getRRWebFrames = () => this._sortedRRWebEvents;
  172. getRRWebMutations = () =>
  173. this._sortedRRWebEvents.filter(
  174. event =>
  175. [EventType.IncrementalSnapshot].includes(event.type) &&
  176. [IncrementalSource.Mutation].includes(
  177. (event as incrementalSnapshotEvent).data.source
  178. ) // filter only for mutation events
  179. );
  180. getErrorFrames = () => this._errors;
  181. getConsoleFrames = memoize(() =>
  182. this._sortedBreadcrumbFrames.filter(
  183. frame =>
  184. frame.category === 'console' || !BreadcrumbCategories.includes(frame.category)
  185. )
  186. );
  187. getNavigationFrames = memoize(() =>
  188. [
  189. ...this._sortedBreadcrumbFrames.filter(frame => frame.category === 'replay.init'),
  190. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  191. ].sort(sortFrames)
  192. );
  193. getNetworkFrames = memoize(() =>
  194. this._sortedSpanFrames.filter(
  195. frame => frame.op.startsWith('navigation.') || frame.op.startsWith('resource.')
  196. )
  197. );
  198. getDOMFrames = memoize(() =>
  199. [
  200. ...removeDuplicateClicks(
  201. this._sortedBreadcrumbFrames
  202. .filter(frame => 'nodeId' in (frame.data ?? {}))
  203. .filter(
  204. frame =>
  205. !(
  206. (frame.category === 'ui.slowClickDetected' &&
  207. !isDeadClick(frame as SlowClickFrame)) ||
  208. frame.category === 'ui.multiClick'
  209. )
  210. )
  211. ),
  212. ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
  213. ].sort(sortFrames)
  214. );
  215. countDomNodes = memoize(() =>
  216. countDomNodes({
  217. frames: this.getRRWebMutations(),
  218. rrwebEvents: this.getRRWebFrames(),
  219. startTimestampMs: this._replayRecord.started_at.getTime(),
  220. })
  221. );
  222. getDomNodes = memoize(() =>
  223. extractDomNodes({
  224. frames: this.getDOMFrames(),
  225. rrwebEvents: this.getRRWebFrames(),
  226. })
  227. );
  228. getMemoryFrames = memoize(() =>
  229. this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory')
  230. );
  231. getChapterFrames = memoize(() =>
  232. [
  233. ...this.getPerfFrames(),
  234. ...this._sortedBreadcrumbFrames.filter(frame =>
  235. ['replay.init', 'replay.mutations'].includes(frame.category)
  236. ),
  237. ...this._errors,
  238. ].sort(sortFrames)
  239. );
  240. getPerfFrames = memoize(() =>
  241. [
  242. ...removeDuplicateClicks(
  243. this._sortedBreadcrumbFrames.filter(
  244. frame =>
  245. ['navigation', 'ui.click'].includes(frame.category) ||
  246. (frame.category === 'ui.slowClickDetected' &&
  247. (isDeadClick(frame as SlowClickFrame) ||
  248. isDeadRageClick(frame as SlowClickFrame)))
  249. )
  250. ),
  251. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  252. ].sort(sortFrames)
  253. );
  254. getLPCFrames = memoize(() => this._sortedSpanFrames.filter(isLCPFrame));
  255. getPaintFrames = memoize(() => this._sortedSpanFrames.filter(isPaintFrame));
  256. getSDKOptions = () => this._optionFrame;
  257. isNetworkDetailsSetup = memoize(() => {
  258. const sdkOptions = this.getSDKOptions();
  259. if (sdkOptions) {
  260. return sdkOptions.networkDetailHasUrls;
  261. }
  262. // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1
  263. // So even if we don't have the config object, we should still fallback and
  264. // look for spans with network data, as that means things are setup!
  265. return this.getNetworkFrames().some(
  266. frame =>
  267. // We'd need to `filter()` before calling `some()` in order for TS to be happy
  268. // @ts-expect-error
  269. Object.keys(frame?.data?.request?.headers ?? {}).length ||
  270. // @ts-expect-error
  271. Object.keys(frame?.data?.response?.headers ?? {}).length
  272. );
  273. });
  274. }