useReplayData.tsx 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287
  1. import {useCallback, useMemo, useRef} from 'react';
  2. import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
  3. import useFetchParallelPages from 'sentry/utils/api/useFetchParallelPages';
  4. import useFetchSequentialPages from 'sentry/utils/api/useFetchSequentialPages';
  5. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  6. import parseLinkHeader from 'sentry/utils/parseLinkHeader';
  7. import type {ApiQueryKey} from 'sentry/utils/queryClient';
  8. import {useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
  9. import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
  10. import type RequestError from 'sentry/utils/requestError/requestError';
  11. import useProjects from 'sentry/utils/useProjects';
  12. import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
  13. type Options = {
  14. /**
  15. * The organization slug
  16. */
  17. orgSlug: string;
  18. /**
  19. * The replayId
  20. */
  21. replayId: string;
  22. /**
  23. * Default: 50
  24. * You can override this for testing
  25. */
  26. errorsPerPage?: number;
  27. /**
  28. * Default: 100
  29. * You can override this for testing
  30. */
  31. segmentsPerPage?: number;
  32. };
  33. interface Result {
  34. attachments: unknown[];
  35. errors: ReplayError[];
  36. fetchError: undefined | RequestError;
  37. fetching: boolean;
  38. onRetry: () => void;
  39. projectSlug: string | null;
  40. replayRecord: ReplayRecord | undefined;
  41. }
  42. /**
  43. * A react hook to load core replay data over the network.
  44. *
  45. * Core replay data includes:
  46. * 1. The root replay EventTransaction object
  47. * - This includes `startTimestamp`, and `tags`
  48. * 2. RRWeb, Breadcrumb, and Span attachment data
  49. * - We make an API call to get a list of segments, each segment contains a
  50. * list of attachments
  51. * - There may be a few large segments, or many small segments. It depends!
  52. * ie: If the replay has many events/errors then there will be many small segments,
  53. * or if the page changes rapidly across each pageload, then there will be
  54. * larger segments, but potentially fewer of them.
  55. * 3. Related Event data
  56. * - Event details are not part of the attachments payload, so we have to
  57. * request them separately
  58. *
  59. * This function should stay focused on loading data over the network.
  60. * Front-end processing, filtering and re-mixing of the different data streams
  61. * must be delegated to the `ReplayReader` class.
  62. *
  63. * @param {orgSlug, replayId} Where to find the root replay event
  64. * @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
  65. */
  66. function useReplayData({
  67. replayId,
  68. orgSlug,
  69. errorsPerPage = 50,
  70. segmentsPerPage = 100,
  71. }: Options): Result {
  72. const hasFetchedAttachments = useRef(false);
  73. const projects = useProjects();
  74. const queryClient = useQueryClient();
  75. // Fetch every field of the replay. The TS type definition lists every field
  76. // that's available. It's easier to ask for them all and not have to deal with
  77. // partial types or nullable fields.
  78. // We're overfetching for sure.
  79. const {
  80. data: replayData,
  81. isFetching: isFetchingReplay,
  82. error: fetchReplayError,
  83. } = useApiQuery<{data: unknown}>([`/organizations/${orgSlug}/replays/${replayId}/`], {
  84. staleTime: Infinity,
  85. retry: false,
  86. });
  87. const replayRecord = useMemo(
  88. () => (replayData?.data ? mapResponseToReplayRecord(replayData.data) : undefined),
  89. [replayData?.data]
  90. );
  91. const projectSlug = useMemo(() => {
  92. if (!replayRecord) {
  93. return null;
  94. }
  95. return projects.projects.find(p => p.id === replayRecord.project_id)?.slug ?? null;
  96. }, [replayRecord, projects.projects]);
  97. const getAttachmentsQueryKey = useCallback(
  98. ({cursor, per_page}): ApiQueryKey => {
  99. return [
  100. `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/`,
  101. {
  102. query: {
  103. download: true,
  104. per_page,
  105. cursor,
  106. },
  107. },
  108. ];
  109. },
  110. [orgSlug, projectSlug, replayId]
  111. );
  112. const {
  113. pages: attachmentPages,
  114. isFetching: isFetchingAttachments,
  115. error: fetchAttachmentsError,
  116. } = useFetchParallelPages({
  117. enabled: !fetchReplayError && Boolean(projectSlug) && Boolean(replayRecord),
  118. hits: replayRecord?.count_segments ?? 0,
  119. getQueryKey: getAttachmentsQueryKey,
  120. perPage: segmentsPerPage,
  121. });
  122. const getErrorsQueryKey = useCallback(
  123. ({cursor, per_page}): ApiQueryKey => {
  124. // Clone the `finished_at` time and bump it up one second because finishedAt
  125. // has the `ms` portion truncated, while replays-events-meta operates on
  126. // timestamps with `ms` attached. So finishedAt could be at time `12:00:00.000Z`
  127. // while the event is saved with `12:00:00.450Z`.
  128. const finishedAtClone = new Date(replayRecord?.finished_at ?? '');
  129. finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
  130. return [
  131. `/organizations/${orgSlug}/replays-events-meta/`,
  132. {
  133. query: {
  134. dataset: DiscoverDatasets.DISCOVER,
  135. start: replayRecord?.started_at.toISOString(),
  136. end: finishedAtClone.toISOString(),
  137. project: ALL_ACCESS_PROJECTS,
  138. query: `replayId:[${replayRecord?.id}]`,
  139. per_page,
  140. cursor,
  141. },
  142. },
  143. ];
  144. },
  145. [orgSlug, replayRecord]
  146. );
  147. const getPlatformErrorsQueryKey = useCallback(
  148. ({cursor, per_page}): ApiQueryKey => {
  149. // Clone the `finished_at` time and bump it up one second because finishedAt
  150. // has the `ms` portion truncated, while replays-events-meta operates on
  151. // timestamps with `ms` attached. So finishedAt could be at time `12:00:00.000Z`
  152. // while the event is saved with `12:00:00.450Z`.
  153. const finishedAtClone = new Date(replayRecord?.finished_at ?? '');
  154. finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
  155. return [
  156. `/organizations/${orgSlug}/replays-events-meta/`,
  157. {
  158. query: {
  159. dataset: DiscoverDatasets.ISSUE_PLATFORM,
  160. start: replayRecord?.started_at.toISOString(),
  161. end: finishedAtClone.toISOString(),
  162. project: ALL_ACCESS_PROJECTS,
  163. query: `replayId:[${replayRecord?.id}]`,
  164. per_page,
  165. cursor,
  166. },
  167. },
  168. ];
  169. },
  170. [orgSlug, replayRecord]
  171. );
  172. const {
  173. pages: errorPages,
  174. isFetching: isFetchingErrors,
  175. getLastResponseHeader: lastErrorsResponseHeader,
  176. } = useFetchParallelPages<{data: ReplayError[]}>({
  177. enabled: !fetchReplayError && Boolean(projectSlug) && Boolean(replayRecord),
  178. hits: replayRecord?.count_errors ?? 0,
  179. getQueryKey: getErrorsQueryKey,
  180. perPage: errorsPerPage,
  181. });
  182. const linkHeader = lastErrorsResponseHeader?.('Link') ?? null;
  183. const links = parseLinkHeader(linkHeader);
  184. const {pages: extraErrorPages, isFetching: isFetchingExtraErrors} =
  185. useFetchSequentialPages<{data: ReplayError[]}>({
  186. enabled:
  187. !fetchReplayError &&
  188. !isFetchingErrors &&
  189. (!replayRecord?.count_errors || Boolean(links.next?.results)),
  190. initialCursor: links.next?.cursor,
  191. getQueryKey: getErrorsQueryKey,
  192. perPage: errorsPerPage,
  193. });
  194. const {pages: platformErrorPages, isFetching: isFetchingPlatformErrors} =
  195. useFetchSequentialPages<{data: ReplayError[]}>({
  196. enabled: true,
  197. getQueryKey: getPlatformErrorsQueryKey,
  198. perPage: errorsPerPage,
  199. });
  200. const clearQueryCache = useCallback(() => {
  201. queryClient.invalidateQueries({
  202. queryKey: [`/organizations/${orgSlug}/replays/${replayId}/`],
  203. });
  204. queryClient.invalidateQueries({
  205. queryKey: [
  206. `/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/`,
  207. ],
  208. });
  209. // The next one isn't optimized
  210. // This statement will invalidate the cache of fetched error events for all replayIds
  211. queryClient.invalidateQueries({
  212. queryKey: [`/organizations/${orgSlug}/replays-events-meta/`],
  213. });
  214. }, [orgSlug, replayId, projectSlug, queryClient]);
  215. return useMemo(() => {
  216. // This hook can enter a state where `fetching` below is false
  217. // before it is entirely ready (i.e. it has not fetched
  218. // attachemnts yet). This can cause downstream components to
  219. // think it is no longer fetching and will display an error
  220. // because there are no attachments. The below will require
  221. // that we have attempted to fetch an attachment once (or it
  222. // errors) before we toggle fetching state to false.
  223. hasFetchedAttachments.current =
  224. hasFetchedAttachments.current || isFetchingAttachments;
  225. const fetching =
  226. isFetchingReplay ||
  227. isFetchingAttachments ||
  228. isFetchingErrors ||
  229. isFetchingExtraErrors ||
  230. isFetchingPlatformErrors ||
  231. (!hasFetchedAttachments.current &&
  232. !fetchAttachmentsError &&
  233. Boolean(replayRecord?.count_segments));
  234. const allErrors = errorPages
  235. .concat(extraErrorPages)
  236. .concat(platformErrorPages)
  237. .flatMap(page => page.data);
  238. return {
  239. attachments: attachmentPages.flat(2),
  240. errors: allErrors,
  241. fetchError: fetchReplayError ?? undefined,
  242. fetching,
  243. onRetry: clearQueryCache,
  244. projectSlug,
  245. replayRecord,
  246. };
  247. }, [
  248. attachmentPages,
  249. clearQueryCache,
  250. errorPages,
  251. extraErrorPages,
  252. fetchReplayError,
  253. fetchAttachmentsError,
  254. isFetchingAttachments,
  255. isFetchingErrors,
  256. isFetchingExtraErrors,
  257. isFetchingPlatformErrors,
  258. isFetchingReplay,
  259. platformErrorPages,
  260. projectSlug,
  261. replayRecord,
  262. ]);
  263. }
  264. export default useReplayData;