Repository: getsentry/sentry
Pull request: perf(issue-stream): Prefetch issue when hovering over title
Closes https://github.com/getsentry/sentry/issues/49236

This takes advantage of the preview tooltip which was already making a request to load the event. It does two things to that component:

1. Modifies the event request to look exactly like the one made on the issue details page
2. Adds a silent request for the group which isn't used, just sent to the cache

This makes the issue load much faster (sometimes instantaneously!) if the user has hovered over it for a little bit before clicking.

https://github.com/getsentry/sentry/assets/10888943/a959aaef-0f97-4867-9a1b-3a7987ed85df

Problem statement:
Prefetch issue when hovering on title
We can load the issues page faster when navigating from the issue stream by beginning the request on hover. We already do this for the event (in order to preview the stacktrace), we just need to add the group details query as well.

There are two steps to doing this:

1. Add the group query to `GroupPreviewHovercard`, but don't do anything with the result.
2. Modify the latest event query to exactly match the query that would be made when on the issue details page. Right now it passes `collapse=stacktraceOnly`.

Hints from issue discussion:

Commit message: perf(issue-stream): Prefetch issue when hovering over title

Patch to review:
diff --git a/static/app/components/groupPreviewTooltip/evidencePreview.spec.tsx b/static/app/components/groupPreviewTooltip/evidencePreview.spec.tsx
index c1ec5d3479ec7e..e405f2d5d9aa2c 100644
--- a/static/app/components/groupPreviewTooltip/evidencePreview.spec.tsx
+++ b/static/app/components/groupPreviewTooltip/evidencePreview.spec.tsx
@@ -8,6 +8,10 @@ describe('EvidencePreview', () => {
   beforeEach(() => {
     jest.useFakeTimers();
     jest.restoreAllMocks();
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: '/issues/group-id/',
+    });
   });
 
   it('does not fetch before hover', () => {
diff --git a/static/app/components/groupPreviewTooltip/evidencePreview.tsx b/static/app/components/groupPreviewTooltip/evidencePreview.tsx
index 02e4c24eaa6c5a..ca22e0ae4378e8 100644
--- a/static/app/components/groupPreviewTooltip/evidencePreview.tsx
+++ b/static/app/components/groupPreviewTooltip/evidencePreview.tsx
@@ -1,62 +1,41 @@
-import {Fragment, ReactChild, useEffect} from 'react';
+import {ReactChild, useEffect} from 'react';
 import styled from '@emotion/styled';
 
 import KeyValueList from 'sentry/components/events/interfaces/keyValueList';
 import {GroupPreviewHovercard} from 'sentry/components/groupPreviewTooltip/groupPreviewHovercard';
-import {useDelayedLoadingState} from 'sentry/components/groupPreviewTooltip/utils';
+import {
+  useDelayedLoadingState,
+  usePreviewEvent,
+} from 'sentry/components/groupPreviewTooltip/utils';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
-import {EventTransaction} from 'sentry/types';
-import {useApiQuery} from 'sentry/utils/queryClient';
-import useOrganization from 'sentry/utils/useOrganization';
 
 type SpanEvidencePreviewProps = {
   children: ReactChild;
+  groupId: string;
   eventId?: string;
-  groupId?: string;
   projectSlug?: string;
 };
 
 type SpanEvidencePreviewBodyProps = {
-  endpointUrl: string;
+  groupId: string;
   onRequestBegin: () => void;
   onRequestEnd: () => void;
   onUnmount: () => void;
-};
-
-const makeGroupPreviewRequestUrl = ({
-  orgSlug,
-  eventId,
-  groupId,
-  projectSlug,
-}: {
-  orgSlug: string;
   eventId?: string;
-  groupId?: string;
   projectSlug?: string;
-}) => {
-  if (eventId && projectSlug) {
-    return `/projects/${orgSlug}/${projectSlug}/events/${eventId}/`;
-  }
-
-  if (groupId) {
-    return `/issues/${groupId}/events/latest/`;
-  }
-
-  return null;
 };
 
 function SpanEvidencePreviewBody({
-  endpointUrl,
   onRequestBegin,
   onRequestEnd,
   onUnmount,
+  groupId,
+  eventId,
+  projectSlug,
 }: SpanEvidencePreviewBodyProps) {
-  const {data, isLoading, isError} = useApiQuery<EventTransaction>(
-    [endpointUrl, {query: {referrer: 'api.issues.preview-performance'}}],
-    {staleTime: 60000}
-  );
+  const {data, isLoading, isError} = usePreviewEvent({groupId, eventId, projectSlug});
 
   useEffect(() => {
     if (isLoading) {
@@ -108,20 +87,9 @@ export function EvidencePreview({
   eventId,
   projectSlug,
 }: SpanEvidencePreviewProps) {
-  const organization = useOrganization();
-  const endpointUrl = makeGroupPreviewRequestUrl({
-    groupId,
-    eventId,
-    projectSlug,
-    orgSlug: organization.slug,
-  });
   const {shouldShowLoadingState, onRequestBegin, onRequestEnd, reset} =
     useDelayedLoadingState();
 
-  if (!endpointUrl) {
-    return <Fragment>{children}</Fragment>;
-  }
-
   return (
     <GroupPreviewHovercard
       hide={!shouldShowLoadingState}
@@ -130,7 +98,9 @@ export function EvidencePreview({
           onRequestBegin={onRequestBegin}
           onRequestEnd={onRequestEnd}
           onUnmount={reset}
-          endpointUrl={endpointUrl}
+          groupId={groupId}
+          eventId={eventId}
+          projectSlug={projectSlug}
         />
       }
     >
diff --git a/static/app/components/groupPreviewTooltip/index.tsx b/static/app/components/groupPreviewTooltip/index.tsx
index e31ef6deeaed87..8c417f2ad83505 100644
--- a/static/app/components/groupPreviewTooltip/index.tsx
+++ b/static/app/components/groupPreviewTooltip/index.tsx
@@ -32,7 +32,7 @@ function GroupPreviewTooltip({
     case IssueCategory.ERROR:
       return (
         <StackTracePreview
-          issueId={groupId}
+          groupId={groupId}
           groupingCurrentLevel={groupingCurrentLevel}
           eventId={eventId}
           projectSlug={projectSlug}
diff --git a/static/app/components/groupPreviewTooltip/spanEvidencePreview.spec.tsx b/static/app/components/groupPreviewTooltip/spanEvidencePreview.spec.tsx
index 2a42953e398fc2..124d56e8916f0b 100644
--- a/static/app/components/groupPreviewTooltip/spanEvidencePreview.spec.tsx
+++ b/static/app/components/groupPreviewTooltip/spanEvidencePreview.spec.tsx
@@ -13,6 +13,11 @@ describe('SpanEvidencePreview', () => {
   beforeEach(() => {
     jest.useFakeTimers();
     jest.restoreAllMocks();
+
+    MockApiClient.clearMockResponses();
+    MockApiClient.addMockResponse({
+      url: '/issues/group-id/',
+    });
   });
 
   it('does not fetch before hover', () => {
diff --git a/static/app/components/groupPreviewTooltip/spanEvidencePreview.tsx b/static/app/components/groupPreviewTooltip/spanEvidencePreview.tsx
index 4beee01e307a7d..17cfcc2f5e1370 100644
--- a/static/app/components/groupPreviewTooltip/spanEvidencePreview.tsx
+++ b/static/app/components/groupPreviewTooltip/spanEvidencePreview.tsx
@@ -3,26 +3,29 @@ import styled from '@emotion/styled';
 
 import {SpanEvidenceKeyValueList} from 'sentry/components/events/interfaces/performance/spanEvidenceKeyValueList';
 import {GroupPreviewHovercard} from 'sentry/components/groupPreviewTooltip/groupPreviewHovercard';
-import {useDelayedLoadingState} from 'sentry/components/groupPreviewTooltip/utils';
+import {
+  useDelayedLoadingState,
+  usePreviewEvent,
+} from 'sentry/components/groupPreviewTooltip/utils';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {EventTransaction} from 'sentry/types';
-import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 
 type SpanEvidencePreviewProps = {
   children: ReactChild;
+  groupId: string;
   eventId?: string;
-  groupId?: string;
   projectSlug?: string;
 };
 
 type SpanEvidencePreviewBodyProps = {
-  endpointUrl: string;
+  groupId: string;
   onRequestBegin: () => void;
   onRequestEnd: () => void;
   onUnmount: () => void;
+  eventId?: string;
   projectSlug?: string;
 };
 
@@ -49,16 +52,18 @@ const makeGroupPreviewRequestUrl = ({
 };
 
 function SpanEvidencePreviewBody({
-  endpointUrl,
+  groupId,
+  eventId,
   onRequestBegin,
   onRequestEnd,
   onUnmount,
   projectSlug,
 }: SpanEvidencePreviewBodyProps) {
-  const {data, isLoading, isError} = useApiQuery<EventTransaction>(
-    [endpointUrl, {query: {referrer: 'api.issues.preview-performance'}}],
-    {staleTime: 60000}
-  );
+  const {data, isLoading, isError} = usePreviewEvent<EventTransaction>({
+    groupId,
+    eventId,
+    projectSlug,
+  });
 
   useEffect(() => {
     if (isLoading) {
@@ -125,8 +130,9 @@ export function SpanEvidencePreview({
           onRequestBegin={onRequestBegin}
           onRequestEnd={onRequestEnd}
           onUnmount={reset}
-          endpointUrl={endpointUrl}
           projectSlug={projectSlug}
+          eventId={eventId}
+          groupId={groupId}
         />
       }
     >
diff --git a/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx b/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx
index 34576ddcd062fc..648ca22cd89cec 100644
--- a/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx
+++ b/static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx
@@ -16,6 +16,9 @@ const makeEvent = (event: Partial<Event> = {}): Event => {
 
 beforeEach(() => {
   MockApiClient.clearMockResponses();
+  MockApiClient.addMockResponse({
+    url: '/issues/group-id/',
+  });
 });
 
 describe('StackTracePreview', () => {
@@ -26,7 +29,7 @@ describe('StackTracePreview', () => {
     });
 
     render(
-      <StackTracePreview issueId="issue" eventId="event_id" projectSlug="project_slug">
+      <StackTracePreview groupId="group-id" eventId="event_id" projectSlug="project_slug">
         Preview Trigger
       </StackTracePreview>
     );
@@ -38,13 +41,13 @@ describe('StackTracePreview', () => {
     });
   });
 
-  it('fetches from issues when issueId when eventId and projectSlug are not provided', async () => {
+  it('fetches from issues when eventId and projectSlug are not provided', async () => {
     const mockGet = MockApiClient.addMockResponse({
-      url: `/issues/issue/events/latest/?collapse=stacktraceOnly`,
+      url: `/issues/group-id/events/latest/`,
       body: makeEvent({id: 'event_id', entries: []}),
     });
 
-    render(<StackTracePreview issueId="issue">Preview Trigger</StackTracePreview>);
+    render(<StackTracePreview groupId="group-id">Preview Trigger</StackTracePreview>);
 
     await userEvent.hover(screen.getByText(/Preview Trigger/));
 
@@ -55,11 +58,11 @@ describe('StackTracePreview', () => {
 
   it('renders error message', async () => {
     MockApiClient.addMockResponse({
-      url: `/issues/issue/events/latest/?collapse=stacktraceOnly`,
+      url: `/issues/group-id/events/latest/`,
       statusCode: 400,
     });
 
-    render(<StackTracePreview issueId="issue">Preview Trigger</StackTracePreview>);
+    render(<StackTracePreview groupId="group-id">Preview Trigger</StackTracePreview>);
 
     await userEvent.hover(screen.getByText(/Preview Trigger/));
 
@@ -68,11 +71,11 @@ describe('StackTracePreview', () => {
 
   it('warns about no stacktrace', async () => {
     MockApiClient.addMockResponse({
-      url: `/issues/issue/events/latest/?collapse=stacktraceOnly`,
+      url: `/issues/group-id/events/latest/`,
       body: makeEvent({id: 'event_id', entries: []}),
     });
 
-    render(<StackTracePreview issueId="issue">Preview Trigger</StackTracePreview>);
+    render(<StackTracePreview groupId="group-id">Preview Trigger</StackTracePreview>);
 
     await userEvent.hover(screen.getByText(/Preview Trigger/));
 
@@ -136,11 +139,11 @@ describe('StackTracePreview', () => {
     } as EventError;
 
     MockApiClient.addMockResponse({
-      url: `/issues/issue/events/latest/?collapse=stacktraceOnly`,
+      url: `/issues/group-id/events/latest/`,
       body: makeEvent(errorEvent),
     });
 
-    render(<StackTracePreview issueId="issue">Preview Trigger</StackTracePreview>, {
+    render(<StackTracePreview groupId="group-id">Preview Trigger</StackTracePreview>, {
       organization: {features},
     });
 
diff --git a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx
index 5ec604de4e1938..996ad12079dd75 100644
--- a/static/app/components/groupPreviewTooltip/stackTracePreview.tsx
+++ b/static/app/components/groupPreviewTooltip/stackTracePreview.tsx
@@ -9,7 +9,10 @@ import findBestThread from 'sentry/components/events/interfaces/threads/threadSe
 import getThreadStacktrace from 'sentry/components/events/interfaces/threads/threadSelector/getThreadStacktrace';
 import {isStacktraceNewestFirst} from 'sentry/components/events/interfaces/utils';
 import {GroupPreviewHovercard} from 'sentry/components/groupPreviewTooltip/groupPreviewHovercard';
-import {useDelayedLoadingState} from 'sentry/components/groupPreviewTooltip/utils';
+import {
+  useDelayedLoadingState,
+  usePreviewEvent,
+} from 'sentry/components/groupPreviewTooltip/utils';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
@@ -18,7 +21,6 @@ import {EntryType, Event} from 'sentry/types/event';
 import {StacktraceType} from 'sentry/types/stacktrace';
 import {defined} from 'sentry/utils';
 import {isNativePlatform} from 'sentry/utils/platform';
-import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 
 export function getStacktrace(event: Event): StacktraceType | null {
@@ -99,7 +101,7 @@ export function StackTracePreviewContent({
 
 type StackTracePreviewProps = {
   children: React.ReactChild;
-  issueId: string;
+  groupId: string;
   eventId?: string;
   groupingCurrentLevel?: number;
   projectSlug?: string;
@@ -108,7 +110,7 @@ type StackTracePreviewProps = {
 interface StackTracePreviewBodyProps
   extends Pick<
     StackTracePreviewProps,
-    'issueId' | 'eventId' | 'groupingCurrentLevel' | 'projectSlug'
+    'groupId' | 'eventId' | 'groupingCurrentLevel' | 'projectSlug'
   > {
   onRequestBegin: () => void;
   onRequestEnd: () => void;
@@ -116,7 +118,7 @@ interface StackTracePreviewBodyProps
 }
 
 function StackTracePreviewBody({
-  issueId,
+  groupId,
   eventId,
   groupingCurrentLevel,
   projectSlug,
@@ -126,15 +128,7 @@ function StackTracePreviewBody({
 }: StackTracePreviewBodyProps) {
   const organization = useOrganization();
 
-  const {data, isLoading, isError} = useApiQuery<Event>(
-    [
-      eventId && projectSlug
-        ? `/projects/${organization.slug}/${projectSlug}/events/${eventId}/`
-        : `/issues/${issueId}/events/latest/?collapse=stacktraceOnly`,
-      {query: {referrer: 'api.issues.preview-error'}},
-    ],
-    {staleTime: 60000}
-  );
+  const {data, isLoading, isError} = usePreviewEvent({groupId, eventId, projectSlug});
 
   useEffect(() => {
     if (isLoading) {
diff --git a/static/app/components/groupPreviewTooltip/utils.tsx b/static/app/components/groupPreviewTooltip/utils.tsx
index b85a21c5331fd1..1d5ca3da0e11ca 100644
--- a/static/app/components/groupPreviewTooltip/utils.tsx
+++ b/static/app/components/groupPreviewTooltip/utils.tsx
@@ -1,6 +1,14 @@
 import {useCallback, useState} from 'react';
 
+import {Event} from 'sentry/types';
+import {defined} from 'sentry/utils';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
 import useTimeout from 'sentry/utils/useTimeout';
+import {
+  getGroupDetailsQueryData,
+  getGroupEventDetailsQueryData,
+} from 'sentry/views/issueDetails/utils';
 
 const HOVERCARD_CONTENT_DELAY = 400;
 
@@ -28,3 +36,36 @@ export function useDelayedLoadingState() {
     reset,
   };
 }
+
+export function usePreviewEvent<T = Event>({
+  groupId,
+  projectSlug,
+  eventId,
+}: {
+  groupId: string;
+  eventId?: string;
+  projectSlug?: string;
+}) {
+  const organization = useOrganization();
+
+  // This query should match the one on group details so that the event will
+  // be fully loaded already if you preview then click.
+  const eventQuery = useApiQuery<T>(
+    [
+      eventId && projectSlug
+        ? `/projects/${organization.slug}/${projectSlug}/events/${eventId}/`
+        : `/issues/${groupId}/events/latest/`,
+      {query: getGroupEventDetailsQueryData()},
+    ],
+    {staleTime: 30000, cacheTime: 30000}
+  );
+
+  // Prefetch the group as well, but don't use the result
+  useApiQuery([`/issues/${groupId}/`, {query: getGroupDetailsQueryData()}], {
+    staleTime: 30000,
+    cacheTime: 30000,
+    enabled: defined(groupId),
+  });
+
+  return eventQuery;
+}
diff --git a/static/app/views/issueDetails/groupDetails.tsx b/static/app/views/issueDetails/groupDetails.tsx
index 476c4b0999d23f..b01bb59e21407e 100644
--- a/static/app/views/issueDetails/groupDetails.tsx
+++ b/static/app/views/issueDetails/groupDetails.tsx
@@ -10,7 +10,6 @@ import {
 import {browserHistory, RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
-import isEmpty from 'lodash/isEmpty';
 import * as qs from 'query-string';
 
 import LoadingError from 'sentry/components/loadingError';
@@ -56,6 +55,8 @@ import GroupHeader from './header';
 import SampleEventAlert from './sampleEventAlert';
 import {Tab, TabPaths} from './types';
 import {
+  getGroupDetailsQueryData,
+  getGroupEventDetailsQueryData,
   getGroupReprocessingStatus,
   markEventSeen,
   ReprocessingStatus,
@@ -91,22 +92,6 @@ interface GroupDetailsContentProps extends GroupDetailsProps, FetchGroupDetailsS
   project: Project;
 }
 
-function getGroupQuery({environments}: {environments: string[]}) {
-  const query: Record<string, string | string[]> = {
-    ...(!isEmpty(environments) ? {environment: environments} : {}),
-    expand: ['inbox', 'owners'],
-    collapse: ['release', 'tags'],
-  };
-
-  return query;
-}
-
-function getEventQuery({environments}: {environments: string[]}) {
-  const query = !isEmpty(environments) ? {environment: environments} : {};
-
-  return query;
-}
-
 function getFetchDataRequestErrorType(status?: number | null): Error {
   if (!status) {
     return null;
@@ -274,7 +259,7 @@ function makeFetchGroupQueryKey({
   groupId,
   environments,
 }: FetchGroupQueryParameters): ApiQueryKey {
-  return [`/issues/${groupId}/`, {query: getGroupQuery({environments})}];
+  return [`/issues/${groupId}/`, {query: getGroupDetailsQueryData({environments})}];
 }
 
 /**
@@ -327,17 +312,15 @@ function useFetchGroupDetails(): FetchGroupDetailsState {
 
   const eventUrl = `/issues/${groupId}/events/${eventId}/`;
 
-  const eventQuery: Record<string, string | string[]> = {collapse: ['fullRelease']};
-  if (environments.length !== 0) {
-    eventQuery.environment = environments;
-  }
-
   const {
     data: eventData,
     isLoading: loadingEvent,
     isError,
     refetch: refetchEvent,
-  } = useEventApiQuery(eventId, [eventUrl, {query: getEventQuery({environments})}]);
+  } = useEventApiQuery(eventId, [
+    eventUrl,
+    {query: getGroupEventDetailsQueryData({environments})},
+  ]);
 
   const {
     data: groupData,
diff --git a/static/app/views/issueDetails/groupSidebar.tsx b/static/app/views/issueDetails/groupSidebar.tsx
index 3fd59529150698..a725d52558fc87 100644
--- a/static/app/views/issueDetails/groupSidebar.tsx
+++ b/static/app/views/issueDetails/groupSidebar.tsx
@@ -31,6 +31,7 @@ import {userDisplayName} from 'sentry/utils/formatters';
 import {isMobilePlatform} from 'sentry/utils/platform';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useLocation} from 'sentry/utils/useLocation';
+import {getGroupDetailsQueryData} from 'sentry/views/issueDetails/utils';
 
 type Props = {
   environments: string[];
@@ -42,15 +43,7 @@ type Props = {
 
 function useFetchAllEnvsGroupData(group: Group) {
   return useApiQuery<Group>(
-    [
-      `/issues/${group.id}/`,
-      {
-        query: {
-          expand: ['inbox', 'owners'],
-          collapse: ['release', 'tags'],
-        },
-      },
-    ],
+    [`/issues/${group.id}/`, {query: getGroupDetailsQueryData()}],
     {
       staleTime: 30000,
       cacheTime: 30000,
diff --git a/static/app/views/issueDetails/utils.tsx b/static/app/views/issueDetails/utils.tsx
index c61e4fa51e9ede..0ffef10404c515 100644
--- a/static/app/views/issueDetails/utils.tsx
+++ b/static/app/views/issueDetails/utils.tsx
@@ -1,4 +1,5 @@
 import {useMemo} from 'react';
+import isEmpty from 'lodash/isEmpty';
 import orderBy from 'lodash/orderBy';
 
 import {bulkUpdate, useFetchIssueTags} from 'sentry/actionCreators/group';
@@ -167,3 +168,32 @@ export function useEnvironmentsFromUrl(): string[] {
 
   return envsArray;
 }
+
+export function getGroupDetailsQueryData({
+  environments,
+}: {
+  environments?: string[];
+} = {}): Record<string, string | string[]> {
+  // Note, we do not want to include the environment key at all if there are no environments
+  const query: Record<string, string | string[]> = {
+    ...(!isEmpty(environments) ? {environment: environments} : {}),
+    expand: ['inbox', 'owners'],
+    collapse: ['release', 'tags'],
+  };
+
+  return query;
+}
+
+export function getGroupEventDetailsQueryData({
+  environments,
+}: {
+  environments?: string[];
+} = {}): Record<string, string | string[]> {
+  const defaultParams = {collapse: ['fullRelease']};
+
+  if (!environments || isEmpty(environments)) {
+    return defaultParams;
+  }
+
+  return {...defaultParams, environment: environments};
+}


Code review comments:
diff_hunk: @@ -26,7 +29,7 @@ describe('StackTracePreview', () => {
     });
 
     render(
-      <StackTracePreview issueId="issue" eventId="event_id" projectSlug="project_slug">
+      <StackTracePreview groupId="group-id" eventId="event_id" projectSlug="project_slug">
path: static/app/components/groupPreviewTooltip/stackTracePreview.spec.tsx
text: @user1:
this is just kinda weird. we don't usually use weird IDs in tests