Browse Source

feat(perf): Update Span Summary page with new design and span metrics dataset (#69159)

Upgrades the old 'Suspect Spans' / 'Similar Spans' view to a new design
fit for a span-centric world. All API requests have been changed to
query from the span metrics and indexed spans datasets, so a summary
page will be available for every unique span hash.

### Note
This is the initial PR and there is still some polishing that needs to
be done, and will be addressed in follow up PRs. This page is currently
hidden behind a feature flag that's only available to certain internal
users, and will slowly be rolled out to GA after it is complete.

To be done in future PRs:
- [ ] Unit tests
- [ ] Transaction throughput chart is bugged
- [ ] Chart cursors should be synchronized
- [ ] Pagination cursor should be reset when `Reset View` is clicked

### Before

![image](https://github.com/getsentry/sentry/assets/16740047/74224e42-3ed8-4cdc-9bf6-1104f318cd22)

### After

![image](https://github.com/getsentry/sentry/assets/16740047/6b725837-d1b4-41cd-91af-22e50f0fff77)

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Co-authored-by: Shruthi <shruthilaya.jaganathan@sentry.io>
Ash 10 months ago
parent
commit
541ed7bc71

+ 13 - 2
static/app/components/events/interfaces/spans/newTraceDetailsSpanDetails.tsx

@@ -229,6 +229,17 @@ function NewTraceDetailsSpanDetail(props: SpanDetailProps) {
     }
 
     const transactionName = event.title;
+    const hasNewSpansUIFlag = organization.features.includes('performance-spans-new-ui');
+
+    // The new spans UI relies on the group hash assigned by Relay, which is different from the hash available on the span itself
+    const groupHash = hasNewSpansUIFlag
+      ? props.node.value.sentry_tags?.group ?? ''
+      : props.node.value.hash;
+
+    // Do not render a button if there is no group hash, since this can result in broken links
+    if (hasNewSpansUIFlag && !groupHash) {
+      return null;
+    }
 
     return (
       <ButtonGroup>
@@ -243,11 +254,11 @@ function NewTraceDetailsSpanDetail(props: SpanDetailProps) {
             orgSlug: organization.slug,
             transaction: transactionName,
             query: location.query,
-            spanSlug: {op: props.node.value.op, group: props.node.value.hash},
+            spanSlug: {op: props.node.value.op, group: groupHash},
             projectID: event.projectID,
           })}
         >
-          {t('View Similar Spans')}
+          {hasNewSpansUIFlag ? t('View Span Summary') : t('View Similar Spans')}
         </StyledButton>
       </ButtonGroup>
     );

+ 7 - 2
static/app/views/performance/newTraceDetails/traceDrawer/details/span/sections/description.tsx

@@ -40,6 +40,11 @@ export function SpanDescription({
     return null;
   }
 
+  const hasNewSpansUIFlag = organization.features.includes('performance-spans-new-ui');
+
+  // The new spans UI relies on the group hash assigned by Relay, which is different from the hash available on the span itself
+  const groupHash = hasNewSpansUIFlag ? span.sentry_tags?.group ?? '' : span.hash ?? '';
+
   const actions =
     !span.op || !span.hash ? null : (
       <ButtonGroup>
@@ -50,11 +55,11 @@ export function SpanDescription({
             orgSlug: organization.slug,
             transaction: event.title,
             query: location.query,
-            spanSlug: {op: span.op, group: span.hash},
+            spanSlug: {op: span.op, group: groupHash},
             projectID: event.projectID,
           })}
         >
-          {t('View Similar Spans')}
+          {hasNewSpansUIFlag ? t('View Span Summary') : t('View Similar Spans')}
         </Button>
       </ButtonGroup>
     );

+ 8 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanDetails/content.tsx

@@ -18,6 +18,7 @@ import {decodeScalar} from 'sentry/utils/queryString';
 import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
 import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
 import Breadcrumb from 'sentry/views/performance/breadcrumb';
+import SpanSummary from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/content';
 import {getSelectedProjectPlatforms} from 'sentry/views/performance/utils';
 
 import Tab from '../../tabs';
@@ -53,6 +54,13 @@ export default function SpanDetailsContentWrapper(props: Props) {
     project_platforms: project ? getSelectedProjectPlatforms(location, [project]) : '',
   });
 
