mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
226 lines
7.2 KiB
TypeScript
226 lines
7.2 KiB
TypeScript
import {useCallback, useMemo} from 'react';
|
|
|
|
import {ALL_ACCESS_PROJECTS} from 'sentry/constants/pageFilters';
|
|
import useFetchParallelPages from 'sentry/utils/api/useFetchParallelPages';
|
|
import useFetchSequentialPages from 'sentry/utils/api/useFetchSequentialPages';
|
|
import parseLinkHeader from 'sentry/utils/parseLinkHeader';
|
|
import type {ApiQueryKey} from 'sentry/utils/queryClient';
|
|
import {useApiQuery, useQueryClient} from 'sentry/utils/queryClient';
|
|
import {mapResponseToReplayRecord} from 'sentry/utils/replays/replayDataUtils';
|
|
import type RequestError from 'sentry/utils/requestError/requestError';
|
|
import useProjects from 'sentry/utils/useProjects';
|
|
import type {ReplayError, ReplayRecord} from 'sentry/views/replays/types';
|
|
|
|
type Options = {
|
|
/**
|
|
* The organization slug
|
|
*/
|
|
orgSlug: string;
|
|
|
|
/**
|
|
* The replayId
|
|
*/
|
|
replayId: string;
|
|
|
|
/**
|
|
* Default: 50
|
|
* You can override this for testing
|
|
*/
|
|
errorsPerPage?: number;
|
|
|
|
/**
|
|
* Default: 100
|
|
* You can override this for testing
|
|
*/
|
|
segmentsPerPage?: number;
|
|
};
|
|
|
|
interface Result {
|
|
attachments: unknown[];
|
|
errors: ReplayError[];
|
|
fetchError: undefined | RequestError;
|
|
fetching: boolean;
|
|
onRetry: () => void;
|
|
projectSlug: string | null;
|
|
replayRecord: ReplayRecord | undefined;
|
|
}
|
|
|
|
/**
|
|
* A react hook to load core replay data over the network.
|
|
*
|
|
* Core replay data includes:
|
|
* 1. The root replay EventTransaction object
|
|
* - This includes `startTimestamp`, and `tags`
|
|
* 2. RRWeb, Breadcrumb, and Span attachment data
|
|
* - We make an API call to get a list of segments, each segment contains a
|
|
* list of attachments
|
|
* - There may be a few large segments, or many small segments. It depends!
|
|
* ie: If the replay has many events/errors then there will be many small segments,
|
|
* or if the page changes rapidly across each pageload, then there will be
|
|
* larger segments, but potentially fewer of them.
|
|
* 3. Related Event data
|
|
* - Event details are not part of the attachments payload, so we have to
|
|
* request them separately
|
|
*
|
|
* This function should stay focused on loading data over the network.
|
|
* Front-end processing, filtering and re-mixing of the different data streams
|
|
* must be delegated to the `ReplayReader` class.
|
|
*
|
|
* @param {orgSlug, replayId} Where to find the root replay event
|
|
* @returns An object representing a unified result of the network requests. Either a single `ReplayReader` data object or fetch errors.
|
|
*/
|
|
function useReplayData({
|
|
replayId,
|
|
orgSlug,
|
|
errorsPerPage = 50,
|
|
segmentsPerPage = 100,
|
|
}: Options): Result {
|
|
const projects = useProjects();
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
// Fetch every field of the replay. The TS type definition lists every field
|
|
// that's available. It's easier to ask for them all and not have to deal with
|
|
// partial types or nullable fields.
|
|
// We're overfetching for sure.
|
|
const {
|
|
data: replayData,
|
|
isFetching: isFetchingReplay,
|
|
error: fetchReplayError,
|
|
} = useApiQuery<{data: unknown}>([`/organizations/${orgSlug}/replays/${replayId}/`], {
|
|
staleTime: Infinity,
|
|
retry: false,
|
|
});
|
|
const replayRecord = useMemo(
|
|
() => (replayData?.data ? mapResponseToReplayRecord(replayData.data) : undefined),
|
|
[replayData?.data]
|
|
);
|
|
|
|
const projectSlug = useMemo(() => {
|
|
if (!replayRecord) {
|
|
return null;
|
|
}
|
|
return projects.projects.find(p => p.id === replayRecord.project_id)?.slug ?? null;
|
|
}, [replayRecord, projects.projects]);
|
|
|
|
const getAttachmentsQueryKey = useCallback(
|
|
({cursor, per_page}): ApiQueryKey => {
|
|
return [
|
|
`/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/`,
|
|
{
|
|
query: {
|
|
download: true,
|
|
per_page,
|
|
cursor,
|
|
},
|
|
},
|
|
];
|
|
},
|
|
[orgSlug, projectSlug, replayId]
|
|
);
|
|
|
|
const {pages: attachmentPages, isFetching: isFetchingAttachments} =
|
|
useFetchParallelPages({
|
|
enabled: !fetchReplayError && Boolean(projectSlug) && Boolean(replayRecord),
|
|
hits: replayRecord?.count_segments ?? 0,
|
|
getQueryKey: getAttachmentsQueryKey,
|
|
perPage: segmentsPerPage,
|
|
});
|
|
|
|
const getErrorsQueryKey = useCallback(
|
|
({cursor, per_page}): ApiQueryKey => {
|
|
// Clone the `finished_at` time and bump it up one second because finishedAt
|
|
// has the `ms` portion truncated, while replays-events-meta operates on
|
|
// timestamps with `ms` attached. So finishedAt could be at time `12:00:00.000Z`
|
|
// while the event is saved with `12:00:00.450Z`.
|
|
const finishedAtClone = new Date(replayRecord?.finished_at ?? '');
|
|
finishedAtClone.setSeconds(finishedAtClone.getSeconds() + 1);
|
|
|
|
return [
|
|
`/organizations/${orgSlug}/replays-events-meta/`,
|
|
{
|
|
query: {
|
|
start: replayRecord?.started_at.toISOString(),
|
|
end: finishedAtClone.toISOString(),
|
|
project: ALL_ACCESS_PROJECTS,
|
|
query: `replayId:[${replayRecord?.id}]`,
|
|
per_page,
|
|
cursor,
|
|
},
|
|
},
|
|
];
|
|
},
|
|
[orgSlug, replayRecord]
|
|
);
|
|
|
|
const {
|
|
pages: errorPages,
|
|
isFetching: isFetchingErrors,
|
|
getLastResponseHeader: lastErrorsResponseHeader,
|
|
} = useFetchParallelPages<{data: ReplayError[]}>({
|
|
enabled: !fetchReplayError && Boolean(projectSlug) && Boolean(replayRecord),
|
|
hits: replayRecord?.count_errors ?? 0,
|
|
getQueryKey: getErrorsQueryKey,
|
|
perPage: errorsPerPage,
|
|
});
|
|
|
|
const linkHeader = lastErrorsResponseHeader?.('Link') ?? null;
|
|
const links = parseLinkHeader(linkHeader);
|
|
const {pages: extraErrorPages, isFetching: isFetchingExtraErrors} =
|
|
useFetchSequentialPages<{data: ReplayError[]}>({
|
|
enabled: !fetchReplayError && !isFetchingErrors && Boolean(links.next?.results),
|
|
initialCursor: links.next?.cursor,
|
|
getQueryKey: getErrorsQueryKey,
|
|
perPage: errorsPerPage,
|
|
});
|
|
|
|
const clearQueryCache = useCallback(() => {
|
|
() => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: [`/organizations/${orgSlug}/replays/${replayId}/`],
|
|
});
|
|
queryClient.invalidateQueries({
|
|
queryKey: [
|
|
`/projects/${orgSlug}/${projectSlug}/replays/${replayId}/recording-segments/`,
|
|
],
|
|
});
|
|
// The next one isn't optimized
|
|
// This statement will invalidate the cache of fetched error events for all replayIds
|
|
queryClient.invalidateQueries({
|
|
queryKey: [`/organizations/${orgSlug}/replays-events-meta/`],
|
|
});
|
|
};
|
|
}, [orgSlug, replayId, projectSlug, queryClient]);
|
|
|
|
return useMemo(() => {
|
|
const fetching =
|
|
isFetchingReplay ||
|
|
isFetchingAttachments ||
|
|
isFetchingErrors ||
|
|
isFetchingExtraErrors;
|
|
return {
|
|
attachments: attachmentPages.flat(2),
|
|
errors: errorPages.concat(extraErrorPages).flatMap(page => page.data),
|
|
fetchError: fetchReplayError ?? undefined,
|
|
fetching,
|
|
onRetry: clearQueryCache,
|
|
projectSlug,
|
|
replayRecord,
|
|
};
|
|
}, [
|
|
attachmentPages,
|
|
clearQueryCache,
|
|
errorPages,
|
|
extraErrorPages,
|
|
fetchReplayError,
|
|
isFetchingAttachments,
|
|
isFetchingErrors,
|
|
isFetchingExtraErrors,
|
|
isFetchingReplay,
|
|
projectSlug,
|
|
replayRecord,
|
|
]);
|
|
}
|
|
|
|
export default useReplayData;
|