replayReader.tsx 11 KB

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