Browse Source

feat(issue-stream): Add performance issue preview tooltip (#40217)

Malachi Willey 2 years ago
parent
commit
71cf6f4372

+ 81 - 54
static/app/components/eventOrGroupTitle.spec.tsx

@@ -1,7 +1,11 @@
+import {FC} from 'react';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 
 
 import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
 import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle';
 import {BaseGroup, EventOrGroupType, IssueCategory} from 'sentry/types';
 import {BaseGroup, EventOrGroupType, IssueCategory} from 'sentry/types';
+import {OrganizationContext} from 'sentry/views/organizationContext';
 
 
 describe('EventOrGroupTitle', function () {
 describe('EventOrGroupTitle', function () {
   const data = {
   const data = {
@@ -13,18 +17,28 @@ describe('EventOrGroupTitle', function () {
     culprit: 'culprit',
     culprit: 'culprit',
   };
   };
 
 
+  const {organization} = initializeOrg();
+
+  const TestComponent: FC = ({children}) => (
+    <OrganizationContext.Provider value={organization}>
+      {children}
+    </OrganizationContext.Provider>
+  );
+
   it('renders with subtitle when `type = error`', function () {
   it('renders with subtitle when `type = error`', function () {
     const wrapper = render(
     const wrapper = render(
-      <EventOrGroupTitle
-        data={
-          {
-            ...data,
-            ...{
-              type: EventOrGroupType.ERROR,
-            },
-          } as BaseGroup
-        }
-      />
+      <TestComponent>
+        <EventOrGroupTitle
+          data={
+            {
+              ...data,
+              ...{
+                type: EventOrGroupType.ERROR,
+              },
+            } as BaseGroup
+          }
+        />
+      </TestComponent>
     );
     );
 
 
     expect(wrapper.container).toSnapshot();
     expect(wrapper.container).toSnapshot();
@@ -32,16 +46,18 @@ describe('EventOrGroupTitle', function () {
 
 
   it('renders with subtitle when `type = csp`', function () {
   it('renders with subtitle when `type = csp`', function () {
     const wrapper = render(
     const wrapper = render(
-      <EventOrGroupTitle
-        data={
-          {
-            ...data,
-            ...{
-              type: EventOrGroupType.CSP,
-            },
-          } as BaseGroup
-        }
-      />
+      <TestComponent>
+        <EventOrGroupTitle
+          data={
+            {
+              ...data,
+              ...{
+                type: EventOrGroupType.CSP,
+              },
+            } as BaseGroup
+          }
+        />
+      </TestComponent>
     );
     );
 
 
     expect(wrapper.container).toSnapshot();
     expect(wrapper.container).toSnapshot();
@@ -49,18 +65,20 @@ describe('EventOrGroupTitle', function () {
 
 
   it('renders with no subtitle when `type = default`', function () {
   it('renders with no subtitle when `type = default`', function () {
     const wrapper = render(
     const wrapper = render(
-      <EventOrGroupTitle
-        data={
-          {
-            ...data,
-            type: EventOrGroupType.DEFAULT,
-            metadata: {
-              ...data.metadata,
-              title: 'metadata title',
-            },
-          } as BaseGroup
-        }
-      />
+      <TestComponent>
+        <EventOrGroupTitle
+          data={
+            {
+              ...data,
+              type: EventOrGroupType.DEFAULT,
+              metadata: {
+                ...data.metadata,
+                title: 'metadata title',
+              },
+            } as BaseGroup
+          }
+        />
+      </TestComponent>
     );
     );
 
 
     expect(wrapper.container).toSnapshot();
     expect(wrapper.container).toSnapshot();
@@ -72,18 +90,20 @@ describe('EventOrGroupTitle', function () {
     ]);
     ]);
 
 
     render(
     render(
-      <EventOrGroupTitle
-        data={
-          {
-            ...data,
-            type: EventOrGroupType.ERROR,
-            metadata: {
-              ...data.metadata,
-              title: 'metadata title',
-            },
-          } as BaseGroup
-        }
-      />,
+      <TestComponent>
+        <EventOrGroupTitle
+          data={
+            {
+              ...data,
+              type: EventOrGroupType.ERROR,
+              metadata: {
+                ...data.metadata,
+                title: 'metadata title',
+              },
+            } as BaseGroup
+          }
+        />
+      </TestComponent>,
       {context: routerContext}
       {context: routerContext}
     );
     );
 
 
@@ -92,15 +112,17 @@ describe('EventOrGroupTitle', function () {
 
 
   it('does not render stack trace when issueCategory is performance', () => {
   it('does not render stack trace when issueCategory is performance', () => {
     render(
     render(
-      <EventOrGroupTitle
-        data={
-          {
-            ...data,
-            issueCategory: IssueCategory.PERFORMANCE,
-          } as BaseGroup
-        }
-        withStackTracePreview
-      />
+      <TestComponent>
+        <EventOrGroupTitle
+          data={
+            {
+              ...data,
+              issueCategory: IssueCategory.PERFORMANCE,
+            } as BaseGroup
+          }
+          withStackTracePreview
+        />
+      </TestComponent>
     );
     );
 
 
     expect(screen.queryByTestId('stacktrace-preview')).not.toBeInTheDocument();
     expect(screen.queryByTestId('stacktrace-preview')).not.toBeInTheDocument();
@@ -122,7 +144,12 @@ describe('EventOrGroupTitle', function () {
         {organization: TestStubs.Organization({features: ['custom-event-title']})},
         {organization: TestStubs.Organization({features: ['custom-event-title']})},
       ]);
       ]);
 
 
-      render(<EventOrGroupTitle data={perfData} />, {context: routerContext});
+      render(
+        <TestComponent>
+          <EventOrGroupTitle data={perfData} />
+        </TestComponent>,
+        {context: routerContext}
+      );
 
 
       expect(screen.getByText('N+1 Query')).toBeInTheDocument();
       expect(screen.getByText('N+1 Query')).toBeInTheDocument();
       expect(screen.getByText('transaction name')).toBeInTheDocument();
       expect(screen.getByText('transaction name')).toBeInTheDocument();

+ 14 - 69
static/app/components/events/interfaces/performance/spanEvidence.tsx

@@ -1,22 +1,16 @@
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
-import * as Sentry from '@sentry/react';
-import keyBy from 'lodash/keyBy';
 
 
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
-import {
-  EntryType,
-  EventTransaction,
-  IssueCategory,
-  KeyValueListData,
-  Organization,
-} from 'sentry/types';
+import {EventTransaction, Organization} from 'sentry/types';
 
 
 import DataSection from '../../eventTagsAndScreenshot/dataSection';
 import DataSection from '../../eventTagsAndScreenshot/dataSection';
-import KeyValueList from '../keyValueList';
 import TraceView from '../spans/traceView';
 import TraceView from '../spans/traceView';
-import {RawSpanType, SpanEntry, TraceContextType} from '../spans/types';
+import {TraceContextType} from '../spans/types';
 import WaterfallModel from '../spans/waterfallModel';
 import WaterfallModel from '../spans/waterfallModel';
 
 
+import {SpanEvidenceKeyValueList} from './spanEvidenceKeyValueList';
+import {getSpanInfoFromTransactionEvent} from './utils';
+
 interface Props {
 interface Props {
   event: EventTransaction;
   event: EventTransaction;
   organization: Organization;
   organization: Organization;
@@ -27,50 +21,13 @@ export type TraceContextSpanProxy = Omit<TraceContextType, 'span_id'> & {
 };
 };
 
 
 export function SpanEvidenceSection({event, organization}: Props) {
 export function SpanEvidenceSection({event, organization}: Props) {
-  if (!event.perfProblem) {
-    if (
-      event.issueCategory === IssueCategory.PERFORMANCE &&
-      event.endTimestamp > 1663560000 //  (Sep 19, 2022 onward), Some events could have been missing evidence before EA
-    ) {
-      Sentry.captureException(new Error('Span Evidence missing for performance issue.'));
-    }
-    return null;
-  }
-
-  // Let's dive into the event to pick off the span evidence data by using the IDs we know
-  const spanEntry = event.entries.find((entry: SpanEntry | any): entry is SpanEntry => {
-    return entry.type === EntryType.SPANS;
-  });
-  const spans: Array<RawSpanType | TraceContextSpanProxy> = [...spanEntry?.data] ?? [];
+  const spanInfo = getSpanInfoFromTransactionEvent(event);
 
 
-  if (event?.contexts?.trace && event?.contexts?.trace?.span_id) {
-    // TODO: Fix this conditional and check if span_id is ever actually undefined.
-    spans.push(event.contexts.trace as TraceContextSpanProxy);
+  if (!spanInfo) {
+    return null;
   }
   }
-  const spansById = keyBy(spans, 'span_id');
-
-  const parentSpan = spansById[event.perfProblem.parentSpanIds[0]];
-  const repeatingSpan = spansById[event.perfProblem.offenderSpanIds[0]];
 
 
-  const data: KeyValueListData = [
-    {
-      key: '0',
-      subject: t('Transaction'),
-      value: event.title,
-    },
-    {
-      key: '1',
-      subject: t('Parent Span'),
-      value: getSpanEvidenceValue(parentSpan),
-    },
-    {
-      key: '2',
-      subject: t('Repeating Span'),
-      value: getSpanEvidenceValue(repeatingSpan),
-    },
-  ];
-
-  const affectedSpanIds = [parentSpan.span_id, ...event.perfProblem.offenderSpanIds];
+  const {parentSpan, repeatingSpan, affectedSpanIds} = spanInfo;
 
 
   return (
   return (
     <DataSection
     <DataSection
@@ -79,7 +36,11 @@ export function SpanEvidenceSection({event, organization}: Props) {
         'Span Evidence identifies the parent span where the N+1 occurs, and the repeating spans.'
         'Span Evidence identifies the parent span where the N+1 occurs, and the repeating spans.'
       )}
       )}
     >
     >
-      <KeyValueList data={data} />
+      <SpanEvidenceKeyValueList
+        transactionName={event.title}
+        parentSpan={parentSpan}
+        repeatingSpan={repeatingSpan}
+      />
 
 
       <TraceViewWrapper>
       <TraceViewWrapper>
         <TraceView
         <TraceView
@@ -92,22 +53,6 @@ export function SpanEvidenceSection({event, organization}: Props) {
   );
   );
 }
 }
 
 
-function getSpanEvidenceValue(span: RawSpanType | TraceContextSpanProxy) {
-  if (!span.op && !span.description) {
-    return t('(no value)');
-  }
-
-  if (!span.op && span.description) {
-    return span.description;
-  }
-
-  if (span.op && !span.description) {
-    return span.op;
-  }
-
-  return `${span.op} - ${span.description}`;
-}
-
 const TraceViewWrapper = styled('div')`
 const TraceViewWrapper = styled('div')`
   border: 1px solid ${p => p.theme.innerBorder};
   border: 1px solid ${p => p.theme.innerBorder};
   border-radius: ${p => p.theme.borderRadius};
   border-radius: ${p => p.theme.borderRadius};

+ 55 - 0
static/app/components/events/interfaces/performance/spanEvidenceKeyValueList.tsx

@@ -0,0 +1,55 @@
+import {t} from 'sentry/locale';
+import {KeyValueListData} from 'sentry/types';
+
+import KeyValueList from '../keyValueList';
+import {RawSpanType} from '../spans/types';
+
+import {TraceContextSpanProxy} from './spanEvidence';
+
+type SpanEvidenceKeyValueListProps = {
+  parentSpan: RawSpanType | TraceContextSpanProxy;
+  repeatingSpan: RawSpanType | TraceContextSpanProxy;
+  transactionName: string;
+};
+
+export function SpanEvidenceKeyValueList({
+  transactionName,
+  parentSpan,
+  repeatingSpan,
+}: SpanEvidenceKeyValueListProps) {
+  const data: KeyValueListData = [
+    {
+      key: '0',
+      subject: t('Transaction'),
+      value: transactionName,
+    },
+    {
+      key: '1',
+      subject: t('Parent Span'),
+      value: getSpanEvidenceValue(parentSpan),
+    },
+    {
+      key: '2',
+      subject: t('Repeating Span'),
+      value: getSpanEvidenceValue(repeatingSpan),
+    },
+  ];
+
+  return <KeyValueList data={data} />;
+}
+
+function getSpanEvidenceValue(span: RawSpanType | TraceContextSpanProxy) {
+  if (!span.op && !span.description) {
+    return t('(no value)');
+  }
+
+  if (!span.op && span.description) {
+    return span.description;
+  }
+
+  if (span.op && !span.description) {
+    return span.op;
+  }
+
+  return `${span.op} - ${span.description}`;
+}

+ 49 - 1
static/app/components/events/interfaces/performance/utils.tsx

@@ -1,7 +1,19 @@
+import * as Sentry from '@sentry/react';
+import keyBy from 'lodash/keyBy';
+
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
-import {IssueType, PlatformType} from 'sentry/types';
+import {
+  EntryType,
+  EventTransaction,
+  IssueCategory,
+  IssueType,
+  PlatformType,
+} from 'sentry/types';
+
+import {RawSpanType, SpanEntry} from '../spans/types';
 
 
 import {ResourceLink} from './resources';
 import {ResourceLink} from './resources';
+import {TraceContextSpanProxy} from './spanEvidence';
 
 
 const ALL_INCLUSIVE_RESOURCE_LINKS: ResourceLink[] = [
 const ALL_INCLUSIVE_RESOURCE_LINKS: ResourceLink[] = [
   {
   {
@@ -52,3 +64,39 @@ export function getResourceLinks(
 
 
   return [...ALL_INCLUSIVE_RESOURCE_LINKS, ...RESOURCE_LINKS[issueType][platform]!];
   return [...ALL_INCLUSIVE_RESOURCE_LINKS, ...RESOURCE_LINKS[issueType][platform]!];
 }
 }
+
+export function getSpanInfoFromTransactionEvent(
+  event: Pick<
+    EventTransaction,
+    'entries' | 'perfProblem' | 'issueCategory' | 'endTimestamp' | 'contexts'
+  >
+) {
+  if (!event.perfProblem) {
+    if (
+      event.issueCategory === IssueCategory.PERFORMANCE &&
+      event.endTimestamp > 1663560000 //  (Sep 19, 2022 onward), Some events could have been missing evidence before EA
+    ) {
+      Sentry.captureException(new Error('Span Evidence missing for performance issue.'));
+    }
+    return null;
+  }
+
+  // Let's dive into the event to pick off the span evidence data by using the IDs we know
+  const spanEntry = event.entries.find((entry: SpanEntry | any): entry is SpanEntry => {
+    return entry.type === EntryType.SPANS;
+  });
+  const spans: Array<RawSpanType | TraceContextSpanProxy> = [...spanEntry?.data] ?? [];
+
+  if (event?.contexts?.trace && event?.contexts?.trace?.span_id) {
+    // TODO: Fix this conditional and check if span_id is ever actually undefined.
+    spans.push(event.contexts.trace as TraceContextSpanProxy);
+  }
+  const spansById = keyBy(spans, 'span_id');
+
+  const parentSpan = spansById[event.perfProblem.parentSpanIds[0]];
+  const repeatingSpan = spansById[event.perfProblem.offenderSpanIds[0]];
+
+  const affectedSpanIds = [parentSpan.span_id, ...event.perfProblem.offenderSpanIds];
+
+  return {parentSpan, repeatingSpan, affectedSpanIds};
+}

+ 9 - 4
static/app/components/groupPreviewTooltip/groupPreviewHovercard.tsx

@@ -1,4 +1,4 @@
-import {ComponentProps, Fragment} from 'react';
+import {ComponentProps, Fragment, useCallback} from 'react';
 import {css} from '@emotion/react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 import styled from '@emotion/styled';
 
 
@@ -10,12 +10,18 @@ interface GroupPreviewHovercardProps extends ComponentProps<typeof Hovercard> {
   hide?: boolean;
   hide?: boolean;
 }
 }
 
 
-const GroupPreviewHovercard = ({
+export const GroupPreviewHovercard = ({
   className,
   className,
   children,
   children,
   hide,
   hide,
+  body,
   ...props
   ...props
 }: GroupPreviewHovercardProps) => {
 }: GroupPreviewHovercardProps) => {
+  const handleStackTracePreviewClick = useCallback(
+    (e: React.MouseEvent) => void e.stopPropagation(),
+    []
+  );
+
   // No need to preview on hover for small devices
   // No need to preview on hover for small devices
   const shouldNotPreview = useMedia(`(max-width: ${theme.breakpoints.medium})`);
   const shouldNotPreview = useMedia(`(max-width: ${theme.breakpoints.medium})`);
   if (shouldNotPreview) {
   if (shouldNotPreview) {
@@ -31,6 +37,7 @@ const GroupPreviewHovercard = ({
       tipBorderColor="border"
       tipBorderColor="border"
       tipColor="background"
       tipColor="background"
       hide={hide}
       hide={hide}
+      body={<div onClick={handleStackTracePreviewClick}>{body}</div>}
       {...props}
       {...props}
     >
     >
       {children}
       {children}
@@ -74,5 +81,3 @@ const StyledHovercard = styled(Hovercard)<{hide?: boolean}>`
     }
     }
   }
   }
 `;
 `;
-
-export default GroupPreviewHovercard;

+ 14 - 1
static/app/components/groupPreviewTooltip/index.tsx

@@ -3,6 +3,7 @@ import {Fragment, ReactChild} from 'react';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {IssueCategory} from 'sentry/types';
 import {IssueCategory} from 'sentry/types';
 
 
+import {SpanEvidencePreview} from './spanEvidencePreview';
 import {StackTracePreview} from './stackTracePreview';
 import {StackTracePreview} from './stackTracePreview';
 
 
 type GroupPreviewTooltipProps = {
 type GroupPreviewTooltipProps = {
@@ -24,6 +25,8 @@ const GroupPreviewTooltip = ({
   issueCategory,
   issueCategory,
   projectId,
   projectId,
 }: GroupPreviewTooltipProps) => {
 }: GroupPreviewTooltipProps) => {
+  const projectSlug = eventId ? ProjectsStore.getById(projectId)?.slug : undefined;
+
   switch (issueCategory) {
   switch (issueCategory) {
     case IssueCategory.ERROR:
     case IssueCategory.ERROR:
       return (
       return (
@@ -31,11 +34,21 @@ const GroupPreviewTooltip = ({
           issueId={groupId}
           issueId={groupId}
           groupingCurrentLevel={groupingCurrentLevel}
           groupingCurrentLevel={groupingCurrentLevel}
           eventId={eventId}
           eventId={eventId}
-          projectSlug={eventId ? ProjectsStore.getById(projectId)?.slug : undefined}
+          projectSlug={projectSlug}
         >
         >
           {children}
           {children}
         </StackTracePreview>
         </StackTracePreview>
       );
       );
+    case IssueCategory.PERFORMANCE:
+      return (
+        <SpanEvidencePreview
+          groupId={groupId}
+          eventId={eventId}
+          projectSlug={projectSlug}
+        >
+          {children}
+        </SpanEvidencePreview>
+      );
     default:
     default:
       return <Fragment>{children}</Fragment>;
       return <Fragment>{children}</Fragment>;
   }
   }

+ 169 - 0
static/app/components/groupPreviewTooltip/spanEvidencePreview.spec.tsx

@@ -0,0 +1,169 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  MockSpan,
+  ProblemSpan,
+  TransactionEventBuilder,
+} from 'sentry-test/performance/utils';
+import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import * as useApi from 'sentry/utils/useApi';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import {RouteContext} from 'sentry/views/routeContext';
+
+import {SpanEvidencePreview} from './spanEvidencePreview';
+
+describe('SpanEvidencePreview', () => {
+  beforeEach(() => {
+    jest.useFakeTimers();
+    jest.restoreAllMocks();
+  });
+
+  const {organization, router} = initializeOrg();
+
+  const TestComponent: typeof SpanEvidencePreview = props => (
+    <OrganizationContext.Provider value={organization}>
+      <RouteContext.Provider
+        value={{router, location: router.location, params: {}, routes: []}}
+      >
+        <SpanEvidencePreview {...props} />
+      </RouteContext.Provider>
+    </OrganizationContext.Provider>
+  );
+
+  it('does not fetch before hover', () => {
+    const api = new MockApiClient();
+    jest.spyOn(useApi, 'default').mockReturnValue(api);
+    const spy = jest.spyOn(api, 'requestPromise');
+
+    render(
+      <TestComponent eventId="event-id" projectSlug="project-slug" groupId="group-id">
+        Hover me
+      </TestComponent>
+    );
+
+    jest.runAllTimers();
+
+    expect(spy).not.toHaveBeenCalled();
+  });
+
+  it('fetches from event URL when event and project are provided', async () => {
+    const mock = MockApiClient.addMockResponse({
+      url: `/projects/org-slug/project-slug/events/event-id/`,
+      body: null,
+    });
+
+    render(
+      <TestComponent eventId="event-id" projectSlug="project-slug" groupId="group-id">
+        Hover me
+      </TestComponent>
+    );
+
+    userEvent.hover(screen.getByText('Hover me'));
+
+    await waitFor(() => {
+      expect(mock).toHaveBeenCalled();
+    });
+  });
+
+  it('fetches from group URL when only group ID is provided', async () => {
+    const mock = MockApiClient.addMockResponse({
+      url: `/issues/group-id/events/latest/`,
+      body: null,
+    });
+
+    render(<TestComponent groupId="group-id">Hover me</TestComponent>);
+
+    userEvent.hover(screen.getByText('Hover me'));
+
+    await waitFor(() => {
+      expect(mock).toHaveBeenCalled();
+    });
+  });
+
+  it('shows error when request fails', async () => {
+    const api = new MockApiClient();
+    jest.spyOn(useApi, 'default').mockReturnValue(api);
+    jest.spyOn(api, 'requestPromise').mockRejectedValue(new Error());
+
+    render(<TestComponent groupId="group-id">Hover me</TestComponent>);
+
+    userEvent.hover(screen.getByText('Hover me'));
+
+    await screen.findByText('Failed to load preview');
+  });
+
+  it('renders the span evidence correctly when request succeeds', async () => {
+    const event = new TransactionEventBuilder()
+      .addSpan(
+        new MockSpan({
+          startTimestamp: 0,
+          endTimestamp: 100,
+          op: 'http',
+          description: 'do a thing',
+        })
+      )
+      .addSpan(
+        new MockSpan({
+          startTimestamp: 100,
+          endTimestamp: 200,
+          op: 'db',
+          description: 'SELECT col FROM table',
+        })
+      )
+      .addSpan(
+        new MockSpan({
+          startTimestamp: 200,
+          endTimestamp: 300,
+          op: 'db',
+          description: 'SELECT col2 FROM table',
+        })
+      )
+      .addSpan(
+        new MockSpan({
+          startTimestamp: 200,
+          endTimestamp: 300,
+          op: 'db',
+          description: 'SELECT col3 FROM table',
+        })
+      )
+      .addSpan(
+        new MockSpan({
+          startTimestamp: 300,
+          endTimestamp: 600,
+          op: 'db',
+          description: 'connect',
+          problemSpan: ProblemSpan.PARENT,
+        }).addChild(
+          {
+            startTimestamp: 300,
+            endTimestamp: 600,
+            op: 'db',
+            description: 'group me',
+            problemSpan: ProblemSpan.OFFENDER,
+          },
+          9
+        )
+      )
+      .getEvent();
+
+    MockApiClient.addMockResponse({
+      url: `/issues/group-id/events/latest/`,
+      body: event,
+    });
+
+    render(<TestComponent groupId="group-id">Hover me</TestComponent>);
+
+    userEvent.hover(screen.getByText('Hover me'));
+
+    await screen.findByTestId('span-evidence-preview-body');
+
+    expect(screen.getByRole('cell', {name: 'Transaction'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: event.title})).toBeInTheDocument();
+
+    expect(screen.getByRole('cell', {name: 'Parent Span'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: 'db - connect'})).toBeInTheDocument();
+
+    expect(screen.getByRole('cell', {name: 'Repeating Span'})).toBeInTheDocument();
+    expect(screen.getByRole('cell', {name: 'db - group me'})).toBeInTheDocument();
+  });
+});

+ 156 - 0
static/app/components/groupPreviewTooltip/spanEvidencePreview.tsx

@@ -0,0 +1,156 @@
+import {Fragment, ReactChild, useEffect} from 'react';
+import styled from '@emotion/styled';
+
+import {SpanEvidenceKeyValueList} from 'sentry/components/events/interfaces/performance/spanEvidenceKeyValueList';
+import {getSpanInfoFromTransactionEvent} from 'sentry/components/events/interfaces/performance/utils';
+import {GroupPreviewHovercard} from 'sentry/components/groupPreviewTooltip/groupPreviewHovercard';
+import {useDelayedLoadingState} 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 useApiRequests from 'sentry/utils/useApiRequests';
+import useOrganization from 'sentry/utils/useOrganization';
+
+type SpanEvidencePreviewProps = {
+  children: ReactChild;
+  eventId?: string;
+  groupId?: string;
+  projectSlug?: string;
+};
+
+type SpanEvidencePreviewBodyProps = {
+  endpointUrl: string;
+  onRequestBegin: () => void;
+  onRequestEnd: () => void;
+  onUnmount: () => void;
+};
+
+type Response = {
+  event: EventTransaction;
+};
+
+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;
+};
+
+const SpanEvidencePreviewBody = ({
+  endpointUrl,
+  onRequestBegin,
+  onRequestEnd,
+  onUnmount,
+}: SpanEvidencePreviewBodyProps) => {
+  const {data, isLoading, hasError} = useApiRequests<Response>({
+    endpoints: [['event', endpointUrl]],
+    onRequestError: onRequestEnd,
+    onRequestSuccess: onRequestEnd,
+  });
+
+  useEffect(() => {
+    onRequestBegin();
+
+    return onUnmount;
+  }, [onRequestBegin, onUnmount]);
+
+  if (isLoading) {
+    return (
+      <EmptyWrapper>
+        <LoadingIndicator hideMessage size={32} />
+      </EmptyWrapper>
+    );
+  }
+
+  if (hasError) {
+    return <EmptyWrapper>{t('Failed to load preview')}</EmptyWrapper>;
+  }
+
+  const spanInfo = data.event && getSpanInfoFromTransactionEvent(data.event);
+
+  if (spanInfo && data.event) {
+    return (
+      <SpanEvidencePreviewWrapper data-test-id="span-evidence-preview-body">
+        <SpanEvidenceKeyValueList
+          transactionName={data.event.title}
+          parentSpan={spanInfo.parentSpan}
+          repeatingSpan={spanInfo.repeatingSpan}
+        />
+      </SpanEvidencePreviewWrapper>
+    );
+  }
+
+  return (
+    <EmptyWrapper>
+      {t('There is no span evidence available for this issue.')}
+    </EmptyWrapper>
+  );
+};
+
+export const SpanEvidencePreview = ({
+  children,
+  groupId,
+  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}
+      body={
+        <SpanEvidencePreviewBody
+          onRequestBegin={onRequestBegin}
+          onRequestEnd={onRequestEnd}
+          onUnmount={reset}
+          endpointUrl={endpointUrl}
+        />
+      }
+    >
+      {children}
+    </GroupPreviewHovercard>
+  );
+};
+
+const EmptyWrapper = styled('div')`
+  color: ${p => p.theme.subText};
+  padding: ${space(1.5)};
+  font-size: ${p => p.theme.fontSizeMedium};
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 56px;
+`;
+
+const SpanEvidencePreviewWrapper = styled('div')`
+  width: 700px;
+  padding: ${space(1.5)} ${space(1.5)} 0 ${space(1.5)};
+`;

+ 6 - 17
static/app/components/groupPreviewTooltip/stackTracePreview.tsx

@@ -8,6 +8,8 @@ import StackTraceContentV3 from 'sentry/components/events/interfaces/crashConten
 import findBestThread from 'sentry/components/events/interfaces/threads/threadSelector/findBestThread';
 import findBestThread from 'sentry/components/events/interfaces/threads/threadSelector/findBestThread';
 import getThreadStacktrace from 'sentry/components/events/interfaces/threads/threadSelector/getThreadStacktrace';
 import getThreadStacktrace from 'sentry/components/events/interfaces/threads/threadSelector/getThreadStacktrace';
 import {isStacktraceNewestFirst} from 'sentry/components/events/interfaces/utils';
 import {isStacktraceNewestFirst} from 'sentry/components/events/interfaces/utils';
+import {GroupPreviewHovercard} from 'sentry/components/groupPreviewTooltip/groupPreviewHovercard';
+import {useDelayedLoadingState} from 'sentry/components/groupPreviewTooltip/utils';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import space from 'sentry/styles/space';
@@ -19,9 +21,6 @@ import {isNativePlatform} from 'sentry/utils/platform';
 import useApi from 'sentry/utils/useApi';
 import useApi from 'sentry/utils/useApi';
 import useOrganization from 'sentry/utils/useOrganization';
 import useOrganization from 'sentry/utils/useOrganization';
 
 
-import GroupPreviewHovercard from './groupPreviewHovercard';
-import {useDelayedLoadingState} from './utils';
-
 function getStacktrace(event: Event): StacktraceType | null {
 function getStacktrace(event: Event): StacktraceType | null {
   const exceptionsWithStacktrace =
   const exceptionsWithStacktrace =
     event.entries
     event.entries
@@ -178,33 +177,23 @@ function StackTracePreviewBody({
     };
     };
   }, [fetchData, onUnmount]);
   }, [fetchData, onUnmount]);
 
 
-  // Not sure why we need to stop propagation, maybe to prevent the
-  // hovercard from closing? If we are doing this often, maybe it should be
-  // part of the hovercard component.
-  const handleStackTracePreviewClick = useCallback(
-    (e: React.MouseEvent) => void e.stopPropagation(),
-    []
-  );
-
   const stacktrace = useMemo(() => (event ? getStacktrace(event) : null), [event]);
   const stacktrace = useMemo(() => (event ? getStacktrace(event) : null), [event]);
 
 
   switch (status) {
   switch (status) {
     case 'loading':
     case 'loading':
       return (
       return (
-        <NoStackTraceWrapper onClick={handleStackTracePreviewClick}>
+        <NoStackTraceWrapper>
           <LoadingIndicator hideMessage size={32} />
           <LoadingIndicator hideMessage size={32} />
         </NoStackTraceWrapper>
         </NoStackTraceWrapper>
       );
       );
     case 'error':
     case 'error':
       return (
       return (
-        <NoStackTraceWrapper onClick={handleStackTracePreviewClick}>
-          {t('Failed to load stack trace.')}
-        </NoStackTraceWrapper>
+        <NoStackTraceWrapper>{t('Failed to load stack trace.')}</NoStackTraceWrapper>
       );
       );
     case 'loaded': {
     case 'loaded': {
       if (stacktrace && event) {
       if (stacktrace && event) {
         return (
         return (
-          <StackTracePreviewWrapper onClick={handleStackTracePreviewClick}>
+          <StackTracePreviewWrapper>
             <StackTracePreviewContent
             <StackTracePreviewContent
               event={event}
               event={event}
               stacktrace={stacktrace}
               stacktrace={stacktrace}
@@ -216,7 +205,7 @@ function StackTracePreviewBody({
       }
       }
 
 
       return (
       return (
-        <NoStackTraceWrapper onClick={handleStackTracePreviewClick}>
+        <NoStackTraceWrapper>
           {t('There is no stack trace available for this issue.')}
           {t('There is no stack trace available for this issue.')}
         </NoStackTraceWrapper>
         </NoStackTraceWrapper>
       );
       );

+ 3 - 2
static/app/components/groupPreviewTooltip/utils.tsx

@@ -11,14 +11,15 @@ export function useDelayedLoadingState() {
     setShouldShowLoadingState(true);
     setShouldShowLoadingState(true);
   }, []);
   }, []);
 
 
-  const {start, end} = useTimeout({
+  const {start, end, cancel} = useTimeout({
     timeMs: HOVERCARD_CONTENT_DELAY,
     timeMs: HOVERCARD_CONTENT_DELAY,
     onTimeout,
     onTimeout,
   });
   });
 
 
   const reset = useCallback(() => {
   const reset = useCallback(() => {
     setShouldShowLoadingState(false);
     setShouldShowLoadingState(false);
-  }, []);
+    cancel();
+  }, [cancel]);
 
 
   return {
   return {
     shouldShowLoadingState,
     shouldShowLoadingState,

Some files were not shown because too many files changed in this diff