useReplayData.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import * as Sentry from '@sentry/react';
  3. import {Client} from 'sentry/api';
  4. import parseLinkHeader, {ParsedHeader} from 'sentry/utils/parseLinkHeader';
  5. import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
  6. import RequestError from 'sentry/utils/requestError/requestError';
  7. import useApi from 'sentry/utils/useApi';
  8. import useProjects from 'sentry/utils/useProjects';
  9. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  10. type State = {
  11. /**
  12. * If any request returned an error then nothing is being returned
  13. */
  14. fetchError: undefined | RequestError;
  15. /**
  16. * If a fetch is underway for the requested root reply.
  17. * This includes fetched all the sub-resources like attachments and `sentry-replay-event`
  18. */
  19. fetchingAttachments: boolean;
  20. fetchingErrors: boolean;
  21. fetchingReplay: boolean;
  22. };
  23. type Options = {
  24. /**
  25. * The organization slug
  26. */
  27. orgSlug: string;
  28. /**
  29. * The replayId
  30. */
  31. replayId: string;
  32. /**
  33. * Default: 50
  34. * You can override this for testing
  35. */
  36. errorsPerPage?: number;
  37. /**
  38. * Default: 100
  39. * You can override this for testing
  40. */
  41. segmentsPerPage?: number;
  42. };
  43. interface Result {
  44. attachments: unknown[];
  45. errors: ReplayError[];
  46. fetchError: undefined | RequestError;
  47. fetching: boolean;
  48. onRetry: () => void;
  49. projectSlug: string | null;
  50. replayRecord: ReplayRecord | undefined;
  51. }
  52. const INITIAL_STATE: State = Object.freeze({
  53. fetchError: undefined,
  54. fetchingAttachments: true,
  55. fetchingErrors: true,
  56. fetchingReplay: true,
  57. });
  58. /**
  59. * A react hook to load core replay data over the network.
  60. *
  61. * Core replay data includes:
  62. * 1. The root replay EventTransaction object
  63. * - This includes `startTimestamp`, and `tags`
  64. * 2. RRWeb, Breadcrumb, and Span attachment data
  65. * - We make an API call to get a list of segments, each segment contains a
  66. * list of attachments
  67. * - There may be a few large segments, or many small segments. It depends!
  68. * ie: If the replay has many events/errors then there will be many small segments,
  69. * or if the page changes rapidly across each pageload, then there will be
  70. * larger segments, but potentially fewer of them.
  71. * 3. Related Event data
  72. * - Event details are not part of the attachments payload, so we have to
  73. * request them separately
  74. *
  75. * This function should stay focused on loading data over the network.
  76. * Front-end processing, filtering and re-mixing of the different data streams
  77. * must be delegated to the `ReplayReader` class.
  78. *
  79. * @param {orgSlug, replayId} Where to find the root replay event
  80. * @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
  81. */
  82. function useReplayData({
  83. replayId,
  84. orgSlug,
  85. errorsPerPage = 50,
  86. segmentsPerPage = 100,
  87. }: Options): Result {
  88. const projects = useProjects();
  89. const api = useApi();
  90. const [state, setState] = useState<State>(INITIAL_STATE);
  91. const [attachments, setAttachments] = useState<unknown[]>([]);
  92. const attachmentMap = useRef<Map<string, unknown[]>>(new Map()); // Map keys are always iterated by insertion order
  93. const [errors, setErrors] = useState<ReplayError[]>([]);
  94. const [replayRecord, setReplayRecord] = useState<ReplayRecord>();
  95. const projectSlug = useMemo(() => {
  96. if (!replayRecord) {
  97. return null;
  98. }
  99. return projects.projects.find(p => p.id === replayRecord.project_id)?.slug ?? null;
  100. }, [replayRecord, projects.projects]);
  101. // Fetch every field of the replay. We're overfetching, not every field is used
  102. const fetchReplay = useCallback(async () => {
  103. const response = await api.requestPromise(makeFetchReplayApiUrl(orgSlug, replayId));
  104. const mappedRecord = mapResponseToReplayRecord(response.data);
  105. setReplayRecord(mappedRecord);
  106. setState(prev => ({...prev, fetchingReplay: false}));
  107. }, [api, orgSlug, replayId]);
  108. const fetchAttachments = useCallback(async () => {
  109. if (!replayRecord || !projectSlug) {
  110. return;
  111. }
  112. if (!replayRecord.count_segments) {
  113. setState(prev => ({...prev, fetchingAttachments: false}));
  114. return;
  115. }
  116. const pages = Math.ceil(replayRecord.count_segments / segmentsPerPage);
  117. const cursors = new Array(pages).fill(0).map((_, i) => `0:${segmentsPerPage * i}:0`);
  118. cursors.forEach(cursor => attachmentMap.current.set(cursor, []));
  119. await Promise.allSettled(
  120. cursors.map(cursor => {
  121. const promise = api.requestPromise(
  122. `/projects/${orgSlug}/${projectSlug}/replays/${replayRecord.id}/recording-segments/`,
  123. {
  124. query: {
  125. download: true,
  126. per_page: segmentsPerPage,
  127. cursor,
  128. },
  129. }
  130. );
  131. promise.then(response => {
  132. attachmentMap.current.set(cursor, response);
  133. const flattened = Array.from(attachmentMap.current.values()).flat(2);
  134. setAttachments(flattened);
  135. });
  136. return promise;
  137. })
  138. );
  139. setState(prev => ({...prev, fetchingAttachments: false}));
  140. }, [segmentsPerPage, api, orgSlug, replayRecord, projectSlug]);
  141. const fetchErrors = useCallback(async () => {
  142. if (!replayRecord) {
  143. return;
  144. }
  145. // Clone the `finished_at` time and bump it up one second because finishedAt
  146. // has the `ms` portion truncated, while replays-events-meta operates on
  147. // timestamps with `ms` attached. So finishedAt could be at time `12:00:00.000Z`
  148. // while the event is saved with `12:00:00.450Z`.
  149. const finishedAtClone = new Date(replayRecord.finished_at);
  150. finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
  151. const paginatedErrors = fetchPaginatedReplayErrors(api, {
  152. orgSlug,
  153. replayId: replayRecord.id,
  154. start: replayRecord.started_at,
  155. end: finishedAtClone,
  156. limit: errorsPerPage,
  157. });
  158. for await (const pagedResults of paginatedErrors) {
  159. setErrors(prev => [...prev, ...(pagedResults || [])]);
  160. }
  161. setState(prev => ({...prev, fetchingErrors: false}));
  162. }, [api, orgSlug, replayRecord, errorsPerPage]);
  163. const onError = useCallback(error => {
  164. Sentry.captureException(error);
  165. setState(prev => ({...prev, fetchError: error}));
  166. }, []);
  167. const loadData = useCallback(
  168. () => fetchReplay().catch(onError),
  169. [fetchReplay, onError]
  170. );
  171. useEffect(() => {
  172. loadData();
  173. }, [loadData]);
  174. useEffect(() => {
  175. if (state.fetchError) {
  176. return;
  177. }
  178. fetchErrors().catch(onError);
  179. }, [state.fetchError, fetchErrors, onError]);
  180. useEffect(() => {
  181. if (state.fetchError) {
  182. return;
  183. }
  184. fetchAttachments().catch(onError);
  185. }, [state.fetchError, fetchAttachments, onError]);
  186. return {
  187. attachments,
  188. errors,
  189. fetchError: state.fetchError,
  190. fetching: state.fetchingAttachments || state.fetchingErrors || state.fetchingReplay,
  191. onRetry: loadData,
  192. projectSlug,
  193. replayRecord,
  194. };
  195. }
  196. function makeFetchReplayApiUrl(orgSlug: string, replayId: string) {
  197. return `/organizations/${orgSlug}/replays/${replayId}/`;
  198. }
  199. async function fetchReplayErrors(
  200. api: Client,
  201. {
  202. orgSlug,
  203. start,
  204. end,
  205. replayId,
  206. limit = 50,
  207. cursor = '0:0:0',
  208. }: {
  209. end: Date;
  210. orgSlug: string;
  211. replayId: string;
  212. start: Date;
  213. cursor?: string;
  214. limit?: number;
  215. }
  216. ) {
  217. return await api.requestPromise(`/organizations/${orgSlug}/replays-events-meta/`, {
  218. includeAllArgs: true,
  219. query: {
  220. start: start.toISOString(),
  221. end: end.toISOString(),
  222. query: `replayId:[${replayId}]`,
  223. per_page: limit,
  224. cursor,
  225. },
  226. });
  227. }
  228. async function* fetchPaginatedReplayErrors(
  229. api: Client,
  230. {
  231. orgSlug,
  232. start,
  233. end,
  234. replayId,
  235. limit = 50,
  236. }: {
  237. end: Date;
  238. orgSlug: string;
  239. replayId: string;
  240. start: Date;
  241. limit?: number;
  242. }
  243. ): AsyncGenerator<ReplayError[]> {
  244. function next(nextCursor: string) {
  245. return fetchReplayErrors(api, {
  246. orgSlug,
  247. replayId,
  248. start,
  249. end,
  250. limit,
  251. cursor: nextCursor,
  252. });
  253. }
  254. let cursor: undefined | ParsedHeader = {
  255. cursor: '0:0:0',
  256. results: true,
  257. href: '',
  258. };
  259. while (cursor && cursor.results) {
  260. const [{data}, , resp] = await next(cursor.cursor);
  261. const pageLinks = resp?.getResponseHeader('Link') ?? null;
  262. cursor = parseLinkHeader(pageLinks)?.next;
  263. yield data;
  264. }
  265. }
  266. export default useReplayData;