replayReader.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708
  1. import * as Sentry from '@sentry/react';
  2. import type {eventWithTime} from '@sentry-internal/rrweb';
  3. import memoize from 'lodash/memoize';
  4. import {type Duration, duration} from 'moment-timezone';
  5. import {defined} from 'sentry/utils';
  6. import domId from 'sentry/utils/domId';
  7. import localStorageWrapper from 'sentry/utils/localStorage';
  8. import clamp from 'sentry/utils/number/clamp';
  9. import extractHtml from 'sentry/utils/replays/extractHtml';
  10. import hydrateBreadcrumbs, {
  11. replayInitBreadcrumb,
  12. } from 'sentry/utils/replays/hydrateBreadcrumbs';
  13. import hydrateErrors from 'sentry/utils/replays/hydrateErrors';
  14. import hydrateFrames from 'sentry/utils/replays/hydrateFrames';
  15. import {
  16. clipEndFrame,
  17. recordingEndFrame,
  18. } from 'sentry/utils/replays/hydrateRRWebRecordingFrames';
  19. import hydrateSpans from 'sentry/utils/replays/hydrateSpans';
  20. import {replayTimestamps} from 'sentry/utils/replays/replayDataUtils';
  21. import replayerStepper from 'sentry/utils/replays/replayerStepper';
  22. import type {
  23. BreadcrumbFrame,
  24. ClipWindow,
  25. ErrorFrame,
  26. fullSnapshotEvent,
  27. incrementalSnapshotEvent,
  28. MemoryFrame,
  29. OptionFrame,
  30. RecordingFrame,
  31. ReplayFrame,
  32. serializedNodeWithId,
  33. SlowClickFrame,
  34. SpanFrame,
  35. VideoEvent,
  36. } from 'sentry/utils/replays/types';
  37. import {
  38. BreadcrumbCategories,
  39. EventType,
  40. getNodeId,
  41. IncrementalSource,
  42. isDeadClick,
  43. isDeadRageClick,
  44. isPaintFrame,
  45. isWebVitalFrame,
  46. } from 'sentry/utils/replays/types';
  47. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  48. interface ReplayReaderParams {
  49. /**
  50. * Loaded segment data
  51. *
  52. * This is a mix of rrweb data, breadcrumbs and spans/transactions sorted by time
  53. * All three types are mixed together.
  54. */
  55. attachments: unknown[] | undefined;
  56. /**
  57. * Error objects related to this replay
  58. *
  59. * Error instances could be frontend, backend, or come from the error platform
  60. * like performance-errors or replay-errors
  61. */
  62. errors: ReplayError[] | undefined;
  63. /**
  64. * The root Replay event, created at the start of the browser session.
  65. */
  66. replayRecord: ReplayRecord | undefined;
  67. /**
  68. * If provided, the replay will be clipped to this window.
  69. */
  70. clipWindow?: ClipWindow;
  71. /**
  72. * The org's feature flags
  73. */
  74. featureFlags?: string[];
  75. }
  76. type RequiredNotNull<T> = {
  77. [P in keyof T]: NonNullable<T[P]>;
  78. };
  79. const sortFrames = (a, b) => a.timestampMs - b.timestampMs;
  80. function removeDuplicateClicks(frames: BreadcrumbFrame[]) {
  81. const slowClickFrames = frames.filter(
  82. frame => frame.category === 'ui.slowClickDetected'
  83. );
  84. const clickFrames = frames.filter(frame => frame.category === 'ui.click');
  85. const otherFrames = frames.filter(
  86. frame => !(slowClickFrames.includes(frame) || clickFrames.includes(frame))
  87. );
  88. const uniqueClickFrames: BreadcrumbFrame[] = clickFrames.filter(clickFrame => {
  89. return !slowClickFrames.some(
  90. slowClickFrame =>
  91. slowClickFrame.data &&
  92. 'nodeId' in slowClickFrame.data &&
  93. clickFrame.data &&
  94. 'nodeId' in clickFrame.data &&
  95. slowClickFrame.data.nodeId === clickFrame.data.nodeId &&
  96. slowClickFrame.timestampMs === clickFrame.timestampMs
  97. );
  98. });
  99. return uniqueClickFrames.concat(otherFrames).concat(slowClickFrames);
  100. }
  101. // If a `navigation` crumb and `navigation.*` span happen within this timeframe,
  102. // we'll consider them duplicates.
  103. const DUPLICATE_NAV_THRESHOLD_MS = 2;
  104. /**
  105. * Return a list of BreadcrumbFrames, where any navigation crumb is removed if
  106. * there is a matching navigation.* span to replace it.
  107. *
  108. * SpanFrame is preferred because they render with more specific titles.
  109. */
  110. function removeDuplicateNavCrumbs(
  111. crumbFrames: BreadcrumbFrame[],
  112. spanFrames: SpanFrame[]
  113. ) {
  114. const navCrumbs = crumbFrames.filter(crumb => crumb.category === 'navigation');
  115. const otherBreadcrumbFrames = crumbFrames.filter(
  116. crumb => crumb.category !== 'navigation'
  117. );
  118. const navSpans = spanFrames.filter(span => span.op.startsWith('navigation.'));
  119. const uniqueNavCrumbs = navCrumbs.filter(
  120. crumb =>
  121. !navSpans.some(
  122. span => Math.abs(crumb.offsetMs - span.offsetMs) <= DUPLICATE_NAV_THRESHOLD_MS
  123. )
  124. );
  125. return otherBreadcrumbFrames.concat(uniqueNavCrumbs);
  126. }
  127. const extractDomNodes = {
  128. shouldVisitFrame: frame => {
  129. const nodeId = getNodeId(frame);
  130. return nodeId !== undefined && nodeId !== -1;
  131. },
  132. onVisitFrame: (frame, collection, replayer) => {
  133. const mirror = replayer.getMirror();
  134. const nodeId = getNodeId(frame);
  135. const html = extractHtml(nodeId as number, mirror);
  136. collection.set(frame as ReplayFrame, {
  137. frame,
  138. html,
  139. timestamp: frame.timestampMs,
  140. });
  141. },
  142. };
  143. const countDomNodes = function (frames: eventWithTime[]) {
  144. let frameCount = 0;
  145. const length = frames?.length ?? 0;
  146. const frameStep = Math.max(Math.round(length * 0.007), 1);
  147. let prevIds: number[] = [];
  148. return {
  149. shouldVisitFrame() {
  150. frameCount++;
  151. return frameCount % frameStep === 0;
  152. },
  153. onVisitFrame(frame, collection, replayer) {
  154. const ids = replayer.getMirror().getIds(); // gets list of DOM nodes present
  155. const count = ids.length;
  156. const added = ids.filter(id => !prevIds.includes(id)).length;
  157. const removed = prevIds.filter(id => !ids.includes(id)).length;
  158. collection.set(frame as RecordingFrame, {
  159. count,
  160. added,
  161. removed,
  162. timestampMs: frame.timestamp,
  163. startTimestampMs: frame.timestamp,
  164. endTimestampMs: frame.timestamp,
  165. });
  166. prevIds = ids;
  167. },
  168. };
  169. };
  170. export default class ReplayReader {
  171. static factory({
  172. attachments,
  173. errors,
  174. replayRecord,
  175. clipWindow,
  176. featureFlags,
  177. }: ReplayReaderParams) {
  178. if (!attachments || !replayRecord || !errors) {
  179. return null;
  180. }
  181. try {
  182. return new ReplayReader({
  183. attachments,
  184. errors,
  185. replayRecord,
  186. featureFlags,
  187. clipWindow,
  188. });
  189. } catch (err) {
  190. Sentry.captureException(err);
  191. // If something happens then we don't really know if it's the attachments
  192. // array or errors array to blame (it's probably attachments though).
  193. // Either way we can use the replayRecord to show some metadata, and then
  194. // put an error message below it.
  195. return new ReplayReader({
  196. attachments: [],
  197. errors: [],
  198. featureFlags,
  199. replayRecord,
  200. clipWindow,
  201. });
  202. }
  203. }
  204. private constructor({
  205. attachments,
  206. errors,
  207. featureFlags,
  208. replayRecord,
  209. clipWindow,
  210. }: RequiredNotNull<ReplayReaderParams>) {
  211. this._cacheKey = domId('replayReader-');
  212. if (replayRecord.is_archived) {
  213. this._replayRecord = replayRecord;
  214. const archivedReader = new Proxy(this, {
  215. get(_target, prop, _receiver) {
  216. if (prop === 'getReplay') {
  217. return () => replayRecord;
  218. }
  219. return () => {};
  220. },
  221. });
  222. return archivedReader;
  223. }
  224. const {breadcrumbFrames, optionFrame, rrwebFrames, spanFrames, videoFrames} =
  225. hydrateFrames(attachments);
  226. if (localStorageWrapper.getItem('REPLAY-BACKEND-TIMESTAMPS') !== '1') {
  227. // TODO(replays): We should get correct timestamps from the backend instead
  228. // of having to fix them up here.
  229. const {startTimestampMs, endTimestampMs} = replayTimestamps(
  230. replayRecord,
  231. rrwebFrames,
  232. breadcrumbFrames,
  233. spanFrames
  234. );
  235. this.timestampDeltas = {
  236. startedAtDelta: startTimestampMs - replayRecord.started_at.getTime(),
  237. finishedAtDelta: endTimestampMs - replayRecord.finished_at.getTime(),
  238. };
  239. replayRecord.started_at = new Date(startTimestampMs);
  240. replayRecord.finished_at = new Date(endTimestampMs);
  241. replayRecord.duration = duration(
  242. replayRecord.finished_at.getTime() - replayRecord.started_at.getTime()
  243. );
  244. }
  245. // Hydrate the data we were given
  246. this._replayRecord = replayRecord;
  247. this._featureFlags = featureFlags;
  248. // Errors don't need to be sorted here, they will be merged with breadcrumbs
  249. // and spans in the getter and then sorted together.
  250. const {errorFrames, feedbackFrames} = hydrateErrors(replayRecord, errors);
  251. this._errors = errorFrames.sort(sortFrames);
  252. // RRWeb Events are not sorted here, they are fetched in sorted order.
  253. this._sortedRRWebEvents = rrwebFrames;
  254. this._videoEvents = videoFrames;
  255. // Breadcrumbs must be sorted. Crumbs like `slowClick` and `multiClick` will
  256. // have the same timestamp as the click breadcrumb, but will be emitted a
  257. // few seconds later.
  258. this._sortedBreadcrumbFrames = hydrateBreadcrumbs(replayRecord, breadcrumbFrames)
  259. .concat(feedbackFrames)
  260. .sort(sortFrames);
  261. // Spans must be sorted so components like the Timeline and Network Chart
  262. // can have an easier time to render.
  263. this._sortedSpanFrames = hydrateSpans(replayRecord, spanFrames).sort(sortFrames);
  264. this._optionFrame = optionFrame;
  265. // Insert extra records to satisfy minimum requirements for the UI
  266. // e.g. we have buffered events from browser that happen *before* replay
  267. // recording is started these can show up in the timeline (navigation) and
  268. // in Network table
  269. //
  270. // We fake the start time so that the timelines of these UI components and
  271. // the replay recording all match up
  272. this._sortedBreadcrumbFrames.unshift(replayInitBreadcrumb(replayRecord));
  273. const startTimestampMs = replayRecord.started_at.getTime();
  274. const firstMeta = rrwebFrames.find(frame => frame.type === EventType.Meta);
  275. const firstSnapshot = rrwebFrames.find(
  276. frame => frame.type === EventType.FullSnapshot
  277. );
  278. if (firstMeta && firstSnapshot && firstMeta.timestamp > startTimestampMs) {
  279. this._sortedRRWebEvents.unshift({
  280. ...firstSnapshot,
  281. timestamp: startTimestampMs,
  282. });
  283. this._sortedRRWebEvents.unshift({
  284. ...firstMeta,
  285. timestamp: startTimestampMs,
  286. });
  287. }
  288. this._sortedRRWebEvents.push(recordingEndFrame(replayRecord));
  289. this._duration = replayRecord.duration;
  290. if (clipWindow) {
  291. this._applyClipWindow(clipWindow);
  292. }
  293. }
  294. public timestampDeltas = {startedAtDelta: 0, finishedAtDelta: 0};
  295. private _cacheKey: string;
  296. private _duration: Duration = duration(0);
  297. private _errors: ErrorFrame[] = [];
  298. private _featureFlags: string[] | undefined = [];
  299. private _optionFrame: undefined | OptionFrame;
  300. private _replayRecord: ReplayRecord;
  301. private _sortedBreadcrumbFrames: BreadcrumbFrame[] = [];
  302. private _sortedRRWebEvents: RecordingFrame[] = [];
  303. private _sortedSpanFrames: SpanFrame[] = [];
  304. private _startOffsetMs = 0;
  305. private _videoEvents: VideoEvent[] = [];
  306. private _clipWindow: ClipWindow | undefined = undefined;
  307. private _applyClipWindow = (clipWindow: ClipWindow) => {
  308. const clipStartTimestampMs = clamp(
  309. clipWindow.startTimestampMs,
  310. this._replayRecord.started_at.getTime(),
  311. this._replayRecord.finished_at.getTime()
  312. );
  313. const clipEndTimestampMs = clamp(
  314. clipWindow.endTimestampMs,
  315. clipStartTimestampMs,
  316. this._replayRecord.finished_at.getTime()
  317. );
  318. this._duration = duration(clipEndTimestampMs - clipStartTimestampMs);
  319. // For video replays, we need to bypass setting the global offset (_startOffsetMs)
  320. // because it messes with the playback time by causing it
  321. // to become negative sometimes. Instead we pass a clip window directly into
  322. // the video player, which runs on an external timer
  323. if (this.isVideoReplay()) {
  324. this._clipWindow = {
  325. startTimestampMs: clipStartTimestampMs,
  326. endTimestampMs: clipEndTimestampMs,
  327. };
  328. // Trim error frames and update offsets so they show inside the clip window
  329. // Do this in here since we bypass setting the global offset
  330. // Eventually when we have video breadcrumbs we'll probably need to trim them here too
  331. const updateVideoFrameOffsets = <T extends {offsetMs: number}>(
  332. frames: Array<T>
  333. ) => {
  334. const offset = clipStartTimestampMs - this._replayRecord.started_at.getTime();
  335. return frames.map(frame => ({
  336. ...frame,
  337. offsetMs: frame.offsetMs - offset,
  338. }));
  339. };
  340. this._errors = updateVideoFrameOffsets(
  341. this._trimFramesToClipWindow(
  342. this._errors,
  343. clipStartTimestampMs,
  344. clipEndTimestampMs
  345. )
  346. );
  347. return;
  348. }
  349. // For RRWeb frames we only trim from the end because playback will
  350. // not work otherwise. The start offset is used to begin playback at
  351. // the correct time.
  352. this._sortedRRWebEvents = this._sortedRRWebEvents.filter(
  353. frame => frame.timestamp <= clipEndTimestampMs
  354. );
  355. this._sortedRRWebEvents.push(clipEndFrame(clipEndTimestampMs));
  356. this._startOffsetMs = clipStartTimestampMs - this._replayRecord.started_at.getTime();
  357. // We also only trim from the back for breadcrumbs/spans to keep
  358. // historical information about the replay, such as the current URL.
  359. this._sortedBreadcrumbFrames = this._updateFrameOffsets(
  360. this._trimFramesToClipWindow(
  361. this._sortedBreadcrumbFrames,
  362. this._replayRecord.started_at.getTime(),
  363. clipEndTimestampMs
  364. )
  365. );
  366. this._sortedSpanFrames = this._updateFrameOffsets(
  367. this._trimFramesToClipWindow(
  368. this._sortedSpanFrames,
  369. this._replayRecord.started_at.getTime(),
  370. clipEndTimestampMs
  371. )
  372. );
  373. this._errors = this._updateFrameOffsets(
  374. this._trimFramesToClipWindow(this._errors, clipStartTimestampMs, clipEndTimestampMs)
  375. );
  376. };
  377. /**
  378. * Filters out frames that are outside of the supplied window
  379. */
  380. _trimFramesToClipWindow = <T extends {timestampMs: number}>(
  381. frames: Array<T>,
  382. startTimestampMs: number,
  383. endTimestampMs: number
  384. ) => {
  385. return frames.filter(
  386. frame =>
  387. frame.timestampMs >= startTimestampMs && frame.timestampMs <= endTimestampMs
  388. );
  389. };
  390. /**
  391. * Updates the offsetMs of all frames to be relative to the start of the clip window
  392. */
  393. _updateFrameOffsets = <T extends {offsetMs: number}>(frames: Array<T>) => {
  394. return frames.map(frame => ({
  395. ...frame,
  396. offsetMs: frame.offsetMs - this.getStartOffsetMs(),
  397. }));
  398. };
  399. toJSON = () => this._cacheKey;
  400. processingErrors = memoize(() => {
  401. return [
  402. this.getRRWebFrames().length < 2
  403. ? `Replay has ${this.getRRWebFrames().length} frames`
  404. : null,
  405. !this.getRRWebFrames().some(frame => frame.type === EventType.Meta)
  406. ? 'Missing Meta Frame'
  407. : null,
  408. ].filter(defined);
  409. });
  410. hasProcessingErrors = () => {
  411. return this.processingErrors().length;
  412. };
  413. getCountDomNodes = memoize(async () => {
  414. const {onVisitFrame, shouldVisitFrame} = countDomNodes(this.getRRWebMutations());
  415. const results = await replayerStepper({
  416. frames: this.getRRWebMutations(),
  417. rrwebEvents: this.getRRWebFrames(),
  418. startTimestampMs: this.getReplay().started_at.getTime() ?? 0,
  419. onVisitFrame,
  420. shouldVisitFrame,
  421. });
  422. return results;
  423. });
  424. getExtractDomNodes = memoize(async () => {
  425. const {onVisitFrame, shouldVisitFrame} = extractDomNodes;
  426. const results = await replayerStepper({
  427. frames: this.getDOMFrames(),
  428. rrwebEvents: this.getRRWebFrames(),
  429. startTimestampMs: this.getReplay().started_at.getTime() ?? 0,
  430. onVisitFrame,
  431. shouldVisitFrame,
  432. });
  433. return results;
  434. });
  435. getClipWindow = () => this._clipWindow;
  436. /**
  437. * @returns Duration of Replay (milliseonds)
  438. */
  439. getDurationMs = () => {
  440. return this._duration.asMilliseconds();
  441. };
  442. getStartOffsetMs = () => this._startOffsetMs;
  443. getStartTimestampMs = () => {
  444. // For video replays we bypass setting the global _startOffsetMs
  445. // because it messes with the player time by causing it to
  446. // be negative in some cases, but we still need that calculated value here
  447. const start =
  448. this.isVideoReplay() && this._clipWindow
  449. ? this._clipWindow?.startTimestampMs - this._replayRecord.started_at.getTime()
  450. : this._startOffsetMs;
  451. return this._replayRecord.started_at.getTime() + start;
  452. };
  453. getReplay = () => {
  454. return this._replayRecord;
  455. };
  456. getRRWebFrames = () => this._sortedRRWebEvents;
  457. getBreadcrumbFrames = () => this._sortedBreadcrumbFrames;
  458. getRRWebMutations = () =>
  459. this._sortedRRWebEvents.filter(
  460. event =>
  461. [EventType.IncrementalSnapshot].includes(event.type) &&
  462. [IncrementalSource.Mutation].includes(
  463. (event as incrementalSnapshotEvent).data.source
  464. ) // filter only for mutation events
  465. );
  466. getErrorFrames = () => this._errors;
  467. getConsoleFrames = memoize(() =>
  468. this._sortedBreadcrumbFrames.filter(
  469. frame =>
  470. frame.category === 'console' || !BreadcrumbCategories.includes(frame.category)
  471. )
  472. );
  473. getNavigationFrames = memoize(() =>
  474. [
  475. ...this._sortedBreadcrumbFrames.filter(frame => frame.category === 'replay.init'),
  476. ...this._sortedSpanFrames.filter(frame => frame.op.startsWith('navigation.')),
  477. ].sort(sortFrames)
  478. );
  479. getMobileNavigationFrames = memoize(() =>
  480. [
  481. ...this._sortedBreadcrumbFrames.filter(frame =>
  482. ['replay.init', 'navigation'].includes(frame.category)
  483. ),
  484. ].sort(sortFrames)
  485. );
  486. getNetworkFrames = memoize(() =>
  487. this._sortedSpanFrames.filter(
  488. frame => frame.op.startsWith('navigation.') || frame.op.startsWith('resource.')
  489. )
  490. );
  491. getDOMFrames = memoize(() =>
  492. [
  493. ...removeDuplicateClicks(
  494. this._sortedBreadcrumbFrames
  495. .filter(frame => 'nodeId' in (frame.data ?? {}))
  496. .filter(
  497. frame =>
  498. !(
  499. (frame.category === 'ui.slowClickDetected' &&
  500. !isDeadClick(frame as SlowClickFrame)) ||
  501. frame.category === 'ui.multiClick'
  502. )
  503. )
  504. ),
  505. ...this._sortedSpanFrames.filter(frame => 'nodeId' in (frame.data ?? {})),
  506. ].sort(sortFrames)
  507. );
  508. getMemoryFrames = memoize(() =>
  509. this._sortedSpanFrames.filter((frame): frame is MemoryFrame => frame.op === 'memory')
  510. );
  511. getChapterFrames = memoize(() =>
  512. this._trimFramesToClipWindow(
  513. [
  514. ...this.getPerfFrames(),
  515. ...this.getWebVitalFrames(),
  516. ...this._sortedBreadcrumbFrames.filter(frame =>
  517. [
  518. 'replay.hydrate-error',
  519. 'replay.init',
  520. 'replay.mutations',
  521. 'feedback',
  522. 'device.battery',
  523. 'device.connectivity',
  524. 'device.orientation',
  525. 'app.foreground',
  526. 'app.background',
  527. ].includes(frame.category)
  528. ),
  529. ...this._errors,
  530. ].sort(sortFrames),
  531. this.getStartTimestampMs(),
  532. this.getStartTimestampMs() + this.getDurationMs()
  533. )
  534. );
  535. getPerfFrames = memoize(() => {
  536. const crumbs = removeDuplicateClicks(
  537. this._sortedBreadcrumbFrames.filter(
  538. frame =>
  539. ['navigation', 'ui.click', 'ui.tap'].includes(frame.category) ||
  540. (frame.category === 'ui.slowClickDetected' &&
  541. (isDeadClick(frame as SlowClickFrame) ||
  542. isDeadRageClick(frame as SlowClickFrame)))
  543. )
  544. );
  545. const spans = this._sortedSpanFrames.filter(frame =>
  546. frame.op.startsWith('navigation.')
  547. );
  548. const uniqueCrumbs = removeDuplicateNavCrumbs(crumbs, spans);
  549. return [...uniqueCrumbs, ...spans].sort(sortFrames);
  550. });
  551. getWebVitalFrames = memoize(() => {
  552. if (this._featureFlags?.includes('session-replay-web-vitals')) {
  553. return this._sortedSpanFrames.filter(isWebVitalFrame);
  554. }
  555. return [];
  556. });
  557. getVideoEvents = () => this._videoEvents;
  558. getPaintFrames = memoize(() => this._sortedSpanFrames.filter(isPaintFrame));
  559. getSDKOptions = () => this._optionFrame;
  560. /**
  561. * Checks the replay to see if user has any canvas elements in their
  562. * application. Needed to inform them that we now support canvas in replays.
  563. */
  564. hasCanvasElementInReplay = memoize(() => {
  565. return Boolean(this._sortedRRWebEvents.filter(findCanvas).length);
  566. });
  567. isVideoReplay = memoize(() => this.getVideoEvents().length > 0);
  568. isNetworkDetailsSetup = memoize(() => {
  569. const sdkOptions = this.getSDKOptions();
  570. if (sdkOptions) {
  571. return sdkOptions.networkDetailHasUrls;
  572. }
  573. // Network data was added in JS SDK 7.50.0 while sdkConfig was added in v7.51.1
  574. // So even if we don't have the config object, we should still fallback and
  575. // look for spans with network data, as that means things are setup!
  576. return this.getNetworkFrames().some(
  577. frame =>
  578. // We'd need to `filter()` before calling `some()` in order for TS to be happy
  579. // @ts-expect-error
  580. Object.keys(frame?.data?.request?.headers ?? {}).length ||
  581. // @ts-expect-error
  582. Object.keys(frame?.data?.response?.headers ?? {}).length
  583. );
  584. });
  585. }
  586. function findCanvas(event: RecordingFrame) {
  587. if (event.type === EventType.FullSnapshot) {
  588. return findCanvasInSnapshot(event);
  589. }
  590. if (event.type === EventType.IncrementalSnapshot) {
  591. return findCanvasInMutation(event);
  592. }
  593. return false;
  594. }
  595. function findCanvasInMutation(event: incrementalSnapshotEvent) {
  596. if (event.data.source !== IncrementalSource.Mutation) {
  597. return false;
  598. }
  599. return event.data.adds.find(
  600. add => add.node && add.node.type === 2 && add.node.tagName === 'canvas'
  601. );
  602. }
  603. function findCanvasInChildNodes(nodes: serializedNodeWithId[]) {
  604. return nodes.find(
  605. node =>
  606. node.type === 2 &&
  607. (node.tagName === 'canvas' || findCanvasInChildNodes(node.childNodes || []))
  608. );
  609. }
  610. function findCanvasInSnapshot(event: fullSnapshotEvent) {
  611. if (event.data.node.type !== 0) {
  612. return false;
  613. }
  614. return findCanvasInChildNodes(event.data.node.childNodes);
  615. }