Browse Source

feat(new-trace): Adding trace header information to drawer. (#70717)

Before:
<img width="1512" alt="Screenshot 2024-05-12 at 11 20 05 PM"
src="https://github.com/getsentry/sentry/assets/60121741/721ed8fd-87db-4e17-9769-1ae5215a1c19">

After:
<img width="1510" alt="Screenshot 2024-05-12 at 11 19 50 PM"
src="https://github.com/getsentry/sentry/assets/60121741/3162dba2-0437-4093-8978-54f265b024f2">

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdkhan14 10 months ago
parent
commit
4b1e57561e

+ 1 - 0
static/app/components/events/interfaces/spans/types.tsx

@@ -214,6 +214,7 @@ export enum TickAlignment {
 }
 
 export type TraceContextType = {
+  client_sample_rate?: number;
   count?: number;
   description?: string;
   exclusive_time?: number;

+ 1 - 1
static/app/utils/queryClient.tsx

@@ -268,7 +268,7 @@ export function fetchInfiniteQuery<TResponseData>(api: Client) {
 function parsePageParam(dir: 'previous' | 'next') {
   return ([, , resp]: ApiResult<unknown>) => {
     const parsed = parseLinkHeader(resp?.getResponseHeader('Link') ?? null);
-    return parsed[dir].results ? parsed[dir] : null;
+    return parsed[dir]?.results ? parsed[dir] : null;
   };
 }
 

+ 4 - 12
static/app/views/performance/newTraceDetails/index.tsx

@@ -67,7 +67,6 @@ import {TraceSearchInput} from './traceSearch/traceSearchInput';
 import {searchInTraceTree} from './traceState/traceSearch';
 import {isTraceNode} from './guards';
 import {Trace} from './trace';
-import {TraceHeader} from './traceHeader';
 import {TraceMetadataHeader} from './traceMetadataHeader';
 import {TraceReducer, type TraceReducerState} from './traceState';
 import {TraceUXChangeAlert} from './traceUXChangeBanner';
@@ -802,14 +801,6 @@ function TraceViewContent(props: TraceViewContentProps) {
         traceSlug={props.traceSlug}
         traceEventView={props.traceEventView}
       />
-      <TraceHeader
-        tree={tree}
-        rootEventResults={rootEvent}
-        metaResults={props.metaResults}
-        organization={props.organization}
-        traces={props.trace}
-        traceID={props.traceSlug}
-      />
       <TraceInnerLayout>
         <TraceToolbar>
           <TraceSearchInput
@@ -845,6 +836,7 @@ function TraceViewContent(props: TraceViewContentProps) {
           ) : null}
 
           <TraceDrawer
+            metaResults={props.metaResults}
             traceType={shape}
             trace={tree}
             traceGridRef={traceGridRef}
@@ -893,7 +885,8 @@ const TraceInnerLayout = styled('div')`
   display: flex;
   flex-direction: column;
   flex: 1 1 100%;
-  padding: 0 ${space(2)} 0 ${space(2)};
+  padding: ${space(2)};
+
   background-color: ${p => p.theme.background};
 
   --info: ${p => p.theme.purple400};
@@ -920,8 +913,7 @@ const TraceGrid = styled('div')<{
   box-shadow: 0 0 0 1px ${p => p.theme.border};
   flex: 1 1 100%;
   display: grid;
-  border-top-left-radius: ${p => p.theme.borderRadius};
-  border-top-right-radius: ${p => p.theme.borderRadius};
+  border-radius: ${p => p.theme.borderRadius};
   overflow: hidden;
   position: relative;
   /* false positive for grid layout */

+ 2 - 2
static/app/views/performance/newTraceDetails/trace.spec.tsx

@@ -102,7 +102,7 @@ function mockTraceTagsResponse(resp?: Partial<ResponseType>) {
     url: '/organizations/org-slug/events-facets/',
     method: 'GET',
     asyncDelay: 1,
-    ...(resp ?? {}),
+    ...(resp ?? []),
   });
 }
 
@@ -523,7 +523,7 @@ describe('trace view', () => {
       },
     });
     mockTraceMetaResponse();
-    mockTraceTagsResponse({});
+    mockTraceTagsResponse();
 
     render(<TraceViewWithProviders traceSlug="trace-id" />);
     expect(

+ 2 - 2
static/app/views/performance/newTraceDetails/traceDrawer/details/styles.tsx

@@ -596,7 +596,7 @@ function SectionCard({
   title: React.ReactNode;
   disableTruncate?: boolean;
 }) {
-  const [showingAll, setShowingAll] = useState(disableTruncate ?? false);
+  const [showingAll, setShowingAll] = useState(false);
   const renderText = showingAll ? t('Show less') : t('Show more') + '...';
 
   if (items.length === 0) {
@@ -606,7 +606,7 @@ function SectionCard({
   return (
     <Card>
       <CardContentTitle>{title}</CardContentTitle>
-      {items.slice(0, showingAll ? items.length : 5).map(item => (
+      {items.slice(0, showingAll || disableTruncate ? items.length : 5).map(item => (
         <SectionCardContent key={`context-card-${item.key}`} meta={{}} item={item} />
       ))}
       {items.length > 5 && !disableTruncate ? (

+ 229 - 0
static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/generalInfo.tsx

@@ -0,0 +1,229 @@
+import {Fragment, useMemo} from 'react';
+
+import Link from 'sentry/components/links/link';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {Tooltip} from 'sentry/components/tooltip';
+import {t, tn} from 'sentry/locale';
+import type {Organization} from 'sentry/types';
+import type {EventTransaction} from 'sentry/types/event';
+import getDuration from 'sentry/utils/duration/getDuration';
+import {getShortEventId} from 'sentry/utils/events';
+import type {
+  TraceErrorOrIssue,
+  TraceFullDetailed,
+  TraceMeta,
+  TraceSplitResults,
+} from 'sentry/utils/performance/quickTrace/types';
+import type {UseApiQueryResult} from 'sentry/utils/queryClient';
+import type RequestError from 'sentry/utils/requestError/requestError';
+import {useParams} from 'sentry/utils/useParams';
+import {normalizeUrl} from 'sentry/utils/withDomainRequired';
+import {SpanTimeRenderer} from 'sentry/views/performance/traces/fieldRenderers';
+
+import {isTraceNode} from '../../../guards';
+import type {TraceTree, TraceTreeNode} from '../../../traceModels/traceTree';
+import {type SectionCardKeyValueList, TraceDrawerComponents} from '../../details/styles';
+
+type GeneralInfoProps = {
+  metaResults: UseApiQueryResult<TraceMeta | null, any>;
+  node: TraceTreeNode<TraceTree.NodeValue> | null;
+  organization: Organization;
+  rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
+  traces: TraceSplitResults<TraceFullDetailed> | null;
+  tree: TraceTree;
+};
+
+export function GeneralInfo(props: GeneralInfoProps) {
+  const params = useParams<{traceSlug?: string}>();
+
+  const traceNode = props.tree.root.children[0];
+
+  const uniqueErrorIssues = useMemo(() => {
+    if (!traceNode) {
+      return [];
+    }
+
+    const unique: TraceErrorOrIssue[] = [];
+
+    const seenIssues: Set<number> = new Set();
+
+    for (const issue of traceNode.errors) {
+      if (seenIssues.has(issue.issue_id)) {
+        continue;
+      }
+      seenIssues.add(issue.issue_id);
+      unique.push(issue);
+    }
+
+    return unique;
+  }, [traceNode]);
+
+  const uniquePerformanceIssues = useMemo(() => {
+    if (!traceNode) {
+      return [];
+    }
+
+    const unique: TraceErrorOrIssue[] = [];
+    const seenIssues: Set<number> = new Set();
+
+    for (const issue of traceNode.performance_issues) {
+      if (seenIssues.has(issue.issue_id)) {
+        continue;
+      }
+      seenIssues.add(issue.issue_id);
+      unique.push(issue);
+    }
+
+    return unique;
+  }, [traceNode]);
+
+  const uniqueIssuesCount = uniqueErrorIssues.length + uniquePerformanceIssues.length;
+
+  const traceSlug = useMemo(() => {
+    return params.traceSlug?.trim() ?? '';
+  }, [params.traceSlug]);
+
+  const isLoading = useMemo(() => {
+    return (
+      props.metaResults.isLoading ||
+      (props.rootEventResults.isLoading && props.rootEventResults.fetchStatus !== 'idle')
+    );
+  }, [
+    props.metaResults.isLoading,
+    props.rootEventResults.isLoading,
+    props.rootEventResults.fetchStatus,
+  ]);
+
+  if (isLoading) {
+    return (
+      <TraceDrawerComponents.SectionCard
+        items={[
+          {
+            key: 'trace_general_loading',
+            subject: null,
+            value: <LoadingIndicator size={30} />,
+          },
+        ]}
+        title={t('General')}
+      />
+    );
+  }
+
+  if (!(traceNode && isTraceNode(traceNode))) {
+    throw new Error('Expected a trace node');
+  }
+
+  if (
+    props.traces?.transactions.length === 0 &&
+    props.traces.orphan_errors.length === 0
+  ) {
+    return null;
+  }
+
+  const replay_id = props.rootEventResults?.data?.contexts?.replay?.replay_id;
+  const browser = props.rootEventResults?.data?.contexts?.browser;
+
+  const items: SectionCardKeyValueList = [
+    {
+      key: 'trace_id',
+      subject: t('Trace ID'),
+      value: <TraceDrawerComponents.CardValueWithCopy value={traceSlug} />,
+    },
+    {
+      key: 'events',
+      subject: t('Events'),
+      value: props.metaResults.data
+        ? props.metaResults.data.transactions + props.metaResults.data.errors
+        : '\u2014',
+    },
+    {
+      key: 'issues',
+      subject: t('Issues'),
+      value: (
+        <Tooltip
+          title={
+            uniqueIssuesCount > 0 ? (
+              <Fragment>
+                <div>
+                  {tn('%s error issue', '%s error issues', uniqueErrorIssues.length)}
+                </div>
+                <div>
+                  {tn(
+                    '%s performance issue',
+                    '%s performance issues',
+                    uniquePerformanceIssues.length
+                  )}
+                </div>
+              </Fragment>
+            ) : null
+          }
+          showUnderline
+          position="bottom"
+        >
+          {uniqueIssuesCount > 0 ? (
+            <TraceDrawerComponents.IssuesLink>
+              {uniqueIssuesCount}
+            </TraceDrawerComponents.IssuesLink>
+          ) : uniqueIssuesCount === 0 ? (
+            0
+          ) : (
+            '\u2014'
+          )}
+        </Tooltip>
+      ),
+    },
+    {
+      key: 'start_timestamp',
+      subject: t('Start Timestamp'),
+      value: traceNode.space?.[1] ? (
+        <SpanTimeRenderer timestamp={traceNode.space?.[0]} tooltipShowSeconds />
+      ) : (
+        '\u2014'
+      ),
+    },
+    {
+      key: 'total_duration',
+      subject: t('Total Duration'),
+      value: traceNode.space?.[1]
+        ? getDuration(traceNode.space[1] / 1000, 2, true)
+        : '\u2014',
+    },
+    {
+      key: 'user',
+      subject: t('User'),
+      value:
+        props.rootEventResults?.data?.user?.email ??
+        props.rootEventResults?.data?.user?.name ??
+        '\u2014',
+    },
+    {
+      key: 'browser',
+      subject: t('Browser'),
+      value: browser ? browser.name + ' ' + browser.version : '\u2014',
+    },
+  ];
+
+  if (replay_id) {
+    items.push({
+      key: 'replay_id',
+      subject: t('Replay ID'),
+      value: (
+        <Link
+          to={normalizeUrl(
+            `/organizations/${props.organization.slug}/replays/${replay_id}/`
+          )}
+        >
+          {getShortEventId(replay_id)}
+        </Link>
+      ),
+    });
+  }
+
+  return (
+    <TraceDrawerComponents.SectionCard
+      items={items}
+      title={t('General')}
+      disableTruncate
+    />
+  );
+}

+ 29 - 26
static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace.tsx → static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/index.tsx

@@ -1,30 +1,34 @@
 import {Fragment, useMemo} from 'react';
 
 import type {Tag} from 'sentry/actionCreators/events';
+import type {ApiResult} from 'sentry/api';
 import type {EventTransaction} from 'sentry/types/event';
-import {generateQueryWithTag} from 'sentry/utils';
 import type EventView from 'sentry/utils/discover/eventView';
-import {formatTagKey} from 'sentry/utils/discover/fields';
 import type {
   TraceFullDetailed,
+  TraceMeta,
   TraceSplitResults,
 } from 'sentry/utils/performance/quickTrace/types';
-import type {UseApiQueryResult} from 'sentry/utils/queryClient';
+import type {UseApiQueryResult, UseInfiniteQueryResult} from 'sentry/utils/queryClient';
 import type RequestError from 'sentry/utils/requestError/requestError';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
-import Tags from 'sentry/views/discover/tags';
 import {TraceWarnings} from 'sentry/views/performance/newTraceDetails/traceWarnings';
 import type {TraceType} from 'sentry/views/performance/traceDetails/newTraceDetailsContent';
 
-import {isTraceNode} from '../../guards';
-import type {TraceTree, TraceTreeNode} from '../../traceModels/traceTree';
-import {IssueList} from '../details/issues/issues';
+import {isTraceNode} from '../../../guards';
+import type {TraceTree, TraceTreeNode} from '../../../traceModels/traceTree';
+import {IssueList} from '../../details/issues/issues';
+import {TraceDrawerComponents} from '../../details/styles';
+
+import {GeneralInfo} from './generalInfo';
+import {TagsSummary} from './tagsSummary';
 
 type TraceDetailsProps = {
+  metaResults: UseApiQueryResult<TraceMeta | null, any>;
   node: TraceTreeNode<TraceTree.NodeValue> | null;
   rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
-  tagsQueryResults: UseApiQueryResult<Tag[], RequestError>;
+  tagsInfiniteQueryResults: UseInfiniteQueryResult<ApiResult<Tag[]>, unknown>;
   traceEventView: EventView;
   traceType: TraceType;
   traces: TraceSplitResults<TraceFullDetailed> | null;
@@ -56,26 +60,25 @@ export function TraceDetails(props: TraceDetailsProps) {
     <Fragment>
       {props.tree.type === 'trace' ? <TraceWarnings type={props.traceType} /> : null}
       <IssueList issues={issues} node={props.node} organization={organization} />
-      {rootEvent ? (
-        <Tags
-          tagsQueryResults={props.tagsQueryResults}
-          generateUrl={(key: string, value: string) => {
-            const url = props.traceEventView.getResultsViewUrlTarget(
-              organization.slug,
-              false
-            );
-            url.query = generateQueryWithTag(url.query, {
-              key: formatTagKey(key),
-              value,
-            });
-            return url;
-          }}
-          totalValues={props.tree.eventsCount}
-          eventView={props.traceEventView}
+      <TraceDrawerComponents.SectionCardGroup>
+        <GeneralInfo
           organization={organization}
-          location={location}
+          traces={props.traces}
+          tree={props.tree}
+          node={props.node}
+          rootEventResults={props.rootEventResults}
+          metaResults={props.metaResults}
         />
-      ) : null}
+        {rootEvent ? (
+          <TagsSummary
+            tagsInfiniteQueryResults={props.tagsInfiniteQueryResults}
+            organization={organization}
+            location={location}
+            eventView={props.traceEventView}
+            totalValues={props.tree.eventsCount}
+          />
+        ) : null}
+      </TraceDrawerComponents.SectionCardGroup>
     </Fragment>
   );
 }

+ 174 - 0
static/app/views/performance/newTraceDetails/traceDrawer/tabs/trace/tagsSummary.tsx

@@ -0,0 +1,174 @@
+import {Fragment, useMemo} from 'react';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+import isEmpty from 'lodash/isEmpty';
+
+import type {Tag, TagSegment} from 'sentry/actionCreators/events';
+import type {ApiResult} from 'sentry/api';
+import {TagFacetsList} from 'sentry/components/group/tagFacets';
+import TagFacetsDistributionMeter from 'sentry/components/group/tagFacets/tagFacetsDistributionMeter';
+import Placeholder from 'sentry/components/placeholder';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Organization} from 'sentry/types';
+import {generateQueryWithTag} from 'sentry/utils';
+import type EventView from 'sentry/utils/discover/eventView';
+import {formatTagKey} from 'sentry/utils/discover/fields';
+import type {UseInfiniteQueryResult} from 'sentry/utils/queryClient';
+import StyledEmptyStateWarning from 'sentry/views/replays/detail/emptyState';
+
+import {TraceDrawerComponents} from '../../details/styles';
+
+const getTagTarget = (
+  tagKey: string,
+  tagValue: string,
+  eventView: EventView,
+  organization: Organization
+) => {
+  const url = eventView.getResultsViewUrlTarget(organization.slug, false);
+  url.query = generateQueryWithTag(url.query, {
+    key: formatTagKey(tagKey),
+    value: tagValue,
+  });
+  return url;
+};
+
+type TagSummaryProps = {
+  eventView: EventView;
+  location: Location;
+  organization: Organization;
+  tagsInfiniteQueryResults: UseInfiniteQueryResult<ApiResult<Tag[]>, unknown>;
+  totalValues: number | null;
+};
+
+function TagsSummaryPlaceholder() {
+  return (
+    <Fragment>
+      <StyledPlaceholderTitle key="title-1" />
+      <StyledPlaceholder key="bar-1" />
+      <StyledPlaceholderTitle key="title-2" />
+      <StyledPlaceholder key="bar-2" />
+      <StyledPlaceholderTitle key="title-3" />
+      <StyledPlaceholder key="bar-3" />
+    </Fragment>
+  );
+}
+
+const StyledPlaceholder = styled(Placeholder)`
+  border-radius: ${p => p.theme.borderRadius};
+  height: 16px;
+  margin-bottom: ${space(1.5)};
+`;
+
+const StyledPlaceholderTitle = styled(Placeholder)`
+  width: 100px;
+  height: 12px;
+  margin-bottom: ${space(0.5)};
+`;
+
+type TagProps = {
+  eventView: EventView;
+  index: number;
+  organization: Organization;
+  tag: Tag;
+  totalValues: number | null;
+};
+
+function TagRow(props: TagProps) {
+  const segments: TagSegment[] = props.tag.topValues.map(segment => {
+    segment.url = getTagTarget(
+      props.tag.key,
+      segment.value,
+      props.eventView,
+      props.organization
+    );
+
+    return segment;
+  });
+
+  // Ensure we don't show >100% if there's a slight mismatch between the facets
+  // endpoint and the totals endpoint
+  const maxTotalValues =
+    segments.length > 0
+      ? Math.max(Number(props.totalValues), segments[0].count)
+      : props.totalValues;
+  return (
+    <li key={props.tag.key} aria-label={props.tag.key}>
+      <TagFacetsDistributionMeter
+        title={props.tag.key}
+        segments={segments}
+        totalValues={Number(maxTotalValues)}
+        expandByDefault={props.index === 0}
+      />
+    </li>
+  );
+}
+
+export function TagsSummary(props: TagSummaryProps) {
+  const {
+    data,
+    fetchNextPage,
+    hasNextPage,
+    isFetchingNextPage,
+    isLoading, // If anything is loaded yet
+  } = props.tagsInfiniteQueryResults;
+
+  const tags: Tag[] = useMemo(() => {
+    if (!data) {
+      return [];
+    }
+    return data.pages.flatMap(([pageData]) => (isEmpty(pageData) ? [] : pageData));
+  }, [data]);
+
+  return (
+    <TraceDrawerComponents.SectionCard
+      items={[
+        {
+          key: 'tags',
+          subject: null,
+          value: (
+            <Fragment>
+              {tags.length > 0 ? (
+                <StyledTagFacetList id="tag-facet-list">
+                  {tags.map((tag, index) => (
+                    <TagRow
+                      key={tag.key}
+                      tag={tag}
+                      index={index}
+                      eventView={props.eventView}
+                      organization={props.organization}
+                      totalValues={props.totalValues}
+                    />
+                  ))}
+                </StyledTagFacetList>
+              ) : null}
+              {isLoading || isFetchingNextPage ? (
+                <TagsSummaryPlaceholder />
+              ) : tags.length === 0 ? (
+                <StyledEmptyStateWarning small>
+                  {t('No tags found')}
+                </StyledEmptyStateWarning>
+              ) : null}
+              {hasNextPage ? (
+                <ShowMoreWrapper>
+                  <a onClick={() => fetchNextPage()}>{t('Show more')}</a>
+                </ShowMoreWrapper>
+              ) : null}
+            </Fragment>
+          ),
+        },
+      ]}
+      title={t('Tags')}
+    />
+  );
+}
+
+const StyledTagFacetList = styled(TagFacetsList)`
+  margin-bottom: 0;
+  width: 100%;
+`;
+
+const ShowMoreWrapper = styled('div')`
+  display: flex;
+  justify-content: center;
+`;

+ 8 - 8
static/app/views/performance/newTraceDetails/traceDrawer/traceDrawer.tsx

@@ -13,6 +13,7 @@ import type EventView from 'sentry/utils/discover/eventView';
 import {PERFORMANCE_URL_PARAM} from 'sentry/utils/performance/constants';
 import type {
   TraceFullDetailed,
+  TraceMeta,
   TraceSplitResults,
 } from 'sentry/utils/performance/quickTrace/types';
 import {
@@ -20,7 +21,7 @@ import {
   requestAnimationTimeout,
 } from 'sentry/utils/profiling/hooks/useVirtualizedTree/virtualizedTreeUtils';
 import type {UseApiQueryResult} from 'sentry/utils/queryClient';
-import {useApiQuery} from 'sentry/utils/queryClient';
+import {useInfiniteApiQuery} from 'sentry/utils/queryClient';
 import type RequestError from 'sentry/utils/requestError/requestError';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
@@ -55,6 +56,7 @@ import {TraceTreeNodeDetails} from './tabs/traceTreeNodeDetails';
 
 type TraceDrawerProps = {
   manager: VirtualizedViewManager;
+  metaResults: UseApiQueryResult<TraceMeta | null, any>;
   onScrollToNode: (node: TraceTreeNode<TraceTree.NodeValue>) => void;
   onTabScrollToNode: (node: TraceTreeNode<TraceTree.NodeValue>) => void;
   rootEventResults: UseApiQueryResult<EventTransaction, RequestError>;
@@ -89,8 +91,8 @@ export function TraceDrawer(props: TraceDrawerProps) {
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, []);
 
-  const tagsQueryResults = useApiQuery<Tag[]>(
-    [
+  const tagsInfiniteQueryResults = useInfiniteApiQuery<Tag[]>({
+    queryKey: [
       `/organizations/${organization.slug}/events-facets/`,
       {
         query: {
@@ -100,10 +102,7 @@ export function TraceDrawer(props: TraceDrawerProps) {
         },
       },
     ],
-    {
-      staleTime: Infinity,
-    }
-  );
+  });
 
   const traceStateRef = useRef(props.trace_state);
   traceStateRef.current = props.trace_state;
@@ -433,12 +432,13 @@ export function TraceDrawer(props: TraceDrawerProps) {
             {props.trace_state.tabs.current_tab ? (
               props.trace_state.tabs.current_tab.node === 'trace' ? (
                 <TraceDetails
+                  metaResults={props.metaResults}
                   traceType={props.traceType}
                   tree={props.trace}
                   node={props.trace.root.children[0]}
                   rootEventResults={props.rootEventResults}
                   traces={props.traces}
-                  tagsQueryResults={tagsQueryResults}
+                  tagsInfiniteQueryResults={tagsInfiniteQueryResults}
                   traceEventView={props.traceEventView}
                 />
               ) : props.trace_state.tabs.current_tab.node === 'vitals' ? (