Browse Source

feat(stat-detectors): Convert span breakdown to table (#58879)

The donut chart wasn't as clear as we wanted it to be and it was taking
up an odd amount of space since we punted on the geo analysis widget.

Closes #58763
Nar Saynorath 1 year ago
parent
commit
0839a4e463

+ 8 - 104
static/app/components/events/eventStatisticalDetector/aggregateSpanDiff.tsx

@@ -1,4 +1,3 @@
-import styled from '@emotion/styled';
 import {Location} from 'history';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
@@ -11,14 +10,10 @@ import Link from 'sentry/components/links/link';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import TextOverflow from 'sentry/components/textOverflow';
 import {Tooltip} from 'sentry/components/tooltip';
-import {IconArrow} from 'sentry/icons';
 import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
 import {Event, Organization} from 'sentry/types';
 import {defined} from 'sentry/utils';
-import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
-import {RateUnits} from 'sentry/utils/discover/fields';
-import {NumberContainer} from 'sentry/utils/discover/styles';
+import {NumericChange, renderHeadCell} from 'sentry/utils/performance/regression/table';
 import {useRelativeDateTime} from 'sentry/utils/profiling/hooks/useRelativeDateTime';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import {useLocation} from 'sentry/utils/useLocation';
@@ -86,75 +81,13 @@ function useFetchAdvancedAnalysis({
 
 function getColumns() {
   return [
-    {key: 'span_op', name: t('Operation'), width: 200},
+    {key: 'span_op', name: t('Span Operation'), width: 200},
     {key: 'span_description', name: t('Description'), width: COL_WIDTH_UNDEFINED},
     {key: 'spm', name: t('Span Frequency'), width: COL_WIDTH_UNDEFINED},
     {key: 'p95', name: t('P95'), width: COL_WIDTH_UNDEFINED},
   ];
 }
 
-function getPercentChange(before: number, after: number) {
-  return ((after - before) / before) * 100;
-}
-
-function renderHeadCell(column: GridColumnOrder<string>) {
-  if (['spm', 'p95'].includes(column.key)) {
-    return <NumericColumnLabel>{column.name}</NumericColumnLabel>;
-  }
-  return column.name;
-}
-
-function NumericChange({
-  columnKey,
-  beforeRawValue,
-  afterRawValue,
-}: {
-  afterRawValue: number;
-  beforeRawValue: number;
-  columnKey: string;
-}) {
-  const organization = useOrganization();
-  const location = useLocation();
-  const percentChange = getPercentChange(beforeRawValue, afterRawValue);
-
-  const unit = columnKey === 'p95' ? 'millisecond' : RateUnits.PER_MINUTE;
-  const renderer = (value: number) =>
-    getFieldRenderer(
-      columnKey,
-      {
-        p95: 'duration',
-        spm: 'rate',
-      },
-      false
-    )({[columnKey]: value}, {organization, location, unit});
-
-  if (Math.round(percentChange) !== 0) {
-    let percentChangeLabel = `${percentChange > 0 ? '+' : ''}${Math.round(
-      percentChange
-    )}%`;
-    if (beforeRawValue === 0) {
-      percentChangeLabel = t('New');
-    }
-    return (
-      <Change>
-        {renderer(beforeRawValue)}
-        <IconArrow direction="right" size="xs" />
-        {renderer(afterRawValue)}
-        <ChangeLabel isPositive={percentChange > 0} isNeutral={beforeRawValue === 0}>
-          {percentChangeLabel}
-        </ChangeLabel>
-      </Change>
-    );
-  }
-
-  return (
-    <Change>
-      {renderer(afterRawValue)}
-      <ChangeDescription>{t('(No significant change)')}</ChangeDescription>
-    </Change>
-  );
-}
-
 function renderBodyCell({
   column,
   row,
@@ -268,41 +201,12 @@ function AggregateSpanDiff({event, projectId}: {event: Event; projectId: string}
     );
   }
 
-  return <DataSection>{content}</DataSection>;
+  return (
+    <DataSection>
+      <strong>{t('Span Analysis:')}</strong>
+      {content}
+    </DataSection>
+  );
 }
 
 export default AggregateSpanDiff;
-
-const ChangeLabel = styled('div')<{isNeutral: boolean; isPositive: boolean}>`
-  color: ${p => {
-    if (p.isNeutral) {
-      return p.theme.gray300;
-    }
-    if (p.isPositive) {
-      return p.theme.red300;
-    }
-    return p.theme.green300;
-  }};
-  text-align: right;
-`;
-
-const NumericColumnLabel = styled('div')`
-  text-align: right;
-  width: 100%;
-`;
-
-const Change = styled('span')`
-  display: flex;
-  align-items: center;
-  gap: ${space(1)};
-  justify-content: right;
-
-  ${NumberContainer} {
-    width: unset;
-  }
-`;
-
-const ChangeDescription = styled('span')`
-  color: ${p => p.theme.gray300};
-  white-space: nowrap;
-`;

+ 0 - 293
static/app/components/events/eventStatisticalDetector/aggregateSpanOps/pieChart.tsx

@@ -1,293 +0,0 @@
-import {Component, createRef} from 'react';
-import {Theme, withTheme} from '@emotion/react';
-import styled from '@emotion/styled';
-import type {PieSeriesOption} from 'echarts';
-
-import BaseChart, {BaseChartProps} from 'sentry/components/charts/baseChart';
-import PieSeries from 'sentry/components/charts/series/pieSeries';
-import CircleIndicator from 'sentry/components/circleIndicator';
-import {Tooltip} from 'sentry/components/tooltip';
-import {t} from 'sentry/locale';
-import {space} from 'sentry/styles/space';
-import {ReactEchartsRef, Series} from 'sentry/types/echarts';
-import {formatPercentage, getDuration} from 'sentry/utils/formatters';
-
-export interface PieChartSeries
-  extends Series,
-    Omit<PieSeriesOption, 'id' | 'color' | 'data'> {}
-
-interface Props extends Omit<BaseChartProps, 'series'> {
-  // TODO improve type
-  data: any;
-  series: PieChartSeries[];
-  theme: Theme;
-  selectOnRender?: boolean;
-}
-
-class PieChart extends Component<Props> {
-  componentDidMount() {
-    const {selectOnRender} = this.props;
-
-    if (!selectOnRender) {
-      return;
-    }
-
-    // Timeout is because we need to wait for rendering animation to complete
-    // And I haven't found a callback for this
-    this.highlightTimeout = window.setTimeout(() => this.highlight(0), 1000);
-  }
-
-  componentWillUnmount() {
-    window.clearTimeout(this.highlightTimeout);
-  }
-
-  highlightTimeout: number | undefined = undefined;
-  isInitialSelected = true;
-  selected = 0;
-  chart = createRef<ReactEchartsRef>();
-  pieChartSliceColors = [...this.props.theme.charts.getColorPalette(5)].reverse();
-
-  // Select a series to highlight (e.g. shows details of series)
-  // This is the same event as when you hover over a series in the chart
-  highlight = dataIndex => {
-    if (!this.chart.current) {
-      return;
-    }
-    this.chart.current.getEchartsInstance().dispatchAction({
-      type: 'highlight',
-      seriesIndex: 0,
-      dataIndex,
-    });
-  };
-
-  // Opposite of `highlight`
-  downplay = dataIndex => {
-    if (!this.chart.current) {
-      return;
-    }
-
-    this.chart.current.getEchartsInstance().dispatchAction({
-      type: 'downplay',
-      seriesIndex: 0,
-      dataIndex,
-    });
-  };
-
-  // echarts Legend does not have access to percentages (but tooltip does :/)
-  getSeriesPercentages = (series: PieChartSeries) => {
-    const total = series.data.reduce((acc, {value}) => acc + value, 0);
-    return series.data
-      .map(({name, value}) => [name, Math.round((value / total) * 10000) / 100])
-      .reduce(
-        (acc, [name, value]) => ({
-          ...acc,
-          [name]: value,
-        }),
-        {}
-      );
-  };
-
-  getSpanOpDurationChange = (op: string) => {
-    return this.props.data[op].oldBaseline / this.props.data[op].newBaseline - 1;
-  };
-
-  render() {
-    const {series, theme, ...props} = this.props;
-    if (!series || !series.length) {
-      return null;
-    }
-    if (series.length > 1) {
-      // eslint-disable-next-line no-console
-      console.warn('PieChart only uses the first series!');
-    }
-
-    // Note, we only take the first series unit!
-    const [firstSeries] = series;
-
-    // Attach a color and index to each operation. This allows us to match custom legend indicator
-    // colors to the op's pie chart color AND display the legend items sorted based on their
-    // percentage changes.
-    const operationToColorMap: {
-      [key: string]: {color: string; index: number};
-    } = {};
-    firstSeries.data.forEach((seriesRow, index) => {
-      operationToColorMap[seriesRow.name] = {
-        color: this.pieChartSliceColors[index],
-        index,
-      };
-    });
-
-    return (
-      <Wrapper>
-        <LegendWrapper>
-          {[...Object.keys(this.props.data)]
-            .sort((a, b) => {
-              return this.getSpanOpDurationChange(a) - this.getSpanOpDurationChange(b);
-            })
-            .map((op, index) => {
-              const change = this.getSpanOpDurationChange(op);
-              const oldValue = getDuration(
-                this.props.data[op].oldBaseline / 1000,
-                2,
-                true
-              );
-              const newValue = getDuration(
-                this.props.data[op].newBaseline / 1000,
-                2,
-                true
-              );
-              const percentage = this.props.data
-                ? formatPercentage(Math.abs(change))
-                : '';
-              const percentageText = change < 0 ? t('up') : t('down');
-              return (
-                <StyledLegendWrapper
-                  key={index}
-                  onMouseEnter={() => this.highlight(operationToColorMap[op].index)}
-                  onMouseLeave={() => this.downplay(operationToColorMap[op].index)}
-                >
-                  <span>
-                    <StyledColorIndicator
-                      color={operationToColorMap[op].color}
-                      size={10}
-                    />
-                    {op}
-                  </span>
-                  <Tooltip
-                    skipWrapper
-                    title={t(
-                      `Total time for %s went %s from %s to %s`,
-                      op,
-                      percentageText,
-                      oldValue,
-                      newValue
-                    )}
-                  >
-                    <SpanOpChange regressed={change < 0}>
-                      {percentageText} {percentage}
-                    </SpanOpChange>
-                  </Tooltip>
-                </StyledLegendWrapper>
-              );
-            })}
-        </LegendWrapper>
-        <BaseChart
-          ref={this.chart}
-          colors={this.pieChartSliceColors}
-          // when legend highlights it does NOT pass dataIndex :(
-          onHighlight={({name}) => {
-            if (
-              !this.isInitialSelected ||
-              !name ||
-              firstSeries.data[this.selected].name === name
-            ) {
-              return;
-            }
-
-            // Unhighlight if not initial "highlight" event and
-            // if name exists (i.e. not dispatched from cDM) and
-            // highlighted series name is different than the initially selected series name
-            this.downplay(this.selected);
-            this.isInitialSelected = false;
-          }}
-          onMouseOver={({dataIndex}) => {
-            if (!this.isInitialSelected) {
-              return;
-            }
-            if (dataIndex === this.selected) {
-              return;
-            }
-            this.downplay(this.selected);
-            this.isInitialSelected = false;
-          }}
-          {...props}
-          tooltip={{
-            formatter: data => {
-              return [
-                '<div class="tooltip-series">',
-                `<div><span class="tooltip-label">${data.marker}<strong>${data.name}</strong></span></div>`,
-                '</div>',
-                `<div class="tooltip-footer">${getDuration(
-                  this.props.data[data.name].oldBaseline / 1000,
-                  2,
-                  true
-                )} to ${getDuration(
-                  this.props.data[data.name].newBaseline / 1000,
-                  2,
-                  true
-                )}</div>`,
-                '</div>',
-                '<div class="tooltip-arrow"></div>',
-              ].join('');
-            },
-          }}
-          series={[
-            PieSeries({
-              name: firstSeries.seriesName,
-              data: firstSeries.data,
-              avoidLabelOverlap: false,
-              label: {
-                position: 'inside',
-                formatter: params => {
-                  return `${params.name} ${Math.round(Number(params.percent))}%`;
-                },
-                show: true,
-                color: theme.background,
-                width: 40,
-                overflow: 'break',
-              },
-              emphasis: {
-                label: {
-                  show: true,
-                },
-              },
-              labelLine: {
-                show: false,
-              },
-              center: ['90', '100'],
-              radius: ['45%', '85%'],
-              itemStyle: {
-                borderColor: theme.background,
-                borderWidth: 2,
-              },
-            }),
-          ]}
-          xAxis={null}
-          yAxis={null}
-        />
-      </Wrapper>
-    );
-  }
-}
-
-const Wrapper = styled('div')`
-  position: relative;
-`;
-
-const LegendWrapper = styled('div')`
-  position: absolute;
-  top: 50%;
-  transform: translateY(-60%);
-  left: 195px;
-  z-index: 100;
-`;
-
-const StyledLegendWrapper = styled('div')`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  gap: ${space(3)};
-`;
-
-const SpanOpChange = styled('span')<{regressed: boolean}>`
-  color: ${p => (p.regressed ? p.theme.red300 : p.theme.green300)};
-  text-decoration-line: underline;
-  text-decoration-style: dotted;
-  text-transform: capitalize;
-`;
-
-const StyledColorIndicator = styled(CircleIndicator)`
-  margin-right: ${space(0.5)};
-`;
-
-export default withTheme(PieChart);

+ 64 - 62
static/app/components/events/eventStatisticalDetector/aggregateSpanOps/spanOpBreakdown.tsx → static/app/components/events/eventStatisticalDetector/spanOpBreakdown.tsx

@@ -5,26 +5,31 @@ import moment from 'moment';
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import {DataSection} from 'sentry/components/events/styles';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  GridColumnOrder,
+} from 'sentry/components/gridEditable';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Event} from 'sentry/types';
+import {defined} from 'sentry/utils';
 import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
 import EventView from 'sentry/utils/discover/eventView';
+import {NumericChange, renderHeadCell} from 'sentry/utils/performance/regression/table';
 import {useLocation} from 'sentry/utils/useLocation';
 import useOrganization from 'sentry/utils/useOrganization';
 
-import PieChart from './pieChart';
-
 const SPAN_OPS = ['db', 'http', 'resource', 'browser', 'ui'];
-const REQUEST_FIELDS = SPAN_OPS.map(op => ({field: `p100(spans.${op})`}));
-const SPAN_OPS_NAME_MAP = {
-  ['p100(spans.db)']: 'db',
-  ['p100(spans.http)']: 'http',
-  ['p100(spans.resource)']: 'resource',
-  ['p100(spans.browser)']: 'browser',
-  ['p100(spans.ui)']: 'ui',
-};
+const REQUEST_FIELDS = SPAN_OPS.map(op => ({field: `p95(spans.${op})`}));
+
+interface SpanOpDiff {
+  p95: {
+    newBaseline: number;
+    oldBaseline: number;
+  };
+  span_op: string;
+}
 
 function getPostBreakpointEventView(location: Location, event: Event, end: number) {
   const eventView = EventView.fromLocation(location);
@@ -64,6 +69,26 @@ function getPreBreakpointEventView(location: Location, event: Event) {
   return eventView;
 }
 
+function renderBodyCell({
+  column,
+  row,
+}: {
+  column: GridColumnOrder<string>;
+  row: SpanOpDiff;
+}) {
+  if (column.key === 'p95') {
+    const {oldBaseline, newBaseline} = row[column.key];
+    return (
+      <NumericChange
+        beforeRawValue={oldBaseline}
+        afterRawValue={newBaseline}
+        columnKey={column.key}
+      />
+    );
+  }
+  return row[column.key];
+}
+
 function EventSpanOpBreakdown({event}: {event: Event}) {
   const organization = useOrganization();
   const location = useLocation();
@@ -96,22 +121,15 @@ function EventSpanOpBreakdown({event}: {event: Event}) {
     queryExtras,
   });
 
-  const postBreakpointPrunedSpanOps = Object.entries(postBreakpointData?.data[0] || {})
-    .filter(entry => (entry[1] as number) > 0)
-    .map(entry => ({
-      value: entry[1] as number,
-      name: SPAN_OPS_NAME_MAP[entry[0]],
-    }));
-
-  const spanOpDiffs = SPAN_OPS.map(op => {
+  const spanOpDiffs: SpanOpDiff[] = SPAN_OPS.map(op => {
     const preBreakpointValue =
-      (preBreakpointData?.data[0][`p100(spans.${op})`] as string) || undefined;
+      (preBreakpointData?.data[0][`p95(spans.${op})`] as string) || undefined;
     const preBreakpointValueAsNumber = preBreakpointValue
       ? parseInt(preBreakpointValue, 10)
       : 0;
 
     const postBreakpointValue =
-      (postBreakpointData?.data[0][`p100(spans.${op})`] as string) || undefined;
+      (postBreakpointData?.data[0][`p95(spans.${op})`] as string) || undefined;
     const postBreakpointValueAsNumber = postBreakpointValue
       ? parseInt(postBreakpointValue, 10)
       : 0;
@@ -120,57 +138,46 @@ function EventSpanOpBreakdown({event}: {event: Event}) {
       return null;
     }
     return {
-      [op]: {
-        percentChange: postBreakpointValueAsNumber / preBreakpointValueAsNumber,
+      span_op: op,
+      p95: {
         oldBaseline: preBreakpointValueAsNumber,
         newBaseline: postBreakpointValueAsNumber,
       },
     };
-  })
-    .filter(Boolean)
-    .reduce((acc, opDiffData) => {
-      if (opDiffData && acc) {
-        Object.keys(opDiffData).forEach(op => {
-          acc[op] = opDiffData[op];
-        });
-      }
-      return acc;
-    }, {});
-
-  const series = [
-    {
-      seriesName: 'Aggregate Span Op Breakdown',
-      data: postBreakpointPrunedSpanOps,
-    },
-  ];
+  }).filter(defined);
 
   if (postBreakpointIsLoading || preBreakpointIsLoading) {
-    return (
-      <Wrapper>
-        <LoadingIndicator />
-      </Wrapper>
-    );
+    return <LoadingIndicator />;
   }
 
   if (postBreakpointIsError || preBreakpointIsError) {
     return (
-      <Wrapper>
-        <EmptyStateWrapper>
-          <EmptyStateWarning withIcon>
-            <div>{t('Unable to fetch span breakdowns')}</div>
-          </EmptyStateWarning>
-        </EmptyStateWrapper>
-      </Wrapper>
+      <EmptyStateWrapper>
+        <EmptyStateWarning withIcon>
+          <div>{t('Unable to fetch span breakdowns')}</div>
+        </EmptyStateWarning>
+      </EmptyStateWrapper>
     );
   }
 
   return (
-    <Wrapper>
-      <DataSection>
-        <strong>{t('Operation Breakdown:')}</strong>
-        <PieChart data={spanOpDiffs} series={series} />
-      </DataSection>
-    </Wrapper>
+    <DataSection>
+      <strong>{t('Operation Breakdown:')}</strong>
+      <GridEditable
+        isLoading={false}
+        data={spanOpDiffs}
+        location={location}
+        columnOrder={[
+          {key: 'span_op', name: t('Span Operation'), width: 200},
+          {key: 'p95', name: t('p95'), width: COL_WIDTH_UNDEFINED},
+        ]}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell,
+          renderBodyCell: (column, row) => renderBodyCell({column, row}),
+        }}
+      />
+    </DataSection>
   );
 }
 