+  const hasNewSpansUIFlag = organization.features.includes('performance-spans-new-ui');
+
+  // TODO: When this feature is rolled out to GA, we will no longer need the entire `spanDetails` directory and can switch to `spanSummary`
+  if (hasNewSpansUIFlag) {
+    return <SpanSummary {...props} />;
+  }
+
   return (
     <Fragment>
       <Layout.Header>

+ 2 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanDetails/utils.tsx

@@ -124,4 +124,6 @@ export function resourceSummaryRouteWithQuery({
 export enum ZoomKeys {
   MIN = 'min',
   MAX = 'max',
+  START = 'start',
+  END = 'end',
 }

+ 139 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/content.tsx

@@ -0,0 +1,139 @@
+import {Fragment} from 'react';
+import type {Location} from 'history';
+
+import IdBadge from 'sentry/components/idBadge';
+import * as Layout from 'sentry/components/layouts/thirds';
+import {t} from 'sentry/locale';
+import type {Organization, Project} from 'sentry/types';
+import type EventView from 'sentry/utils/discover/eventView';
+import type {SpanSlug} from 'sentry/utils/performance/suspectSpans/types';
+import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
+import useRouteAnalyticsParams from 'sentry/utils/routeAnalytics/useRouteAnalyticsParams';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useParams} from 'sentry/utils/useParams';
+import Breadcrumb from 'sentry/views/performance/breadcrumb';
+import {SpanSummaryReferrer} from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/referrers';
+import SpanSummaryCharts from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryCharts';
+import SpanSummaryTable from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryTable';
+import {getSelectedProjectPlatforms} from 'sentry/views/performance/utils';
+import {useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover';
+import type {SpanMetricsQueryFilters} from 'sentry/views/starfish/types';
+
+import Tab from '../../tabs';
+
+import SpanSummaryControls from './spanSummaryControls';
+import SpanSummaryHeader from './spanSummaryHeader';
+
+type Props = {
+  eventView: EventView;
+  location: Location;
+  organization: Organization;
+  project: Project | undefined;
+  spanSlug: SpanSlug;
+  transactionName: string;
+};
+
+export default function SpanSummary(props: Props) {
+  const {location, organization, eventView, project, transactionName, spanSlug} = props;
+
+  // customize the route analytics event we send
+  useRouteAnalyticsEventNames(
+    'performance_views.span_summary.view',
+    'Performance Views: Span Summary page viewed'
+  );
+  useRouteAnalyticsParams({
+    project_platforms: project ? getSelectedProjectPlatforms(location, [project]) : '',
+  });
+
+  return (
+    <Fragment>
+      <Layout.Header>
+        <Layout.HeaderContent>
+          <Breadcrumb
+            organization={organization}
+            location={location}
+            transaction={{
+              project: project?.id ?? '',
+              name: transactionName,
+            }}
+            tab={Tab.SPANS}
+            spanSlug={spanSlug}
+          />
+          <Layout.Title>
+            {project && (
+              <IdBadge
+                project={project}
+                avatarSize={28}
+                hideName
+                avatarProps={{hasTooltip: true, tooltip: project.slug}}
+              />
+            )}
+            {transactionName}
+          </Layout.Title>
+        </Layout.HeaderContent>
+      </Layout.Header>
+      <Layout.Body>
+        <Layout.Main fullWidth>
+          <SpanSummaryContent
+            location={location}
+            organization={organization}
+            project={project}
+            eventView={eventView}
+            spanSlug={spanSlug}
+            transactionName={transactionName}
+          />
+        </Layout.Main>
+      </Layout.Body>
+    </Fragment>
+  );
+}
+
+type ContentProps = {
+  eventView: EventView;
+  location: Location;
+  organization: Organization;
+  project: Project | undefined;
+  spanSlug: SpanSlug;
+  transactionName: string;
+};
+
+function SpanSummaryContent(props: ContentProps) {
+  const {transactionName, project} = props;
+
+  const {spanSlug: spanParam} = useParams();
+  const [spanOp, groupId] = spanParam.split(':');
+
+  const filters: SpanMetricsQueryFilters = {
+    'span.group': groupId,
+    'span.op': spanOp,
+    transaction: transactionName,
+  };
+
+  const {data: spanHeaderData} = useSpanMetrics({
+    search: MutableSearch.fromQueryObject(filters),
+    // TODO: query average duration instead of self time before releasing this
+    fields: ['span.description', 'avg(span.self_time)', 'sum(span.self_time)', 'count()'],
+    enabled: Boolean(groupId),
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_HEADER_DATA,
+  });
+
+  const description = spanHeaderData[0]?.['span.description'] ?? t('unknown');
+  const timeSpent = spanHeaderData[0]?.['sum(span.self_time)'];
+  const avgDuration = spanHeaderData[0]?.['avg(span.self_time)'];
+  const spanCount = spanHeaderData[0]?.['count()'];
+
+  return (
+    <Fragment>
+      <SpanSummaryControls />
+      <SpanSummaryHeader
+        spanOp={spanOp}
+        spanDescription={description}
+        avgDuration={avgDuration}
+        timeSpent={timeSpent}
+        spanCount={spanCount}
+      />
+      <SpanSummaryCharts />
+      <SpanSummaryTable project={project} />
+    </Fragment>
+  );
+}

+ 7 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/referrers.tsx

@@ -0,0 +1,7 @@
+export enum SpanSummaryReferrer {
+  SPAN_SUMMARY_HEADER_DATA = 'api.performance.span-summary-header-data',
+  SPAN_SUMMARY_TABLE = 'api.performance.span-summary-table',
+  SPAN_SUMMARY_DURATION_CHART = 'api.performance.span-summary-duration-chart',
+  SPAN_SUMMARY_THROUGHPUT_CHART = 'api.performance.span-summary-throughput-chart',
+  SPAN_SUMMARY_TRANSACTION_THROUGHPUT_CHART = 'api.performance.span-summary-transaction-throughput-chart',
+}

+ 179 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryCharts.tsx

@@ -0,0 +1,179 @@
+import {t} from 'sentry/locale';
+import type {Series} from 'sentry/types/echarts';
+import EventView, {type MetaType} from 'sentry/utils/discover/eventView';
+import {RateUnit} from 'sentry/utils/discover/fields';
+import {
+  type DiscoverQueryProps,
+  useGenericDiscoverQuery,
+} from 'sentry/utils/discover/genericDiscoverQuery';
+import {formatRate} from 'sentry/utils/formatters';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useParams} from 'sentry/utils/useParams';
+import {SpanSummaryReferrer} from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/referrers';
+import {
+  AVG_COLOR,
+  THROUGHPUT_COLOR,
+  TXN_THROUGHPUT_COLOR,
+} from 'sentry/views/starfish/colors';
+import Chart, {ChartType} from 'sentry/views/starfish/components/chart';
+import ChartPanel from 'sentry/views/starfish/components/chartPanel';
+import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useDiscoverSeries';
+import {
+  SpanMetricsField,
+  type SpanMetricsQueryFilters,
+} from 'sentry/views/starfish/types';
+import {Block, BlockContainer} from 'sentry/views/starfish/views/spanSummaryPage/block';
+
+function SpanSummaryCharts() {
+  const organization = useOrganization();
+  const {spanSlug} = useParams();
+  const [spanOp, groupId] = spanSlug.split(':');
+
+  const location = useLocation();
+  const {transaction} = location.query;
+
+  const filters: SpanMetricsQueryFilters = {
+    'span.group': groupId,
+    'span.op': spanOp,
+    transaction: transaction as string,
+  };
+
+  const {
+    isLoading: isThroughputDataLoading,
+    data: throughputData,
+    error: throughputError,
+  } = useSpanMetricsSeries({
+    search: MutableSearch.fromQueryObject(filters),
+    yAxis: ['spm()'],
+    enabled: Boolean(groupId),
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_THROUGHPUT_CHART,
+  });
+
+  const {
+    isLoading: isAvgDurationDataLoading,
+    data: avgDurationData,
+    error: avgDurationError,
+  } = useSpanMetricsSeries({
+    search: MutableSearch.fromQueryObject(filters),
+    // TODO: Switch this to SPAN_DURATION before release
+    yAxis: [`avg(${SpanMetricsField.SPAN_SELF_TIME})`],
+    enabled: Boolean(groupId),
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_DURATION_CHART,
+  });
+
+  const eventView = EventView.fromNewQueryWithLocation(
+    {
+      yAxis: ['tpm()'],
+      name: 'Transaction Throughput',
+      query: MutableSearch.fromQueryObject({
+        transaction: transaction as string,
+      }).formatString(),
+      fields: [],
+      version: 2,
+    },
+    location
+  );
+
+  const {
+    isLoading: isTxnThroughputDataLoading,
+    data: txnThroughputData,
+    error: txnThroughputError,
+  } = useGenericDiscoverQuery<
+    {
+      data: any[];
+      meta: MetaType;
+    },
+    DiscoverQueryProps
+  >({
+    route: 'events-stats',
+    eventView,
+    location,
+    orgSlug: organization.slug,
+    getRequestPayload: () => ({
+      ...eventView.getEventsAPIPayload(location),
+      yAxis: eventView.yAxis,
+      topEvents: eventView.topEvents,
+      excludeOther: 0,
+      partial: 1,
+      orderby: undefined,
+      interval: eventView.interval,
+    }),
+    options: {
+      refetchOnWindowFocus: false,
+    },
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_TRANSACTION_THROUGHPUT_CHART,
+  });
+
+  const transactionSeries: Series = {
+    seriesName: 'tpm()',
+    data:
+      txnThroughputData?.data.map(datum => ({
+        value: datum[1][0].count,
+        name: datum[0],
+      })) ?? [],
+  };
+
+  return (
+    <BlockContainer>
+      <Block>
+        <ChartPanel title={t('Average Duration')}>
+          <Chart
+            height={160}
+            data={[avgDurationData?.[`avg(${SpanMetricsField.SPAN_SELF_TIME})`]]}
+            loading={isAvgDurationDataLoading}
+            type={ChartType.LINE}
+            definedAxisTicks={4}
+            aggregateOutputFormat="duration"
+            stacked
+            error={avgDurationError}
+            chartColors={[AVG_COLOR]}
+          />
+        </ChartPanel>
+      </Block>
+
+      <Block>
+        <ChartPanel title={t('Span Throughput')}>
+          <Chart
+            height={160}
+            data={[throughputData?.[`spm()`]]}
+            loading={isThroughputDataLoading}
+            type={ChartType.LINE}
+            definedAxisTicks={4}
+            aggregateOutputFormat="rate"
+            rateUnit={RateUnit.PER_MINUTE}
+            stacked
+            error={throughputError}
+            chartColors={[THROUGHPUT_COLOR]}
+            tooltipFormatterOptions={{
+              valueFormatter: value => formatRate(value, RateUnit.PER_MINUTE),
+            }}
+          />
+        </ChartPanel>
+      </Block>
+
+      <Block>
+        <ChartPanel title={t('Transaction Throughput')}>
+          <Chart
+            height={160}
+            data={[transactionSeries]}
+            loading={isTxnThroughputDataLoading}
+            type={ChartType.LINE}
+            definedAxisTicks={4}
+            aggregateOutputFormat="rate"
+            rateUnit={RateUnit.PER_MINUTE}
+            stacked
+            error={txnThroughputError}
+            chartColors={[TXN_THROUGHPUT_COLOR]}
+            tooltipFormatterOptions={{
+              valueFormatter: value => formatRate(value, RateUnit.PER_MINUTE),
+            }}
+          />
+        </ChartPanel>
+      </Block>
+    </BlockContainer>
+  );
+}
+
+export default SpanSummaryCharts;

+ 33 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryControls.tsx

@@ -0,0 +1,33 @@
+import styled from '@emotion/styled';
+
+import {DatePageFilter} from 'sentry/components/organizations/datePageFilter';
+import {EnvironmentPageFilter} from 'sentry/components/organizations/environmentPageFilter';
+import PageFilterBar from 'sentry/components/organizations/pageFilterBar';
+import {space} from 'sentry/styles/space';
+
+import {SPAN_RELATIVE_PERIODS, SPAN_RETENTION_DAYS} from '../utils';
+
+export default function SpanDetailsControls() {
+  return (
+    <FilterActions>
+      <PageFilterBar condensed>
+        <EnvironmentPageFilter />
+        <DatePageFilter
+          relativeOptions={SPAN_RELATIVE_PERIODS}
+          maxPickableDays={SPAN_RETENTION_DAYS}
+        />
+      </PageFilterBar>
+    </FilterActions>
+  );
+}
+
+const FilterActions = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: ${space(2)};
+  flex-direction: column;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    flex-direction: row;
+  }
+`;

+ 108 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryHeader.tsx

@@ -0,0 +1,108 @@
+import styled from '@emotion/styled';
+
+import {SectionHeading} from 'sentry/components/charts/styles';
+import Count from 'sentry/components/count';
+import PerformanceDuration from 'sentry/components/performanceDuration';
+import {t, tct} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {defined} from 'sentry/utils';
+import {formatMetricUsingUnit} from 'sentry/utils/metrics/formatters';
+import {DataTitles} from 'sentry/views/starfish/views/spans/types';
+
+type Props = {
+  avgDuration: number;
+  spanCount: number;
+  spanDescription: string;
+  spanOp: string;
+  timeSpent: number;
+};
+
+export default function SpanSummaryHeader(props: Props) {
+  const {spanOp, spanDescription, avgDuration, timeSpent, spanCount} = props;
+
+  return (
+    <ContentHeader>
+      <HeaderInfo data-test-id="header-operation-name">
+        <StyledSectionHeading>{t('Span')}</StyledSectionHeading>
+        <SectionBody>
+          <SpanLabelContainer>{spanDescription ?? emptyValue}</SpanLabelContainer>
+        </SectionBody>
+        <SectionSubtext data-test-id="operation-name">{spanOp}</SectionSubtext>
+      </HeaderInfo>
+
+      <HeaderInfo data-test-id="header-avg-duration">
+        <StyledSectionHeading>{DataTitles.avg}</StyledSectionHeading>
+        <NumericSectionWrapper>
+          <SectionBody>
+            {defined(avgDuration)
+              ? formatMetricUsingUnit(avgDuration, 'milliseconds')
+              : '\u2014'}
+          </SectionBody>
+        </NumericSectionWrapper>
+      </HeaderInfo>
+
+      <HeaderInfo data-test-id="header-total-exclusive-time">
+        <StyledSectionHeading>{DataTitles.timeSpent}</StyledSectionHeading>
+        <NumericSectionWrapper>
+          <SectionBody>
+            {defined(timeSpent) ? (
+              <PerformanceDuration abbreviation milliseconds={timeSpent} />
+            ) : (
+              '\u2014'
+            )}
+          </SectionBody>
+          <SectionSubtext>
+            {defined(spanCount)
+              ? tct('[spanCount] spans', {spanCount: <Count value={spanCount} />})
+              : '\u2014'}
+          </SectionSubtext>
+        </NumericSectionWrapper>
+      </HeaderInfo>
+    </ContentHeader>
+  );
+}
+
+const ContentHeader = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr;
+  gap: ${space(4)};
+  margin-bottom: ${space(2)};
+
+  @media (min-width: ${p => p.theme.breakpoints.medium}) {
+    grid-template-columns: 1fr repeat(3, max-content);
+  }
+`;
+
+const HeaderInfo = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+  height: 78px;
+`;
+
+const StyledSectionHeading = styled(SectionHeading)`
+  margin: 0;
+`;
+
+const NumericSectionWrapper = styled('div')`
+  text-align: right;
+`;
+
+const SectionBody = styled('div')<{overflowEllipsis?: boolean}>`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  padding: ${space(0.5)} 0;
+  max-height: 32px;
+`;
+
+const SectionSubtext = styled('div')`
+  color: ${p => p.theme.subText};
+  font-size: ${p => p.theme.fontSizeMedium};
+`;
+
+export const SpanLabelContainer = styled('div')`
+  ${p => p.theme.overflowEllipsis};
+`;
+
+const EmptyValueContainer = styled('span')`
+  color: ${p => p.theme.gray300};
+`;
+
+const emptyValue = <EmptyValueContainer>{t('(unnamed span)')}</EmptyValueContainer>;

+ 282 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryTable.tsx

@@ -0,0 +1,282 @@
+import {Fragment} from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+
+import type {GridColumnHeader} from 'sentry/components/gridEditable';
+import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import Pagination, {type CursorHandler} from 'sentry/components/pagination';
+import {ROW_HEIGHT, ROW_PADDING} from 'sentry/components/performance/waterfall/constants';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Organization, Project} from 'sentry/types';
+import EventView, {type MetaType} from 'sentry/utils/discover/eventView';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import type {ColumnType} from 'sentry/utils/discover/fields';
+import {
+  type DiscoverQueryProps,
+  useGenericDiscoverQuery,
+} from 'sentry/utils/discover/genericDiscoverQuery';
+import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
+import {decodeScalar} from 'sentry/utils/queryString';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useParams} from 'sentry/utils/useParams';
+import {SpanDurationBar} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/spanDetailsTable';
+import {SpanSummaryReferrer} from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/referrers';
+import {useSpanSummarySort} from 'sentry/views/performance/transactionSummary/transactionSpans/spanSummary/useSpanSummarySort';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import {SpanIdCell} from 'sentry/views/starfish/components/tableCells/spanIdCell';
+import {useIndexedSpans} from 'sentry/views/starfish/queries/useIndexedSpans';
+import {
+  type IndexedResponse,
+  SpanIndexedField,
+  type SpanMetricsQueryFilters,
+} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type DataRowKeys =
+  | SpanIndexedField.ID
+  | SpanIndexedField.TIMESTAMP
+  | SpanIndexedField.SPAN_DURATION
+  | SpanIndexedField.TRANSACTION_ID
+  | SpanIndexedField.TRACE
+  | SpanIndexedField.PROJECT;
+
+type ColumnKeys =
+  | SpanIndexedField.ID
+  | SpanIndexedField.TIMESTAMP
+  | SpanIndexedField.SPAN_DURATION;
+
+type DataRow = Pick<IndexedResponse, DataRowKeys> & {'transaction.duration': number};
+
+type Column = GridColumnHeader<ColumnKeys>;
+
+const COLUMN_ORDER: Column[] = [
+  {
+    key: SpanIndexedField.ID,
+    name: t('Span ID'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.TIMESTAMP,
+    name: t('Timestamp'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanIndexedField.SPAN_DURATION,
+    name: t('Span Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+const COLUMN_TYPE: Omit<
+  Record<ColumnKeys, ColumnType>,
+  'spans' | 'transactionDuration'
+> = {
+  span_id: 'string',
+  timestamp: 'date',
+  'span.duration': 'duration',
+};
+
+const LIMIT = 8;
+
+type Props = {
+  project: Project | undefined;
+};
+
+export default function SpanSummaryTable(props: Props) {
+  const {project} = props;
+  const organization = useOrganization();
+  const {spanSlug} = useParams();
+  const [spanOp, groupId] = spanSlug.split(':');
+
+  const location = useLocation();
+  const {transaction} = location.query;
+  const spansCursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
+
+  const filters: SpanMetricsQueryFilters = {
+    'span.group': groupId,
+    'span.op': spanOp,
+    transaction: transaction as string,
+  };
+
+  const sort = useSpanSummarySort();
+
+  const {
+    data: rowData,
+    pageLinks,
+    isLoading: isRowDataLoading,
+  } = useIndexedSpans({
+    fields: [
+      SpanIndexedField.ID,
+      SpanIndexedField.TRANSACTION_ID,
+      SpanIndexedField.TIMESTAMP,
+      SpanIndexedField.SPAN_DURATION,
+      SpanIndexedField.TRACE,
+    ],
+    search: MutableSearch.fromQueryObject(filters),
+    limit: LIMIT,
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_TABLE,
+    sorts: [sort],
+    cursor: spansCursor,
+  });
+
+  const transactionIds = rowData?.map(row => row[SpanIndexedField.TRANSACTION_ID]);
+
+  const eventView = EventView.fromNewQueryWithLocation(
+    {
+      name: 'Transaction Durations',
+      query: MutableSearch.fromQueryObject({
+        project: project?.slug,
+        id: `[${transactionIds?.join() ?? ''}]`,
+      }).formatString(),
+      fields: ['id', 'transaction.duration'],
+      version: 2,
+    },
+    location
+  );
+
+  const {
+    isLoading: isTxnDurationDataLoading,
+    data: txnDurationData,
+    isError: isTxnDurationError,
+  } = useGenericDiscoverQuery<
+    {
+      data: any[];
+      meta: MetaType;
+    },
+    DiscoverQueryProps
+  >({
+    route: 'events',
+    eventView,
+    location,
+    orgSlug: organization.slug,
+    getRequestPayload: () => ({
+      ...eventView.getEventsAPIPayload(location),
+      interval: eventView.interval,
+    }),
+    limit: LIMIT,
+    options: {
+      refetchOnWindowFocus: false,
+      enabled: Boolean(rowData),
+    },
+    referrer: SpanSummaryReferrer.SPAN_SUMMARY_TABLE,
+  });
+
+  // Restructure the transaction durations into a map for faster lookup
+  const transactionDurationMap = {};
+  txnDurationData?.data.forEach(datum => {
+    transactionDurationMap[datum.id] = datum['transaction.duration'];
+  });
+
+  const mergedData: DataRow[] =
+    rowData?.map((row: Pick<IndexedResponse, DataRowKeys>) => {
+      const transactionId = row[SpanIndexedField.TRANSACTION_ID];
+      const newRow = {
+        ...row,
+        'transaction.duration': transactionDurationMap[transactionId],
+      };
+      return newRow;
+    }) ?? [];
+
+  const handleCursor: CursorHandler = (cursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [QueryParameterNames.SPANS_CURSOR]: cursor},
+    });
+  };
+
+  return (
+    <Fragment>
+      <VisuallyCompleteWithData
+        id="SpanDetails-SpanDetailsTable"
+        hasData={!!mergedData?.length}
+        isLoading={isRowDataLoading}
+      >
+        <GridEditable
+          isLoading={isRowDataLoading}
+          data={mergedData}
+          columnOrder={COLUMN_ORDER}
+          columnSortBy={[
+            {
+              key: sort.field,
+              order: sort.kind,
+            },
+          ]}
+          grid={{
+            renderHeadCell: column =>
+              renderHeadCell({
+                column,
+                location,
+                sort,
+              }),
+            renderBodyCell: renderBodyCell(
+              location,
+              organization,
+              spanOp,
+              isTxnDurationDataLoading || isTxnDurationError
+            ),
+          }}
+          location={location}
+        />
+      </VisuallyCompleteWithData>
+      <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
+    </Fragment>
+  );
+}
+
+function renderBodyCell(
+  location: Location,
+  organization: Organization,
+  spanOp: string = '',
+  isTxnDurationDataLoading: boolean
+) {
+  return function (column: Column, dataRow: DataRow): React.ReactNode {
+    const {timestamp, span_id, trace, project} = dataRow;
+    const spanDuration = dataRow[SpanIndexedField.SPAN_DURATION];
+    const transactionId = dataRow[SpanIndexedField.TRANSACTION_ID];
+    const transactionDuration = dataRow['transaction.duration'];
+
+    if (column.key === SpanIndexedField.SPAN_DURATION) {
+      if (isTxnDurationDataLoading) {
+        return <SpanDurationBarLoading />;
+      }
+
+      return (
+        <SpanDurationBar
+          spanOp={spanOp}
+          spanDuration={spanDuration}
+          transactionDuration={transactionDuration}
+        />
+      );
+    }
+
+    if (column.key === SpanIndexedField.ID) {
+      return (
+        <SpanIdCell
+          projectSlug={project}
+          spanId={span_id}
+          timestamp={timestamp}
+          traceId={trace}
+          transactionId={transactionId}
+        />
+      );
+    }
+
+    const fieldRenderer = getFieldRenderer(column.key, COLUMN_TYPE);
+    const rendered = fieldRenderer(dataRow, {location, organization});
+
+    return rendered;
+  };
+}
+
+const SpanDurationBarLoading = styled('div')`
+  height: ${ROW_HEIGHT - 2 * ROW_PADDING}px;
+  width: 100%;
+  position: relative;
+  display: flex;
+  top: ${space(0.5)};
+  background-color: ${p => p.theme.gray100};
+`;

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