replayReader.tsx 22 KB

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