Browse Source

feat(explore): Supporting multi chart top N events (#78055)

Did some data massaging to handle Single series, multiple series and
grouped multiple series from the same `events-stats` query response

Dev-ui testing:
[link](https://sentry.dev.getsentry.net:7999/traces/?field=project&field=id&field=span.op&field=span.description&field=span.duration&field=timestamp&field=plan.tier&groupBy=plan.tier&mode=aggregate&project=-1&statsPeriod=1h&utc=true&visualize=%7B%22yAxes%22%3A%5B%22count%28span.duration%29%22%5D%2C%22chartType%22%3A1%7D&visualize=%7B%22yAxes%22%3A%5B%22p50%28span.duration%29%22%5D%2C%22chartType%22%3A1%7D)

![Screenshot 2024-09-24 at 3 32
28 PM](https://github.com/user-attachments/assets/28f99070-30b2-42c4-b245-87ed4b76fd4f)

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdullah Khan 5 months ago
parent
commit
257ac7ad71

+ 12 - 3
static/app/components/charts/utils.tsx

@@ -8,7 +8,11 @@ import moment from 'moment-timezone';
 import {DEFAULT_STATS_PERIOD} from 'sentry/constants';
 import type {PageFilters} from 'sentry/types/core';
 import type {ReactEchartsRef, Series} from 'sentry/types/echarts';
-import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
+import type {
+  EventsStats,
+  GroupedMultiSeriesEventsStats,
+  MultiSeriesEventsStats,
+} from 'sentry/types/organization';
 import {defined, escape} from 'sentry/utils';
 import {getFormattedDate} from 'sentry/utils/dates';
 import type {TableDataWithTitle} from 'sentry/utils/discover/discoverQuery';
@@ -216,7 +220,7 @@ export function getSeriesSelection(
 }
 
 function isSingleSeriesStats(
-  data: MultiSeriesEventsStats | EventsStats
+  data: MultiSeriesEventsStats | EventsStats | GroupedMultiSeriesEventsStats
 ): data is EventsStats {
   return (
     (defined(data.data) || defined(data.totals)) &&
@@ -226,7 +230,12 @@ function isSingleSeriesStats(
 }
 
 export function isMultiSeriesStats(
-  data: MultiSeriesEventsStats | EventsStats | null | undefined,
+  data:
+    | MultiSeriesEventsStats
+    | EventsStats
+    | GroupedMultiSeriesEventsStats
+    | null
+    | undefined,
   isTopN?: boolean
 ): data is MultiSeriesEventsStats {
   return (

+ 4 - 0
static/app/types/organization.tsx

@@ -311,6 +311,10 @@ export type MultiSeriesEventsStats = {
   [seriesName: string]: EventsStats;
 };
 
+export type GroupedMultiSeriesEventsStats = {
+  [seriesName: string]: MultiSeriesEventsStats & {order: number};
+};
+
 export type EventsStatsSeries<F extends string> = {
   data: {
     axis: F;

+ 10 - 28
static/app/views/explore/charts/index.tsx

@@ -16,8 +16,7 @@ import {useDataset} from 'sentry/views/explore/hooks/useDataset';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
 import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
-import {useSpanIndexedSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
-import {useSortedTopNSeries} from 'sentry/views/insights/common/queries/useSortedTopNSeries';
+import {useSortedTimeSeries} from 'sentry/views/insights/common/queries/useSortedTimeSeries';
 import {CHART_HEIGHT} from 'sentry/views/insights/database/settings';
 
 import {useGroupBys} from '../hooks/useGroupBys';
@@ -81,23 +80,12 @@ export function ExploreCharts({query}: ExploreChartsProps) {
     return deduped;
   }, [visualizes]);
 
-  const singleSeriesResult = useSpanIndexedSeries(
+  const timeSeriesResult = useSortedTimeSeries(
     {
       search: new MutableSearch(query ?? ''),
       yAxis: yAxes,
       interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
-      enabled: topEvents === undefined,
-    },
-    'api.explorer.stats',
-    dataset
-  );
-
-  const topNSeriesResult = useSortedTopNSeries(
-    {
-      search: new MutableSearch(query ?? ''),
-      yAxis: yAxes,
-      interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
-      enabled: topEvents !== undefined,
+      enabled: true,
       fields,
       orderby,
       topEvents,
@@ -108,13 +96,12 @@ export function ExploreCharts({query}: ExploreChartsProps) {
 
   const getSeries = useCallback(
     (dedupedYAxes: string[]) => {
-      if (topEvents !== undefined) {
-        return topNSeriesResult.data;
-      }
-
-      return dedupedYAxes.map(yAxis => singleSeriesResult.data[yAxis]);
+      return dedupedYAxes.flatMap(yAxis => {
+        const series = timeSeriesResult.data[yAxis];
+        return series !== undefined ? series : [];
+      });
     },
-    [singleSeriesResult, topNSeriesResult, topEvents]
+    [timeSeriesResult]
   );
 
   const handleChartTypeChange = useCallback(
@@ -126,11 +113,6 @@ export function ExploreCharts({query}: ExploreChartsProps) {
     [visualizes, setVisualizes]
   );
 
-  const error =
-    topEvents === undefined ? singleSeriesResult.error : topNSeriesResult.error;
-  const loading =
-    topEvents === undefined ? singleSeriesResult.isPending : topNSeriesResult.isPending;
-
   return (
     <Fragment>
       {visualizes.map((visualize, index) => {
@@ -170,8 +152,8 @@ export function ExploreCharts({query}: ExploreChartsProps) {
                 }}
                 legendFormatter={value => formatVersion(value)}
                 data={getSeries(dedupedYAxes)}
-                error={error}
-                loading={loading}
+                error={timeSeriesResult.error}
+                loading={timeSeriesResult.isPending}
                 // TODO Abdullah: Make chart colors dynamic, with changing topN events count and overlay count.
                 chartColors={CHART_PALETTE[TOP_EVENTS_LIMIT - 1]}
                 type={chartType}

+ 6 - 10
static/app/views/explore/hooks/useTopEvents.tsx

@@ -1,7 +1,5 @@
 import {useMemo} from 'react';
 
-import {dedupeArray} from 'sentry/utils/dedupeArray';
-
 import {useGroupBys} from './useGroupBys';
 import {useResultMode} from './useResultsMode';
 import {useVisualizes} from './useVisualizes';
@@ -13,10 +11,8 @@ export function useTopEvents(): number | undefined {
   const [groupBys] = useGroupBys();
   const [resultMode] = useResultMode();
 
-  const yAxes = useMemo(() => {
-    const deduped = dedupeArray(visualizes.flatMap(visualize => visualize.yAxes));
-    deduped.sort();
-    return deduped;
+  const hasChartWithMultipleYaxes = useMemo(() => {
+    return visualizes.some(visualize => visualize.yAxes.length > 1);
   }, [visualizes]);
 
   const topEvents: number | undefined = useMemo(() => {
@@ -24,12 +20,12 @@ export function useTopEvents(): number | undefined {
       return undefined;
     }
 
-    // We only support top events for a single chart with no overlaps in aggregate mode and
-    // the data must be grouped by at least one field
-    return yAxes.length > 1 || (groupBys.length === 1 && groupBys[0] === '')
+    // We only support top events for when there are no multiple y-axes chart
+    // and there is at least one group by.
+    return hasChartWithMultipleYaxes || (groupBys.length === 1 && groupBys[0] === '')
       ? undefined
       : TOP_EVENTS_LIMIT;
-  }, [yAxes, groupBys, resultMode]);
+  }, [hasChartWithMultipleYaxes, groupBys, resultMode]);
 
   return topEvents;
 }

+ 223 - 0
static/app/views/insights/common/queries/useSortedTimeSeries.tsx

@@ -0,0 +1,223 @@
+import type {Series} from 'sentry/types/echarts';
+import type {
+  EventsStats,
+  GroupedMultiSeriesEventsStats,
+  MultiSeriesEventsStats,
+} from 'sentry/types/organization';
+import {encodeSort} from 'sentry/utils/discover/eventView';
+import {DURATION_UNITS, SIZE_UNITS} from 'sentry/utils/discover/fieldRenderers';
+import {getAggregateAlias} from 'sentry/utils/discover/fields';
+import {
+  type DiscoverQueryProps,
+  useGenericDiscoverQuery,
+} from 'sentry/utils/discover/genericDiscoverQuery';
+import {DiscoverDatasets} from 'sentry/utils/discover/types';
+import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+import {getSeriesEventView} from 'sentry/views/insights/common/queries/getSeriesEventView';
+import type {SpanFunctions, SpanIndexedField} from 'sentry/views/insights/types';
+
+import {getRetryDelay, shouldRetryHandler} from '../utils/retryHandlers';
+
+type SeriesMap = {
+  [seriesName: string]: Series[];
+};
+
+interface Options<Fields> {
+  enabled?: boolean;
+  fields?: string[];
+  interval?: string;
+  orderby?: string | string[];
+  overriddenRoute?: string;
+  referrer?: string;
+  search?: MutableSearch;
+  topEvents?: number;
+  yAxis?: Fields;
+}
+
+export const useSortedTimeSeries = <
+  Fields extends SpanIndexedField[] | SpanFunctions[] | string[],
+>(
+  options: Options<Fields> = {},
+  referrer: string,
+  dataset?: DiscoverDatasets
+) => {
+  const location = useLocation();
+  const organization = useOrganization();
+  const {
+    search,
+    yAxis = [],
+    interval,
+    topEvents,
+    fields,
+    orderby,
+    overriddenRoute,
+    enabled,
+  } = options;
+
+  const pageFilters = usePageFilters();
+
+  const eventView = getSeriesEventView(
+    search,
+    fields,
+    pageFilters.selection,
+    yAxis,
+    topEvents,
+    dataset ?? DiscoverDatasets.SPANS_INDEXED,
+    orderby
+  );
+
+  if (interval) {
+    eventView.interval = interval;
+  }
+
+  const result = useGenericDiscoverQuery<
+    MultiSeriesEventsStats | GroupedMultiSeriesEventsStats,
+    DiscoverQueryProps
+  >({
+    route: overriddenRoute ?? 'events-stats',
+    eventView,
+    location,
+    orgSlug: organization.slug,
+    getRequestPayload: () => ({
+      ...eventView.getEventsAPIPayload(location),
+      yAxis: eventView.yAxis,
+      topEvents: eventView.topEvents,
+      excludeOther: 0,
+      partial: 1,
+      orderby: eventView.sorts?.[0] ? encodeSort(eventView.sorts?.[0]) : undefined,
+      interval: eventView.interval,
+    }),
+    options: {
+      enabled: enabled && pageFilters.isReady,
+      refetchOnWindowFocus: false,
+      retry: shouldRetryHandler,
+      retryDelay: getRetryDelay,
+      staleTime: Infinity,
+    },
+    referrer,
+  });
+
+  const isFetchingOrLoading = result.isPending || result.isFetching;
+
+  const data: SeriesMap = isFetchingOrLoading
+    ? {}
+    : transformToSeriesMap(result.data, yAxis);
+
+  const pageLinks = result.response?.getResponseHeader('Link') ?? undefined;
+
+  return {
+    ...result,
+    pageLinks,
+    data,
+    meta: result.data?.meta,
+  };
+};
+
+function isEventsStats(
+  obj: EventsStats | MultiSeriesEventsStats | GroupedMultiSeriesEventsStats
+): obj is EventsStats {
+  return typeof obj === 'object' && obj !== null && typeof obj.data === 'object';
+}
+
+function isMultiSeriesEventsStats(
+  obj: EventsStats | MultiSeriesEventsStats | GroupedMultiSeriesEventsStats
+): obj is MultiSeriesEventsStats {
+  if (typeof obj !== 'object' || obj === null) {
+    return false;
+  }
+
+  return Object.values(obj).every(series => isEventsStats(series));
+}
+
+function transformToSeriesMap(
+  result: MultiSeriesEventsStats | GroupedMultiSeriesEventsStats | undefined,
+  yAxis: string[]
+): SeriesMap {
+  if (!result) {
+    return {};
+  }
+
+  // Single series, applies to single axis queries
+  const firstYAxis = yAxis[0] || '';
+  if (isEventsStats(result)) {
+    const [, series] = processSingleEventStats(firstYAxis, result);
+    return {
+      [firstYAxis]: [series],
+    };
+  }
+
+  // Multiple series, applies to multi axis or topN events queries
+  const hasMultipleYAxes = yAxis.length > 1;
+  if (isMultiSeriesEventsStats(result)) {
+    const processedResults: [number, Series][] = Object.keys(result).map(seriesName =>
+      processSingleEventStats(seriesName, result[seriesName])
+    );
+
+    if (!hasMultipleYAxes) {
+      return {
+        [firstYAxis]: processedResults
+          .sort(([a], [b]) => a - b)
+          .map(([, series]) => series),
+      };
+    }
+
+    return processedResults
+      .sort(([a], [b]) => a - b)
+      .reduce((acc, [, series]) => {
+        acc[series.seriesName] = [series];
+        return acc;
+      }, {});
+  }
+
+  // Grouped multi series, applies to topN events queries with multiple y-axes
+  // First, we process the grouped multi series into a list of [seriesName, order, {[aggFunctionAlias]: EventsStats}]
+  // to enable sorting.
+  const processedResults: [string, number, MultiSeriesEventsStats][] = [];
+  Object.keys(result).forEach(seriesName => {
+    const {order: groupOrder, ...groupData} = result[seriesName];
+    processedResults.push([seriesName, groupOrder || 0, groupData]);
+  });
+
+  return processedResults
+    .sort(([, orderA], [, orderB]) => orderA - orderB)
+    .reduce((acc, [seriesName, , groupData]) => {
+      Object.keys(groupData).forEach(aggFunctionAlias => {
+        const [, series] = processSingleEventStats(
+          seriesName,
+          groupData[aggFunctionAlias]
+        );
+
+        if (!acc[aggFunctionAlias]) {
+          acc[aggFunctionAlias] = [series];
+        } else {
+          acc[aggFunctionAlias].push(series);
+        }
+      });
+      return acc;
+    }, {} as SeriesMap);
+}
+
+function processSingleEventStats(
+  seriesName: string,
+  seriesData: EventsStats
+): [number, Series] {
+  let scale = 1;
+  if (seriesName) {
+    const unit = seriesData.meta?.units?.[getAggregateAlias(seriesName)];
+    // Scale series values to milliseconds or bytes depending on units from meta
+    scale = (unit && (DURATION_UNITS[unit] ?? SIZE_UNITS[unit])) ?? 1;
+  }
+
+  const processsedData: Series = {
+    seriesName: seriesName || '(empty string)',
+    data: seriesData.data.map(([timestamp, countsForTimestamp]) => ({
+      name: timestamp * 1000,
+      value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0) * scale,
+    })),
+  };
+
+  return [seriesData.order || 0, processsedData];
+}

+ 0 - 133
static/app/views/insights/common/queries/useSortedTopNSeries.tsx

@@ -1,133 +0,0 @@
-import type {Series} from 'sentry/types/echarts';
-import type {EventsStats, MultiSeriesEventsStats} from 'sentry/types/organization';
-import {encodeSort} from 'sentry/utils/discover/eventView';
-import {DURATION_UNITS, SIZE_UNITS} from 'sentry/utils/discover/fieldRenderers';
-import {getAggregateAlias} from 'sentry/utils/discover/fields';
-import {
-  type DiscoverQueryProps,
-  useGenericDiscoverQuery,
-} from 'sentry/utils/discover/genericDiscoverQuery';
-import {DiscoverDatasets} from 'sentry/utils/discover/types';
-import type {MutableSearch} from 'sentry/utils/tokenizeSearch';
-import {useLocation} from 'sentry/utils/useLocation';
-import useOrganization from 'sentry/utils/useOrganization';
-import usePageFilters from 'sentry/utils/usePageFilters';
-import {getSeriesEventView} from 'sentry/views/insights/common/queries/getSeriesEventView';
-import type {SpanFunctions, SpanIndexedField} from 'sentry/views/insights/types';
-
-import {getRetryDelay, shouldRetryHandler} from '../utils/retryHandlers';
-
-interface Options<Fields> {
-  enabled?: boolean;
-  fields?: string[];
-  interval?: string;
-  orderby?: string | string[];
-  overriddenRoute?: string;
-  referrer?: string;
-  search?: MutableSearch;
-  topEvents?: number;
-  yAxis?: Fields;
-}
-
-export const useSortedTopNSeries = <
-  Fields extends SpanIndexedField[] | SpanFunctions[] | string[],
->(
-  options: Options<Fields> = {},
-  referrer: string,
-  dataset?: DiscoverDatasets
-) => {
-  const location = useLocation();
-  const organization = useOrganization();
-  const {
-    search = undefined,
-    yAxis = [],
-    interval = undefined,
-    topEvents = 5,
-    fields,
-    orderby,
-    overriddenRoute,
-    enabled,
-  } = options;
-
-  const pageFilters = usePageFilters();
-
-  const eventView = getSeriesEventView(
-    search,
-    fields,
-    pageFilters.selection,
-    yAxis,
-    topEvents,
-    dataset ?? DiscoverDatasets.SPANS_INDEXED,
-    orderby
-  );
-
-  if (interval) {
-    eventView.interval = interval;
-  }
-
-  const result = useGenericDiscoverQuery<MultiSeriesEventsStats, DiscoverQueryProps>({
-    route: overriddenRoute ?? 'events-stats',
-    eventView,
-    location,
-    orgSlug: organization.slug,
-    getRequestPayload: () => ({
-      ...eventView.getEventsAPIPayload(location),
-      yAxis: eventView.yAxis,
-      topEvents: eventView.topEvents,
-      excludeOther: 0,
-      partial: 1,
-      orderby: eventView.sorts?.[0] ? encodeSort(eventView.sorts?.[0]) : undefined,
-      interval: eventView.interval,
-    }),
-    options: {
-      enabled: enabled && pageFilters.isReady,
-      refetchOnWindowFocus: false,
-      retry: shouldRetryHandler,
-      retryDelay: getRetryDelay,
-      staleTime: Infinity,
-    },
-    referrer,
-  });
-
-  const isFetchingOrLoading = result.isPending || result.isFetching;
-
-  const data: Series[] = isFetchingOrLoading ? [] : transformToSeries(result.data);
-
-  const pageLinks = result.response?.getResponseHeader('Link') ?? undefined;
-
-  return {
-    ...result,
-    pageLinks,
-    data,
-    meta: result.data?.meta,
-  };
-};
-
-function transformToSeries(result: MultiSeriesEventsStats | undefined): Series[] {
-  if (!result) {
-    return [];
-  }
-
-  const processedResults: [number, Series][] = Object.keys(result).map(seriesName => {
-    const seriesData: EventsStats = result[seriesName];
-
-    let scale = 1;
-    if (seriesName) {
-      const unit = seriesData.meta?.units?.[getAggregateAlias(seriesName)];
-      // Scale series values to milliseconds or bytes depending on units from meta
-      scale = (unit && (DURATION_UNITS[unit] ?? SIZE_UNITS[unit])) ?? 1;
-    }
-
-    const processsedData: Series = {
-      seriesName: seriesName || '(empty string)',
-      data: seriesData.data.map(([timestamp, countsForTimestamp]) => ({
-        name: timestamp * 1000,
-        value: countsForTimestamp.reduce((acc, {count}) => acc + count, 0) * scale,
-      })),
-    };
-
-    return [seriesData.order || 0, processsedData];
-  });
-
-  return processedResults.sort(([a], [b]) => a - b).map(([, series]) => series);
-}