replayReader.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. if (replayRecord.is_archived) {
  110. this._replayRecord = replayRecord;
  111. const archivedReader = new Proxy(this, {
  112. get(_target, prop, _receiver) {
  113. if (prop === '_replayRecord') {
  114. return replayRecord;
  115. }
  116. return () => {};
  117. },
  118. });
  119. return archivedReader;
  120. }
  121. const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames} =
  122. hydrateFrames(attachments);
  123. if (localStorageWrapper.getItem('REPLAY-BACKEND-TIMESTAMPS') !== '1') {
  124. // TODO(replays): We should get correct timestamps from the backend instead
  125. // of having to fix them up here.
  126. const {startTimestampMs, endTimestampMs} = replayTimestamps(
  127. replayRecord,
  128. rrwebFrames,
  129. breadcrumbFrames,
  130. spanFrames
  131. );
  132. this.timestampDeltas = {
  133. startedAtDelta: startTimestampMs - replayRecord.started_at.getTime(),
  134. finishedAtDelta: endTimestampMs - replayRecord.finished_at.getTime(),
  135. };
  136. replayRecord.started_at = new Date(startTimestampMs);
  137. replayRecord.finished_at = new Date(endTimestampMs);
  138. replayRecord.duration = duration(
  139. replayRecord.finished_at.getTime() - replayRecord.started_at.getTime()
  140. );
  141. }
  142. // Hydrate the data we were given
  143. this._replayRecord = replayRecord;
  144. // Errors don't need to be sorted here, they will be merged with breadcrumbs
  145. // and spans in the getter and then sorted together.
  146. this._errors = hydrateErrors(replayRecord, errors).sort(sortFrames);
  147. // RRWeb Events are not sorted here, they are fetched in sorted order.
  148. this._sortedRRWebEvents = rrwebFrames;
  149. // Breadcrumbs must be sorted. Crumbs like `slowClick` and `multiClick` will
  150. // have the same timestamp as the click breadcrumb, but will be emitted a
  151. // few seconds later.
  152. this._sortedBreadcrumbFrames = hydrateBreadcrumbs(
  153. replayRecord,
  154. breadcrumbFrames
  155. ).sort(sortFrames);
  156. // Spans must be sorted so components like the Timeline and Network Chart
  157. // can have an easier time to render.
  158. this._sortedSpanFrames = hydrateSpans(replayRecord, spanFrames).sort(sortFrames);
  159. this._optionFrame = optionFrame;
  160. // Insert extra records to satisfy minimum requirements for the UI
  161. this._sortedBreadcrumbFrames.push(replayInitBreadcrumb(replayRecord));
  162. this._sortedRRWebEvents.unshift(recordingStartFrame(replayRecord));
  163. this._sortedRRWebEvents.push(recordingEndFrame(replayRecord));
  164. }
  165. public timestampDeltas = {startedAtDelta: 0, finishedAtDelta: 0};
  166. private _cacheKey: string;
  167. private _errors: ErrorFrame[] = [];
  168. private _optionFrame: undefined | OptionFrame;
  169. private _replayRecord: ReplayRecord;
  170. private _sortedBreadcrumbFrames: BreadcrumbFrame[] = [];
  171. private _sortedRRWebEvents: RecordingFrame[] = [];
  172. private _sortedSpanFrames: SpanFrame[] = [];
  173. toJSON = () => this._cacheKey;
  174. /**
  175. * @returns Duration of Replay (milliseonds)
  176. */
  177. getDurationMs = () => {
  178. return this._replayRecord.duration.asMilliseconds();
  179. };
  180. getReplay = () => {
  181. return this._replayRecord;
  182. };
  183. getRRWebFrames = () => this._sortedRRWebEvents;
  184. getRRWebMutations = () =>
  185. this._sortedRRWebEvents.filter(
  186. event =>
  187. [EventType.IncrementalSnapshot].includes(event.type) &&
  188. [IncrementalSource.Mutation].includes(
  189. (event as incrementalSnapshotEvent).data.source
  190. ) // filter only for mutation events
  191. );
  192. getErrorFrames = () => this._errors;
  193. getConsoleFrames = memoize(() =>
  194. this._sortedBreadcrumbFrames.filter(
  195. frame =>
  196. frame.category === 'console' || !BreadcrumbCategories.includes(frame.category)
  197. )
  198. );
  199. getNavigationFrames = memoize(() =>
  200. [
  201. ...this._sortedBreadcrumbFrames.filter(frame => frame.category === 'replay.init'),
  202. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  203. ].sort(sortFrames)
  204. );
  205. getNetworkFrames = memoize(() =>
  206. this._sortedSpanFrames.filter(
  207. frame => frame.op.startsWith('navigation.') || frame.op.startsWith('resource.')
  208. )
  209. );
  210. getDOMFrames = memoize(() =>
  211. [
  212. ...removeDuplicateClicks(
  213. this._sortedBreadcrumbFrames
  214. .filter(frame => 'nodeId' in (frame.data ?? {}))
  215. .filter(
  216. frame =>
  217. !(
  218. (frame.category === 'ui.slowClickDetected' &&
  219. !isDeadClick(frame as SlowClickFrame)) ||
  220. frame.category === 'ui.multiClick'
  221. )
  222. )
  223. ),
  224. ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
  225. ].sort(sortFrames)
  226. );
  227. countDomNodes = memoize(() =>
  228. countDomNodes({
  229. frames: this.getRRWebMutations(),
  230. rrwebEvents: this.getRRWebFrames(),
  231. startTimestampMs: this._replayRecord.started_at.getTime(),
  232. })
  233. );
  234. getDomNodes = memoize(() =>
  235. extractDomNodes({
  236. frames: this.getDOMFrames(),
  237. rrwebEvents: this.getRRWebFrames(),
  238. })
  239. );
  240. getMemoryFrames = memoize(() =>
  241. this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory')
  242. );
  243. getChapterFrames = memoize(() =>
  244. [
  245. ...this.getPerfFrames(),
  246. ...this._sortedBreadcrumbFrames.filter(frame =>
  247. ['replay.init', 'replay.mutations'].includes(frame.category)
  248. ),
  249. ...this._errors,
  250. ].sort(sortFrames)
  251. );
  252. getPerfFrames = memoize(() =>
  253. [
  254. ...removeDuplicateClicks(
  255. this._sortedBreadcrumbFrames.filter(
  256. frame =>
  257. ['navigation', 'ui.click'].includes(frame.category) ||
  258. (frame.category === 'ui.slowClickDetected' &&
  259. (isDeadClick(frame as SlowClickFrame) ||
  260. isDeadRageClick(frame as SlowClickFrame)))
  261. )
  262. ),
  263. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  264. ].sort(sortFrames)
  265. );
  266. getLPCFrames = memoize(() => this._sortedSpanFrames.filter(isLCPFrame));
  267. getPaintFrames = memoize(() => this._sortedSpanFrames.filter(isPaintFrame));
  268. getSDKOptions = () => this._optionFrame;
  269. isNetworkDetailsSetup = memoize(() => {
  270. const sdkOptions = this.getSDKOptions();
  271. if (sdkOptions) {
  272. return sdkOptions.networkDetailHasUrls;
  273. }
  274. // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1
  275. // So even if we don't have the config object, we should still fallback and
  276. // look for spans with network data, as that means things are setup!
  277. return this.getNetworkFrames().some(
  278. frame =>
  279. // We'd need to `filter()` before calling `some()` in order for TS to be happy
  280. // @ts-expect-error
  281. Object.keys(frame?.data?.request?.headers ?? {}).length ||
  282. // @ts-expect-error
  283. Object.keys(frame?.data?.response?.headers ?? {}).length
  284. );
  285. });
  286. }