Browse Source

feat(perf): Update the Spans tab in Transaction Summary (#70373)

Upgrades the `Spans` tab on the transaction summary to query from the
Span metrics dataset and changes the table to have columns more relevant
to span metrics.

### Before

![image](https://github.com/getsentry/sentry/assets/16740047/c3b46299-28ff-485e-b440-cb557819fbfc)


### After

![image](https://github.com/getsentry/sentry/assets/16740047/596aabfc-3ff2-4f0b-955f-694fc2ad1c25)

**TODO in followup PRs**
- [ ] Searchbar needs to be implemented (and not auto-suggest irrelevant
transaction tags that don't work)

---------

Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
Ash 9 months ago
parent
commit
28357df509

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

@@ -22,6 +22,7 @@ import SuspectSpansQuery from 'sentry/utils/performance/suspectSpans/suspectSpan
 import {VisuallyCompleteWithData} from 'sentry/utils/performanceForSentry';
 import {decodeScalar} from 'sentry/utils/queryString';
 import useProjects from 'sentry/utils/useProjects';
+import SpanMetricsTable from 'sentry/views/performance/transactionSummary/transactionSpans/spanMetricsTable';
 
 import type {SetStateAction} from '../types';
 
@@ -94,6 +95,13 @@ function SpansContent(props: Props) {
 
   const {projects} = useProjects();
 
+  const hasNewSpansUIFlag = organization.features.includes('performance-spans-new-ui');
+
+  // TODO: Remove this flag when the feature is GA'd and replace the old content entirely
+  if (hasNewSpansUIFlag) {
+    return <SpansContentV2 {...props} />;
+  }
+
   return (
     <Layout.Main fullWidth>
       <FilterActions>
@@ -176,6 +184,66 @@ function SpansContent(props: Props) {
   );
 }
 
+// TODO: Temporary component while we make the switch to spans only. Will fully replace the old Spans tab when GA'd
+function SpansContentV2(props: Props) {
+  const {location, organization, eventView, projectId, transactionName} = props;
+  const {projects} = useProjects();
+  const project = projects.find(p => p.id === projectId);
+
+  function handleChange(key: string) {
+    return function (value: string | undefined) {
+      ANALYTICS_VALUES[key]?.(organization, value);
+
+      const queryParams = normalizeDateTimeParams({
+        ...(location.query || {}),
+        [key]: value,
+      });
+
+      // do not propagate pagination when making a new search
+      const toOmit = ['cursor'];
+      if (!defined(value)) {
+        toOmit.push(key);
+      }
+      const searchQueryParams = omit(queryParams, toOmit);
+
+      browserHistory.push({
+        ...location,
+        query: searchQueryParams,
+      });
+    };
+  }
+
+  return (
+    <Layout.Main fullWidth>
+      <FilterActions>
+        <OpsFilter
+          location={location}
+          eventView={eventView}
+          organization={organization}
+          handleOpChange={handleChange('spanOp')}
+          transactionName={transactionName}
+        />
+        <PageFilterBar condensed>
+          <EnvironmentPageFilter />
+          <DatePageFilter
+            maxPickableDays={SPAN_RETENTION_DAYS}
+            relativeOptions={SPAN_RELATIVE_PERIODS}
+          />
+        </PageFilterBar>
+        <StyledSearchBar
+          organization={organization}
+          projectIds={eventView.project}
+          query={''}
+          fields={eventView.fields}
+          onSearch={handleChange('query')}
+        />
+      </FilterActions>
+
+      <SpanMetricsTable project={project} transactionName={transactionName} />
+    </Layout.Main>
+  );
+}
+
 function getSpansEventView(eventView: EventView, sort: SpanSort): EventView {
   eventView = eventView.clone();
   const fields = SPAN_SORT_TO_FIELDS[sort];

+ 67 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanMetricsTable.spec.tsx

@@ -0,0 +1,67 @@
+import {initializeData as _initializeData} from 'sentry-test/performance/initializePerformanceData';
+import {act, render, screen, waitFor} from 'sentry-test/reactTestingLibrary';
+
+import ProjectsStore from 'sentry/stores/projectsStore';
+import SpanMetricsTable from 'sentry/views/performance/transactionSummary/transactionSpans/spanMetricsTable';
+
+const initializeData = () => {
+  const data = _initializeData({
+    features: ['performance-view'],
+  });
+
+  act(() => ProjectsStore.loadInitialData(data.organization.projects));
+  return data;
+};
+
+describe('SuspectSpansTable', () => {
+  it('should render the table and rows of data', async () => {
+    const initialData = initializeData();
+    const {organization, project, routerContext} = initialData;
+
+    const mockRequest = MockApiClient.addMockResponse({
+      url: `/organizations/${organization.slug}/events/`,
+      method: 'GET',
+      body: {
+        data: [
+          {
+            'span.group': '',
+            'span.op': 'navigation',
+            'span.description': '',
+            'spm()': 4.448963396488444,
+            'sum(span.self_time)': 1236071121.5044901,
+            'avg(span.duration)': 30900.700924083318,
+          },
+        ],
+      },
+    });
+
+    render(<SpanMetricsTable transactionName="Test Transaction" project={project} />, {
+      context: routerContext,
+    });
+
+    await waitFor(() =>
+      expect(screen.queryByTestId('loading-indicator')).not.toBeInTheDocument()
+    );
+
+    expect(mockRequest).toHaveBeenCalled();
+
+    const tableHeaders = await screen.findAllByTestId('grid-head-cell');
+    const [opHeader, nameHeader, throughputHeader, avgDurationHeader, timeSpentHeader] =
+      tableHeaders;
+
+    expect(opHeader).toHaveTextContent('Span Operation');
+    expect(nameHeader).toHaveTextContent('Span Name');
+    expect(throughputHeader).toHaveTextContent('Throughput');
+    expect(avgDurationHeader).toHaveTextContent('Avg Duration');
+    expect(timeSpentHeader).toHaveTextContent('Time Spent');
+
+    const bodyCells = await screen.findAllByTestId('grid-body-cell');
+    const [opCell, nameCell, throughputCell, avgDurationCell, timeSpentCell] = bodyCells;
+
+    expect(opCell).toHaveTextContent('navigation');
+    expect(nameCell).toHaveTextContent('(unnamed span)');
+    expect(throughputCell).toHaveTextContent('4.45/s');
+    expect(avgDurationCell).toHaveTextContent('30.90s');
+    expect(timeSpentCell).toHaveTextContent('2.04wk');
+  });
+});

+ 197 - 0
static/app/views/performance/transactionSummary/transactionSpans/spanMetricsTable.tsx

@@ -0,0 +1,197 @@
+import {Fragment} from 'react';
+import {browserHistory} from 'react-router';
+import type {Location} from 'history';
+
+import type {GridColumnHeader} from 'sentry/components/gridEditable';
+import GridEditable, {COL_WIDTH_UNDEFINED} from 'sentry/components/gridEditable';
+import Link from 'sentry/components/links/link';
+import Pagination, {type CursorHandler} from 'sentry/components/pagination';
+import {t} from 'sentry/locale';
+import type {Organization, Project} from 'sentry/types';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import type {ColumnType} from 'sentry/utils/discover/fields';
+import {Container as TableCellContainer} from 'sentry/utils/discover/styles';
+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 {spanDetailsRouteWithQuery} from 'sentry/views/performance/transactionSummary/transactionSpans/spanDetails/utils';
+import {useSpansTabTableSort} from 'sentry/views/performance/transactionSummary/transactionSpans/useSpansTabTableSort';
+import {renderHeadCell} from 'sentry/views/starfish/components/tableCells/renderHeadCell';
+import {useSpanMetrics} from 'sentry/views/starfish/queries/useDiscover';
+import {
+  SpanMetricsField,
+  type SpanMetricsQueryFilters,
+} from 'sentry/views/starfish/types';
+import {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type DataRow = {
+  [SpanMetricsField.SPAN_OP]: string;
+  [SpanMetricsField.SPAN_DESCRIPTION]: string;
+  [SpanMetricsField.SPAN_GROUP]: string;
+  'avg(span.duration)': number;
+  'spm()': number;
+  'sum(span.self_time)': number;
+};
+
+type ColumnKeys =
+  | SpanMetricsField.SPAN_OP
+  | SpanMetricsField.SPAN_DESCRIPTION
+  | 'spm()'
+  | `avg(${SpanMetricsField.SPAN_DURATION})`
+  | `sum(${SpanMetricsField.SPAN_SELF_TIME})`;
+
+type Column = GridColumnHeader<ColumnKeys>;
+
+const COLUMN_ORDER: Column[] = [
+  {
+    key: SpanMetricsField.SPAN_OP,
+    name: t('Span Operation'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: SpanMetricsField.SPAN_DESCRIPTION,
+    name: t('Span Name'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: 'spm()',
+    name: t('Throughput'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: `avg(${SpanMetricsField.SPAN_DURATION})`,
+    name: t('Avg Duration'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+  {
+    key: `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
+    name: t('Time Spent'),
+    width: COL_WIDTH_UNDEFINED,
+  },
+];
+
+const COLUMN_TYPE: Record<ColumnKeys, ColumnType> = {
+  [SpanMetricsField.SPAN_OP]: 'string',
+  [SpanMetricsField.SPAN_DESCRIPTION]: 'string',
+  ['spm()']: 'rate',
+  [`avg(${SpanMetricsField.SPAN_DURATION})`]: 'duration',
+  [`sum(${SpanMetricsField.SPAN_SELF_TIME})`]: 'duration',
+};
+
+const LIMIT = 12;
+
+type Props = {
+  project: Project | undefined;
+  transactionName: string;
+};
+
+export default function SpanMetricsTable(props: Props) {
+  const {project, transactionName} = props;
+  const organization = useOrganization();
+
+  const location = useLocation();
+  const spansCursor = decodeScalar(location.query?.[QueryParameterNames.SPANS_CURSOR]);
+  const spanOp = decodeScalar(location.query?.spanOp);
+
+  const filters: SpanMetricsQueryFilters = {
+    transaction: transactionName,
+    ['span.op']: spanOp,
+  };
+
+  const handleCursor: CursorHandler = (cursor, pathname, query) => {
+    browserHistory.push({
+      pathname,
+      query: {...query, [QueryParameterNames.SPANS_CURSOR]: cursor},
+    });
+  };
+
+  const sort = useSpansTabTableSort();
+
+  const {data, isLoading, pageLinks} = useSpanMetrics(
+    {
+      search: MutableSearch.fromQueryObject(filters),
+      fields: [
+        SpanMetricsField.SPAN_OP,
+        SpanMetricsField.SPAN_DESCRIPTION,
+        SpanMetricsField.SPAN_GROUP,
+        `spm()`,
+        `avg(${SpanMetricsField.SPAN_DURATION})`,
+        `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
+      ],
+      sorts: [sort],
+      cursor: spansCursor,
+      limit: LIMIT,
+    },
+    ''
+  );
+
+  return (
+    <Fragment>
+      <VisuallyCompleteWithData
+        id="TransactionSpans-SpanMetricsTable"
+        hasData={!!data?.length}
+        isLoading={isLoading}
+      >
+        <GridEditable
+          isLoading={isLoading}
+          data={data}
+          columnOrder={COLUMN_ORDER}
+          columnSortBy={[
+            {
+              key: sort.field,
+              order: sort.kind,
+            },
+          ]}
+          grid={{
+            renderHeadCell: column =>
+              renderHeadCell({
+                column,
+                location,
+                sort,
+              }),
+            renderBodyCell: renderBodyCell(
+              location,
+              organization,
+              transactionName,
+              project
+            ),
+          }}
+          location={location}
+        />
+      </VisuallyCompleteWithData>
+      <Pagination pageLinks={pageLinks} onCursor={handleCursor} />
+    </Fragment>
+  );
+}
+
+function renderBodyCell(
+  location: Location,
+  organization: Organization,
+  transactionName: string,
+  project?: Project
+) {
+  return function (column: Column, dataRow: DataRow): React.ReactNode {
+    if (column.key === SpanMetricsField.SPAN_DESCRIPTION) {
+      const target = spanDetailsRouteWithQuery({
+        orgSlug: organization.slug,
+        transaction: transactionName,
+        query: location.query,
+        spanSlug: {op: dataRow['span.op'], group: dataRow['span.group']},
+        projectID: project?.id,
+      });
+
+      return (
+        <TableCellContainer>
+          <Link to={target}>{dataRow[column.key] || t('(unnamed span)')}</Link>
+        </TableCellContainer>
+      );
+    }
+
+    const fieldRenderer = getFieldRenderer(column.key, COLUMN_TYPE, false);
+    const rendered = fieldRenderer(dataRow, {location, organization});
+
+    return rendered;
+  };
+}

+ 1 - 3
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/content.tsx

@@ -3,7 +3,6 @@ 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';
@@ -119,12 +118,11 @@ function SpanSummaryContent(props: ContentProps) {
         'sum(span.self_time)',
         'count()',
       ],
-      enabled: Boolean(groupId),
     },
     SpanSummaryReferrer.SPAN_SUMMARY_HEADER_DATA
   );
 
-  const description = spanHeaderData[0]?.['span.description'] ?? t('unknown');
+  const description = spanHeaderData[0]?.['span.description'];
   const timeSpent = spanHeaderData[0]?.['sum(span.self_time)'];
   const avgDuration = spanHeaderData[0]?.['avg(span.self_time)'];
   const spanCount = spanHeaderData[0]?.['count()'];

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

@@ -49,7 +49,6 @@ function SpanSummaryCharts() {
     {
       search: MutableSearch.fromQueryObject(filters),
       yAxis: ['spm()'],
-      enabled: Boolean(groupId),
     },
     SpanSummaryReferrer.SPAN_SUMMARY_THROUGHPUT_CHART
   );
@@ -63,7 +62,6 @@ function SpanSummaryCharts() {
       search: MutableSearch.fromQueryObject(filters),
       // TODO: Switch this to SPAN_DURATION before release
       yAxis: [`avg(${SpanMetricsField.SPAN_SELF_TIME})`],
-      enabled: Boolean(groupId),
     },
     SpanSummaryReferrer.SPAN_SUMMARY_DURATION_CHART
   );

+ 3 - 1
static/app/views/performance/transactionSummary/transactionSpans/spanSummary/spanSummaryHeader.tsx

@@ -25,7 +25,9 @@ export default function SpanSummaryHeader(props: Props) {
       <HeaderInfo data-test-id="header-operation-name">
         <StyledSectionHeading>{t('Span')}</StyledSectionHeading>
         <SectionBody>
-          <SpanLabelContainer>{spanDescription ?? emptyValue}</SpanLabelContainer>
+          <SpanLabelContainer>
+            {spanDescription ? spanDescription : emptyValue}
+          </SpanLabelContainer>
         </SectionBody>
         <SectionSubtext data-test-id="operation-name">{spanOp}</SectionSubtext>
       </HeaderInfo>

+ 43 - 0
static/app/views/performance/transactionSummary/transactionSpans/useSpansTabTableSort.tsx

@@ -0,0 +1,43 @@
+import type {Sort} from 'sentry/utils/discover/fields';
+import {decodeSorts} from 'sentry/utils/queryString';
+import {useLocation} from 'sentry/utils/useLocation';
+import {SpanMetricsField} from 'sentry/views/starfish/types';
+import type {QueryParameterNames} from 'sentry/views/starfish/views/queryParameters';
+
+type Query = {
+  sort?: string;
+};
+
+const SORTABLE_FIELDS = [
+  `sum(${SpanMetricsField.SPAN_SELF_TIME})`,
+  'spm()',
+  `avg(${SpanMetricsField.SPAN_DURATION})`,
+] as const;
+
+export type ValidSort = Sort & {
+  field: (typeof SORTABLE_FIELDS)[number];
+};
+
+/**
+ * Parses a `Sort` object from the URL. In case of multiple specified sorts
+ * picks the first one, since span module UIs only support one sort at a time.
+ */
+export function useSpansTabTableSort(
+  sortParameterName: QueryParameterNames | 'sort' = 'sort',
+  fallback: Sort = DEFAULT_SORT
+) {
+  const location = useLocation<Query>();
+
+  return (
+    decodeSorts(location.query[sortParameterName]).filter(isAValidSort)[0] ?? fallback
+  );
+}
+
+const DEFAULT_SORT: Sort = {
+  kind: 'desc',
+  field: SORTABLE_FIELDS[0],
+};
+
+function isAValidSort(sort: Sort): sort is ValidSort {
+  return (SORTABLE_FIELDS as unknown as string[]).includes(sort.field);
+}

+ 4 - 1
static/app/views/starfish/components/tableCells/renderHeadCell.tsx

@@ -25,7 +25,8 @@ type Options = {
 
 const DEFAULT_SORT_PARAMETER_NAME = 'sort';
 
-const {SPAN_SELF_TIME, HTTP_RESPONSE_CONTENT_LENGTH, CACHE_ITEM_SIZE} = SpanMetricsField;
+const {SPAN_SELF_TIME, SPAN_DURATION, HTTP_RESPONSE_CONTENT_LENGTH, CACHE_ITEM_SIZE} =
+  SpanMetricsField;
 const {
   TIME_SPENT_PERCENTAGE,
   SPS,
@@ -38,6 +39,8 @@ const {
 
 export const SORTABLE_FIELDS = new Set([
   `avg(${SPAN_SELF_TIME})`,
+  `avg(${SPAN_DURATION})`,
+  `sum(${SPAN_SELF_TIME})`,
   `p95(${SPAN_SELF_TIME})`,
   `p75(transaction.duration)`,
   `transaction.duration`,