replayReader.tsx 9.4 KB

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