@@ -183,9 +190,4 @@ const EmptyStateWrapper = styled('div')`
   margin: ${space(1.5)} ${space(4)};
 `;
 
-const Wrapper = styled('div')`
-  display: grid;
-  grid-template-columns: 1fr 1fr;
-`;
-
 export default EventSpanOpBreakdown;

+ 107 - 0
static/app/utils/performance/regression/table.tsx

@@ -0,0 +1,107 @@
+import styled from '@emotion/styled';
+
+import {GridColumnOrder} from 'sentry/components/gridEditable';
+import {IconArrow} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {getFieldRenderer} from 'sentry/utils/discover/fieldRenderers';
+import {RateUnits} from 'sentry/utils/discover/fields';
+import {NumberContainer} from 'sentry/utils/discover/styles';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+
+function getPercentChange(before: number, after: number) {
+  return ((after - before) / before) * 100;
+}
+
+export function renderHeadCell(column: GridColumnOrder<string>) {
+  if (['spm', 'p95'].includes(column.key)) {
+    return <NumericColumnLabel>{column.name}</NumericColumnLabel>;
+  }
+  return column.name;
+}
+
+export function NumericChange({
+  columnKey,
+  beforeRawValue,
+  afterRawValue,
+}: {
+  afterRawValue: number;
+  beforeRawValue: number;
+  columnKey: string;
+}) {
+  const organization = useOrganization();
+  const location = useLocation();
+  const percentChange = getPercentChange(beforeRawValue, afterRawValue);
+
+  const unit = columnKey === 'p95' ? 'millisecond' : RateUnits.PER_MINUTE;
+  const renderer = (value: number) =>
+    getFieldRenderer(
+      columnKey,
+      {
+        p95: 'duration',
+        spm: 'rate',
+      },
+      false
+    )({[columnKey]: value}, {organization, location, unit});
+
+  if (Math.round(percentChange) !== 0) {
+    let percentChangeLabel = `${percentChange > 0 ? '+' : ''}${Math.round(
+      percentChange
+    )}%`;
+    if (beforeRawValue === 0) {
+      percentChangeLabel = t('New');
+    }
+    return (
+      <Change>
+        {renderer(beforeRawValue)}
+        <IconArrow direction="right" size="xs" />
+        {renderer(afterRawValue)}
+        <ChangeLabel isPositive={percentChange > 0} isNeutral={beforeRawValue === 0}>
+          ({percentChangeLabel})
+        </ChangeLabel>
+      </Change>
+    );
+  }
+
+  return (
+    <Change>
+      {renderer(afterRawValue)}
+      <ChangeDescription>{t('(No significant change)')}</ChangeDescription>
+    </Change>
+  );
+}
+
+export const NumericColumnLabel = styled('div')`
+  text-align: right;
+  width: 100%;
+`;
+
+const ChangeLabel = styled('div')<{isNeutral: boolean; isPositive: boolean}>`
+  color: ${p => {
+    if (p.isNeutral) {
+      return p.theme.gray300;
+    }
+    if (p.isPositive) {
+      return p.theme.red300;
+    }
+    return p.theme.green300;
+  }};
+  text-align: right;
+`;
+
+const Change = styled('span')`
+  display: flex;
+  align-items: center;
+  gap: ${space(1)};
+  justify-content: right;
+
+  ${NumberContainer} {
+    width: unset;
+  }
+`;
+
+const ChangeDescription = styled('span')`
+  color: ${p => p.theme.gray300};
+  white-space: nowrap;
+`;

