replayReader.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  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 localStorageWrapper from 'sentry/utils/localStorage';
  7. import extractDomNodes from 'sentry/utils/replays/extractDomNodes';
  8. import hydrateBreadcrumbs, {
  9. replayInitBreadcrumb,
  10. } from 'sentry/utils/replays/hydrateBreadcrumbs';
  11. import hydrateErrors from 'sentry/utils/replays/hydrateErrors';
  12. import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
  13. import {
  14. recordingEndFrame,
  15. recordingStartFrame,
  16. } from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
  17. import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
  18. import {
  19. breadcrumbFactory,
  20. replayTimestamps,
  21. rrwebEventListFactory,
  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, EventType} from 'sentry/utils/replays/types';
  34. import type {
  35. MemorySpan,
  36. NetworkSpan,
  37. RecordingEvent,
  38. RecordingOptions,
  39. ReplayCrumb,
  40. ReplayError,
  41. ReplayRecord,
  42. ReplaySpan,
  43. } from 'sentry/views/replays/types';
  44. interface ReplayReaderParams {
  45. /**
  46. * Loaded segment data
  47. *
  48. * This is a mix of rrweb data, breadcrumbs and spans/transactions sorted by time
  49. * All three types are mixed together.
  50. */
  51. attachments: unknown[] | undefined;
  52. /**
  53. * Error objects related to this replay
  54. *
  55. * Error instances could be frontend, backend, or come from the error platform
  56. * like performance-errors or replay-errors
  57. */
  58. errors: ReplayError[] | undefined;
  59. /**
  60. * The root Replay event, created at the start of the browser session.
  61. */
  62. replayRecord: ReplayRecord | undefined;
  63. }
  64. type RequiredNotNull<T> = {
  65. [P in keyof T]: NonNullable<T[P]>;
  66. };
  67. const sortFrames = (a, b) => a.timestampMs - b.timestampMs;
  68. export default class ReplayReader {
  69. static factory({attachments, errors, replayRecord}: ReplayReaderParams) {
  70. if (!attachments || !replayRecord || !errors) {
  71. return null;
  72. }
  73. try {
  74. return new ReplayReader({attachments, errors, replayRecord});
  75. } catch (err) {
  76. Sentry.captureException(err);
  77. // If something happens then we don't really know if it's the attachments
  78. // array or errors array to blame (it's probably attachments though).
  79. // Either way we can use the replayRecord to show some metadata, and then
  80. // put an error message below it.
  81. return new ReplayReader({
  82. attachments: [],
  83. errors: [],
  84. replayRecord,
  85. });
  86. }
  87. }
  88. private constructor({
  89. attachments,
  90. errors,
  91. replayRecord,
  92. }: RequiredNotNull<ReplayReaderParams>) {
  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.rrwebEvents = rrwebEventListFactory(
  164. replayRecord,
  165. rawRRWebEvents as RecordingEvent[]
  166. );
  167. this.replayRecord = replayRecord;
  168. }
  169. public timestampDeltas = {startedAtDelta: 0, finishedAtDelta: 0};
  170. private _errors: ErrorFrame[];
  171. private _optionFrame: undefined | OptionFrame;
  172. private _sortedBreadcrumbFrames: BreadcrumbFrame[];
  173. private _sortedRRWebEvents: RecordingFrame[];
  174. private _sortedSpanFrames: SpanFrame[];
  175. private rawErrors: ReplayError[];
  176. private sortedSpans: ReplaySpan[];
  177. private replayRecord: ReplayRecord;
  178. private rrwebEvents: RecordingEvent[];
  179. private breadcrumbs: Crumb[];
  180. /**
  181. * @returns Duration of Replay (milliseonds)
  182. */
  183. getDurationMs = () => {
  184. return this.replayRecord.duration.asMilliseconds();
  185. };
  186. getReplay = () => {
  187. return this.replayRecord;
  188. };
  189. getRRWebFrames = () => this._sortedRRWebEvents;
  190. getErrorFrames = () => this._errors;
  191. getConsoleFrames = memoize(() =>
  192. this._sortedBreadcrumbFrames.filter(frame => frame.category === 'console')
  193. );
  194. getNavigationFrames = memoize(() =>
  195. [
  196. ...this._sortedBreadcrumbFrames.filter(frame => frame.category === 'replay.init'),
  197. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  198. ].sort(sortFrames)
  199. );
  200. getNetworkFrames = memoize(() =>
  201. this._sortedSpanFrames.filter(
  202. frame => frame.op.startsWith('navigation.') || frame.op.startsWith('resource.')
  203. )
  204. );
  205. getDOMFrames = memoize(() =>
  206. this._sortedBreadcrumbFrames.filter(frame => 'nodeId' in (frame.data ?? {}))
  207. );
  208. getMemoryFrames = memoize(() =>
  209. this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory')
  210. );
  211. getChapterFrames = memoize(() =>
  212. [
  213. ...this._sortedBreadcrumbFrames.filter(
  214. frame =>
  215. [
  216. 'replay.init',
  217. 'ui.click',
  218. 'replay.mutations',
  219. 'ui.slowClickDetected',
  220. ].includes(frame.category) || !BreadcrumbCategories.includes(frame.category)
  221. ),
  222. ...this._sortedSpanFrames.filter(frame =>
  223. ['navigation.navigate', 'navigation.reload', 'largest-contentful-paint'].includes(
  224. frame.op
  225. )
  226. ),
  227. ...this._errors,
  228. ].sort(sortFrames)
  229. );
  230. getTimelineFrames = memoize(() =>
  231. [
  232. ...this._sortedBreadcrumbFrames.filter(frame =>
  233. ['replay.init', 'ui.click'].includes(frame.category)
  234. ),
  235. ...this._sortedSpanFrames.filter(frame =>
  236. ['navigation.navigate', 'navigation.reload'].includes(frame.op)
  237. ),
  238. ...this._errors,
  239. ].sort(sortFrames)
  240. );
  241. getSDKOptions = () => this._optionFrame;
  242. // TODO: move isNetworkDetailsSetup() up here? or extract it
  243. /*********************/
  244. /** OLD STUFF BELOW **/
  245. /*********************/
  246. getCrumbsWithRRWebNodes = memoize(() =>
  247. this.breadcrumbs.filter(
  248. crumb => crumb.data && typeof crumb.data === 'object' && 'nodeId' in crumb.data
  249. )
  250. );
  251. getUserActionCrumbs = memoize(() => {
  252. const USER_ACTIONS = [
  253. BreadcrumbType.ERROR,
  254. BreadcrumbType.INIT,
  255. BreadcrumbType.NAVIGATION,
  256. BreadcrumbType.UI,
  257. BreadcrumbType.USER,
  258. ];
  259. return this.breadcrumbs.filter(crumb => USER_ACTIONS.includes(crumb.type));
  260. });
  261. getConsoleCrumbs = memoize(() =>
  262. this.breadcrumbs.filter(crumb => crumb.category === 'console')
  263. );
  264. getRawErrors = memoize(() => this.rawErrors);
  265. getIssueCrumbs = memoize(() =>
  266. this.breadcrumbs.filter(crumb => crumb.category === 'issue')
  267. );
  268. getNonConsoleCrumbs = memoize(() =>
  269. this.breadcrumbs.filter(crumb => crumb.category !== 'console')
  270. );
  271. getNavCrumbs = memoize(() =>
  272. this.breadcrumbs.filter(crumb =>
  273. [BreadcrumbType.INIT, BreadcrumbType.NAVIGATION].includes(crumb.type)
  274. )
  275. );
  276. getNetworkSpans = memoize(() => this.sortedSpans.filter(isNetworkSpan));
  277. getMemorySpans = memoize(() => this.sortedSpans.filter(isMemorySpan));
  278. getDomNodes = memoize(() =>
  279. extractDomNodes({
  280. crumbs: this.getCrumbsWithRRWebNodes(),
  281. rrwebEvents: this.getRRWebFrames(),
  282. finishedAt: this.replayRecord.finished_at,
  283. })
  284. );
  285. sdkConfig = memoize(() => {
  286. const found = this.rrwebEvents.find(
  287. event => event.type === EventType.Custom && event.data.tag === 'options'
  288. ) as undefined | RecordingOptions;
  289. return found?.data?.payload;
  290. });
  291. isNetworkDetailsSetup = memoize(() => {
  292. const config = this.sdkConfig();
  293. if (config) {
  294. return this.sdkConfig()?.networkDetailHasUrls;
  295. }
  296. // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1
  297. // So even if we don't have the config object, we should still fallback and
  298. // look for spans with network data, as that means things are setup!
  299. return this.getNetworkSpans().some(
  300. span =>
  301. Object.keys(span.data.request?.headers || {}).length ||
  302. Object.keys(span.data.response?.headers || {}).length
  303. );
  304. });
  305. }
  306. const isMemorySpan = (span: ReplaySpan): span is MemorySpan => {
  307. return span.op === 'memory';
  308. };
  309. const isNetworkSpan = (span: ReplaySpan): span is NetworkSpan => {
  310. return span.op?.startsWith('navigation.') || span.op?.startsWith('resource.');
  311. };