replayReader.tsx 8.5 KB

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