useReplayData.tsx 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266
  1. import {useCallback, useEffect, useMemo, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import parseLinkHeader, {ParsedHeader} from 'sentry/utils/parseLinkHeader';
  4. import flattenListOfObjects from 'sentry/utils/replays/flattenListOfObjects';
  5. import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
  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. ReplayRecord,
  14. ReplaySpan,
  15. } from 'sentry/views/replays/types';
  16. type State = {
  17. breadcrumbs: undefined | ReplayCrumb[];
  18. /**
  19. * List of errors that occurred during replay
  20. */
  21. errors: undefined | ReplayError[];
  22. /**
  23. * If any request returned an error then nothing is being returned
  24. */
  25. fetchError: undefined | RequestError;
  26. /**
  27. * If a fetch is underway for the requested root reply.
  28. * This includes fetched all the sub-resources like attachments and `sentry-replay-event`
  29. */
  30. fetching: boolean;
  31. /**
  32. * The root replay event
  33. */
  34. replayRecord: undefined | ReplayRecord;
  35. /**
  36. * The flattened list of rrweb events. These are stored as multiple attachments on the root replay object: the `event` prop.
  37. */
  38. rrwebEvents: undefined | RecordingEvent[];
  39. spans: undefined | ReplaySpan[];
  40. };
  41. type Options = {
  42. /**
  43. * The organization slug
  44. */
  45. orgSlug: string;
  46. /**
  47. * The projectSlug and replayId concatenated together
  48. */
  49. replaySlug: string;
  50. };
  51. // Errors if it is an interface
  52. // See https://github.com/microsoft/TypeScript/issues/15300
  53. type ReplayAttachment = {
  54. breadcrumbs: ReplayCrumb[];
  55. recording: RecordingEvent[];
  56. replaySpans: ReplaySpan[];
  57. };
  58. interface Result extends Pick<State, 'fetchError' | 'fetching'> {
  59. onRetry: () => void;
  60. replay: ReplayReader | null;
  61. replayRecord: ReplayRecord | undefined;
  62. }
  63. export function mapRRWebAttachments(unsortedReplayAttachments): ReplayAttachment {
  64. const replayAttachments: ReplayAttachment = {
  65. breadcrumbs: [],
  66. replaySpans: [],
  67. recording: [],
  68. };
  69. unsortedReplayAttachments.forEach(attachment => {
  70. if (attachment.data?.tag === 'performanceSpan') {
  71. replayAttachments.replaySpans.push(attachment.data.payload);
  72. } else if (attachment?.data?.tag === 'breadcrumb') {
  73. replayAttachments.breadcrumbs.push(attachment.data.payload);
  74. } else {
  75. replayAttachments.recording.push(attachment);
  76. }
  77. });
  78. return replayAttachments;
  79. }
  80. const INITIAL_STATE: State = Object.freeze({
  81. breadcrumbs: undefined,
  82. errors: undefined,
  83. fetchError: undefined,
  84. fetching: true,
  85. replayRecord: undefined,
  86. rrwebEvents: undefined,
  87. spans: undefined,
  88. });
  89. /**
  90. * A react hook to load core replay data over the network.
  91. *
  92. * Core replay data includes:
  93. * 1. The root replay EventTransaction object
  94. * - This includes `startTimestamp` and `tags` data
  95. * 2. Breadcrumb and Span data from all the related Event objects
  96. * - Data is merged for consumption
  97. * 3. RRWeb payloads for the replayer video stream
  98. * - TODO(replay): incrementally load the stream to speedup pageload
  99. *
  100. * This function should stay focused on loading data over the network.
  101. * Front-end processing, filtering and re-mixing of the different data streams
  102. * must be delegated to the `ReplayReader` class.
  103. *
  104. * @param {orgSlug, replaySlug} Where to find the root replay event
  105. * @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
  106. */
  107. function useReplayData({replaySlug, orgSlug}: Options): Result {
  108. const [projectSlug, replayId] = replaySlug.split(':');
  109. const api = useApi();
  110. const [state, setState] = useState<State>(INITIAL_STATE);
  111. // Fetch every field of the replay. We're overfetching, not every field is needed
  112. const fetchReplay = useCallback(async () => {
  113. const response = await api.requestPromise(
  114. `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/`
  115. );
  116. return response.data;
  117. }, [api, orgSlug, projectSlug, replayId]);
  118. const fetchAllRRwebEvents = useCallback(async () => {
  119. const rootUrl = `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/?download`;
  120. let next: ParsedHeader = {
  121. href: rootUrl,
  122. results: true,
  123. cursor: '',
  124. };
  125. const segmentRanges: any = [];
  126. // TODO(replay): It would be good to load the first page of results then
  127. // start to render the UI while the next N pages continue to get fetched in
  128. // the background.
  129. while (next.results) {
  130. const url = rootUrl + '&cursor=' + next.cursor;
  131. const [data, _textStatus, resp] = await api.requestPromise(url, {
  132. includeAllArgs: true,
  133. });
  134. segmentRanges.push(data);
  135. const links = parseLinkHeader(resp?.getResponseHeader('Link') ?? '');
  136. next = links.next;
  137. }
  138. const rrwebEvents = segmentRanges
  139. .flatMap(segment => segment)
  140. .flatMap(attachments => mapRRWebAttachments(attachments));
  141. return flattenListOfObjects(rrwebEvents);
  142. }, [api, orgSlug, projectSlug, replayId]);
  143. const fetchErrors = useCallback(
  144. async (replayRecord: ReplayRecord) => {
  145. if (!replayRecord.errorIds.length) {
  146. return [];
  147. }
  148. // Clone the `finishedAt` time and bump it up one second because finishedAt
  149. // has the `ms` portion truncated, while replays-events-meta operates on
  150. // timestamps with `ms` attached. So finishedAt could be at time `12:00:00.000Z`
  151. // while the event is saved with `12:00:00.450Z`.
  152. const finishedAtClone = new Date(replayRecord.finishedAt);
  153. finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
  154. const response = await api.requestPromise(
  155. `/organizations/${orgSlug}/replays-events-meta/`,
  156. {
  157. query: {
  158. start: replayRecord.startedAt.toISOString(),
  159. end: finishedAtClone.toISOString(),
  160. query: `id:[${String(replayRecord.errorIds)}]`,
  161. },
  162. }
  163. );
  164. return response.data;
  165. },
  166. [api, orgSlug]
  167. );
  168. const fetchReplayAndErrors = useCallback(async (): Promise<[ReplayRecord, any]> => {
  169. const fetchedRecord = await fetchReplay();
  170. const mappedRecord = mapResponseToReplayRecord(fetchedRecord);
  171. setState(prev => ({
  172. ...prev,
  173. replayRecord: mappedRecord,
  174. }));
  175. const fetchedErrors = await fetchErrors(mappedRecord);
  176. return [mappedRecord, fetchedErrors];
  177. }, [fetchReplay, fetchErrors]);
  178. const loadEvents = useCallback(async () => {
  179. setState(INITIAL_STATE);
  180. try {
  181. const [replayAndErrors, attachments] = await Promise.all([
  182. fetchReplayAndErrors(),
  183. fetchAllRRwebEvents(),
  184. ]);
  185. const [replayRecord, errors] = replayAndErrors;
  186. setState(prev => ({
  187. ...prev,
  188. breadcrumbs: attachments.breadcrumbs,
  189. errors,
  190. fetchError: undefined,
  191. fetching: false,
  192. replayRecord,
  193. rrwebEvents: attachments.recording,
  194. spans: attachments.replaySpans,
  195. }));
  196. } catch (error) {
  197. Sentry.captureException(error);
  198. setState({
  199. ...INITIAL_STATE,
  200. fetchError: error,
  201. fetching: false,
  202. });
  203. }
  204. }, [fetchReplayAndErrors, fetchAllRRwebEvents]);
  205. useEffect(() => {
  206. loadEvents();
  207. }, [loadEvents]);
  208. const replay = useMemo(() => {
  209. return ReplayReader.factory({
  210. breadcrumbs: state.breadcrumbs,
  211. errors: state.errors,
  212. replayRecord: state.replayRecord,
  213. rrwebEvents: state.rrwebEvents,
  214. spans: state.spans,
  215. });
  216. }, [
  217. state.breadcrumbs,
  218. state.errors,
  219. state.replayRecord,
  220. state.rrwebEvents,
  221. state.spans,
  222. ]);
  223. return {
  224. fetchError: state.fetchError,
  225. fetching: state.fetching,
  226. onRetry: loadEvents,
  227. replay,
  228. replayRecord: state.replayRecord,
  229. };
  230. }
  231. export default useReplayData;