replayReader.spec.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. import {EventType, IncrementalSource} from '@sentry-internal/rrweb';
  2. import {
  3. ReplayClickEventFixture,
  4. ReplayConsoleEventFixture,
  5. ReplayDeadClickEventFixture,
  6. ReplayMemoryEventFixture,
  7. ReplayNavigateEventFixture,
  8. } from 'sentry-fixture/replay/helpers';
  9. import {ReplayNavFrameFixture} from 'sentry-fixture/replay/replayBreadcrumbFrameData';
  10. import {
  11. ReplayBreadcrumbFrameEventFixture,
  12. ReplayOptionFrameEventFixture,
  13. ReplayOptionFrameFixture,
  14. ReplaySpanFrameEventFixture,
  15. } from 'sentry-fixture/replay/replayFrameEvents';
  16. import {ReplayRequestFrameFixture} from 'sentry-fixture/replay/replaySpanFrameData';
  17. import {
  18. RRWebDOMFrameFixture,
  19. RRWebFullSnapshotFrameEventFixture,
  20. } from 'sentry-fixture/replay/rrweb';
  21. import {ReplayErrorFixture} from 'sentry-fixture/replayError';
  22. import {ReplayRecordFixture} from 'sentry-fixture/replayRecord';
  23. import {BreadcrumbType} from 'sentry/types/breadcrumbs';
  24. import ReplayReader from 'sentry/utils/replays/replayReader';
  25. describe('ReplayReader', () => {
  26. const replayRecord = ReplayRecordFixture({});
  27. it('Should return null if there are missing arguments', () => {
  28. const missingAttachments = ReplayReader.factory({
  29. attachments: undefined,
  30. errors: [],
  31. replayRecord,
  32. });
  33. expect(missingAttachments).toBeNull();
  34. const missingErrors = ReplayReader.factory({
  35. attachments: [],
  36. errors: undefined,
  37. replayRecord,
  38. });
  39. expect(missingErrors).toBeNull();
  40. const missingRecord = ReplayReader.factory({
  41. attachments: [],
  42. errors: [],
  43. replayRecord: undefined,
  44. });
  45. expect(missingRecord).toBeNull();
  46. });
  47. it('should calculate started_at/finished_at/duration based on first/last events', () => {
  48. const minuteZero = new Date('2023-12-25T00:00:00');
  49. const minuteTen = new Date('2023-12-25T00:10:00');
  50. const replay = ReplayReader.factory({
  51. attachments: [
  52. ReplayConsoleEventFixture({timestamp: minuteZero}),
  53. ReplayConsoleEventFixture({timestamp: minuteTen}),
  54. ],
  55. errors: [],
  56. replayRecord: ReplayRecordFixture({
  57. started_at: new Date('2023-12-25T00:01:00'),
  58. finished_at: new Date('2023-12-25T00:09:00'),
  59. duration: undefined, // will be calculated
  60. }),
  61. });
  62. const expectedDuration = 10 * 60 * 1000; // 10 minutes, in ms
  63. expect(replay?.getReplay().started_at).toEqual(minuteZero);
  64. expect(replay?.getReplay().finished_at).toEqual(minuteTen);
  65. expect(replay?.getReplay().duration.asMilliseconds()).toEqual(expectedDuration);
  66. expect(replay?.getDurationMs()).toEqual(expectedDuration);
  67. });
  68. it('should make the replayRecord available through a getter method', () => {
  69. const replay = ReplayReader.factory({
  70. attachments: [],
  71. errors: [],
  72. replayRecord,
  73. });
  74. expect(replay?.getReplay()).toEqual(replayRecord);
  75. });
  76. describe('attachment splitting', () => {
  77. const timestamp = new Date('2023-12-25T00:02:00');
  78. const secondTimestamp = new Date('2023-12-25T00:04:00');
  79. const thirdTimestamp = new Date('2023-12-25T00:05:00');
  80. const optionsFrame = ReplayOptionFrameFixture({});
  81. const optionsEvent = ReplayOptionFrameEventFixture({
  82. timestamp,
  83. data: {payload: optionsFrame},
  84. });
  85. const firstDiv = RRWebFullSnapshotFrameEventFixture({timestamp});
  86. const secondDiv = RRWebFullSnapshotFrameEventFixture({timestamp});
  87. const clickEvent = ReplayClickEventFixture({timestamp});
  88. const secondClickEvent = ReplayClickEventFixture({timestamp: secondTimestamp});
  89. const thirdClickEvent = ReplayClickEventFixture({timestamp: thirdTimestamp});
  90. const deadClickEvent = ReplayDeadClickEventFixture({timestamp});
  91. const firstMemory = ReplayMemoryEventFixture({
  92. startTimestamp: timestamp,
  93. endTimestamp: timestamp,
  94. });
  95. const secondMemory = ReplayMemoryEventFixture({
  96. startTimestamp: timestamp,
  97. endTimestamp: timestamp,
  98. });
  99. const navigationEvent = ReplayNavigateEventFixture({
  100. startTimestamp: new Date('2023-12-25T00:03:00'),
  101. endTimestamp: new Date('2023-12-25T00:03:30'),
  102. });
  103. const navCrumb = ReplayBreadcrumbFrameEventFixture({
  104. timestamp: new Date('2023-12-25T00:03:00'),
  105. data: {
  106. payload: ReplayNavFrameFixture({
  107. timestamp: new Date('2023-12-25T00:03:00'),
  108. }),
  109. },
  110. });
  111. const consoleEvent = ReplayConsoleEventFixture({timestamp});
  112. const customEvent = ReplayBreadcrumbFrameEventFixture({
  113. timestamp: new Date('2023-12-25T00:02:30'),
  114. data: {
  115. payload: {
  116. category: 'redux.action',
  117. data: {
  118. action: 'save.click',
  119. },
  120. message: '',
  121. timestamp: new Date('2023-12-25T00:02:30').getTime() / 1000,
  122. type: BreadcrumbType.DEFAULT,
  123. },
  124. },
  125. });
  126. const attachments = [
  127. clickEvent,
  128. secondClickEvent,
  129. thirdClickEvent,
  130. consoleEvent,
  131. firstDiv,
  132. firstMemory,
  133. navigationEvent,
  134. navCrumb,
  135. optionsEvent,
  136. secondDiv,
  137. secondMemory,
  138. customEvent,
  139. deadClickEvent,
  140. ];
  141. it.each([
  142. {
  143. method: 'getRRWebFrames',
  144. expected: [
  145. {
  146. type: EventType.Custom,
  147. timestamp: expect.any(Number),
  148. data: {tag: 'replay.start', payload: {}},
  149. },
  150. firstDiv,
  151. secondDiv,
  152. {
  153. type: EventType.Custom,
  154. timestamp: expect.any(Number),
  155. data: {tag: 'replay.end', payload: {}},
  156. },
  157. ],
  158. },
  159. {
  160. method: 'getConsoleFrames',
  161. expected: [
  162. expect.objectContaining({category: 'console'}),
  163. expect.objectContaining({category: 'redux.action'}),
  164. ],
  165. },
  166. {
  167. method: 'getNetworkFrames',
  168. expected: [expect.objectContaining({op: 'navigation.navigate'})],
  169. },
  170. {
  171. method: 'getDOMFrames',
  172. expected: [
  173. expect.objectContaining({category: 'ui.slowClickDetected'}),
  174. expect.objectContaining({category: 'ui.click'}),
  175. expect.objectContaining({category: 'ui.click'}),
  176. ],
  177. },
  178. {
  179. method: 'getMemoryFrames',
  180. expected: [
  181. expect.objectContaining({op: 'memory'}),
  182. expect.objectContaining({op: 'memory'}),
  183. ],
  184. },
  185. {
  186. method: 'getChapterFrames',
  187. expected: [
  188. expect.objectContaining({category: 'replay.init'}),
  189. expect.objectContaining({category: 'ui.slowClickDetected'}),
  190. expect.objectContaining({category: 'navigation'}),
  191. expect.objectContaining({op: 'navigation.navigate'}),
  192. expect.objectContaining({category: 'ui.click'}),
  193. expect.objectContaining({category: 'ui.click'}),
  194. ],
  195. },
  196. {
  197. method: 'getSDKOptions',
  198. expected: optionsFrame,
  199. },
  200. ])('Calling $method will filter frames', ({method, expected}) => {
  201. const replay = ReplayReader.factory({
  202. attachments,
  203. errors: [],
  204. replayRecord,
  205. });
  206. const exec = replay?.[method];
  207. expect(exec()).toStrictEqual(expected);
  208. });
  209. });
  210. it('shoud return the SDK config if there is a RecordingOptions event found', () => {
  211. const timestamp = new Date();
  212. const optionsFrame = ReplayOptionFrameFixture({});
  213. const replay = ReplayReader.factory({
  214. attachments: [
  215. ReplayOptionFrameEventFixture({
  216. timestamp,
  217. data: {payload: optionsFrame},
  218. }),
  219. ],
  220. errors: [],
  221. replayRecord,
  222. });
  223. expect(replay?.getSDKOptions()).toBe(optionsFrame);
  224. });
  225. describe('isNetworkDetailsSetup', () => {
  226. it('should have isNetworkDetailsSetup=true if sdkConfig says so', () => {
  227. const timestamp = new Date();
  228. const replay = ReplayReader.factory({
  229. attachments: [
  230. ReplayOptionFrameEventFixture({
  231. timestamp,
  232. data: {
  233. payload: ReplayOptionFrameFixture({
  234. networkDetailHasUrls: true,
  235. }),
  236. },
  237. }),
  238. ],
  239. errors: [],
  240. replayRecord,
  241. });
  242. expect(replay?.isNetworkDetailsSetup()).toBeTruthy();
  243. });
  244. it.each([
  245. {
  246. data: {
  247. method: 'GET',
  248. request: {headers: {accept: 'application/json'}},
  249. },
  250. expected: true,
  251. },
  252. {
  253. data: {
  254. method: 'GET',
  255. },
  256. expected: false,
  257. },
  258. ])('should have isNetworkDetailsSetup=$expected', ({data, expected}) => {
  259. const startTimestamp = new Date();
  260. const endTimestamp = new Date();
  261. const replay = ReplayReader.factory({
  262. attachments: [
  263. ReplaySpanFrameEventFixture({
  264. timestamp: startTimestamp,
  265. data: {
  266. payload: ReplayRequestFrameFixture({
  267. op: 'resource.fetch',
  268. startTimestamp,
  269. endTimestamp,
  270. description: '/api/0/issues/',
  271. data,
  272. }),
  273. },
  274. }),
  275. ],
  276. errors: [],
  277. replayRecord,
  278. });
  279. expect(replay?.isNetworkDetailsSetup()).toBe(expected);
  280. });
  281. });
  282. it('detects canvas element from full snapshot', () => {
  283. const timestamp = new Date('2023-12-25T00:02:00');
  284. const firstDiv = RRWebFullSnapshotFrameEventFixture({
  285. timestamp,
  286. childNodes: [
  287. RRWebDOMFrameFixture({
  288. tagName: 'div',
  289. childNodes: [
  290. RRWebDOMFrameFixture({
  291. tagName: 'canvas',
  292. }),
  293. ],
  294. }),
  295. ],
  296. });
  297. const attachments = [firstDiv];
  298. const replay = ReplayReader.factory({
  299. attachments,
  300. errors: [],
  301. replayRecord,
  302. });
  303. expect(replay?.hasCanvasElementInReplay()).toBe(true);
  304. });
  305. it('detects canvas element from dom mutations', () => {
  306. const timestamp = new Date('2023-12-25T00:02:00');
  307. const snapshot = RRWebFullSnapshotFrameEventFixture({timestamp});
  308. const attachments = [
  309. snapshot,
  310. {
  311. type: EventType.IncrementalSnapshot,
  312. timestamp,
  313. data: {
  314. source: IncrementalSource.Mutation,
  315. adds: [
  316. {
  317. node: RRWebDOMFrameFixture({
  318. tagName: 'canvas',
  319. }),
  320. },
  321. ],
  322. removes: [],
  323. texts: [],
  324. attributes: [],
  325. },
  326. },
  327. ];
  328. const replay = ReplayReader.factory({
  329. attachments,
  330. errors: [],
  331. replayRecord,
  332. });
  333. expect(replay?.hasCanvasElementInReplay()).toBe(true);
  334. });
  335. describe('clip window', () => {
  336. const replayStartedAt = new Date('2024-01-01T00:02:00');
  337. const replayFinishedAt = new Date('2024-01-01T00:04:00');
  338. const clipStartTimestamp = new Date('2024-01-01T00:03:00');
  339. const clipEndTimestamp = new Date('2024-01-01T00:03:10');
  340. const rrwebFrame1 = RRWebFullSnapshotFrameEventFixture({
  341. timestamp: new Date('2024-01-01T00:02:30'),
  342. });
  343. const rrwebFrame2 = RRWebFullSnapshotFrameEventFixture({
  344. timestamp: new Date('2024-01-01T00:03:09'),
  345. });
  346. const rrwebFrame3 = RRWebFullSnapshotFrameEventFixture({
  347. timestamp: new Date('2024-01-01T00:03:30'),
  348. });
  349. const breadcrumbAttachment1 = ReplayBreadcrumbFrameEventFixture({
  350. timestamp: new Date('2024-01-01T00:02:30'),
  351. data: {
  352. payload: ReplayNavFrameFixture({
  353. timestamp: new Date('2024-01-01T00:02:30'),
  354. }),
  355. },
  356. });
  357. const breadcrumbAttachment2 = ReplayBreadcrumbFrameEventFixture({
  358. timestamp: new Date('2024-01-01T00:03:05'),
  359. data: {
  360. payload: ReplayNavFrameFixture({
  361. timestamp: new Date('2024-01-01T00:03:05'),
  362. }),
  363. },
  364. });
  365. const breadcrumbAttachment3 = ReplayBreadcrumbFrameEventFixture({
  366. timestamp: new Date('2024-01-01T00:03:30'),
  367. data: {
  368. payload: ReplayNavFrameFixture({
  369. timestamp: new Date('2024-01-01T00:03:30'),
  370. }),
  371. },
  372. });
  373. const error1 = ReplayErrorFixture({
  374. id: '1',
  375. issue: '100',
  376. timestamp: '2024-01-01T00:02:30',
  377. });
  378. const error2 = ReplayErrorFixture({
  379. id: '2',
  380. issue: '200',
  381. timestamp: '2024-01-01T00:03:06',
  382. });
  383. const error3 = ReplayErrorFixture({
  384. id: '1',
  385. issue: '100',
  386. timestamp: '2024-01-01T00:03:30',
  387. });
  388. const replay = ReplayReader.factory({
  389. attachments: [
  390. rrwebFrame1,
  391. rrwebFrame2,
  392. rrwebFrame3,
  393. breadcrumbAttachment1,
  394. breadcrumbAttachment2,
  395. breadcrumbAttachment3,
  396. ],
  397. errors: [error1, error2, error3],
  398. replayRecord: ReplayRecordFixture({
  399. started_at: replayStartedAt,
  400. finished_at: replayFinishedAt,
  401. }),
  402. clipWindow: {
  403. startTimestampMs: clipStartTimestamp.getTime(),
  404. endTimestampMs: clipEndTimestamp.getTime(),
  405. },
  406. });
  407. it('should adjust the end time and duration for the clip window', () => {
  408. // Duration should be between the clip start time and end time
  409. expect(replay?.getDurationMs()).toEqual(10_000);
  410. // Start offset should be set
  411. expect(replay?.getStartOffsetMs()).toEqual(
  412. clipStartTimestamp.getTime() - replayStartedAt.getTime()
  413. );
  414. expect(replay?.getStartTimestampMs()).toEqual(clipStartTimestamp.getTime());
  415. });
  416. it('should trim rrweb frames from the end but not the beginning', () => {
  417. expect(replay?.getRRWebFrames()).toEqual([
  418. expect.objectContaining({
  419. type: EventType.Custom,
  420. data: {tag: 'replay.start', payload: {}},
  421. }),
  422. expect.objectContaining({
  423. type: EventType.FullSnapshot,
  424. timestamp: rrwebFrame1.timestamp,
  425. }),
  426. expect.objectContaining({
  427. type: EventType.FullSnapshot,
  428. timestamp: rrwebFrame2.timestamp,
  429. }),
  430. expect.objectContaining({
  431. type: EventType.Custom,
  432. data: {tag: 'replay.clip_end', payload: {}},
  433. timestamp: clipEndTimestamp.getTime(),
  434. }),
  435. // rrwebFrame3 should not be returned
  436. ]);
  437. });
  438. it('should only return chapter frames within window and shift their clipOffsets', () => {
  439. expect(replay?.getChapterFrames()).toEqual([
  440. // Only breadcrumb2 and error2 should be included
  441. expect.objectContaining({
  442. category: 'navigation',
  443. timestampMs: breadcrumbAttachment2.timestamp,
  444. // offset is relative to the start of the clip window
  445. offsetMs: 5_000,
  446. }),
  447. expect.objectContaining({
  448. category: 'issue',
  449. timestampMs: new Date(error2.timestamp).getTime(),
  450. offsetMs: 6_000,
  451. }),
  452. ]);
  453. });
  454. });
  455. });