+ 1 - 1
static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

@@ -15,7 +15,6 @@ import {EventExtraData} from 'sentry/components/events/eventExtraData';
 import EventReplay from 'sentry/components/events/eventReplay';
 import {EventSdk} from 'sentry/components/events/eventSdk';
 import AggregateSpanDiff from 'sentry/components/events/eventStatisticalDetector/aggregateSpanDiff';
-import EventSpanOpBreakdown from 'sentry/components/events/eventStatisticalDetector/aggregateSpanOps/spanOpBreakdown';
 import EventBreakpointChart from 'sentry/components/events/eventStatisticalDetector/breakpointChart';
 import {EventAffectedTransactions} from 'sentry/components/events/eventStatisticalDetector/eventAffectedTransactions';
 import EventComparison from 'sentry/components/events/eventStatisticalDetector/eventComparison';
@@ -23,6 +22,7 @@ import {EventFunctionComparisonList} from 'sentry/components/events/eventStatist
 import {EventFunctionRegressionEvidence} from 'sentry/components/events/eventStatisticalDetector/eventFunctionRegressionEvidence';
 import {EventFunctionBreakpointChart} from 'sentry/components/events/eventStatisticalDetector/functionBreakpointChart';
 import RegressionMessage from 'sentry/components/events/eventStatisticalDetector/regressionMessage';
+import EventSpanOpBreakdown from 'sentry/components/events/eventStatisticalDetector/spanOpBreakdown';
 import {EventTagsAndScreenshot} from 'sentry/components/events/eventTagsAndScreenshot';
 import {EventViewHierarchy} from 'sentry/components/events/eventViewHierarchy';
 import {EventGroupingInfo} from 'sentry/components/events/groupingInfo';