Browse Source

feat(stats-detectors): Add span op breakdown (#55587)

This pr adds an aggregate span op breakdown. Some styles to be improved.
Will clean this up in follow up PR
<img width="854" alt="image"
src="https://github.com/getsentry/sentry/assets/23648387/6f0cc04c-fd4f-4536-8bd8-0b4463b76479">

---------

Co-authored-by: Nar Saynorath <nar.saynorath@sentry.io>
Dameli Ushbayeva 1 year ago
parent
commit
87b855a809

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

@@ -0,0 +1,200 @@
+import {Component, createRef} from 'react';
+import {Theme, withTheme} from '@emotion/react';
+import type {PieSeriesOption} from 'echarts';
+
+import BaseChart, {BaseChartProps} from 'sentry/components/charts/baseChart';
+import Legend from 'sentry/components/charts/components/legend';
+import PieSeries from 'sentry/components/charts/series/pieSeries';
+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>();
+
+  // 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,
+        }),
+        {}
+      );
+  };
+
+  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;
+
+    return (
+      <BaseChart
+        ref={this.chart}
+        colors={[...theme.charts.getColorPalette(5)].reverse()}
+        // 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}
+        legend={Legend({
+          theme,
+          orient: 'vertical',
+          align: 'left',
+          show: true,
+          right: 0,
+          top: 10,
+          formatter: name => {
+            return `${name} ${
+              this.props.data
+                ? getDuration(this.props.data[name].newBaseline / 1000, 2, true)
+                : ''
+            } ${
+              this.props.data
+                ? formatPercentage(
+                    this.props.data[name].oldBaseline /
+                      this.props.data[name].newBaseline -
+                      1
+                  )
+                : ''
+            }`;
+          },
+        })}
+        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: 'inner',
+              // TODO show labels after they're styled
+              formatter: () => '',
+              show: false,
+            },
+            emphasis: {
+              label: {
+                show: true,
+              },
+            },
+            labelLine: {
+              show: false,
+            },
+          }),
+        ]}
+        xAxis={null}
+        yAxis={null}
+      />
+    );
+  }
+}
+
+export default withTheme(PieChart);

+ 186 - 0
static/app/components/events/eventStatisticalDetector/aggregateSpanOps/spanOpBreakdown.tsx

