Browse Source

ref(trace-explorer): Split trace explorer contents into smaller files (#75926)

This file is getting rather huge, split it out into a few smaller
pieces.
Tony Xiao 7 months ago
parent
commit
8fa4f2b1b1

+ 12 - 519
static/app/views/traces/content.tsx

@@ -1,89 +1,45 @@
-import {Fragment, useCallback, useMemo, useState} from 'react';
-import {useTheme} from '@emotion/react';
+import {useCallback, useMemo} from 'react';
 import styled from '@emotion/styled';
-import debounce from 'lodash/debounce';
 import omit from 'lodash/omit';
-import moment from 'moment-timezone';
 
 import {Alert} from 'sentry/components/alert';
 import FeatureBadge from 'sentry/components/badge/featureBadge';
-import {Button} from 'sentry/components/button';
-import Count from 'sentry/components/count';
-import EmptyStateWarning, {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
 import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton';
 import * as Layout from 'sentry/components/layouts/thirds';
-import ExternalLink from 'sentry/components/links/externalLink';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
 import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
 import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
 import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
 import {ProjectPageFilter} from 'sentry/components/organizations/projectPageFilter';
 import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip';
-import Panel from 'sentry/components/panels/panel';
-import PanelHeader from 'sentry/components/panels/panelHeader';
-import PanelItem from 'sentry/components/panels/panelItem';
-import PerformanceDuration from 'sentry/components/performanceDuration';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
-import {IconChevron} from 'sentry/icons/iconChevron';
 import {IconClose} from 'sentry/icons/iconClose';
-import {IconWarning} from 'sentry/icons/iconWarning';
 import {t, tct} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {MetricAggregation, MRI} from 'sentry/types/metrics';
-import type {Organization} from 'sentry/types/organization';
-import {defined} from 'sentry/utils';
-import {trackAnalytics} from 'sentry/utils/analytics';
 import {browserHistory} from 'sentry/utils/browserHistory';
-import {getUtcDateString} from 'sentry/utils/dates';
 import {getFormattedMQL} from 'sentry/utils/metrics';
-import {decodeInteger, decodeList} from 'sentry/utils/queryString';
+import {decodeInteger} from 'sentry/utils/queryString';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import useProjects from 'sentry/utils/useProjects';
 import {ExploreContent} from 'sentry/views/explore/content';
 import * as ModuleLayout from 'sentry/views/insights/common/components/moduleLayout';
 
 import {usePageParams} from './hooks/usePageParams';
-import type {TraceResult} from './hooks/useTraces';
 import {useTraces} from './hooks/useTraces';
-import type {SpanResult} from './hooks/useTraceSpans';
-import {useTraceSpans} from './hooks/useTraceSpans';
-import {type Field, FIELDS, SORTS} from './data';
-import {
-  Description,
-  ProjectBadgeWrapper,
-  ProjectsRenderer,
-  SpanBreakdownSliceRenderer,
-  SpanDescriptionRenderer,
-  SpanIdRenderer,
-  SpanTimeRenderer,
-  TraceBreakdownContainer,
-  TraceBreakdownRenderer,
-  TraceIdRenderer,
-  TraceIssuesRenderer,
-} from './fieldRenderers';
 import {TracesChart} from './tracesChart';
 import {TracesSearchBar} from './tracesSearchBar';
-import {
-  areQueriesEmpty,
-  getSecondaryNameFromSpan,
-  getStylingSliceName,
-  normalizeTraces,
-} from './utils';
+import {TracesTable} from './tracesTable';
+import {normalizeTraces} from './utils';
 
 const TRACE_EXPLORER_DOCS_URL = 'https://docs.sentry.io/product/explore/traces/';
 const DEFAULT_STATS_PERIOD = '24h';
 const DEFAULT_PER_PAGE = 50;
-const SPAN_PROPS_DOCS_URL =
-  'https://docs.sentry.io/concepts/search/searchable-properties/spans/';
-const ONE_MINUTE = 60 * 1000; // in milliseconds
 
 export default function Wrapper(props) {
   const organization = useOrganization();
 
-  if (organization.features.includes('visibility-explore-view')) {
+  if (organization.features.includes('xvisibility-explore-view')) {
     return <ExploreContent {...props} />;
   }
 
@@ -237,71 +193,13 @@ function Content() {
               <ModuleLayout.Full>
                 <TracesChart />
               </ModuleLayout.Full>
-              <StyledPanel>
-                <TracePanelContent>
-                  <StyledPanelHeader align="left" lightText>
-                    {t('Trace ID')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="left" lightText>
-                    {t('Trace Root')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="right" lightText>
-                    {areQueriesEmpty(queries) ? t('Total Spans') : t('Matching Spans')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="left" lightText>
-                    {t('Timeline')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="right" lightText>
-                    {t('Duration')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="right" lightText>
-                    {t('Timestamp')}
-                  </StyledPanelHeader>
-                  <StyledPanelHeader align="right" lightText>
-                    {t('Issues')}
-                  </StyledPanelHeader>
-                  {isLoading && (
-                    <StyledPanelItem span={7} overflow>
-                      <LoadingIndicator />
-                    </StyledPanelItem>
-                  )}
-                  {isError && ( // TODO: need an error state
-                    <StyledPanelItem span={7} overflow>
-                      <EmptyStreamWrapper>
-                        <IconWarning color="gray300" size="lg" />
-                      </EmptyStreamWrapper>
-                    </StyledPanelItem>
-                  )}
-                  {isEmpty && (
-                    <StyledPanelItem span={7} overflow>
-                      <EmptyStateWarning withIcon>
-                        <EmptyStateText size="fontSizeExtraLarge">
-                          {t('No trace results found')}
-                        </EmptyStateText>
-                        <EmptyStateText size="fontSizeMedium">
-                          {tct(
-                            'Try adjusting your filters or refer to [docSearchProps].',
-                            {
-                              docSearchProps: (
-                                <ExternalLink href={SPAN_PROPS_DOCS_URL}>
-                                  {t('docs for search properties')}
-                                </ExternalLink>
-                              ),
-                            }
-                          )}
-                        </EmptyStateText>
-                      </EmptyStateWarning>
-                    </StyledPanelItem>
-                  )}
-                  {data?.map((trace, i) => (
-                    <TraceRow
-                      key={trace.trace}
-                      trace={trace}
-                      defaultExpanded={!areQueriesEmpty(queries) && i === 0}
-                    />
-                  ))}
-                </TracePanelContent>
-              </StyledPanel>
+              <TracesTable
+                isEmpty={isEmpty}
+                isError={isError}
+                isLoading={isLoading}
+                queries={queries}
+                data={data}
+              />
             </LayoutMain>
           </Layout.Body>
         </Layout.Page>
@@ -310,314 +208,6 @@ function Content() {
   );
 }
 
-function TraceRow({defaultExpanded, trace}: {defaultExpanded; trace: TraceResult}) {
-  const {selection} = usePageFilters();
-  const {projects} = useProjects();
-
-  const [expanded, setExpanded] = useState<boolean>(defaultExpanded);
-  const [highlightedSliceName, _setHighlightedSliceName] = useState('');
-  const location = useLocation();
-  const organization = useOrganization();
-  const queries = useMemo(() => {
-    return decodeList(location.query.query);
-  }, [location.query.query]);
-
-  const setHighlightedSliceName = useMemo(
-    () =>
-      debounce(sliceName => _setHighlightedSliceName(sliceName), 100, {
-        leading: true,
-      }),
-    [_setHighlightedSliceName]
-  );
-
-  const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
-
-  const selectedProjects = useMemo(() => {
-    const selectedProjectIds = new Set(
-      selection.projects.map(project => project.toString())
-    );
-    return new Set(
-      projects
-        .filter(project => selectedProjectIds.has(project.id))
-        .map(project => project.slug)
-    );
-  }, [projects, selection.projects]);
-
-  const traceProjects = useMemo(() => {
-    const seenProjects: Set<string> = new Set();
-
-    const leadingProjects: string[] = [];
-    const trailingProjects: string[] = [];
-
-    for (let i = 0; i < trace.breakdowns.length; i++) {
-      const project = trace.breakdowns[i].project;
-      if (!defined(project) || seenProjects.has(project)) {
-        continue;
-      }
-      seenProjects.add(project);
-
-      // Priotize projects that are selected in the page filters
-      if (selectedProjects.has(project)) {
-        leadingProjects.push(project);
-      } else {
-        trailingProjects.push(project);
-      }
-    }
-
-    return [...leadingProjects, ...trailingProjects];
-  }, [selectedProjects, trace]);
-
-  return (
-    <Fragment>
-      <StyledPanelItem align="center" center onClick={onClickExpand}>
-        <Button
-          icon={<IconChevron size="xs" direction={expanded ? 'down' : 'right'} />}
-          aria-label={t('Toggle trace details')}
-          aria-expanded={expanded}
-          size="zero"
-          borderless
-          onClick={() =>
-            trackAnalytics('trace_explorer.toggle_trace_details', {
-              organization,
-              expanded,
-            })
-          }
-        />
-        <TraceIdRenderer
-          traceId={trace.trace}
-          timestamp={trace.end}
-          onClick={() =>
-            trackAnalytics('trace_explorer.open_trace', {
-              organization,
-            })
-          }
-          location={location}
-        />
-      </StyledPanelItem>
-      <StyledPanelItem align="left" overflow>
-        <Description>
-          <ProjectBadgeWrapper>
-            <ProjectsRenderer
-              projectSlugs={
-                traceProjects.length > 0
-                  ? traceProjects
-                  : trace.project
-                    ? [trace.project]
-                    : []
-              }
-            />
-          </ProjectBadgeWrapper>
-          {trace.name ? (
-            <WrappingText>{trace.name}</WrappingText>
-          ) : (
-            <EmptyValueContainer>{t('Missing Trace Root')}</EmptyValueContainer>
-          )}
-        </Description>
-      </StyledPanelItem>
-      <StyledPanelItem align="right">
-        {areQueriesEmpty(queries) ? (
-          <Count value={trace.numSpans} />
-        ) : (
-          tct('[numerator][space]of[space][denominator]', {
-            numerator: <Count value={trace.matchingSpans} />,
-            denominator: <Count value={trace.numSpans} />,
-            space: <Fragment>&nbsp;</Fragment>,
-          })
-        )}
-      </StyledPanelItem>
-      <BreakdownPanelItem
-        align="right"
-        highlightedSliceName={highlightedSliceName}
-        onMouseLeave={() => setHighlightedSliceName('')}
-      >
-        <TraceBreakdownRenderer
-          trace={trace}
-          setHighlightedSliceName={setHighlightedSliceName}
-        />
-      </BreakdownPanelItem>
-      <StyledPanelItem align="right">
-        <PerformanceDuration milliseconds={trace.duration} abbreviation />
-      </StyledPanelItem>
-      <StyledPanelItem align="right">
-        <SpanTimeRenderer timestamp={trace.end} tooltipShowSeconds />
-      </StyledPanelItem>
-      <StyledPanelItem align="right">
-        <TraceIssuesRenderer
-          trace={trace}
-          onClick={() =>
-            trackAnalytics('trace_explorer.open_in_issues', {
-              organization,
-            })
-          }
-        />
-      </StyledPanelItem>
-      {expanded && (
-        <SpanTable trace={trace} setHighlightedSliceName={setHighlightedSliceName} />
-      )}
-    </Fragment>
-  );
-}
-
-function SpanTable({
-  trace,
-  setHighlightedSliceName,
-}: {
-  setHighlightedSliceName: (sliceName: string) => void;
-  trace: TraceResult;
-}) {
-  const location = useLocation();
-  const organization = useOrganization();
-
-  const {queries, metricsMax, metricsMin, metricsOp, metricsQuery, mri} =
-    usePageParams(location);
-  const hasMetric = metricsOp && mri;
-
-  const spansQuery = useTraceSpans({
-    trace,
-    fields: [
-      ...FIELDS,
-      ...SORTS.map(field =>
-        field.startsWith('-') ? (field.substring(1) as Field) : (field as Field)
-      ),
-    ],
-    datetime: {
-      // give a 1 minute buffer on each side so that start != end
-      start: getUtcDateString(moment(trace.start - ONE_MINUTE)),
-      end: getUtcDateString(moment(trace.end + ONE_MINUTE)),
-      period: null,
-      utc: true,
-    },
-    limit: 10,
-    query: queries,
-    sort: SORTS,
-    mri: hasMetric ? mri : undefined,
-    metricsMax: hasMetric ? metricsMax : undefined,
-    metricsMin: hasMetric ? metricsMin : undefined,
-    metricsOp: hasMetric ? metricsOp : undefined,
-    metricsQuery: hasMetric ? metricsQuery : undefined,
-  });
-
-  const isLoading = spansQuery.isFetching;
-  const isError = !isLoading && spansQuery.isError;
-  const hasData = !isLoading && !isError && (spansQuery?.data?.data?.length ?? 0) > 0;
-  const spans = spansQuery.data?.data ?? [];
-
-  return (
-    <SpanTablePanelItem span={7} overflow>
-      <StyledPanel>
-        <SpanPanelContent>
-          <StyledPanelHeader align="left" lightText>
-            {t('Span ID')}
-          </StyledPanelHeader>
-          <StyledPanelHeader align="left" lightText>
-            {t('Span Description')}
-          </StyledPanelHeader>
-          <StyledPanelHeader align="right" lightText />
-          <StyledPanelHeader align="right" lightText>
-            {t('Span Duration')}
-          </StyledPanelHeader>
-          <StyledPanelHeader align="right" lightText>
-            {t('Timestamp')}
-          </StyledPanelHeader>
-          {isLoading && (
-            <StyledPanelItem span={5} overflow>
-              <LoadingIndicator />
-            </StyledPanelItem>
-          )}
-          {isError && ( // TODO: need an error state
-            <StyledPanelItem span={5} overflow>
-              <EmptyStreamWrapper>
-                <IconWarning color="gray300" size="lg" />
-              </EmptyStreamWrapper>
-            </StyledPanelItem>
-          )}
-          {spans.map(span => (
-            <SpanRow
-              organization={organization}
-              key={span.id}
-              span={span}
-              trace={trace}
-              setHighlightedSliceName={setHighlightedSliceName}
-            />
-          ))}
-          {hasData && spans.length < trace.matchingSpans && (
-            <MoreMatchingSpans span={5}>
-              {tct('[more][space]more [matching]spans can be found in the trace.', {
-                more: <Count value={trace.matchingSpans - spans.length} />,
-                space: <Fragment>&nbsp;</Fragment>,
-                matching: areQueriesEmpty(queries) ? '' : 'matching ',
-              })}
-            </MoreMatchingSpans>
-          )}
-        </SpanPanelContent>
-      </StyledPanel>
-    </SpanTablePanelItem>
-  );
-}
-
-function SpanRow({
-  organization,
-  span,
-  trace,
-  setHighlightedSliceName,
-}: {
-  organization: Organization;
-  setHighlightedSliceName: (sliceName: string) => void;
-  span: SpanResult<Field>;
-
-  trace: TraceResult;
-}) {
-  const theme = useTheme();
-  return (
-    <Fragment>
-      <StyledSpanPanelItem align="right">
-        <SpanIdRenderer
-          projectSlug={span.project}
-          transactionId={span['transaction.id']}
-          spanId={span.id}
-          traceId={trace.trace}
-          timestamp={span.timestamp}
-          onClick={() =>
-            trackAnalytics('trace_explorer.open_trace_span', {
-              organization,
-            })
-          }
-        />
-      </StyledSpanPanelItem>
-      <StyledSpanPanelItem align="left" overflow>
-        <SpanDescriptionRenderer span={span} />
-      </StyledSpanPanelItem>
-      <StyledSpanPanelItem align="right" onMouseLeave={() => setHighlightedSliceName('')}>
-        <TraceBreakdownContainer>
-          <SpanBreakdownSliceRenderer
-            sliceName={span.project}
-            sliceSecondaryName={getSecondaryNameFromSpan(span)}
-            sliceStart={Math.ceil(span['precise.start_ts'] * 1000)}
-            sliceEnd={Math.floor(span['precise.finish_ts'] * 1000)}
-            trace={trace}
-            theme={theme}
-            onMouseEnter={() =>
-              setHighlightedSliceName(
-                getStylingSliceName(span.project, getSecondaryNameFromSpan(span)) ?? ''
-              )
-            }
-          />
-        </TraceBreakdownContainer>
-      </StyledSpanPanelItem>
-      <StyledSpanPanelItem align="right">
-        <PerformanceDuration milliseconds={span['span.duration']} abbreviation />
-      </StyledSpanPanelItem>
-
-      <StyledSpanPanelItem align="right">
-        <SpanTimeRenderer
-          timestamp={span['precise.finish_ts'] * 1000}
-          tooltipShowSeconds
-        />
-      </StyledSpanPanelItem>
-    </Fragment>
-  );
-}
-
 const HeaderContentBar = styled('div')`
   display: flex;
   align-items: center;
@@ -631,103 +221,6 @@ const LayoutMain = styled(Layout.Main)`
   gap: ${space(2)};
 `;
 
-const StyledPanel = styled(Panel)`
-  margin-bottom: 0px;
-`;
-
-const TracePanelContent = styled('div')`
-  width: 100%;
-  display: grid;
-  grid-template-columns: 116px auto repeat(2, min-content) 85px 112px 66px;
-`;
-
-const SpanPanelContent = styled('div')`
-  width: 100%;
-  display: grid;
-  grid-template-columns: 100px auto repeat(1, min-content) 160px 85px;
-`;
-
-const StyledPanelHeader = styled(PanelHeader)<{align: 'left' | 'right'}>`
-  white-space: nowrap;
-  justify-content: ${p => (p.align === 'left' ? 'flex-start' : 'flex-end')};
-`;
-
-const EmptyStateText = styled('div')<{size: 'fontSizeExtraLarge' | 'fontSizeMedium'}>`
-  color: ${p => p.theme.gray300};
-  font-size: ${p => p.theme[p.size]};
-  padding-bottom: ${space(1)};
-`;
-
-const StyledPanelItem = styled(PanelItem)<{
-  align?: 'left' | 'center' | 'right';
-  overflow?: boolean;
-  span?: number;
-}>`
-  align-items: center;
-  padding: ${space(1)} ${space(2)};
-  ${p => (p.align === 'left' ? 'justify-content: flex-start;' : null)}
-  ${p => (p.align === 'right' ? 'justify-content: flex-end;' : null)}
-  ${p => (p.overflow ? p.theme.overflowEllipsis : null)};
-  ${p =>
-    p.align === 'center'
-      ? `
-  justify-content: space-around;`
-      : p.align === 'left' || p.align === 'right'
-        ? `text-align: ${p.align};`
-        : undefined}
-  ${p => p.span && `grid-column: auto / span ${p.span};`}
-  white-space: nowrap;
-`;
-
-const MoreMatchingSpans = styled(StyledPanelItem)`
-  color: ${p => p.theme.gray300};
-`;
-
-const WrappingText = styled('div')`
-  width: 100%;
-  ${p => p.theme.overflowEllipsis};
-`;
-
-const StyledSpanPanelItem = styled(StyledPanelItem)`
-  &:nth-child(10n + 1),
-  &:nth-child(10n + 2),
-  &:nth-child(10n + 3),
-  &:nth-child(10n + 4),
-  &:nth-child(10n + 5) {
-    background-color: ${p => p.theme.backgroundSecondary};
-  }
-`;
-
-const SpanTablePanelItem = styled(StyledPanelItem)`
-  background-color: ${p => p.theme.gray100};
-`;
-
-const BreakdownPanelItem = styled(StyledPanelItem)<{highlightedSliceName: string}>`
-  ${p =>
-    p.highlightedSliceName
-      ? `--highlightedSlice-${p.highlightedSliceName}-opacity: 1.0;
-         --highlightedSlice-${p.highlightedSliceName}-saturate: saturate(1.0) contrast(1.0);
-         --highlightedSlice-${p.highlightedSliceName}-transform: translateY(0px);
-       `
-      : null}
-  ${p =>
-    p.highlightedSliceName
-      ? `
-        --defaultSlice-opacity: 1.0;
-        --defaultSlice-saturate: saturate(0.7) contrast(0.9) brightness(1.2);
-        --defaultSlice-transform: translateY(0px);
-        `
-      : `
-        --defaultSlice-opacity: 1.0;
-        --defaultSlice-saturate: saturate(1.0) contrast(1.0);
-        --defaultSlice-transform: translateY(0px);
-        `}
-`;
-
-const EmptyValueContainer = styled('span')`
-  color: ${p => p.theme.gray300};
-`;
-
 const StyledAlert = styled(Alert)`
   margin-bottom: 0;
 `;

+ 200 - 0
static/app/views/traces/spansTable.tsx

@@ -0,0 +1,200 @@
+import {Fragment} from 'react';
+import {useTheme} from '@emotion/react';
+import moment from 'moment-timezone';
+
+import Count from 'sentry/components/count';
+import {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {IconWarning} from 'sentry/icons/iconWarning';
+import {t, tct} from 'sentry/locale';
+import type {Organization} from 'sentry/types/organization';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {getUtcDateString} from 'sentry/utils/dates';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+import {usePageParams} from './hooks/usePageParams';
+import type {TraceResult} from './hooks/useTraces';
+import type {SpanResult} from './hooks/useTraceSpans';
+import {useTraceSpans} from './hooks/useTraceSpans';
+import {type Field, FIELDS, SORTS} from './data';
+import {
+  SpanBreakdownSliceRenderer,
+  SpanDescriptionRenderer,
+  SpanIdRenderer,
+  SpanTimeRenderer,
+  TraceBreakdownContainer,
+} from './fieldRenderers';
+import {
+  MoreMatchingSpans,
+  SpanPanelContent,
+  SpanTablePanelItem,
+  StyledPanel,
+  StyledPanelHeader,
+  StyledPanelItem,
+  StyledSpanPanelItem,
+} from './styles';
+import {areQueriesEmpty, getSecondaryNameFromSpan, getStylingSliceName} from './utils';
+
+const ONE_MINUTE = 60 * 1000; // in milliseconds
+
+export function SpanTable({
+  trace,
+  setHighlightedSliceName,
+}: {
+  setHighlightedSliceName: (sliceName: string) => void;
+  trace: TraceResult;
+}) {
+  const location = useLocation();
+  const organization = useOrganization();
+
+  const {queries, metricsMax, metricsMin, metricsOp, metricsQuery, mri} =
+    usePageParams(location);
+  const hasMetric = metricsOp && mri;
+
+  const spansQuery = useTraceSpans({
+    trace,
+    fields: [
+      ...FIELDS,
+      ...SORTS.map(field =>
+        field.startsWith('-') ? (field.substring(1) as Field) : (field as Field)
+      ),
+    ],
+    datetime: {
+      // give a 1 minute buffer on each side so that start != end
+      start: getUtcDateString(moment(trace.start - ONE_MINUTE)),
+      end: getUtcDateString(moment(trace.end + ONE_MINUTE)),
+      period: null,
+      utc: true,
+    },
+    limit: 10,
+    query: queries,
+    sort: SORTS,
+    mri: hasMetric ? mri : undefined,
+    metricsMax: hasMetric ? metricsMax : undefined,
+    metricsMin: hasMetric ? metricsMin : undefined,
+    metricsOp: hasMetric ? metricsOp : undefined,
+    metricsQuery: hasMetric ? metricsQuery : undefined,
+  });
+
+  const isLoading = spansQuery.isFetching;
+  const isError = !isLoading && spansQuery.isError;
+  const hasData = !isLoading && !isError && (spansQuery?.data?.data?.length ?? 0) > 0;
+  const spans = spansQuery.data?.data ?? [];
+
+  return (
+    <SpanTablePanelItem span={7} overflow>
+      <StyledPanel>
+        <SpanPanelContent>
+          <StyledPanelHeader align="left" lightText>
+            {t('Span ID')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="left" lightText>
+            {t('Span Description')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="right" lightText />
+          <StyledPanelHeader align="right" lightText>
+            {t('Span Duration')}
+          </StyledPanelHeader>
+          <StyledPanelHeader align="right" lightText>
+            {t('Timestamp')}
+          </StyledPanelHeader>
+          {isLoading && (
+            <StyledPanelItem span={5} overflow>
+              <LoadingIndicator />
+            </StyledPanelItem>
+          )}
+          {isError && ( // TODO: need an error state
+            <StyledPanelItem span={5} overflow>
+              <EmptyStreamWrapper>
+                <IconWarning color="gray300" size="lg" />
+              </EmptyStreamWrapper>
+            </StyledPanelItem>
+          )}
+          {spans.map(span => (
+            <SpanRow
+              organization={organization}
+              key={span.id}
+              span={span}
+              trace={trace}
+              setHighlightedSliceName={setHighlightedSliceName}
+            />
+          ))}
+          {hasData && spans.length < trace.matchingSpans && (
+            <MoreMatchingSpans span={5}>
+              {tct('[more][space]more [matching]spans can be found in the trace.', {
+                more: <Count value={trace.matchingSpans - spans.length} />,
+                space: <Fragment>&nbsp;</Fragment>,
+                matching: areQueriesEmpty(queries) ? '' : 'matching ',
+              })}
+            </MoreMatchingSpans>
+          )}
+        </SpanPanelContent>
+      </StyledPanel>
+    </SpanTablePanelItem>
+  );
+}
+
+function SpanRow({
+  organization,
+  span,
+  trace,
+  setHighlightedSliceName,
+}: {
+  organization: Organization;
+  setHighlightedSliceName: (sliceName: string) => void;
+  span: SpanResult<Field>;
+
+  trace: TraceResult;
+}) {
+  const theme = useTheme();
+  return (
+    <Fragment>
+      <StyledSpanPanelItem align="right">
+        <SpanIdRenderer
+          projectSlug={span.project}
+          transactionId={span['transaction.id']}
+          spanId={span.id}
+          traceId={trace.trace}
+          timestamp={span.timestamp}
+          onClick={() =>
+            trackAnalytics('trace_explorer.open_trace_span', {
+              organization,
+            })
+          }
+        />
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="left" overflow>
+        <SpanDescriptionRenderer span={span} />
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="right" onMouseLeave={() => setHighlightedSliceName('')}>
+        <TraceBreakdownContainer>
+          <SpanBreakdownSliceRenderer
+            sliceName={span.project}
+            sliceSecondaryName={getSecondaryNameFromSpan(span)}
+            sliceStart={Math.ceil(span['precise.start_ts'] * 1000)}
+            sliceEnd={Math.floor(span['precise.finish_ts'] * 1000)}
+            trace={trace}
+            theme={theme}
+            onMouseEnter={() =>
+              setHighlightedSliceName(
+                getStylingSliceName(span.project, getSecondaryNameFromSpan(span)) ?? ''
+              )
+            }
+          />
+        </TraceBreakdownContainer>
+      </StyledSpanPanelItem>
+      <StyledSpanPanelItem align="right">
+        <PerformanceDuration milliseconds={span['span.duration']} abbreviation />
+      </StyledSpanPanelItem>
+
+      <StyledSpanPanelItem align="right">
+        <SpanTimeRenderer
+          timestamp={span['precise.finish_ts'] * 1000}
+          tooltipShowSeconds
+        />
+      </StyledSpanPanelItem>
+    </Fragment>
+  );
+}

+ 105 - 0
static/app/views/traces/styles.tsx

@@ -0,0 +1,105 @@
+import styled from '@emotion/styled';
+
+import Panel from 'sentry/components/panels/panel';
+import PanelHeader from 'sentry/components/panels/panelHeader';
+import PanelItem from 'sentry/components/panels/panelItem';
+import {space} from 'sentry/styles/space';
+
+export const StyledPanel = styled(Panel)`
+  margin-bottom: 0px;
+`;
+
+export const StyledPanelHeader = styled(PanelHeader)<{align: 'left' | 'right'}>`
+  white-space: nowrap;
+  justify-content: ${p => (p.align === 'left' ? 'flex-start' : 'flex-end')};
+`;
+
+export const TracePanelContent = styled('div')`
+  width: 100%;
+  display: grid;
+  grid-template-columns: 116px auto repeat(2, min-content) 85px 112px 66px;
+`;
+
+export const StyledPanelItem = styled(PanelItem)<{
+  align?: 'left' | 'center' | 'right';
+  overflow?: boolean;
+  span?: number;
+}>`
+  align-items: center;
+  padding: ${space(1)} ${space(2)};
+  ${p => (p.align === 'left' ? 'justify-content: flex-start;' : null)}
+  ${p => (p.align === 'right' ? 'justify-content: flex-end;' : null)}
+  ${p => (p.overflow ? p.theme.overflowEllipsis : null)};
+  ${p =>
+    p.align === 'center'
+      ? `
+  justify-content: space-around;`
+      : p.align === 'left' || p.align === 'right'
+        ? `text-align: ${p.align};`
+        : undefined}
+  ${p => p.span && `grid-column: auto / span ${p.span};`}
+  white-space: nowrap;
+`;
+
+export const MoreMatchingSpans = styled(StyledPanelItem)`
+  color: ${p => p.theme.gray300};
+`;
+
+export const WrappingText = styled('div')`
+  width: 100%;
+  ${p => p.theme.overflowEllipsis};
+`;
+
+export const StyledSpanPanelItem = styled(StyledPanelItem)`
+  &:nth-child(10n + 1),
+  &:nth-child(10n + 2),
+  &:nth-child(10n + 3),
+  &:nth-child(10n + 4),
+  &:nth-child(10n + 5) {
+    background-color: ${p => p.theme.backgroundSecondary};
+  }
+`;
+
+export const SpanTablePanelItem = styled(StyledPanelItem)`
+  background-color: ${p => p.theme.gray100};
+`;
+
+export const BreakdownPanelItem = styled(StyledPanelItem)<{highlightedSliceName: string}>`
+  ${p =>
+    p.highlightedSliceName
+      ? `--highlightedSlice-${p.highlightedSliceName}-opacity: 1.0;
+         --highlightedSlice-${p.highlightedSliceName}-saturate: saturate(1.0) contrast(1.0);
+         --highlightedSlice-${p.highlightedSliceName}-transform: translateY(0px);
+       `
+      : null}
+  ${p =>
+    p.highlightedSliceName
+      ? `
+        --defaultSlice-opacity: 1.0;
+        --defaultSlice-saturate: saturate(0.7) contrast(0.9) brightness(1.2);
+        --defaultSlice-transform: translateY(0px);
+        `
+      : `
+        --defaultSlice-opacity: 1.0;
+        --defaultSlice-saturate: saturate(1.0) contrast(1.0);
+        --defaultSlice-transform: translateY(0px);
+        `}
+`;
+
+export const EmptyStateText = styled('div')<{
+  size: 'fontSizeExtraLarge' | 'fontSizeMedium';
+}>`
+  color: ${p => p.theme.gray300};
+  font-size: ${p => p.theme[p.size]};
+  padding-bottom: ${space(1)};
+`;
+
+export const EmptyValueContainer = styled('span')`
+  color: ${p => p.theme.gray300};
+`;
+
+export const SpanPanelContent = styled('div')`
+  width: 100%;
+  display: grid;
+  grid-template-columns: 100px auto repeat(1, min-content) 160px 85px;
+`;

+ 274 - 0
static/app/views/traces/tracesTable.tsx

@@ -0,0 +1,274 @@
+import {Fragment, useCallback, useMemo, useState} from 'react';
+import debounce from 'lodash/debounce';
+
+import {Button} from 'sentry/components/button';
+import Count from 'sentry/components/count';
+import EmptyStateWarning, {EmptyStreamWrapper} from 'sentry/components/emptyStateWarning';
+import ExternalLink from 'sentry/components/links/externalLink';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {IconChevron} from 'sentry/icons/iconChevron';
+import {IconWarning} from 'sentry/icons/iconWarning';
+import {t, tct} from 'sentry/locale';
+import {defined} from 'sentry/utils';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import {decodeList} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+
+import type {TraceResult} from './hooks/useTraces';
+import {
+  Description,
+  ProjectBadgeWrapper,
+  ProjectsRenderer,
+  SpanTimeRenderer,
+  TraceBreakdownRenderer,
+  TraceIdRenderer,
+  TraceIssuesRenderer,
+} from './fieldRenderers';
+import {SpanTable} from './spansTable';
+import {
+  BreakdownPanelItem,
+  EmptyStateText,
+  EmptyValueContainer,
+  StyledPanel,
+  StyledPanelHeader,
+  StyledPanelItem,
+  TracePanelContent,
+  WrappingText,
+} from './styles';
+import {areQueriesEmpty} from './utils';
+
+const SPAN_PROPS_DOCS_URL =
+  'https://docs.sentry.io/concepts/search/searchable-properties/spans/';
+
+interface TracesTableProps {
+  isEmpty: boolean;
+  isError: boolean;
+  isLoading: boolean;
+  queries: string[];
+  data?: TraceResult[];
+}
+
+export function TracesTable({
+  isEmpty,
+  isError,
+  isLoading,
+  queries,
+  data,
+}: TracesTableProps) {
+  return (
+    <StyledPanel>
+      <TracePanelContent>
+        <StyledPanelHeader align="left" lightText>
+          {t('Trace ID')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="left" lightText>
+          {t('Trace Root')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {areQueriesEmpty(queries) ? t('Total Spans') : t('Matching Spans')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="left" lightText>
+          {t('Timeline')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {t('Duration')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {t('Timestamp')}
+        </StyledPanelHeader>
+        <StyledPanelHeader align="right" lightText>
+          {t('Issues')}
+        </StyledPanelHeader>
+        {isLoading && (
+          <StyledPanelItem span={7} overflow>
+            <LoadingIndicator />
+          </StyledPanelItem>
+        )}
+        {isError && ( // TODO: need an error state
+          <StyledPanelItem span={7} overflow>
+            <EmptyStreamWrapper>
+              <IconWarning color="gray300" size="lg" />
+            </EmptyStreamWrapper>
+          </StyledPanelItem>
+        )}
+        {isEmpty && (
+          <StyledPanelItem span={7} overflow>
+            <EmptyStateWarning withIcon>
+              <EmptyStateText size="fontSizeExtraLarge">
+                {t('No trace results found')}
+              </EmptyStateText>
+              <EmptyStateText size="fontSizeMedium">
+                {tct('Try adjusting your filters or refer to [docSearchProps].', {
+                  docSearchProps: (
+                    <ExternalLink href={SPAN_PROPS_DOCS_URL}>
+                      {t('docs for search properties')}
+                    </ExternalLink>
+                  ),
+                })}
+              </EmptyStateText>
+            </EmptyStateWarning>
+          </StyledPanelItem>
+        )}
+        {data?.map((trace, i) => (
+          <TraceRow
+            key={trace.trace}
+            trace={trace}
+            defaultExpanded={!areQueriesEmpty(queries) && i === 0}
+          />
+        ))}
+      </TracePanelContent>
+    </StyledPanel>
+  );
+}
+
+function TraceRow({defaultExpanded, trace}: {defaultExpanded; trace: TraceResult}) {
+  const {selection} = usePageFilters();
+  const {projects} = useProjects();
+
+  const [expanded, setExpanded] = useState<boolean>(defaultExpanded);
+  const [highlightedSliceName, _setHighlightedSliceName] = useState('');
+  const location = useLocation();
+  const organization = useOrganization();
+  const queries = useMemo(() => {
+    return decodeList(location.query.query);
+  }, [location.query.query]);
+
+  const setHighlightedSliceName = useMemo(
+    () =>
+      debounce(sliceName => _setHighlightedSliceName(sliceName), 100, {
+        leading: true,
+      }),
+    [_setHighlightedSliceName]
+  );
+
+  const onClickExpand = useCallback(() => setExpanded(e => !e), [setExpanded]);
+
+  const selectedProjects = useMemo(() => {
+    const selectedProjectIds = new Set(
+      selection.projects.map(project => project.toString())
+    );
+    return new Set(
+      projects
+        .filter(project => selectedProjectIds.has(project.id))
+        .map(project => project.slug)
+    );
+  }, [projects, selection.projects]);
+
+  const traceProjects = useMemo(() => {
+    const seenProjects: Set<string> = new Set();
+
+    const leadingProjects: string[] = [];
+    const trailingProjects: string[] = [];
+
+    for (let i = 0; i < trace.breakdowns.length; i++) {
+      const project = trace.breakdowns[i].project;
+      if (!defined(project) || seenProjects.has(project)) {
+        continue;
+      }
+      seenProjects.add(project);
+
+      // Priotize projects that are selected in the page filters
+      if (selectedProjects.has(project)) {
+        leadingProjects.push(project);
+      } else {
+        trailingProjects.push(project);
+      }
+    }
+
+    return [...leadingProjects, ...trailingProjects];
+  }, [selectedProjects, trace]);
+
+  return (
+    <Fragment>
+      <StyledPanelItem align="center" center onClick={onClickExpand}>
+        <Button
+          icon={<IconChevron size="xs" direction={expanded ? 'down' : 'right'} />}
+          aria-label={t('Toggle trace details')}
+          aria-expanded={expanded}
+          size="zero"
+          borderless
+          onClick={() =>
+            trackAnalytics('trace_explorer.toggle_trace_details', {
+              organization,
+              expanded,
+            })
+          }
+        />
+        <TraceIdRenderer
+          traceId={trace.trace}
+          timestamp={trace.end}
+          onClick={() =>
+            trackAnalytics('trace_explorer.open_trace', {
+              organization,
+            })
+          }
+          location={location}
+        />
+      </StyledPanelItem>
+      <StyledPanelItem align="left" overflow>
+        <Description>
+          <ProjectBadgeWrapper>
+            <ProjectsRenderer
+              projectSlugs={
+                traceProjects.length > 0
+                  ? traceProjects
+                  : trace.project
+                    ? [trace.project]
+                    : []
+              }
+            />
+          </ProjectBadgeWrapper>
+          {trace.name ? (
+            <WrappingText>{trace.name}</WrappingText>
+          ) : (
+            <EmptyValueContainer>{t('Missing Trace Root')}</EmptyValueContainer>
+          )}
+        </Description>
+      </StyledPanelItem>
+      <StyledPanelItem align="right">
+        {areQueriesEmpty(queries) ? (
+          <Count value={trace.numSpans} />
+        ) : (
+          tct('[numerator][space]of[space][denominator]', {
+            numerator: <Count value={trace.matchingSpans} />,
+            denominator: <Count value={trace.numSpans} />,
+            space: <Fragment>&nbsp;</Fragment>,
+          })
+        )}
+      </StyledPanelItem>
+      <BreakdownPanelItem
+        align="right"
+        highlightedSliceName={highlightedSliceName}
+        onMouseLeave={() => setHighlightedSliceName('')}
+      >
+        <TraceBreakdownRenderer
+          trace={trace}
+          setHighlightedSliceName={setHighlightedSliceName}
+        />
+      </BreakdownPanelItem>
+      <StyledPanelItem align="right">
+        <PerformanceDuration milliseconds={trace.duration} abbreviation />
+      </StyledPanelItem>
+      <StyledPanelItem align="right">
+        <SpanTimeRenderer timestamp={trace.end} tooltipShowSeconds />
+      </StyledPanelItem>
+      <StyledPanelItem align="right">
+        <TraceIssuesRenderer
+          trace={trace}
+          onClick={() =>
+            trackAnalytics('trace_explorer.open_in_issues', {
+              organization,
+            })
+          }
+        />
+      </StyledPanelItem>
+      {expanded && (
+        <SpanTable trace={trace} setHighlightedSliceName={setHighlightedSliceName} />
+      )}
+    </Fragment>
+  );
+}