Просмотр исходного кода

feat(explore-charts): Displaying topN events in chart and table. (#77566)

Dev-ui link to test it out:
[link](https://sentry.dev.getsentry.net:7999/traces/?field=project&field=id&field=span.op&field=span.description&field=span.duration&field=timestamp&field=organization_slug&field=project.organization.id&groupBy=deviceMemory&interval=1m&mode=aggregate&project=-1&query=&sort=organization_slug&statsPeriod=1h&utc=true&visualize=%7B%22yAxes%22%3A%5B%22avg%28span.duration%29%22%5D%2C%22chartType%22%3A1%7D)
![Screenshot 2024-09-16 at 1 36
15 PM](https://github.com/user-attachments/assets/a2b5558e-497e-4ae1-956b-04b685c91b88)

---------

Co-authored-by: Abdullah Khan <abdullahkhan@PG9Y57YDXQ.local>
Abdullah Khan 5 месяцев назад
Родитель
Сommit
71e10c8aaa

+ 68 - 6
static/app/views/explore/charts/index.tsx

@@ -10,14 +10,22 @@ import {dedupeArray} from 'sentry/utils/dedupeArray';
 import {aggregateOutputType} from 'sentry/utils/discover/fields';
 import {aggregateOutputType} from 'sentry/utils/discover/fields';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import {MutableSearch} from 'sentry/utils/tokenizeSearch';
 import usePageFilters from 'sentry/utils/usePageFilters';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import {formatVersion} from 'sentry/utils/versions/formatVersion';
 import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval';
 import {useChartInterval} from 'sentry/views/explore/hooks/useChartInterval';
 import {useDataset} from 'sentry/views/explore/hooks/useDataset';
 import {useDataset} from 'sentry/views/explore/hooks/useDataset';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
 import Chart, {ChartType} from 'sentry/views/insights/common/components/chart';
 import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
 import ChartPanel from 'sentry/views/insights/common/components/chartPanel';
 import {useSpanIndexedSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
 import {useSpanIndexedSeries} from 'sentry/views/insights/common/queries/useDiscoverSeries';
+import {useSortedTopNSeries} from 'sentry/views/insights/common/queries/useSortedTopNSeries';
 import {CHART_HEIGHT} from 'sentry/views/insights/database/settings';
 import {CHART_HEIGHT} from 'sentry/views/insights/database/settings';
 
 
+import {useGroupBys} from '../hooks/useGroupBys';
+import {useResultMode} from '../hooks/useResultsMode';
+import {useSorts} from '../hooks/useSorts';
+import {TOP_EVENTS_LIMIT, useTopEvents} from '../hooks/useTopEvents';
+import {formatSort} from '../tables/aggregatesTable';
+
 interface ExploreChartsProps {
 interface ExploreChartsProps {
   query: string;
   query: string;
 }
 }
@@ -44,6 +52,28 @@ export function ExploreCharts({query}: ExploreChartsProps) {
   const [dataset] = useDataset();
   const [dataset] = useDataset();
   const [visualizes, setVisualizes] = useVisualizes();
   const [visualizes, setVisualizes] = useVisualizes();
   const [interval, setInterval, intervalOptions] = useChartInterval();
   const [interval, setInterval, intervalOptions] = useChartInterval();
+  const [groupBys] = useGroupBys();
+  const [resultMode] = useResultMode();
+  const topEvents = useTopEvents();
+
+  const fields: string[] = useMemo(() => {
+    if (resultMode === 'samples') {
+      return [];
+    }
+
+    return [...groupBys, ...visualizes.flatMap(visualize => visualize.yAxes)].filter(
+      Boolean
+    );
+  }, [resultMode, groupBys, visualizes]);
+  const [sorts] = useSorts({fields});
+
+  const orderby: string | string[] | undefined = useMemo(() => {
+    if (!sorts.length) {
+      return undefined;
+    }
+
+    return sorts.map(formatSort);
+  }, [sorts]);
 
 
   const yAxes = useMemo(() => {
   const yAxes = useMemo(() => {
     const deduped = dedupeArray(visualizes.flatMap(visualize => visualize.yAxes));
     const deduped = dedupeArray(visualizes.flatMap(visualize => visualize.yAxes));
@@ -51,17 +81,42 @@ export function ExploreCharts({query}: ExploreChartsProps) {
     return deduped;
     return deduped;
   }, [visualizes]);
   }, [visualizes]);
 
 
-  const series = useSpanIndexedSeries(
+  const singleSeriesResult = useSpanIndexedSeries(
     {
     {
       search: new MutableSearch(query ?? ''),
       search: new MutableSearch(query ?? ''),
       yAxis: yAxes,
       yAxis: yAxes,
       interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
       interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
-      enabled: true,
+      enabled: topEvents === undefined,
     },
     },
     'api.explorer.stats',
     'api.explorer.stats',
     dataset
     dataset
   );
   );
 
 
+  const topNSeriesResult = useSortedTopNSeries(
+    {
+      search: new MutableSearch(query ?? ''),
+      yAxis: yAxes,
+      interval: interval ?? getInterval(pageFilters.selection.datetime, 'metrics'),
+      enabled: topEvents !== undefined,
+      fields,
+      orderby,
+      topEvents,
+    },
+    'api.explorer.stats',
+    dataset
+  );
+
+  const getSeries = useCallback(
+    (dedupedYAxes: string[]) => {
+      if (topEvents !== undefined) {
+        return topNSeriesResult.data;
+      }
+
+      return dedupedYAxes.map(yAxis => singleSeriesResult.data[yAxis]);
+    },
+    [singleSeriesResult, topNSeriesResult, topEvents]
+  );
+
   const handleChartTypeChange = useCallback(
   const handleChartTypeChange = useCallback(
     (chartType: ChartType, index: number) => {
     (chartType: ChartType, index: number) => {
       const newVisualizes = visualizes.slice();
       const newVisualizes = visualizes.slice();
@@ -71,6 +126,11 @@ export function ExploreCharts({query}: ExploreChartsProps) {
     [visualizes, setVisualizes]
     [visualizes, setVisualizes]
   );
   );
 
 
+  const error =
+    topEvents === undefined ? singleSeriesResult.error : topNSeriesResult.error;
+  const loading =
+    topEvents === undefined ? singleSeriesResult.isPending : topNSeriesResult.isPending;
+
   return (
   return (
     <Fragment>
     <Fragment>
       {visualizes.map((visualize, index) => {
       {visualizes.map((visualize, index) => {
@@ -108,10 +168,12 @@ export function ExploreCharts({query}: ExploreChartsProps) {
                   top: '8px',
                   top: '8px',
                   bottom: '0',
                   bottom: '0',
                 }}
                 }}
-                data={dedupedYAxes.map(yAxis => series.data[yAxis])}
-                error={series.error}
-                loading={series.isPending}
-                chartColors={CHART_PALETTE[2]}
+                legendFormatter={value => formatVersion(value)}
+                data={getSeries(dedupedYAxes)}
+                error={error}
+                loading={loading}
+                // TODO Abdullah: Make chart colors dynamic, with changing topN events count and overlay count.
+                chartColors={CHART_PALETTE[TOP_EVENTS_LIMIT - 1]}
                 type={chartType}
                 type={chartType}
                 // for now, use the first y axis unit
                 // for now, use the first y axis unit
                 aggregateOutputFormat={aggregateOutputType(dedupedYAxes[0])}
                 aggregateOutputFormat={aggregateOutputType(dedupedYAxes[0])}

+ 35 - 0
static/app/views/explore/hooks/useTopEvents.tsx

@@ -0,0 +1,35 @@
+import {useMemo} from 'react';
+
+import {dedupeArray} from 'sentry/utils/dedupeArray';
+
+import {useGroupBys} from './useGroupBys';
+import {useResultMode} from './useResultsMode';
+import {useVisualizes} from './useVisualizes';
+
+export const TOP_EVENTS_LIMIT: number = 5;
+
+export function useTopEvents(): number | undefined {
+  const [visualizes] = useVisualizes();
+  const [groupBys] = useGroupBys();
+  const [resultMode] = useResultMode();
+
+  const yAxes = useMemo(() => {
+    const deduped = dedupeArray(visualizes.flatMap(visualize => visualize.yAxes));
+    deduped.sort();
+    return deduped;
+  }, [visualizes]);
+
+  const topEvents: number | undefined = useMemo(() => {
+    if (resultMode === 'samples') {
+      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] === '')
+      ? undefined
+      : TOP_EVENTS_LIMIT;
+  }, [yAxes, groupBys, resultMode]);
+
+  return topEvents;
+}

+ 22 - 1
static/app/views/explore/tables/aggregatesTable.tsx

@@ -1,8 +1,10 @@
 import {Fragment, useMemo} from 'react';
 import {Fragment, useMemo} from 'react';
+import styled from '@emotion/styled';
 
 
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import EmptyStateWarning from 'sentry/components/emptyStateWarning';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import Pagination from 'sentry/components/pagination';
 import Pagination from 'sentry/components/pagination';
+import {CHART_PALETTE} from 'sentry/constants/chartPalette';
 import {IconWarning} from 'sentry/icons';
 import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {t} from 'sentry/locale';
 import type {NewQuery} from 'sentry/types/organization';
 import type {NewQuery} from 'sentry/types/organization';
@@ -29,7 +31,9 @@ import {useUserQuery} from 'sentry/views/explore/hooks/useUserQuery';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery';
 import {useSpansQuery} from 'sentry/views/insights/common/queries/useSpansQuery';
 
 
-function formatSort(sort: Sort): string {
+import {TOP_EVENTS_LIMIT, useTopEvents} from '../hooks/useTopEvents';
+
+export function formatSort(sort: Sort): string {
   const direction = sort.kind === 'desc' ? '-' : '';
   const direction = sort.kind === 'desc' ? '-' : '';
   return `${direction}${getAggregateAlias(sort.field)}`;
   return `${direction}${getAggregateAlias(sort.field)}`;
 }
 }
@@ -40,6 +44,7 @@ export function AggregatesTable({}: AggregatesTableProps) {
   const location = useLocation();
   const location = useLocation();
   const organization = useOrganization();
   const organization = useOrganization();
   const {selection} = usePageFilters();
   const {selection} = usePageFilters();
+  const topEvents = useTopEvents();
 
 
   const [dataset] = useDataset();
   const [dataset] = useDataset();
   const [groupBys] = useGroupBys();
   const [groupBys] = useGroupBys();
@@ -111,6 +116,9 @@ export function AggregatesTable({}: AggregatesTableProps) {
                   const renderer = getFieldRenderer(field, meta.fields, false);
                   const renderer = getFieldRenderer(field, meta.fields, false);
                   return (
                   return (
                     <TableBodyCell key={j}>
                     <TableBodyCell key={j}>
+                      {topEvents && i < topEvents && j === 0 && (
+                        <TopResultsIndicator index={i} />
+                      )}
                       {renderer(row, {
                       {renderer(row, {
                         location,
                         location,
                         organization,
                         organization,
@@ -134,3 +142,16 @@ export function AggregatesTable({}: AggregatesTableProps) {
     </Fragment>
     </Fragment>
   );
   );
 }
 }
+
+const TopResultsIndicator = styled('div')<{index: number}>`
+  position: absolute;
+  left: -1px;
+  margin-top: 4.5px;
+  width: 9px;
+  height: 15px;
+  border-radius: 0 3px 3px 0;
+
+  background-color: ${p => {
+    return CHART_PALETTE[TOP_EVENTS_LIMIT - 1][p.index];
+  }};
+`;

+ 0 - 2
static/app/views/explore/toolbar/index.tsx

@@ -8,7 +8,6 @@ import {useSorts} from 'sentry/views/explore/hooks/useSorts';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import {useVisualizes} from 'sentry/views/explore/hooks/useVisualizes';
 import {ToolbarDataset} from 'sentry/views/explore/toolbar/toolbarDataset';
 import {ToolbarDataset} from 'sentry/views/explore/toolbar/toolbarDataset';
 import {ToolbarGroupBy} from 'sentry/views/explore/toolbar/toolbarGroupBy';
 import {ToolbarGroupBy} from 'sentry/views/explore/toolbar/toolbarGroupBy';
-import {ToolbarLimitTo} from 'sentry/views/explore/toolbar/toolbarLimitTo';
 import {ToolbarResults} from 'sentry/views/explore/toolbar/toolbarResults';
 import {ToolbarResults} from 'sentry/views/explore/toolbar/toolbarResults';
 import {ToolbarSortBy} from 'sentry/views/explore/toolbar/toolbarSortBy';
 import {ToolbarSortBy} from 'sentry/views/explore/toolbar/toolbarSortBy';
 import {ToolbarVisualize} from 'sentry/views/explore/toolbar/toolbarVisualize';
 import {ToolbarVisualize} from 'sentry/views/explore/toolbar/toolbarVisualize';
@@ -48,7 +47,6 @@ export function ExploreToolbar({extras}: ExploreToolbarProps) {
       <ToolbarVisualize />
       <ToolbarVisualize />
       <ToolbarGroupBy disabled={resultMode !== 'aggregate'} />
       <ToolbarGroupBy disabled={resultMode !== 'aggregate'} />
       <ToolbarSortBy fields={fields} sorts={sorts} setSorts={setSorts} />
       <ToolbarSortBy fields={fields} sorts={sorts} setSorts={setSorts} />
-      <ToolbarLimitTo />
     </div>
     </div>
   );
   );
 }
 }

+ 0 - 15
static/app/views/explore/toolbar/toolbarLimitTo.tsx

@@ -1,15 +0,0 @@
-import {t} from 'sentry/locale';
-
-import {ToolbarHeader, ToolbarHeading, ToolbarSection} from './styles';
-
-interface ToolbarLimitToProps {}
-
-export function ToolbarLimitTo({}: ToolbarLimitToProps) {
-  return (
-    <ToolbarSection>
-      <ToolbarHeader>
-        <ToolbarHeading>{t('Limit To')}</ToolbarHeading>
-      </ToolbarHeader>
-    </ToolbarSection>
-  );
-}

+ 3 - 1
static/app/views/insights/common/queries/getSeriesEventView.tsx

@@ -15,7 +15,8 @@ export function getSeriesEventView(
   pageFilters: PageFilters,
   pageFilters: PageFilters,
   yAxis: string[],
   yAxis: string[],
   topEvents?: number,
   topEvents?: number,
-  dataset?: DiscoverDatasets
+  dataset?: DiscoverDatasets,
+  orderby?: string | string[]
 ) {
 ) {
   // Pick the highest possible interval for the given yAxis selection. Find the ideal interval for each function, then choose the largest one. This results in the lowest granularity, but best performance.
   // Pick the highest possible interval for the given yAxis selection. Find the ideal interval for each function, then choose the largest one. This results in the lowest granularity, but best performance.
   const interval = sortBy(
   const interval = sortBy(
@@ -43,6 +44,7 @@ export function getSeriesEventView(
       interval,
       interval,
       topEvents: topEvents?.toString(),
       topEvents: topEvents?.toString(),
       version: 2,
       version: 2,
+      orderby,
     },
     },
     pageFilters
     pageFilters
   );
   );

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

@@ -0,0 +1,133 @@
+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);
+}