useReplayData.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {inflate} from 'pako';
  4. import {IssueAttachment} from 'sentry/types';
  5. import {EventTransaction} from 'sentry/types/event';
  6. import ReplayReader from 'sentry/utils/replays/replayReader';
  7. import RequestError from 'sentry/utils/requestError/requestError';
  8. import useApi from 'sentry/utils/useApi';
  9. import type {
  10. RecordingEvent,
  11. ReplayCrumb,
  12. ReplayError,
  13. ReplaySpan,
  14. } from 'sentry/views/replays/types';
  15. import flattenListOfObjects from '../flattenListOfObjects';
  16. import useReplayErrors from './useReplayErrors';
  17. type State = {
  18. breadcrumbs: undefined | ReplayCrumb[];
  19. /**
  20. * List of errors that occurred during replay
  21. */
  22. errors: undefined | ReplayError[];
  23. /**
  24. * The root replay event
  25. */
  26. event: undefined | EventTransaction;
  27. /**
  28. * If any request returned an error then nothing is being returned
  29. */
  30. fetchError: undefined | RequestError;
  31. /**
  32. * If a fetch is underway for the requested root reply.
  33. * This includes fetched all the sub-resources like attachments and `sentry-replay-event`
  34. */
  35. fetching: boolean;
  36. /**
  37. * Are errors currently being fetched
  38. */
  39. isErrorsFetching: boolean;
  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 | RecordingEvent[];
  44. spans: undefined | ReplaySpan[];
  45. };
  46. type Options = {
  47. /**
  48. * The projectSlug and eventId concatenated together
  49. */
  50. eventSlug: string;
  51. /**
  52. * The organization slug
  53. */
  54. orgId: string;
  55. };
  56. // Errors if it is an interface
  57. // See https://github.com/microsoft/TypeScript/issues/15300
  58. type ReplayAttachment = {
  59. breadcrumbs: ReplayCrumb[];
  60. recording: RecordingEvent[];
  61. replaySpans: ReplaySpan[];
  62. };
  63. interface Result extends Pick<State, 'fetchError' | 'fetching'> {
  64. onRetry: () => void;
  65. replay: ReplayReader | null;
  66. }
  67. const IS_RRWEB_ATTACHMENT_FILENAME = /rrweb-[0-9]{13}.json/;
  68. function isRRWebEventAttachment(attachment: IssueAttachment) {
  69. return IS_RRWEB_ATTACHMENT_FILENAME.test(attachment.name);
  70. }
  71. export function mapRRWebAttachments(unsortedReplayAttachments): ReplayAttachment {
  72. const replayAttachments: ReplayAttachment = {
  73. breadcrumbs: [],
  74. replaySpans: [],
  75. recording: [],
  76. };
  77. unsortedReplayAttachments.forEach(attachment => {
  78. if (attachment.data?.tag === 'performanceSpan') {
  79. replayAttachments.replaySpans.push(attachment.data.payload);
  80. } else if (attachment?.data?.tag === 'breadcrumb') {
  81. replayAttachments.breadcrumbs.push(attachment.data.payload);
  82. } else {
  83. replayAttachments.recording.push(attachment);
  84. }
  85. });
  86. return replayAttachments;
  87. }
  88. const INITIAL_STATE: State = Object.freeze({
  89. errors: undefined,
  90. event: undefined,
  91. fetchError: undefined,
  92. fetching: true,
  93. isErrorsFetching: true,
  94. rrwebEvents: undefined,
  95. spans: undefined,
  96. breadcrumbs: undefined,
  97. });
  98. /**
  99. * A react hook to load core replay data over the network.
  100. *
  101. * Core replay data includes:
  102. * 1. The root replay EventTransaction object
  103. * - This includes `startTimestamp` and `tags` data
  104. * 2. Breadcrumb and Span data from all the related Event objects
  105. * - Data is merged for consumption
  106. * 3. RRWeb payloads for the replayer video stream
  107. * - TODO(replay): incrementally load the stream to speedup pageload
  108. *
  109. * This function should stay focused on loading data over the network.
  110. * Front-end processing, filtering and re-mixing of the different data streams
  111. * must be delegated to the `ReplayReader` class.
  112. *
  113. * @param {orgId, eventSlug} Where to find the root replay event
  114. * @returns An object representing a unified result of the network reqeusts. Either a single `ReplayReader` data object or fetch errors.
  115. */
  116. function useReplayData({eventSlug, orgId}: Options): Result {
  117. const [projectId, eventId] = eventSlug.split(':');
  118. const api = useApi();
  119. const [state, setState] = useState<State>(INITIAL_STATE);
  120. const fetchEvent = useCallback(() => {
  121. return api.requestPromise(
  122. `/organizations/${orgId}/events/${eventSlug}/`
  123. ) as Promise<EventTransaction>;
  124. }, [api, orgId, eventSlug]);
  125. const fetchRRWebEvents = useCallback(async () => {
  126. const attachmentIds = (await api.requestPromise(
  127. `/projects/${orgId}/${projectId}/events/${eventId}/attachments/`
  128. )) as IssueAttachment[];
  129. const rrwebAttachmentIds = attachmentIds.filter(isRRWebEventAttachment);
  130. const attachments = await Promise.all(
  131. rrwebAttachmentIds.map(async attachment => {
  132. const response = await api.requestPromise(
  133. `/api/0/projects/${orgId}/${projectId}/events/${eventId}/attachments/${attachment.id}/?download`,
  134. {
  135. includeAllArgs: true,
  136. }
  137. );
  138. // for non-compressed events, parse and return
  139. try {
  140. return JSON.parse(response[0]) as ReplayAttachment;
  141. } catch (error) {
  142. // swallow exception.. if we can't parse it, it's going to be compressed
  143. }
  144. // for non-compressed events, parse and return
  145. try {
  146. // for compressed events, inflate the blob and map the events
  147. const responseBlob = await response[2]?.rawResponse.blob();
  148. const responseArray = (await responseBlob?.arrayBuffer()) as Uint8Array;
  149. const parsedPayload = JSON.parse(inflate(responseArray, {to: 'string'}));
  150. const replayAttachments = mapRRWebAttachments(parsedPayload);
  151. return replayAttachments;
  152. } catch (error) {
  153. return {};
  154. }
  155. })
  156. );
  157. // ReplayAttachment[] => ReplayAttachment (merge each key of ReplayAttachment)
  158. return flattenListOfObjects(attachments);
  159. }, [api, eventId, orgId, projectId]);
  160. const {isLoading: isErrorsFetching, data: errors} = useReplayErrors({
  161. replayId: eventId,
  162. });
  163. useEffect(() => {
  164. if (!isErrorsFetching) {
  165. setState(prevState => ({
  166. ...prevState,
  167. fetching: prevState.fetching || isErrorsFetching,
  168. isErrorsFetching,
  169. errors,
  170. }));
  171. }
  172. }, [isErrorsFetching, errors]);
  173. const loadEvents = useCallback(async () => {
  174. setState(INITIAL_STATE);
  175. try {
  176. const [event, attachments] = await Promise.all([fetchEvent(), fetchRRWebEvents()]);
  177. setState(prev => ({
  178. ...prev,
  179. event,
  180. fetchError: undefined,
  181. fetching: prev.isErrorsFetching || false,
  182. rrwebEvents: attachments.recording,
  183. spans: attachments.replaySpans,
  184. breadcrumbs: attachments.breadcrumbs,
  185. }));
  186. } catch (error) {
  187. Sentry.captureException(error);
  188. setState({
  189. ...INITIAL_STATE,
  190. fetchError: error,
  191. fetching: false,
  192. });
  193. }
  194. }, [fetchEvent, fetchRRWebEvents]);
  195. useEffect(() => {
  196. loadEvents();
  197. }, [loadEvents]);
  198. const replay = useMemo(() => {
  199. return ReplayReader.factory({
  200. event: state.event,
  201. errors: state.errors,
  202. rrwebEvents: state.rrwebEvents,
  203. breadcrumbs: state.breadcrumbs,
  204. spans: state.spans,
  205. });
  206. }, [state.event, state.rrwebEvents, state.breadcrumbs, state.spans, state.errors]);
  207. return {
  208. fetchError: state.fetchError,
  209. fetching: state.fetching,
  210. onRetry: loadEvents,
  211. replay,
  212. };
  213. }
  214. export default useReplayData;