replayReader.tsx 11 KB

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