useReplayData.tsx 7.6 KB

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