Browse Source

feat(perf): Add a new span operations widget (#42295)

This PR adds a new span ops widget that shows a stacked bar series of
`p75(spans.db)`, `p75(spans.resource)`, `p75(spans.http)`,
`p75(spans.ui)`, `p75(spans.browser)`. The new widget is hidden behind a
server-side sampling flag as it uses processed data.

Example for frontend transactions that have >2 span ops
<img width="627" alt="Screen Shot 2022-12-13 at 12 40 04 PM"
src="https://user-images.githubusercontent.com/23648387/207409481-f8a3b28d-cba9-44cb-8e13-ed3831fcde5f.png">

Example for backend transactions that have 2 span ops
<img width="629" alt="Screen Shot 2022-12-13 at 12 40 15 PM"
src="https://user-images.githubusercontent.com/23648387/207409493-91cf5bbc-41b6-43fa-b4f8-ac8d1e979b39.png">

Fixes PERF-1857
Dameli Ushbayeva 2 years ago
parent
commit
502315a12a

+ 18 - 1
static/app/views/performance/landing/index.spec.tsx

@@ -306,7 +306,7 @@ describe('Performance > Landing > Index', function () {
     expect(screen.getByTestId('frontend-pageload-view')).toBeInTheDocument();
   });
 
-  describe('with transaction search feature', function () {
+  describe('With transaction search feature', function () {
     it('renders the search bar', async function () {
       addMetricsDataMock();
 
@@ -340,4 +340,21 @@ describe('Performance > Landing > Index', function () {
       expect(await screen.findByPlaceholderText('Search Transactions')).toHaveValue('');
     });
   });
+
+  describe('With span operations widget feature flag', function () {
+    it('Displays the span operations widget', async function () {
+      addMetricsDataMock();
+
+      const data = initializeData({
+        features: [
+          'performance-transaction-name-only-search',
+          'performance-new-widget-designs',
+        ],
+      });
+
+      wrapper = render(<WrappedComponent data={data} />, data.routerContext);
+      const titles = await screen.findAllByTestId('performance-widget-title');
+      expect(titles.at(0)).toHaveTextContent('Span Operations');
+    });
+  });
 });

+ 16 - 8
static/app/views/performance/landing/views/allTransactionsView.tsx

@@ -1,3 +1,4 @@
+import {canUseMetricsData} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
 import {usePageError} from 'sentry/utils/performance/contexts/pageError';
 import {PerformanceDisplayProvider} from 'sentry/utils/performance/contexts/performanceDisplayContext';
 
@@ -9,17 +10,24 @@ import {PerformanceWidgetSetting} from '../widgets/widgetDefinitions';
 import {BasePerformanceViewProps} from './types';
 
 export function AllTransactionsView(props: BasePerformanceViewProps) {
+  const showSpanOperationsWidget =
+    props.organization.features.includes('performance-new-widget-designs') &&
+    canUseMetricsData(props.organization);
+
+  const doubleChartRowCharts = [
+    PerformanceWidgetSetting.MOST_RELATED_ISSUES,
+    PerformanceWidgetSetting.MOST_IMPROVED,
+    PerformanceWidgetSetting.MOST_REGRESSED,
+  ];
+
+  if (showSpanOperationsWidget) {
+    doubleChartRowCharts.unshift(PerformanceWidgetSetting.SPAN_OPERATIONS);
+  }
+
   return (
     <PerformanceDisplayProvider value={{performanceType: PROJECT_PERFORMANCE_TYPE.ANY}}>
       <div data-test-id="all-transactions-view">
-        <DoubleChartRow
-          {...props}
-          allowedCharts={[
-            PerformanceWidgetSetting.MOST_RELATED_ISSUES,
-            PerformanceWidgetSetting.MOST_IMPROVED,
-            PerformanceWidgetSetting.MOST_REGRESSED,
-          ]}
-        />
+        <DoubleChartRow {...props} allowedCharts={doubleChartRowCharts} />
         <TripleChartRow
           {...props}
           allowedCharts={[

+ 16 - 9
static/app/views/performance/landing/views/backendView.tsx

@@ -1,4 +1,5 @@
 import {
+  canUseMetricsData,
   MetricsEnhancedSettingContext,
   useMEPSettingContext,
 } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
@@ -35,18 +36,24 @@ function getAllowedChartsSmall(
 
 export function BackendView(props: BasePerformanceViewProps) {
   const mepSetting = useMEPSettingContext();
+  const showSpanOperationsWidget =
+    props.organization.features.includes('performance-new-widget-designs') &&
+    canUseMetricsData(props.organization);
+
+  const doubleChartRowCharts = [
+    PerformanceWidgetSetting.SLOW_HTTP_OPS,
+    PerformanceWidgetSetting.SLOW_DB_OPS,
+    PerformanceWidgetSetting.MOST_IMPROVED,
+    PerformanceWidgetSetting.MOST_REGRESSED,
+  ];
+
+  if (showSpanOperationsWidget) {
+    doubleChartRowCharts.unshift(PerformanceWidgetSetting.SPAN_OPERATIONS);
+  }
   return (
     <PerformanceDisplayProvider value={{performanceType: PROJECT_PERFORMANCE_TYPE.ANY}}>
       <div>
-        <DoubleChartRow
-          {...props}
-          allowedCharts={[
-            PerformanceWidgetSetting.SLOW_HTTP_OPS,
-            PerformanceWidgetSetting.SLOW_DB_OPS,
-            PerformanceWidgetSetting.MOST_IMPROVED,
-            PerformanceWidgetSetting.MOST_REGRESSED,
-          ]}
-        />
+        <DoubleChartRow {...props} allowedCharts={doubleChartRowCharts} />
         <TripleChartRow
           {...props}
           allowedCharts={getAllowedChartsSmall(props, mepSetting)}

+ 16 - 8
static/app/views/performance/landing/views/frontendOtherView.tsx

@@ -1,4 +1,5 @@
 import {
+  canUseMetricsData,
   MetricsEnhancedSettingContext,
   useMEPSettingContext,
 } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
@@ -32,19 +33,26 @@ function getAllowedChartsSmall(
 
 export function FrontendOtherView(props: BasePerformanceViewProps) {
   const mepSetting = useMEPSettingContext();
+  const showSpanOperationsWidget =
+    props.organization.features.includes('performance-new-widget-designs') &&
+    canUseMetricsData(props.organization);
+
+  const doubleChartRowCharts = [
+    PerformanceWidgetSetting.MOST_RELATED_ISSUES,
+    PerformanceWidgetSetting.SLOW_HTTP_OPS,
+    PerformanceWidgetSetting.SLOW_RESOURCE_OPS,
+  ];
+
+  if (showSpanOperationsWidget) {
+    doubleChartRowCharts.unshift(PerformanceWidgetSetting.SPAN_OPERATIONS);
+  }
+
   return (
     <PerformanceDisplayProvider
       value={{performanceType: PROJECT_PERFORMANCE_TYPE.FRONTEND_OTHER}}
     >
       <div>
-        <DoubleChartRow
-          {...props}
-          allowedCharts={[
-            PerformanceWidgetSetting.MOST_RELATED_ISSUES,
-            PerformanceWidgetSetting.SLOW_HTTP_OPS,
-            PerformanceWidgetSetting.SLOW_RESOURCE_OPS,
-          ]}
-        />
+        <DoubleChartRow {...props} allowedCharts={doubleChartRowCharts} />
         <TripleChartRow
           {...props}
           allowedCharts={getAllowedChartsSmall(props, mepSetting)}

+ 19 - 12
static/app/views/performance/landing/views/frontendPageloadView.tsx

@@ -1,4 +1,5 @@
 import {
+  canUseMetricsData,
   MetricsEnhancedSettingContext,
   useMEPSettingContext,
 } from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
@@ -31,23 +32,29 @@ function getAllowedChartsSmall(
 
 export function FrontendPageloadView(props: BasePerformanceViewProps) {
   const mepSetting = useMEPSettingContext();
+  const showSpanOperationsWidget =
+    props.organization.features.includes('performance-new-widget-designs') &&
+    canUseMetricsData(props.organization);
+
+  const doubleChartRowCharts = [
+    PerformanceWidgetSetting.WORST_LCP_VITALS,
+    PerformanceWidgetSetting.WORST_FCP_VITALS,
+    PerformanceWidgetSetting.WORST_FID_VITALS,
+    PerformanceWidgetSetting.MOST_RELATED_ISSUES,
+    PerformanceWidgetSetting.SLOW_HTTP_OPS,
+    PerformanceWidgetSetting.SLOW_BROWSER_OPS,
+    PerformanceWidgetSetting.SLOW_RESOURCE_OPS,
+  ];
+
+  if (showSpanOperationsWidget) {
+    doubleChartRowCharts.unshift(PerformanceWidgetSetting.SPAN_OPERATIONS);
+  }
   return (
     <PerformanceDisplayProvider
       value={{performanceType: PROJECT_PERFORMANCE_TYPE.FRONTEND}}
     >
       <div data-test-id="frontend-pageload-view">
-        <DoubleChartRow
-          {...props}
-          allowedCharts={[
-            PerformanceWidgetSetting.WORST_LCP_VITALS,
-            PerformanceWidgetSetting.WORST_FCP_VITALS,
-            PerformanceWidgetSetting.WORST_FID_VITALS,
-            PerformanceWidgetSetting.MOST_RELATED_ISSUES,
-            PerformanceWidgetSetting.SLOW_HTTP_OPS,
-            PerformanceWidgetSetting.SLOW_BROWSER_OPS,
-            PerformanceWidgetSetting.SLOW_RESOURCE_OPS,
-          ]}
-        />
+        <DoubleChartRow {...props} allowedCharts={doubleChartRowCharts} />
         <TripleChartRow
           {...props}
           allowedCharts={getAllowedChartsSmall(props, mepSetting)}

+ 3 - 0
static/app/views/performance/landing/widgets/components/widgetContainer.tsx

@@ -32,6 +32,7 @@ import {
 import {HistogramWidget} from '../widgets/histogramWidget';
 import {LineChartListWidget} from '../widgets/lineChartListWidget';
 import {SingleFieldAreaWidget} from '../widgets/singleFieldAreaWidget';
+import {StackedBarsChartListWidget} from '../widgets/stackedBarsChartListWidget';
 import {TrendsWidget} from '../widgets/trendsWidget';
 import {VitalWidget} from '../widgets/vitalWidget';
 
@@ -195,6 +196,8 @@ const _WidgetContainer = (props: Props) => {
       return (
         <HistogramWidget {...passedProps} {...widgetProps} titleTooltip={titleTooltip} />
       );
+    case GenericPerformanceWidgetDataType.stacked_bars:
+      return <StackedBarsChartListWidget {...passedProps} {...widgetProps} />;
     default:
       throw new Error(`Widget type "${widgetProps.dataType}" has no implementation.`);
   }

+ 34 - 0
static/app/views/performance/landing/widgets/transforms/transformEventsToStackedBars.tsx

@@ -0,0 +1,34 @@
+import {RenderProps} from 'sentry/components/charts/eventsRequest';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import {defined} from 'sentry/utils';
+
+import {QueryDefinitionWithKey, WidgetDataConstraint, WidgetPropUnion} from '../types';
+
+export function transformEventsRequestToStackedBars<T extends WidgetDataConstraint>(
+  widgetProps: WidgetPropUnion<T>,
+  results: RenderProps,
+  _: QueryDefinitionWithKey<T>
+) {
+  const {start, end, utc, interval, statsPeriod} = normalizeDateTimeParams(
+    widgetProps.location.query
+  );
+
+  const data = results.results ?? [];
+
+  const childData = {
+    ...results,
+    isLoading: results.loading || results.reloading,
+    isErrored: results.errored,
+    hasData: defined(data) && !!data.length && !!data[0].data.length,
+    data,
+    previousData: results.previousTimeseriesData ?? undefined,
+
+    utc: utc === 'true',
+    interval,
+    statsPeriod: statsPeriod ?? undefined,
+    start: start ?? '',
+    end: end ?? '',
+  };
+
+  return childData;
+}

+ 1 - 0
static/app/views/performance/landing/widgets/types.tsx

@@ -22,6 +22,7 @@ export enum GenericPerformanceWidgetDataType {
   vitals = 'vitals',
   line_list = 'line_list',
   trends = 'trends',
+  stacked_bars = 'stacked_bars',
 }
 
 export type PerformanceWidgetProps = {

+ 8 - 0
static/app/views/performance/landing/widgets/widgetDefinitions.tsx

@@ -1,6 +1,7 @@
 import CHART_PALETTE from 'sentry/constants/chartPalette';
 import {t} from 'sentry/locale';
 import {Organization} from 'sentry/types';
+import {SPAN_OP_BREAKDOWN_FIELDS} from 'sentry/utils/discover/fields';
 
 import {getTermHelp, PERFORMANCE_TERM} from '../../data';
 
@@ -54,6 +55,7 @@ export enum PerformanceWidgetSetting {
   FROZEN_FRAMES_AREA = 'frozen_frames_area',
   MOST_SLOW_FRAMES = 'most_slow_frames',
   MOST_FROZEN_FRAMES = 'most_frozen_frames',
+  SPAN_OPERATIONS = 'span_operations',
 }
 
 const WIDGET_PALETTE = CHART_PALETTE[5];
@@ -312,4 +314,10 @@ export const WIDGET_DEFINITIONS: ({
     fields: [],
     dataType: GenericPerformanceWidgetDataType.trends,
   },
+  [PerformanceWidgetSetting.SPAN_OPERATIONS]: {
+    title: t('Span Operations'),
+    titleTooltip: '',
+    fields: SPAN_OP_BREAKDOWN_FIELDS.map(spanOp => `p75(${spanOp})`),
+    dataType: GenericPerformanceWidgetDataType.stacked_bars,
+  },
 });

+ 266 - 0
static/app/views/performance/landing/widgets/widgets/stackedBarsChartListWidget.tsx

@@ -0,0 +1,266 @@
+import {Fragment, useMemo, useState} from 'react';
+import {useTheme} from '@emotion/react';
+import pick from 'lodash/pick';
+
+import {BarChart} from 'sentry/components/charts/barChart';
+import _EventsRequest from 'sentry/components/charts/eventsRequest';
+import {getInterval} from 'sentry/components/charts/utils';
+import Count from 'sentry/components/count';
+import Truncate from 'sentry/components/truncate';
+import {t} from 'sentry/locale';
+import {tooltipFormatter} from 'sentry/utils/discover/charts';
+import DiscoverQuery from 'sentry/utils/discover/discoverQuery';
+import {
+  canUseMetricsData,
+  useMEPSettingContext,
+} from 'sentry/utils/performance/contexts/metricsEnhancedSetting';
+import {usePageError} from 'sentry/utils/performance/contexts/pageError';
+import {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import withApi from 'sentry/utils/withApi';
+import {transactionSummaryRouteWithQuery} from 'sentry/views/performance/transactionSummary/utils';
+import {
+  createUnnamedTransactionsDiscoverTarget,
+  UNPARAMETERIZED_TRANSACTION,
+} from 'sentry/views/performance/utils';
+
+import Accordion from '../components/accordion';
+import {GenericPerformanceWidget} from '../components/performanceWidget';
+import {
+  GrowLink,
+  RightAlignedCell,
+  Subtitle,
+  WidgetEmptyStateWarning,
+} from '../components/selectableList';
+import {transformDiscoverToList} from '../transforms/transformDiscoverToList';
+import {transformEventsRequestToStackedBars} from '../transforms/transformEventsToStackedBars';
+import {PerformanceWidgetProps, QueryDefinition, WidgetDataResult} from '../types';
+import {eventsRequestQueryProps, getMEPParamsIfApplicable} from '../utils';
+
+type DataType = {
+  chart: WidgetDataResult & ReturnType<typeof transformEventsRequestToStackedBars>;
+  list: WidgetDataResult & ReturnType<typeof transformDiscoverToList>;
+};
+
+export function StackedBarsChartListWidget(props: PerformanceWidgetProps) {
+  const location = useLocation();
+  const mepSetting = useMEPSettingContext();
+  const [selectedListIndex, setSelectListIndex] = useState<number>(0);
+  const {ContainerActions, organization, InteractiveTitle, fields} = props;
+  const pageError = usePageError();
+  const theme = useTheme();
+
+  const colors = [...theme.charts.getColorPalette(5)].reverse();
+
+  const listQuery = useMemo<QueryDefinition<DataType, WidgetDataResult>>(
+    () => ({
+      fields,
+      component: provided => {
+        const eventView = provided.eventView.clone();
+
+        eventView.fields = [
+          {field: 'transaction'},
+          {field: 'team_key_transaction'},
+          {field: 'count()'},
+          {field: 'project.id'},
+          ...fields.map(f => ({field: f})),
+        ];
+
+        eventView.sorts = [
+          {kind: 'desc', field: 'team_key_transaction'},
+          {kind: 'desc', field: 'count()'},
+        ];
+
+        if (canUseMetricsData(organization)) {
+          eventView.additionalConditions.setFilterValues('!transaction', [
+            UNPARAMETERIZED_TRANSACTION,
+          ]);
+        }
+        const mutableSearch = new MutableSearch(eventView.query);
+        mutableSearch.removeFilter('transaction.duration');
+
+        eventView.query = mutableSearch.formatString();
+
+        // Don't retrieve list items with 0 in the field.
+        eventView.additionalConditions.setFilterValues('count()', ['>0']);
+        eventView.additionalConditions.setFilterValues('!transaction.op', ['']);
+        return (
+          <DiscoverQuery
+            {...provided}
+            eventView={eventView}
+            location={location}
+            limit={3}
+            cursor="0:0:1"
+            noPagination
+            queryExtras={getMEPParamsIfApplicable(mepSetting, props.chartSetting)}
+          />
+        );
+      },
+      transform: transformDiscoverToList,
+    }),
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [props.chartSetting, mepSetting.memoizationKey]
+  );
+
+  const chartQuery = useMemo<QueryDefinition<DataType, WidgetDataResult>>(
+    () => {
+      return {
+        enabled: widgetData => {
+          return !!widgetData?.list?.data?.length;
+        },
+        fields,
+        component: provided => {
+          const eventView = props.eventView.clone();
+          if (!provided.widgetData.list.data[selectedListIndex]?.transaction) {
+            return null;
+          }
+          eventView.additionalConditions.setFilterValues('transaction', [
+            provided.widgetData.list.data[selectedListIndex].transaction as string,
+          ]);
+
+          if (canUseMetricsData(organization)) {
+            eventView.additionalConditions.setFilterValues('!transaction', [
+              UNPARAMETERIZED_TRANSACTION,
+            ]);
+          }
+          const listResult = provided.widgetData.list.data[selectedListIndex];
+          const nonEmptySpanOpFields = Object.entries(listResult)
+            .filter(result => fields.includes(result[0]) && result[1] !== 0)
+            .map(result => result[0]);
+          const prunedProvided = {...provided, yAxis: nonEmptySpanOpFields};
+
+          return (
+            <EventsRequest
+              {...pick(prunedProvided, eventsRequestQueryProps)}
+              limit={5}
+              includePrevious={false}
+              includeTransformedData
+              partial
+              currentSeriesNames={nonEmptySpanOpFields}
+              query={eventView.getQueryWithAdditionalConditions()}
+              interval={getInterval(
+                {
+                  start: prunedProvided.start,
+                  end: prunedProvided.end,
+                  period: prunedProvided.period,
+                },
+                'low'
+              )}
+              hideError
+              onError={pageError.setPageError}
+              queryExtras={getMEPParamsIfApplicable(mepSetting, props.chartSetting)}
+            />
+          );
+        },
+        transform: transformEventsRequestToStackedBars,
+      };
+    },
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+    [props.chartSetting, selectedListIndex, mepSetting.memoizationKey]
+  );
+
+  const Queries = {
+    list: listQuery,
+    chart: chartQuery,
+  };
+
+  const getHeaders = provided =>
+    provided.widgetData.list.data.map(listItem => () => {
+      const transaction = (listItem.transaction as string | undefined) ?? '';
+
+      const isUnparameterizedRow = transaction === UNPARAMETERIZED_TRANSACTION;
+      const transactionTarget = isUnparameterizedRow
+        ? createUnnamedTransactionsDiscoverTarget({
+            organization,
+            location,
+          })
+        : transactionSummaryRouteWithQuery({
+            orgSlug: props.organization.slug,
+            projectID: listItem['project.id'] as string,
+            transaction,
+            query: props.eventView.generateQueryStringObject(),
+            subPath: 'spans',
+          });
+
+      const displayedField = 'count()';
+      const rightValue = listItem[displayedField];
+
+      return (
+        <Fragment>
+          <GrowLink to={transactionTarget}>
+            <Truncate value={transaction} maxLength={40} />
+          </GrowLink>
+          <RightAlignedCell>
+            <Count value={rightValue} />
+          </RightAlignedCell>
+        </Fragment>
+      );
+    });
+
+  return (
+    <GenericPerformanceWidget<DataType>
+      {...props}
+      location={location}
+      Subtitle={() => <Subtitle>{t('Top transactions in count')}</Subtitle>}
+      HeaderActions={provided =>
+        ContainerActions && (
+          <ContainerActions isLoading={provided.widgetData.list?.isLoading} />
+        )
+      }
+      InteractiveTitle={
+        InteractiveTitle
+          ? provided => <InteractiveTitle {...provided.widgetData.chart} />
+          : null
+      }
+      EmptyComponent={WidgetEmptyStateWarning}
+      Queries={Queries}
+      Visualizations={[
+        {
+          component: provided => {
+            return (
+              <Accordion
+                expandedIndex={selectedListIndex}
+                setExpandedIndex={setSelectListIndex}
+                content={
+                  <BarChart
+                    {...provided.widgetData.chart}
+                    {...provided}
+                    colors={colors}
+                    series={provided.widgetData.chart.data}
+                    stacked
+                    animation
+                    isGroupedByDate
+                    showTimeInTooltip
+                    xAxis={{
+                      show: false,
+                      axisLabel: {show: true, margin: 8},
+                      axisLine: {show: false},
+                    }}
+                    tooltip={{
+                      valueFormatter: value => tooltipFormatter(value, 'duration'),
+                    }}
+                    start={
+                      provided.widgetData.chart.start
+                        ? new Date(provided.widgetData.chart.start)
+                        : undefined
+                    }
+                    end={
+                      provided.widgetData.chart.end
+                        ? new Date(provided.widgetData.chart.end)
+                        : undefined
+                    }
+                  />
+                }
+                headers={getHeaders(provided)}
+              />
+            );
+          },
+          height: 124 + props.chartHeight,
+          noPadding: true,
+        },
+      ]}
+    />
+  );
+}
+
+const EventsRequest = withApi(_EventsRequest);

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