useReplayEvent.tsx 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231
  1. import {useCallback, useEffect, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import type {eventWithTime} from 'rrweb/typings/types';
  4. import {MemorySpanType} from 'sentry/components/events/interfaces/spans/types';
  5. import {IssueAttachment} from 'sentry/types';
  6. import {Entry, Event, EventTransaction} from 'sentry/types/event';
  7. import EventView from 'sentry/utils/discover/eventView';
  8. import {generateEventSlug} from 'sentry/utils/discover/urls';
  9. import ReplayReader from 'sentry/utils/replays/replayReader';
  10. import RequestError from 'sentry/utils/requestError/requestError';
  11. import useApi from 'sentry/utils/useApi';
  12. import createHighlightEvents from './createHighlightEvents';
  13. import mergeAndSortEvents from './mergeAndSortEvents';
  14. import mergeBreadcrumbsEntries from './mergeBreadcrumbsEntries';
  15. import mergeEventsWithSpans from './mergeEventsWithSpans';
  16. type State = {
  17. /**
  18. * List of breadcrumbs
  19. */
  20. breadcrumbEntry: undefined | Entry;
  21. /**
  22. * The root replay event
  23. */
  24. event: undefined | EventTransaction;
  25. /**
  26. * If any request returned an error then nothing is being returned
  27. */
  28. fetchError: undefined | RequestError;
  29. /**
  30. * If a fetch is underway for the requested root reply.
  31. * This includes fetched all the sub-resources like attachments and `sentry-replay-event`
  32. */
  33. fetching: boolean;
  34. memorySpans: undefined | MemorySpanType[];
  35. mergedReplayEvent: undefined | Event;
  36. /**
  37. * The list of related `sentry-replay-event` objects that were captured during this `sentry-replay`
  38. */
  39. replayEvents: undefined | Event[];
  40. /**
  41. * The flattened list of rrweb events. These are stored as multiple attachments on the root replay object: the `event` prop.
  42. */
  43. rrwebEvents: undefined | eventWithTime[];
  44. };
  45. type Options = {
  46. /**
  47. * When provided, fetches specified replay event by slug
  48. */
  49. eventSlug: string;
  50. /**
  51. *
  52. */
  53. location: any;
  54. /**
  55. *
  56. */
  57. orgId: string;
  58. };
  59. interface Result extends State {
  60. onRetry: () => void;
  61. replay: ReplayReader | null;
  62. }
  63. const IS_RRWEB_ATTACHMENT_FILENAME = /rrweb-[0-9]{13}.json/;
  64. function isRRWebEventAttachment(attachment: IssueAttachment) {
  65. return IS_RRWEB_ATTACHMENT_FILENAME.test(attachment.name);
  66. }
  67. const INITIAL_STATE: State = Object.freeze({
  68. fetchError: undefined,
  69. fetching: true,
  70. breadcrumbEntry: undefined,
  71. event: undefined,
  72. replayEvents: undefined,
  73. rrwebEvents: undefined,
  74. mergedReplayEvent: undefined,
  75. memorySpans: undefined,
  76. });
  77. function useReplayEvent({eventSlug, location, orgId}: Options): Result {
  78. const [projectId, eventId] = eventSlug.split(':');
  79. const api = useApi();
  80. const [retry, setRetry] = useState(false);
  81. const [state, setState] = useState<State>(INITIAL_STATE);
  82. function fetchEvent() {
  83. return api.requestPromise(
  84. `/organizations/${orgId}/events/${eventSlug}/`
  85. ) as Promise<EventTransaction>;
  86. }
  87. async function fetchRRWebEvents() {
  88. const attachmentIds = (await api.requestPromise(
  89. `/projects/${orgId}/${projectId}/events/${eventId}/attachments/`
  90. )) as IssueAttachment[];
  91. const rrwebAttachmentIds = attachmentIds.filter(isRRWebEventAttachment);
  92. const attachments = await Promise.all(
  93. rrwebAttachmentIds.map(async attachment => {
  94. const response = await api.requestPromise(
  95. `/api/0/projects/${orgId}/${projectId}/events/${eventId}/attachments/${attachment.id}/?download`
  96. );
  97. return JSON.parse(response).events as eventWithTime;
  98. })
  99. );
  100. return attachments.flat();
  101. }
  102. async function fetchReplayEvents() {
  103. const replayEventsView = EventView.fromSavedQuery({
  104. id: '',
  105. name: '',
  106. version: 2,
  107. fields: ['timestamp', 'replayId'],
  108. orderby: 'timestamp',
  109. projects: [],
  110. range: '14d',
  111. query: `transaction:sentry-replay-event`,
  112. });
  113. replayEventsView.additionalConditions.addFilterValues('replayId', [eventId]);
  114. const replayEventsQuery = replayEventsView.getEventsAPIPayload(location);
  115. const replayEventList = await api.requestPromise(
  116. `/organizations/${orgId}/eventsv2/`,
  117. {
  118. query: replayEventsQuery,
  119. }
  120. );
  121. return Promise.all(
  122. replayEventList.data.map(
  123. event =>
  124. api.requestPromise(
  125. `/organizations/${orgId}/events/${generateEventSlug(event)}/`
  126. ) as Promise<Event>
  127. )
  128. );
  129. }
  130. async function loadEvents() {
  131. setRetry(false);
  132. setState({
  133. ...INITIAL_STATE,
  134. });
  135. try {
  136. const [event, rrwebEvents, replayEvents] = await Promise.all([
  137. fetchEvent(),
  138. fetchRRWebEvents(),
  139. fetchReplayEvents(),
  140. ]);
  141. const breadcrumbEntry = mergeBreadcrumbsEntries(replayEvents || [], event);
  142. const mergedReplayEvent = mergeEventsWithSpans(replayEvents || []);
  143. const memorySpans =
  144. mergedReplayEvent?.entries[0]?.data?.filter(datum => datum?.data?.memory) || [];
  145. if (mergedReplayEvent.entries[0]) {
  146. mergedReplayEvent.entries[0].data = mergedReplayEvent?.entries[0]?.data?.filter(
  147. datum => !datum?.data?.memory
  148. );
  149. }
  150. // Find LCP spans that have a valid replay node id, this will be used to
  151. const highlights = createHighlightEvents(mergedReplayEvent?.entries[0].data);
  152. // TODO(replays): ideally this would happen on SDK, but due
  153. // to how plugins work, we are unable to specify a timestamp for an event
  154. // (rrweb applies it), so it's possible actual LCP timestamp does not
  155. // match when the observer happens and we emit an rrweb event (will
  156. // look into this)
  157. const rrwebEventsWithHighlights = mergeAndSortEvents(rrwebEvents, highlights);
  158. setState({
  159. ...state,
  160. fetchError: undefined,
  161. fetching: false,
  162. event,
  163. mergedReplayEvent,
  164. replayEvents,
  165. rrwebEvents: rrwebEventsWithHighlights,
  166. breadcrumbEntry,
  167. memorySpans,
  168. });
  169. } catch (error) {
  170. Sentry.captureException(error);
  171. setState({
  172. ...INITIAL_STATE,
  173. fetchError: error,
  174. fetching: false,
  175. });
  176. }
  177. }
  178. useEffect(() => void loadEvents(), [orgId, eventSlug, retry]); // eslint-disable-line react-hooks/exhaustive-deps
  179. const onRetry = useCallback(() => {
  180. setRetry(true);
  181. }, []);
  182. return {
  183. fetchError: state.fetchError,
  184. fetching: state.fetching,
  185. onRetry,
  186. replay: ReplayReader.factory(state.event, state.rrwebEvents, state.replayEvents),
  187. breadcrumbEntry: state.breadcrumbEntry,
  188. event: state.event,
  189. replayEvents: state.replayEvents,
  190. rrwebEvents: state.rrwebEvents,
  191. mergedReplayEvent: state.mergedReplayEvent,
  192. memorySpans: state.memorySpans,
  193. };
  194. }
  195. export default useReplayEvent;