@@ -0,0 +1,186 @@
+import styled from '@emotion/styled';
+import {Location} from 'history';
+
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import {DataSection} from 'sentry/components/events/styles';
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Event} from 'sentry/types';
+import {useDiscoverQuery} from 'sentry/utils/discover/discoverQuery';
+import EventView from 'sentry/utils/discover/eventView';
+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',
+};
+
+function getPostBreakpointEventView(location: Location, event: Event) {
+  const eventView = EventView.fromLocation(location);
+  eventView.fields = REQUEST_FIELDS;
+
+  if (event?.occurrence) {
+    const {breakpoint, aggregateRange2, transaction, requestEnd} =
+      event?.occurrence?.evidenceData;
+    eventView.start = new Date(breakpoint * 1000).toISOString();
+    eventView.end = new Date(requestEnd * 1000).toISOString();
+
+    eventView.query = `event.type:transaction transaction:"${transaction}" transaction.duration:<${
+      aggregateRange2 * 1.15
+    }`;
+  }
+
+  return eventView;
+}
+
+function getPreBreakpointEventView(location: Location, event: Event) {
+  const eventView = EventView.fromLocation(location);
+  eventView.fields = REQUEST_FIELDS;
+
+  if (event?.occurrence) {
+    const {breakpoint, aggregateRange1, transaction, requestStart} =
+      event?.occurrence?.evidenceData;
+    eventView.start = new Date(requestStart * 1000).toISOString();
+    eventView.end = new Date(breakpoint * 1000).toISOString();
+
+    eventView.query = `event.type:transaction transaction:"${transaction}" transaction.duration:<${
+      aggregateRange1 * 1.15
+    }`;
+  }
+
+  return eventView;
+}
+
+function EventSpanOpBreakdown({event}: {event: Event}) {
+  const organization = useOrganization();
+  const location = useLocation();
+
+  const postBreakpointEventView = getPostBreakpointEventView(location, event);
+  const preBreakpointEventView = getPreBreakpointEventView(location, event);
+
+  const queryExtras = {dataset: 'metricsEnhanced'};
+
+  const {
+    data: postBreakpointData,
+    isLoading: postBreakpointIsLoading,
+    isError: postBreakpointIsError,
+  } = useDiscoverQuery({
+    eventView: postBreakpointEventView,
+    orgSlug: organization.slug,
+    location,
+    queryExtras,
+  });
+
+  const {
+    data: preBreakpointData,
+    isLoading: preBreakpointIsLoading,
+    isError: preBreakpointIsError,
+  } = useDiscoverQuery({
+    eventView: preBreakpointEventView,
+    orgSlug: organization.slug,
+    location,
+    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 preBreakpointValue =
+      (preBreakpointData?.data[0][`p100(spans.${op})`] as string) || undefined;
+    const preBreakpointValueAsNumber = preBreakpointValue
+      ? parseInt(preBreakpointValue, 10)
+      : 0;
+
+    const postBreakpointValue =
+      (postBreakpointData?.data[0][`p100(spans.${op})`] as string) || undefined;
+    const postBreakpointValueAsNumber = postBreakpointValue
+      ? parseInt(postBreakpointValue, 10)
+      : 0;
+
+    if (preBreakpointValueAsNumber === 0 || postBreakpointValueAsNumber === 0) {
+      return null;
+    }
+    return {
+      [op]: {
+        percentChange: postBreakpointValueAsNumber / preBreakpointValueAsNumber,
+        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,
+    },
+  ];
+
+  if (postBreakpointIsLoading || preBreakpointIsLoading) {
+    return (
+      <Wrapper>
+        <LoadingIndicator />
+      </Wrapper>
+    );
+  }
+
+  if (postBreakpointIsError || preBreakpointIsError) {
+    return (
+      <Wrapper>
+        <EmptyStateWrapper>
+          <EmptyStateWarning withIcon>
+            <div>{t('Unable to fetch span breakdowns')}</div>
+          </EmptyStateWarning>
+        </EmptyStateWrapper>
+      </Wrapper>
+    );
+  }
+
+  return (
+    <Wrapper>
+      <DataSection>
+        <strong>{t('Operation Breakdown:')}</strong>
+        <PieChart data={spanOpDiffs} series={series} />
+      </DataSection>
+    </Wrapper>
+  );
+}
+
+const EmptyStateWrapper = styled('div')`
+  border: ${({theme}) => `1px solid ${theme.border}`};
+  border-radius: ${({theme}) => theme.borderRadius};
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin: ${space(1.5)} ${space(4)};
+`;
+
+const Wrapper = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+`;
+
+export default EventSpanOpBreakdown;

+ 2 - 0
static/app/views/issueDetails/groupEventDetails/groupEventDetailsContent.tsx

@@ -14,6 +14,7 @@ import {EventEvidence} from 'sentry/components/events/eventEvidence';
 import {EventExtraData} from 'sentry/components/events/eventExtraData';
 import EventReplay from 'sentry/components/events/eventReplay';
 import {EventSdk} from 'sentry/components/events/eventSdk';
+import EventSpanOpBreakdown from 'sentry/components/events/eventStatisticalDetector/aggregateSpanOps/spanOpBreakdown';
 import EventBreakpointChart from 'sentry/components/events/eventStatisticalDetector/breakpointChart';
 import EventComparison from 'sentry/components/events/eventStatisticalDetector/eventComparison';
 import RegressionMessage from 'sentry/components/events/eventStatisticalDetector/regressionMessage';
@@ -97,6 +98,7 @@ function GroupEventDetailsContent({
         <Fragment>
           <RegressionMessage event={event} />
           <EventBreakpointChart event={event} />
+          <EventSpanOpBreakdown event={event} />
           <EventComparison event={event} group={group} project={project} />
         </Fragment>
       </Feature>