Browse Source

feat(chartcuterie): Refactor Function Regression Chart to separate data (#70250)

I'm adding Chartcuterie support for Function Regression chart. The first
step is to refactor it so we can generate the chart props in the
backend. I updated the `getBreakpointChartOptionsFromData` function to
remove backwards compatibility for `functionBreakpointChart` since I am
updating it to move all the transformation to a separate function.

I tested using `yarn dev-ui`:
<img width="1183" alt="image"
src="https://github.com/getsentry/sentry/assets/33237075/ebd827ef-0535-4230-baf1-1440a40f1d5d">

<img width="1183" alt="image"
src="https://github.com/getsentry/sentry/assets/33237075/e3af0ae3-94a9-48f8-8c7a-dc997ec167ae">

Preview of the Slack Image 👀 

![example3](https://github.com/getsentry/sentry/assets/33237075/de8da223-13b4-427e-a2e4-4220ef256a05)
Raj Joshi 10 months ago
parent
commit
716dcf2e5e

+ 54 - 1
static/app/chartcuterie/performance.tsx

@@ -3,7 +3,10 @@ import {transformToLineSeries} from 'sentry/components/charts/lineChart';
 import getBreakpointChartOptionsFromData, {
   type EventBreakpointChartData,
 } from 'sentry/components/events/eventStatisticalDetector/breakpointChartOptions';
+import type {EventsStatsSeries} from 'sentry/types';
+import {transformStatsResponse} from 'sentry/utils/profiling/hooks/utils';
 import {lightTheme as theme} from 'sentry/utils/theme';
+import type {NormalizedTrendsTransaction} from 'sentry/views/performance/trends/types';
 
 import {slackChartDefaults, slackChartSize} from './slack';
 import type {RenderDescriptor} from './types';
@@ -11,6 +14,10 @@ import {ChartType} from './types';
 
 export const performanceCharts: RenderDescriptor<ChartType>[] = [];
 
+export type FunctionRegressionPercentileData = {
+  data: EventsStatsSeries<'p95()'>;
+};
+
 function modifyOptionsForSlack(options: Omit<LineChartProps, 'series'>) {
   options.legend = options.legend || {};
   options.legend.icon = 'none';
@@ -23,11 +30,57 @@ function modifyOptionsForSlack(options: Omit<LineChartProps, 'series'>) {
     visualMap: options.options?.visualMap,
   };
 }
+type FunctionRegressionChartData = {
+  evidenceData: NormalizedTrendsTransaction;
+  rawResponse: any;
+};
 
 performanceCharts.push({
   key: ChartType.SLACK_PERFORMANCE_ENDPOINT_REGRESSION,
   getOption: (data: EventBreakpointChartData) => {
-    const {chartOptions, series} = getBreakpointChartOptionsFromData(data, theme);
+    const {chartOptions, series} = getBreakpointChartOptionsFromData(
+      data,
+      ChartType.SLACK_PERFORMANCE_ENDPOINT_REGRESSION,
+      theme
+    );
+    const transformedSeries = transformToLineSeries({series});
+    const modifiedOptions = modifyOptionsForSlack(chartOptions);
+
+    return {
+      ...modifiedOptions,
+
+      backgroundColor: theme.background,
+      series: transformedSeries,
+      grid: slackChartDefaults.grid,
+      visualMap: modifiedOptions.options?.visualMap,
+    };
+  },
+  ...slackChartSize,
+});
+
+performanceCharts.push({
+  key: ChartType.SLACK_PERFORMANCE_FUNCTION_REGRESSION,
+  getOption: (data: FunctionRegressionChartData) => {
+    const transformed = transformStatsResponse(
+      'profileFunctions',
+      ['p95()'],
+      data.rawResponse
+    );
+
+    const percentileData = {
+      data: transformed,
+    };
+
+    const param = {
+      percentileData: percentileData as FunctionRegressionPercentileData,
+      evidenceData: data.evidenceData,
+    };
+
+    const {chartOptions, series} = getBreakpointChartOptionsFromData(
+      param,
+      ChartType.SLACK_PERFORMANCE_FUNCTION_REGRESSION,
+      theme
+    );
     const transformedSeries = transformToLineSeries({series});
     const modifiedOptions = modifyOptionsForSlack(chartOptions);
 

+ 1 - 0
static/app/chartcuterie/types.tsx

@@ -17,6 +17,7 @@ export enum ChartType {
   SLACK_METRIC_ALERT_EVENTS = 'slack:metricAlert.events',
   SLACK_METRIC_ALERT_SESSIONS = 'slack:metricAlert.sessions',
   SLACK_PERFORMANCE_ENDPOINT_REGRESSION = 'slack:performance.endpointRegression',
+  SLACK_PERFORMANCE_FUNCTION_REGRESSION = 'slack:performance.functionRegression',
 }
 
 /**

+ 2 - 0
static/app/components/events/eventStatisticalDetector/breakpointChart.tsx

@@ -1,3 +1,4 @@
+import {ChartType} from 'sentry/chartcuterie/types';
 import TransitionChart from 'sentry/components/charts/transitionChart';
 import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingMask';
 import type {Event, EventsStatsData} from 'sentry/types';
@@ -80,6 +81,7 @@ function EventBreakpointChart({event}: EventBreakpointChartProps) {
           percentileData={data?.['p95(transaction.duration)']?.data ?? []}
           evidenceData={normalizedOccurrenceEvent}
           datetime={datetime}
+          chartType={ChartType.SLACK_PERFORMANCE_ENDPOINT_REGRESSION}
         />
       </TransitionChart>
     </DataSection>

+ 32 - 12
static/app/components/events/eventStatisticalDetector/breakpointChartOptions.tsx

@@ -1,8 +1,9 @@
 import type {Theme} from '@emotion/react';
 
+import type {FunctionRegressionPercentileData} from 'sentry/chartcuterie/performance';
+import {ChartType} from 'sentry/chartcuterie/types';
 import VisualMap from 'sentry/components/charts/components/visualMap';
 import type {LineChart as EChartsLineChart} from 'sentry/components/charts/lineChart';
-import type {Series} from 'sentry/types/echarts';
 import type {EventsStatsData} from 'sentry/types/organization';
 import {
   axisLabelFormatter,
@@ -20,22 +21,41 @@ import {getIntervalLine} from 'sentry/views/performance/utils/getIntervalLine';
 
 export type EventBreakpointChartData = {
   evidenceData: NormalizedTrendsTransaction;
-  percentileData?: EventsStatsData;
-  percentileSeries?: Series[];
+  percentileData: EventsStatsData | FunctionRegressionPercentileData;
 };
 
 function getBreakpointChartOptionsFromData(
-  {percentileData, evidenceData, percentileSeries}: EventBreakpointChartData,
+  {percentileData, evidenceData}: EventBreakpointChartData,
+  chartType: ChartType,
   theme: Theme
 ) {
-  const transformedSeries = percentileData
-    ? transformEventStats(
-        percentileData,
-        generateTrendFunctionAsString(TrendFunctionField.P95, 'transaction.duration')
-      )
-    : percentileSeries
-      ? percentileSeries
-      : [];
+  const trendFunctionName: Partial<{[key in ChartType]: string}> = {
+    [ChartType.SLACK_PERFORMANCE_ENDPOINT_REGRESSION]: 'transaction.duration',
+    [ChartType.SLACK_PERFORMANCE_FUNCTION_REGRESSION]: 'function.duration',
+  };
+
+  const defaultTransform = stats => stats;
+
+  const transformFunctionStats = (stats: any) => {
+    const rawData = stats?.data?.data?.find(({axis}) => axis === 'p95()');
+    const timestamps = stats?.data?.timestamps;
+    if (!timestamps) {
+      return [];
+    }
+    return timestamps.map((timestamp, i) => [timestamp, [{count: rawData.values[i]}]]);
+  };
+
+  // Mapping from BreakpointType to transformation functions
+  const transformFunction: Partial<{[key in ChartType]: (arg: any) => EventsStatsData}> =
+    {
+      [ChartType.SLACK_PERFORMANCE_ENDPOINT_REGRESSION]: defaultTransform,
+      [ChartType.SLACK_PERFORMANCE_FUNCTION_REGRESSION]: transformFunctionStats,
+    };
+
+  const transformedSeries = transformEventStats(
+    transformFunction[chartType]!(percentileData),
+    generateTrendFunctionAsString(TrendFunctionField.P95, trendFunctionName[chartType]!)
+  );
 
   const intervalSeries = getIntervalLine(
     theme,

+ 4 - 15
static/app/components/events/eventStatisticalDetector/functionBreakpointChart.tsx

@@ -1,6 +1,7 @@
-import {useEffect, useMemo} from 'react';
+import {useEffect} from 'react';
 import * as Sentry from '@sentry/react';
 
+import {ChartType} from 'sentry/chartcuterie/types';
 import Chart from 'sentry/components/events/eventStatisticalDetector/lineChart';
 import {DataSection} from 'sentry/components/events/styles';
 import type {Event} from 'sentry/types/event';
@@ -8,7 +9,6 @@ import {defined} from 'sentry/utils';
 import {useProfileEventsStats} from 'sentry/utils/profiling/hooks/useProfileEventsStats';
 import {useRelativeDateTime} from 'sentry/utils/profiling/hooks/useRelativeDateTime';
 import type {NormalizedTrendsTransaction} from 'sentry/views/performance/trends/types';
-import transformEventStats from 'sentry/views/performance/trends/utils/transformEventStats';
 
 import {RELATIVE_DAYS_WINDOW} from './consts';
 
@@ -75,18 +75,6 @@ function EventFunctionBreakpointChartInner({
     yAxes: SERIES,
   });
 
-  const p95Series = useMemo(() => {
-    const rawData = functionStats?.data?.data?.find(({axis}) => axis === 'p95()');
-    const timestamps = functionStats?.data?.timestamps;
-    if (!timestamps) {
-      return [];
-    }
-    return transformEventStats(
-      timestamps.map((timestamp, i) => [timestamp, [{count: rawData.values[i]}]]),
-      'p95(function.duration)'
-    );
-  }, [functionStats]);
-
   const normalizedOccurrenceEvent = {
     aggregate_range_1: evidenceData.aggregateRange1 / 1e6,
     aggregate_range_2: evidenceData.aggregateRange2 / 1e6,
@@ -96,9 +84,10 @@ function EventFunctionBreakpointChartInner({
   return (
     <DataSection>
       <Chart
-        percentileSeries={p95Series}
+        percentileData={functionStats}
         evidenceData={normalizedOccurrenceEvent}
         datetime={datetime}
+        chartType={ChartType.SLACK_PERFORMANCE_FUNCTION_REGRESSION}
       />
     </DataSection>
   );

+ 11 - 13
static/app/components/events/eventStatisticalDetector/lineChart.tsx

@@ -1,37 +1,35 @@
 import {useMemo} from 'react';
 import {useTheme} from '@emotion/react';
 
+import type {FunctionRegressionPercentileData} from 'sentry/chartcuterie/performance';
+import type {ChartType} from 'sentry/chartcuterie/types';
 import ChartZoom from 'sentry/components/charts/chartZoom';
 import {LineChart as EChartsLineChart} from 'sentry/components/charts/lineChart';
 import getBreakpointChartOptionsFromData from 'sentry/components/events/eventStatisticalDetector/breakpointChartOptions';
-import type {EventsStatsData, PageFilters} from 'sentry/types';
-import type {Series} from 'sentry/types/echarts';
+import type {PageFilters} from 'sentry/types';
+import type {EventsStatsData} from 'sentry/types/organization';
 import useRouter from 'sentry/utils/useRouter';
 import type {NormalizedTrendsTransaction} from 'sentry/views/performance/trends/types';
 
 interface ChartProps {
+  chartType: ChartType;
   datetime: PageFilters['datetime'];
   evidenceData: NormalizedTrendsTransaction;
-  // TODO @athena: Refactor functionBreakpointChart to use percentileData
-  percentileData?: EventsStatsData;
-  percentileSeries?: Series[];
+  percentileData: EventsStatsData | FunctionRegressionPercentileData;
+  trendFunctionName?: string;
 }
 
-function LineChart({
-  datetime,
-  percentileData,
-  percentileSeries,
-  evidenceData,
-}: ChartProps) {
+function LineChart({datetime, percentileData, evidenceData, chartType}: ChartProps) {
   const theme = useTheme();
   const router = useRouter();
 
   const {series, chartOptions} = useMemo(() => {
     return getBreakpointChartOptionsFromData(
-      {percentileData, percentileSeries, evidenceData},
+      {percentileData, evidenceData},
+      chartType,
       theme
     );
-  }, [percentileData, percentileSeries, evidenceData, theme]);
+  }, [percentileData, evidenceData, chartType, theme]);
 
   return (
     <ChartZoom router={router} {...datetime}>

+ 2 - 123
static/app/utils/profiling/hooks/useProfileEventsStats.tsx

@@ -1,10 +1,8 @@
 import {useMemo} from 'react';
 
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
-import type {EventsStatsSeries, PageFilters} from 'sentry/types';
-import {defined} from 'sentry/utils';
-import {getAggregateAlias} from 'sentry/utils/discover/fields';
-import {makeFormatTo} from 'sentry/utils/profiling/units/units';
+import type {PageFilters} from 'sentry/types';
+import {transformStatsResponse} from 'sentry/utils/profiling/hooks/utils';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
@@ -73,122 +71,3 @@ export function useProfileEventsStats<F extends string>({
     ...rest,
   };
 }
-
-export function transformStatsResponse<F extends string>(
-  dataset: 'discover' | 'profiles' | 'profileFunctions',
-  yAxes: readonly F[],
-  rawData: any
-): EventsStatsSeries<F> {
-  // the events stats endpoint has a legacy response format so here we transform it
-  // into the proposed update for forward compatibility and ease of use
-
-  if (yAxes.length === 0) {
-    return {
-      data: [],
-      meta: {
-        dataset,
-        end: 0,
-        start: 0,
-      },
-      timestamps: [],
-    };
-  }
-
-  if (yAxes.length === 1) {
-    const {series, meta, timestamps} = transformSingleSeries(dataset, yAxes[0], rawData);
-    return {
-      data: [series],
-      meta,
-      timestamps,
-    };
-  }
-
-  const data: EventsStatsSeries<F>['data'] = [];
-  let meta: EventsStatsSeries<F>['meta'] = {
-    dataset,
-    end: -1,
-    start: -1,
-  };
-  let timestamps: EventsStatsSeries<F>['timestamps'] = [];
-
-  let firstAxis = true;
-
-  for (const yAxis of yAxes) {
-    const dataForYAxis = rawData[yAxis];
-    if (!defined(dataForYAxis)) {
-      continue;
-    }
-    const transformed = transformSingleSeries(dataset, yAxis, dataForYAxis);
-
-    if (firstAxis) {
-      meta = transformed.meta;
-      timestamps = transformed.timestamps;
-    } else if (
-      meta.start !== transformed.meta.start ||
-      meta.end !== transformed.meta.end
-    ) {
-      throw new Error('Mismatching start/end times');
-    } else if (
-      timestamps.length !== transformed.timestamps.length ||
-      timestamps.some((ts, i) => ts !== transformed.timestamps[i])
-    ) {
-      throw new Error('Mismatching timestamps');
-    }
-
-    data.push(transformed.series);
-
-    firstAxis = false;
-  }
-
-  return {
-    data,
-    meta,
-    timestamps,
-  };
-}
-
-export function transformSingleSeries<F extends string>(
-  dataset: 'discover' | 'profiles' | 'profileFunctions',
-  yAxis: F,
-  rawSeries: any,
-  label?: string
-) {
-  const type =
-    rawSeries.meta.fields[yAxis] ?? rawSeries.meta.fields[getAggregateAlias(yAxis)];
-  const formatter =
-    type === 'duration'
-      ? makeFormatTo(
-          rawSeries.meta.units[yAxis] ??
-            rawSeries.meta.units[getAggregateAlias(yAxis)] ??
-            'nanoseconds',
-          'milliseconds'
-        )
-      : type === 'string'
-        ? value => value || ''
-        : value => value;
-
-  const series: EventsStatsSeries<F>['data'][number] = {
-    axis: yAxis,
-    values: [],
-    label,
-  };
-  const meta: EventsStatsSeries<F>['meta'] = {
-    dataset,
-    end: rawSeries.end,
-    start: rawSeries.start,
-  };
-  const timestamps: EventsStatsSeries<F>['timestamps'] = [];
-
-  for (let i = 0; i < rawSeries.data.length; i++) {
-    const [timestamp, value] = rawSeries.data[i];
-    // the api has this awkward structure for legacy reason
-    series.values.push(formatter(value[0].count as number));
-    timestamps.push(timestamp);
-  }
-
-  return {
-    series,
-    meta,
-    timestamps,
-  };
-}

+ 1 - 1
static/app/utils/profiling/hooks/useProfileTopEventsStats.tsx

@@ -3,7 +3,7 @@ import {useMemo} from 'react';
 import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
 import type {EventsStatsSeries, PageFilters} from 'sentry/types';
 import {defined} from 'sentry/utils';
-import {transformSingleSeries} from 'sentry/utils/profiling/hooks/useProfileEventsStats';
+import {transformSingleSeries} from 'sentry/utils/profiling/hooks/utils';
 import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';

+ 122 - 0
static/app/utils/profiling/hooks/utils.tsx

@@ -1,5 +1,8 @@
 import {t} from 'sentry/locale';
+import type {EventsStatsSeries} from 'sentry/types';
 import {defined} from 'sentry/utils';
+import {getAggregateAlias} from 'sentry/utils/discover/fields';
+import {makeFormatTo} from 'sentry/utils/profiling/units/units';
 
 import type {Sort} from './types';
 
@@ -36,3 +39,122 @@ export function formatError(error: any): string | null {
 
   return t('An unknown error occurred.');
 }
+
+export function transformStatsResponse<F extends string>(
+  dataset: 'discover' | 'profiles' | 'profileFunctions',
+  yAxes: readonly F[],
+  rawData: any
+): EventsStatsSeries<F> {
+  // the events stats endpoint has a legacy response format so here we transform it
+  // into the proposed update for forward compatibility and ease of use
+
+  if (yAxes.length === 0) {
+    return {
+      data: [],
+      meta: {
+        dataset,
+        end: 0,
+        start: 0,
+      },
+      timestamps: [],
+    };
+  }
+
+  if (yAxes.length === 1) {
+    const {series, meta, timestamps} = transformSingleSeries(dataset, yAxes[0], rawData);
+    return {
+      data: [series],
+      meta,
+      timestamps,
+    };
+  }
+
+  const data: EventsStatsSeries<F>['data'] = [];
+  let meta: EventsStatsSeries<F>['meta'] = {
+    dataset,
+    end: -1,
+    start: -1,
+  };
+  let timestamps: EventsStatsSeries<F>['timestamps'] = [];
+
+  let firstAxis = true;
+
+  for (const yAxis of yAxes) {
+    const dataForYAxis = rawData[yAxis];
+    if (!defined(dataForYAxis)) {
+      continue;
+    }
+    const transformed = transformSingleSeries(dataset, yAxis, dataForYAxis);
+
+    if (firstAxis) {
+      meta = transformed.meta;
+      timestamps = transformed.timestamps;
+    } else if (
+      meta.start !== transformed.meta.start ||
+      meta.end !== transformed.meta.end
+    ) {
+      throw new Error('Mismatching start/end times');
+    } else if (
+      timestamps.length !== transformed.timestamps.length ||
+      timestamps.some((ts, i) => ts !== transformed.timestamps[i])
+    ) {
+      throw new Error('Mismatching timestamps');
+    }
+
+    data.push(transformed.series);
+
+    firstAxis = false;
+  }
+
+  return {
+    data,
+    meta,
+    timestamps,
+  };
+}
+
+export function transformSingleSeries<F extends string>(
+  dataset: 'discover' | 'profiles' | 'profileFunctions',
+  yAxis: F,
+  rawSeries: any,
+  label?: string
+) {
+  const type =
+    rawSeries.meta.fields[yAxis] ?? rawSeries.meta.fields[getAggregateAlias(yAxis)];
+  const formatter =
+    type === 'duration'
+      ? makeFormatTo(
+          rawSeries.meta.units[yAxis] ??
+            rawSeries.meta.units[getAggregateAlias(yAxis)] ??
+            'nanoseconds',
+          'milliseconds'
+        )
+      : type === 'string'
+        ? value => value || ''
+        : value => value;
+
+  const series: EventsStatsSeries<F>['data'][number] = {
+    axis: yAxis,
+    values: [],
+    label,
+  };
+  const meta: EventsStatsSeries<F>['meta'] = {
+    dataset,
+    end: rawSeries.end,
+    start: rawSeries.start,
+  };
+  const timestamps: EventsStatsSeries<F>['timestamps'] = [];
+
+  for (let i = 0; i < rawSeries.data.length; i++) {
+    const [timestamp, value] = rawSeries.data[i];
+    // the api has this awkward structure for legacy reason
+    series.values.push(formatter(value[0].count as number));
+    timestamps.push(timestamp);
+  }
+
+  return {
+    series,
+    meta,
+    timestamps,
+  };
+}