Browse Source

feat(discover-quick-context): Transaction event contexts. (#41521)

1. Added transaction duration and status contexts for transaction
events.
2. Added functionality that allows user to add the contexts as columns. 
3. Added tests.

Co-authored-by: Abdullah Khan <abdullahkhan@P40P69L0VQ-Abdullah-Khan.local>
Abdkhan14 2 years ago
parent
commit
5503d47603

+ 139 - 7
static/app/views/eventsV2/table/quickContext.spec.tsx

@@ -1,3 +1,4 @@
+import {browserHistory} from 'react-router';
 import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
 
 import {
@@ -19,7 +20,8 @@ import {
   ExceptionValue,
   Frame,
 } from 'sentry/types/event';
-import {EventData} from 'sentry/utils/discover/eventView';
+import EventView, {EventData} from 'sentry/utils/discover/eventView';
+import {useLocation} from 'sentry/utils/useLocation';
 
 import {ContextType, QuickContextHoverWrapper} from './quickContext';
 
@@ -46,6 +48,14 @@ let mockedGroup = TestStubs.Group({
   },
 });
 
+const mockEventView = EventView.fromSavedQuery({
+  id: '',
+  name: 'test query',
+  version: 2,
+  fields: ['title', 'issue'],
+  projects: [1],
+});
+
 const mockedCommit: Commit = {
   dateCreated: '2020-11-30T18:46:31Z',
   id: 'f7f395d14b2fe29a4e253bf1d3094d61e6ad4434',
@@ -100,7 +110,8 @@ const queryClient = new QueryClient();
 
 const renderQuickContextContent = (
   dataRow: EventData = defaultRow,
-  contextType: ContextType = ContextType.ISSUE
+  contextType: ContextType = ContextType.ISSUE,
+  eventView?: EventView
 ) => {
   const organization = TestStubs.Organization();
   render(
@@ -109,6 +120,7 @@ const renderQuickContextContent = (
         dataRow={dataRow}
         contextType={contextType}
         organization={organization}
+        eventView={eventView}
       >
         Text from Child
       </QuickContextHoverWrapper>
@@ -126,6 +138,9 @@ const makeEvent = (event: Partial<Event> = {}): Event => {
   return evt;
 };
 
+jest.mock('sentry/utils/useLocation');
+const mockUseLocation = useLocation as jest.MockedFunction<typeof useLocation>;
+
 describe('Quick Context', function () {
   describe('Quick Context default behaviour', function () {
     afterEach(() => {
@@ -472,19 +487,136 @@ describe('Quick Context', function () {
   });
 
   describe('Quick Context Content: Event ID Column', function () {
-    it('Renders NO context message for events that are not errors', async () => {
+    it('Renders transaction duration context', async () => {
+      const currentTime = Date.now();
+      mockUseLocation.mockReturnValueOnce(
+        TestStubs.location({
+          query: {
+            field: 'title',
+          },
+        })
+      );
       MockApiClient.addMockResponse({
         url: '/organizations/org-slug/events/sentry:6b43e285de834ec5b5fe30d62d549b20/',
-        body: makeEvent({type: EventOrGroupType.TRANSACTION, entries: []}),
+        body: makeEvent({
+          type: EventOrGroupType.TRANSACTION,
+          entries: [],
+          endTimestamp: currentTime,
+          startTimestamp: currentTime - 2,
+        }),
       });
+      renderQuickContextContent(defaultRow, ContextType.EVENT);
+
+      userEvent.hover(screen.getByText('Text from Child'));
 
+      expect(await screen.findByText(/Transaction Duration/i)).toBeInTheDocument();
+      expect(screen.getByText(/2.00s/i)).toBeInTheDocument();
+
+      const addAsColumnButton = screen.getByTestId(
+        'quick-context-transaction-duration-add-button'
+      );
+      expect(addAsColumnButton).toBeInTheDocument();
+
+      userEvent.click(addAsColumnButton);
+      expect(browserHistory.push).toHaveBeenCalledWith(
+        expect.objectContaining({
+          pathname: '/mock-pathname/',
+          query: expect.objectContaining({
+            field: ['title', 'transaction.duration'],
+          }),
+        })
+      );
+    });
+
+    it('Renders transaction status context', async () => {
+      const currentTime = Date.now();
+      mockUseLocation.mockReturnValueOnce(
+        TestStubs.location({
+          query: {
+            field: 'title',
+          },
+        })
+      );
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/events/sentry:6b43e285de834ec5b5fe30d62d549b20/',
+        body: makeEvent({
+          type: EventOrGroupType.TRANSACTION,
+          entries: [],
+          endTimestamp: currentTime,
+          startTimestamp: currentTime - 2,
+          contexts: {
+            trace: {
+              status: 'ok',
+            },
+          },
+          tags: [
+            {
+              key: 'http.status_code',
+              value: '200',
+            },
+          ],
+        }),
+      });
       renderQuickContextContent(defaultRow, ContextType.EVENT);
 
       userEvent.hover(screen.getByText('Text from Child'));
 
-      expect(
-        await screen.findByText(/There is no context available./i)
-      ).toBeInTheDocument();
+      expect(await screen.findByText(/Status/i)).toBeInTheDocument();
+      expect(screen.getByText(/ok/i)).toBeInTheDocument();
+      expect(screen.getByText(/HTTP 200/i)).toBeInTheDocument();
+
+      const addAsColumnButton = screen.getByTestId(
+        'quick-context-http-status-add-button'
+      );
+      expect(addAsColumnButton).toBeInTheDocument();
+
+      userEvent.click(addAsColumnButton);
+      expect(browserHistory.push).toHaveBeenCalledWith(
+        expect.objectContaining({
+          pathname: '/mock-pathname/',
+          query: expect.objectContaining({
+            field: ['title', 'tags[http.status_code]'],
+          }),
+        })
+      );
+    });
+
+    it('Adds columns for saved query', async () => {
+      const currentTime = Date.now();
+      mockUseLocation.mockReturnValueOnce(
+        TestStubs.location({
+          query: {
+            field: null,
+          },
+        })
+      );
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/events/sentry:6b43e285de834ec5b5fe30d62d549b20/',
+        body: makeEvent({
+          type: EventOrGroupType.TRANSACTION,
+          entries: [],
+          endTimestamp: currentTime,
+          startTimestamp: currentTime - 2,
+        }),
+      });
+      renderQuickContextContent(defaultRow, ContextType.EVENT, mockEventView);
+
+      userEvent.hover(screen.getByText('Text from Child'));
+
+      const addAsColumnButton = await screen.findByTestId(
+        'quick-context-transaction-duration-add-button'
+      );
+      expect(addAsColumnButton).toBeInTheDocument();
+
+      userEvent.click(addAsColumnButton);
+      expect(browserHistory.push).toHaveBeenCalledWith(
+        expect.objectContaining({
+          pathname: '/mock-pathname/',
+          query: expect.objectContaining({
+            field: ['title', 'issue', 'transaction.duration'],
+          }),
+        })
+      );
     });
 
     it('Renders NO stack trace message for error events without stackTraces', async () => {

+ 184 - 19
static/app/views/eventsV2/table/quickContext.tsx

@@ -1,5 +1,7 @@
 import {Fragment, useEffect} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
+import {Location} from 'history';
 
 import {Client} from 'sentry/api';
 import AvatarList from 'sentry/components/avatar/avatarList';
@@ -18,17 +20,26 @@ import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Panel} from 'sentry/components/panels';
 import * as SidebarSection from 'sentry/components/sidebarSection';
 import TimeSince from 'sentry/components/timeSince';
+import Tooltip from 'sentry/components/tooltip';
 import {VersionHoverHeader} from 'sentry/components/versionHoverCard';
-import {IconCheckmark, IconMute, IconNot} from 'sentry/icons';
+import {IconAdd, IconCheckmark, IconMute, IconNot} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import ConfigStore from 'sentry/stores/configStore';
 import GroupStore from 'sentry/stores/groupStore';
 import space from 'sentry/styles/space';
-import {Event, Group, Organization, ReleaseWithHealth, User} from 'sentry/types';
-import {EventData} from 'sentry/utils/discover/eventView';
+import {Event, Group, Organization, Project, ReleaseWithHealth, User} from 'sentry/types';
+import EventView, {EventData} from 'sentry/utils/discover/eventView';
+import {getDuration} from 'sentry/utils/formatters';
+import TraceMetaQuery from 'sentry/utils/performance/quickTrace/traceMetaQuery';
+import {getTraceTimeRangeFromEvent} from 'sentry/utils/performance/quickTrace/utils';
 import {useQuery, useQueryClient} from 'sentry/utils/queryClient';
+import toArray from 'sentry/utils/toArray';
 import useApi from 'sentry/utils/useApi';
-
+import {useLocation} from 'sentry/utils/useLocation';
+import {
+  getStatusBodyText,
+  HttpStatus,
+} from 'sentry/views/performance/transactionDetails/eventMetas';
 // Will extend this enum as we add contexts for more columns
 export enum ContextType {
   ISSUE = 'issue',
@@ -46,7 +57,10 @@ function getHoverBody(
   api: Client,
   dataRow: EventData,
   contextType: ContextType,
-  organization?: Organization
+  organization?: Organization,
+  location?: Location,
+  projects?: Project[],
+  eventView?: EventView
 ) {
   const noContext = (
     <NoContextWrapper>{t('There is no context available.')}</NoContextWrapper>
@@ -62,7 +76,14 @@ function getHoverBody(
       );
     case ContextType.EVENT:
       return organization ? (
-        <EventContext api={api} dataRow={dataRow} organization={organization} />
+        <EventContext
+          api={api}
+          dataRow={dataRow}
+          organization={organization}
+          location={location}
+          projects={projects}
+          eventView={eventView}
+        />
       ) : (
         noContext
       );
@@ -78,6 +99,22 @@ function getHoverHeader(dataRow: EventData, contextType: ContextType) {
   ) : null;
 }
 
+const addFieldAsColumn = (
+  fieldName: string,
+  location?: Location,
+  eventView?: EventView
+) => {
+  const oldField = location?.query.field || eventView?.fields.map(field => field.field);
+  const newField = toArray(oldField).concat(fieldName);
+  browserHistory.push({
+    ...location,
+    query: {
+      ...location?.query,
+      field: newField,
+    },
+  });
+};
+
 const fiveMinutesInMs = 5 * 60 * 1000;
 
 type IssueContextProps = {
@@ -111,10 +148,10 @@ function IssueContext(props: IssueContextProps) {
   const renderStatus = () =>
     data && (
       <IssueContextContainer data-test-id="quick-context-issue-status-container">
-        <ContextTitle>
+        <ContextHeader>
           {statusTitle}
           <FeatureBadge type="alpha" />
-        </ContextTitle>
+        </ContextHeader>
         <ContextBody>
           {data.status === 'ignored' ? (
             <IconMute
@@ -252,10 +289,10 @@ function ReleaseContext(props: BaseContextProps) {
     const statusText = data?.status === 'open' ? t('Active') : t('Archived');
     return (
       <ReleaseContextContainer data-test-id="quick-context-release-details-container">
-        <ContextTitle>
+        <ContextHeader>
           {t('Release Details')}
           <FeatureBadge type="alpha" />
-        </ContextTitle>
+        </ContextHeader>
         <ContextBody>
           <StyledKeyValueTable>
             <KeyValueTableRow keyName={t('Status')} value={statusText} />
@@ -287,7 +324,7 @@ function ReleaseContext(props: BaseContextProps) {
     data &&
     data.lastCommit && (
       <ReleaseContextContainer data-test-id="quick-context-release-last-commit-container">
-        <ContextTitle>{t('Last Commit')}</ContextTitle>
+        <ContextHeader>{t('Last Commit')}</ContextHeader>
         <DataSection>
           <Panel>
             <QuickContextCommitRow commit={data.lastCommit} />
@@ -301,7 +338,7 @@ function ReleaseContext(props: BaseContextProps) {
       <ReleaseContextContainer data-test-id="quick-context-release-issues-and-authors-container">
         <ContextRow>
           <div>
-            <ContextTitle>{t('New Issues')}</ContextTitle>
+            <ContextHeader>{t('New Issues')}</ContextHeader>
             <ReleaseStatusBody>{data.newGroups}</ReleaseStatusBody>
           </div>
           <div>
@@ -331,7 +368,13 @@ function ReleaseContext(props: BaseContextProps) {
   );
 }
 
-function EventContext(props: BaseContextProps) {
+interface EventContextProps extends BaseContextProps {
+  eventView?: EventView;
+  location?: Location;
+  projects?: Project[];
+}
+
+function EventContext(props: EventContextProps) {
   const {isLoading, isError, data} = useQuery<Event>(
     [
       `/organizations/${props.organization.slug}/events/${props.dataRow['project.name']}:${props.dataRow.id}/`,
@@ -345,8 +388,94 @@ function EventContext(props: BaseContextProps) {
     return <NoContext isLoading={isLoading} />;
   }
 
-  if (data?.type !== 'error') {
-    return <NoContextWrapper>{t('There is no context available.')}</NoContextWrapper>;
+  if (data.type === 'transaction') {
+    const traceId = data.contexts?.trace?.trace_id ?? '';
+    const {start, end} = getTraceTimeRangeFromEvent(data);
+    const project = props.projects?.find(p => p.slug === data.projectID);
+    return (
+      <Wrapper data-test-id="quick-context-hover-body">
+        <EventContextContainer>
+          <ContextHeader>
+            <Title>
+              {t('Transaction Duration')}
+              {!('transaction.duration' in props.dataRow) && (
+                <Tooltip
+                  skipWrapper
+                  title={t('Add transaction duration as a column')}
+                  position="right"
+                >
+                  <IconAdd
+                    data-test-id="quick-context-transaction-duration-add-button"
+                    cursor="pointer"
+                    onClick={() =>
+                      addFieldAsColumn(
+                        'transaction.duration',
+                        props.location,
+                        props.eventView
+                      )
+                    }
+                    color="gray300"
+                    size="xs"
+                    isCircled
+                  />
+                </Tooltip>
+              )}
+            </Title>
+            <FeatureBadge type="alpha" />
+          </ContextHeader>
+          <EventContextBody>
+            {getDuration(data.endTimestamp - data.startTimestamp, 2, true)}
+          </EventContextBody>
+        </EventContextContainer>
+        {props.location && (
+          <EventContextContainer>
+            <ContextHeader>
+              <Title>
+                {t('Status')}
+                {!('tags[http.status_code]' in props.dataRow) && (
+                  <Tooltip
+                    skipWrapper
+                    title={t('Add HTTP status code as a column')}
+                    position="right"
+                  >
+                    <IconAdd
+                      data-test-id="quick-context-http-status-add-button"
+                      cursor="pointer"
+                      onClick={() =>
+                        addFieldAsColumn(
+                          'tags[http.status_code]',
+                          props.location,
+                          props.eventView
+                        )
+                      }
+                      color="gray300"
+                      size="xs"
+                      isCircled
+                    />
+                  </Tooltip>
+                )}
+              </Title>
+            </ContextHeader>
+            <EventContextBody>
+              <ContextRow>
+                <TraceMetaQuery
+                  location={props.location}
+                  orgSlug={props.organization.slug}
+                  traceId={traceId}
+                  start={start}
+                  end={end}
+                >
+                  {metaResults => getStatusBodyText(project, data, metaResults?.meta)}
+                </TraceMetaQuery>
+                <HttpStatusWrapper>
+                  (<HttpStatus event={data} />)
+                </HttpStatusWrapper>
+              </ContextRow>
+            </EventContextBody>
+          </EventContextContainer>
+        )}
+      </Wrapper>
+    );
   }
 
   const stackTrace = getStacktrace(data);
@@ -366,12 +495,16 @@ type ContextProps = {
   children: React.ReactNode;
   contextType: ContextType;
   dataRow: EventData;
+  eventView?: EventView;
   organization?: Organization;
+  projects?: Project[];
 };
 
 export function QuickContextHoverWrapper(props: ContextProps) {
   const api = useApi();
+  const location = useLocation();
   const queryClient = useQueryClient();
+  const {dataRow, contextType, organization, projects, eventView} = props;
 
   useEffect(() => {
     return () => {
@@ -385,8 +518,16 @@ export function QuickContextHoverWrapper(props: ContextProps) {
       <StyledHovercard
         showUnderline
         delay={HOVER_DELAY}
-        header={getHoverHeader(props.dataRow, props.contextType)}
-        body={getHoverBody(api, props.dataRow, props.contextType, props.organization)}
+        header={getHoverHeader(dataRow, contextType)}
+        body={getHoverBody(
+          api,
+          dataRow,
+          contextType,
+          organization,
+          location,
+          projects,
+          eventView
+        )}
       >
         {props.children}
       </StyledHovercard>
@@ -438,7 +579,7 @@ const IssueContextContainer = styled(ContextContainer)`
   }
 `;
 
-const ContextTitle = styled('h6')`
+const ContextHeader = styled('h6')`
   color: ${p => p.theme.subText};
   display: flex;
   justify-content: space-between;
@@ -455,6 +596,13 @@ const ContextBody = styled('div')`
   align-items: center;
 `;
 
+const EventContextBody = styled(ContextBody)`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  margin: 0;
+  align-items: flex-start;
+  flex-direction: column;
+`;
+
 const StatusText = styled('span')`
   margin-left: ${space(1)};
   text-transform: capitalize;
@@ -498,7 +646,13 @@ const ReleaseContextContainer = styled(ContextContainer)`
   }
 `;
 
-const ReleaseAuthorsTitle = styled(ContextTitle)`
+const EventContextContainer = styled(ContextContainer)`
+  & + & {
+    margin-top: ${space(2)};
+  }
+`;
+
+const ReleaseAuthorsTitle = styled(ContextHeader)`
   max-width: 200px;
   text-align: right;
 `;
@@ -526,4 +680,15 @@ const StackTraceWrapper = styled('div')`
     border: 0;
     box-shadow: none;
   }
+  border-radius: ${p => p.theme.borderRadius};
+`;
+
+const Title = styled('div')`
+  display: flex;
+  align-items: center;
+  gap: ${space(0.5)};
+`;
+
+const HttpStatusWrapper = styled('span')`
+  margin-left: ${space(0.5)};
 `;

+ 4 - 1
static/app/views/eventsV2/table/tableView.tsx

@@ -114,7 +114,8 @@ class TableView extends Component<TableViewProps & WithRouterProps> {
     dataRow?: any,
     rowIndex?: number
   ): React.ReactNode[] => {
-    const {organization, eventView, tableData, location, isHomepage} = this.props;
+    const {organization, eventView, tableData, location, isHomepage, projects} =
+      this.props;
     const hasAggregates = eventView.hasAggregateField();
     const hasIdField = eventView.hasIdField();
 
@@ -203,6 +204,8 @@ class TableView extends Component<TableViewProps & WithRouterProps> {
           dataRow={dataRow}
           contextType={ContextType.EVENT}
           organization={organization}
+          projects={projects}
+          eventView={eventView}
         >
           {eventIdLink}
         </QuickContextHoverWrapper>,

+ 2 - 2
static/app/views/performance/transactionDetails/eventMetas.tsx

@@ -265,7 +265,7 @@ const EventIDWrapper = styled('span')`
   margin-right: ${space(1)};
 `;
 
-function HttpStatus({event}: {event: Event}) {
+export function HttpStatus({event}: {event: Event}) {
   const {tags} = event;
 
   const emptyStatus = <Fragment>{'\u2014'}</Fragment>;
@@ -292,7 +292,7 @@ function HttpStatus({event}: {event: Event}) {
   event.contexts?.trace?.status ?? '\u2014';
 */
 
-function getStatusBodyText(
+export function getStatusBodyText(
   project: AvatarProject | undefined,
   event: EventTransaction,
   meta: TraceMeta | null