Browse Source

feat(ddm): plot samples in chart (#63753)

Ogi 1 year ago
parent
commit
ffb8a42d72

+ 2 - 2
static/app/utils/metrics/index.spec.tsx

@@ -1,4 +1,4 @@
-import {PageFilters} from 'sentry/types';
+import {MetricsOperation, PageFilters} from 'sentry/types';
 import {
   formatMetricsUsingUnitAndOp,
   formatMetricUsingFixedUnit,
@@ -307,7 +307,7 @@ describe('stringifyMetricWidget', () => {
 
   it('defaults to an empty string', () => {
     const result = stringifyMetricWidget({
-      op: '',
+      op: '' as MetricsOperation,
       mri: 'd:custom/sentry.process_profile.symbolicate.process@second',
       groupBy: [],
       query: '',

+ 13 - 0
static/app/utils/metrics/index.tsx

@@ -122,6 +122,7 @@ export interface MetricWidgetQueryParams extends MetricsQuerySubject {
     seriesName: string;
     groupBy?: Record<string, string>;
   };
+  highlightedSample?: string | null;
   powerUserMode?: boolean;
   showSummaryTable?: boolean;
   sort?: SortState;
@@ -175,10 +176,22 @@ export type MetricMetaCodeLocation = {
 
 export type MetricCorrelation = {
   duration: number;
+  metricSummaries: {
+    spanId: string;
+    count?: number;
+    max?: number;
+    min?: number;
+    sum?: number;
+  }[];
   profileId: string;
   projectId: number;
   segmentName: string;
   spanId: string;
+  spansDetails: {
+    spanDuration: number;
+    spanId: string;
+    spanTimestamp: string;
+  }[];
   spansNumber: number;
   timestamp: string;
   traceId: string;

+ 5 - 30
static/app/utils/metrics/useMetricsData.tsx

@@ -1,37 +1,19 @@
 import {useCallback, useEffect, useState} from 'react';
 
-import {ApiResult} from 'sentry/api';
 import {DateString, MetricsApiResponse} from 'sentry/types';
 import {
   getMetricsApiRequestQuery,
   mapToMRIFields,
   MetricsQuery,
 } from 'sentry/utils/metrics';
-import {useApiQuery, UseApiQueryOptions} from 'sentry/utils/queryClient';
+import {useApiQuery} from 'sentry/utils/queryClient';
 import useOrganization from 'sentry/utils/useOrganization';
 
 import {MetricsApiRequestQueryOptions} from '../../types/metrics';
 
-function getRefetchInterval(
-  data: ApiResult | undefined,
-  interval: string
-): number | false {
-  // no data means request failed - don't refetch
-  if (!data) {
-    return false;
-  }
-  if (interval === '10s') {
-    // refetch every 10 seconds
-    return 10 * 1000;
-  }
-  // refetch every 60 seconds
-  return 60 * 1000;
-}
-
 export function useMetricsData(
   {mri, op, datetime, projects, environments, query, groupBy}: MetricsQuery,
-  overrides: Partial<MetricsApiRequestQueryOptions> = {},
-  options: Partial<UseApiQueryOptions<MetricsApiResponse>> = {}
+  overrides: Partial<MetricsApiRequestQueryOptions> = {}
 ) {
   const organization = useOrganization();
 
@@ -54,17 +36,11 @@ export function useMetricsData(
   const metricsApiRepsonse = useApiQuery<MetricsApiResponse>(
     [`/organizations/${organization.slug}/metrics/data/`, {query: queryToSend}],
     {
-      ...options,
       retry: 0,
       staleTime: 0,
       refetchOnReconnect: true,
       refetchOnWindowFocus: true,
-      refetchInterval: data => {
-        if (options.refetchInterval === false) {
-          return false;
-        }
-        return getRefetchInterval(data, queryToSend.interval);
-      },
+      refetchInterval: false,
     }
   );
   mapToMRIFields(metricsApiRepsonse.data, [field]);
@@ -77,8 +53,7 @@ export function useMetricsData(
 // 2. provides a callback to trim the data to a specific time range when chart zoom is used
 export function useMetricsDataZoom(
   metricsQuery: MetricsQuery,
-  overrides: Partial<MetricsApiRequestQueryOptions> = {},
-  options: Partial<UseApiQueryOptions<MetricsApiResponse>> = {}
+  overrides: Partial<MetricsApiRequestQueryOptions> = {}
 ) {
   const [metricsData, setMetricsData] = useState<MetricsApiResponse | undefined>();
   const {
@@ -86,7 +61,7 @@ export function useMetricsDataZoom(
     isLoading,
     isError,
     error,
-  } = useMetricsData(metricsQuery, overrides, options);
+  } = useMetricsData(metricsQuery, overrides);
 
   useEffect(() => {
     if (rawData) {

+ 137 - 31
static/app/views/ddm/chart.tsx

@@ -5,15 +5,18 @@ import * as echarts from 'echarts/core';
 import {CanvasRenderer} from 'echarts/renderers';
 
 import {updateDateTime} from 'sentry/actionCreators/pageFilters';
-import {AreaChart} from 'sentry/components/charts/areaChart';
-import {BarChart} from 'sentry/components/charts/barChart';
-import {LineChart} from 'sentry/components/charts/lineChart';
+import {transformToAreaSeries} from 'sentry/components/charts/areaChart';
+import {transformToBarSeries} from 'sentry/components/charts/barChart';
+import BaseChart, {BaseChartProps} from 'sentry/components/charts/baseChart';
+import {transformToLineSeries} from 'sentry/components/charts/lineChart';
+import ScatterSeries from 'sentry/components/charts/series/scatterSeries';
 import {DateTimeObject} from 'sentry/components/charts/utils';
 import {ReactEchartsRef} from 'sentry/types/echarts';
 import mergeRefs from 'sentry/utils/mergeRefs';
 import {
   formatMetricsUsingUnitAndOp,
   isCumulativeOp,
+  MetricCorrelation,
   MetricDisplayType,
 } from 'sentry/utils/metrics';
 import useRouter from 'sentry/utils/useRouter';
@@ -22,7 +25,8 @@ import {FocusArea, useFocusArea} from 'sentry/views/ddm/focusArea';
 
 import {getFormatter} from '../../components/charts/components/tooltip';
 
-import {Series} from './widget';
+import {useMetricSamples} from './useMetricSamples';
+import {Sample, ScatterSeries as ScatterSeriesType, Series} from './widget';
 
 type ChartProps = {
   displayType: MetricDisplayType;
@@ -30,8 +34,11 @@ type ChartProps = {
   series: Series[];
   widgetIndex: number;
   addFocusArea?: (area: FocusArea) => void;
+  correlations?: MetricCorrelation[];
   drawFocusArea?: () => void;
   height?: number;
+  highlightedSampleId?: string;
+  onSampleClick?: (sample: Sample) => void;
   operation?: string;
   removeFocusArea?: () => void;
 };
@@ -53,6 +60,9 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
       focusArea,
       removeFocusArea,
       height,
+      correlations,
+      onSampleClick,
+      highlightedSampleId,
     },
     forwardedRef
   ) => {
@@ -88,6 +98,7 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
     });
 
     const unit = series[0]?.unit;
+
     const seriesToShow = useMemo(
       () =>
         series
@@ -99,6 +110,22 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
       [series]
     );
 
+    const valueFormatter = useCallback(
+      (value: number) => {
+        return formatMetricsUsingUnitAndOp(value, unit, operation);
+      },
+      [unit, operation]
+    );
+
+    const samples = useMetricSamples({
+      chartRef,
+      correlations,
+      onClick: onSampleClick,
+      highlightedSampleId,
+      operation,
+      timeseries: series,
+    });
+
     // TODO(ddm): This assumes that all series have the same bucket size
     const bucketSize = seriesToShow[0]?.data[1]?.name - seriesToShow[0]?.data[0]?.name;
     const isSubMinuteBucket = bucketSize < 60_000;
@@ -106,26 +133,31 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
     const displayFogOfWar = isCumulativeOp(operation);
 
     const chartProps = useMemo(() => {
-      const formatters = {
-        valueFormatter: (value: number) =>
-          formatMetricsUsingUnitAndOp(value, unit, operation),
+      const timeseriesFormatters = {
+        valueFormatter,
         isGroupedByDate: true,
         bucketSize,
         showTimeInTooltip: true,
         addSecondsToTimeFormat: isSubMinuteBucket,
         limit: 10,
+        filter: (_, seriesParam) => {
+          return seriesParam?.axisId === 'xAxis';
+        },
       };
+
       const heightOptions = height ? {height} : {autoHeightResize: true};
 
       return {
         ...heightOptions,
         ...focusAreaBrush.options,
+
         forwardedRef: mergeRefs([forwardedRef, chartRef]),
         series: seriesToShow,
         renderer: seriesToShow.length > 20 ? ('canvas' as const) : ('svg' as const),
         isGroupedByDate: true,
         colors: seriesToShow.map(s => s.color),
         grid: {top: 5, bottom: 0, left: 0, right: 0},
+        onClick: samples.handleClick,
         tooltip: {
           formatter: (params, asyncTicket) => {
             if (focusAreaBrush.isDrawingRef.current) {
@@ -136,29 +168,37 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
             ).find(element => {
               return element.classList.contains('echarts-for-react');
             });
-
+            if (params.seriesType === 'scatter') {
+              return getFormatter(samples.formatters)(params, asyncTicket);
+            }
             if (hoveredEchartElement === chartRef?.current?.ele) {
-              return getFormatter(formatters)(params, asyncTicket);
+              return getFormatter(timeseriesFormatters)(params, asyncTicket);
             }
             return '';
           },
         },
-        yAxis: {
-          // used to find and convert datapoint to pixel position
-          id: 'yAxis',
-          axisLabel: {
-            formatter: (value: number) => {
-              return formatMetricsUsingUnitAndOp(value, unit, operation);
+        yAxes: [
+          {
+            // used to find and convert datapoint to pixel position
+            id: 'yAxis',
+            axisLabel: {
+              formatter: (value: number) => {
+                return valueFormatter(value);
+              },
             },
           },
-        },
-        xAxis: {
-          // used to find and convert datapoint to pixel position
-          id: 'xAxis',
-          axisPointer: {
-            snap: true,
+          samples.yAxis,
+        ],
+        xAxes: [
+          {
+            // used to find and convert datapoint to pixel position
+            id: 'xAxis',
+            axisPointer: {
+              snap: true,
+            },
           },
-        },
+          samples.xAxis,
+        ],
       };
     }, [
       bucketSize,
@@ -166,22 +206,23 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
       focusAreaBrush.isDrawingRef,
       forwardedRef,
       isSubMinuteBucket,
-      operation,
       seriesToShow,
-      unit,
       height,
+      samples.handleClick,
+      samples.xAxis,
+      samples.yAxis,
+      samples.formatters,
+      valueFormatter,
     ]);
 
     return (
       <ChartWrapper>
         {focusAreaBrush.overlay}
-        {displayType === MetricDisplayType.LINE ? (
-          <LineChart {...chartProps} />
-        ) : displayType === MetricDisplayType.AREA ? (
-          <AreaChart stacked {...chartProps} />
-        ) : (
-          <BarChart stacked animation={false} {...chartProps} />
-        )}
+        <CombinedChart
+          {...chartProps}
+          displayType={displayType}
+          scatterSeries={samples.series}
+        />
         {displayFogOfWar && (
           <FogOfWar bucketSize={bucketSize} seriesLength={seriesLength} />
         )}
@@ -190,6 +231,71 @@ export const MetricChart = forwardRef<ReactEchartsRef, ChartProps>(
   }
 );
 
+type CombinedChartProps = BaseChartProps & {
+  displayType: MetricDisplayType;
+  series: Series[];
+  scatterSeries?: ScatterSeriesType[];
+};
+
+function CombinedChart({
+  displayType,
+  series,
+  scatterSeries = [],
+  ...chartProps
+}: CombinedChartProps) {
+  const combinedSeries = useMemo(() => {
+    if (displayType === MetricDisplayType.LINE) {
+      return [
+        ...transformToLineSeries({series}),
+        ...transformToScatterSeries({series: scatterSeries, displayType}),
+      ];
+    }
+
+    if (displayType === MetricDisplayType.BAR) {
+      return [
+        ...transformToBarSeries({series, stacked: true, animation: false}),
+        ...transformToScatterSeries({series: scatterSeries, displayType}),
+      ];
+    }
+
+    if (displayType === MetricDisplayType.AREA) {
+      return [
+        ...transformToAreaSeries({series, stacked: true, colors: chartProps.colors}),
+        ...transformToScatterSeries({series: scatterSeries, displayType}),
+      ];
+    }
+
+    return [];
+  }, [displayType, scatterSeries, series, chartProps.colors]);
+
+  return <BaseChart {...chartProps} series={combinedSeries} />;
+}
+
+function transformToScatterSeries({
+  series,
+  displayType,
+}: {
+  displayType: MetricDisplayType;
+  series: Series[];
+}) {
+  return series.map(({seriesName, data: seriesData, ...options}) => {
+    if (displayType === MetricDisplayType.BAR) {
+      return ScatterSeries({
+        ...options,
+        name: seriesName,
+        data: seriesData?.map(({value, name}) => ({value: [name, value]})),
+      });
+    }
+
+    return ScatterSeries({
+      ...options,
+      name: seriesName,
+      data: seriesData?.map(({value, name}) => [name, value]),
+      animation: false,
+    });
+  });
+}
+
 function FogOfWar({
   bucketSize,
   seriesLength,

+ 10 - 1
static/app/views/ddm/context.tsx

@@ -38,10 +38,12 @@ interface DDMContextValue {
   removeWidget: (index: number) => void;
   selectedWidgetIndex: number;
   setDefaultQuery: (query: Record<string, any> | null) => void;
+  setHighlightedSampleId: (sample?: string) => void;
   setSelectedWidgetIndex: (index: number) => void;
   showQuerySymbols: boolean;
   updateWidget: (index: number, data: Partial<MetricWidgetQueryParams>) => void;
   widgets: MetricWidgetQueryParams[];
+  highlightedSampleId?: string;
 }
 
 export const DDMContext = createContext<DDMContextValue>({
@@ -60,6 +62,8 @@ export const DDMContext = createContext<DDMContextValue>({
   showQuerySymbols: false,
   updateWidget: () => {},
   widgets: [],
+  highlightedSampleId: undefined,
+  setHighlightedSampleId: () => {},
 });
 
 export function useDDMContext() {
@@ -162,7 +166,6 @@ export function useMetricWidgets() {
     widgets,
     updateWidget,
     addWidget,
-
     removeWidget,
     duplicateWidget,
   };
@@ -204,6 +207,8 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
     useMetricWidgets();
   const [focusArea, setFocusArea] = useState<FocusArea | null>(null);
 
+  const [highlightedSampleId, setHighlightedSampleId] = useState<string | undefined>();
+
   const pageFilters = usePageFilters().selection;
   const {data: metricsMeta, isLoading} = useMetricsMeta(pageFilters.projects);
 
@@ -289,6 +294,8 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
       setDefaultQuery,
       isDefaultQuery,
       showQuerySymbols: widgets.length > 1,
+      highlightedSampleId,
+      setHighlightedSampleId,
     }),
     [
       handleAddWidget,
@@ -304,6 +311,8 @@ export function DDMContextProvider({children}: {children: React.ReactNode}) {
       handleRemoveFocusArea,
       setDefaultQuery,
       isDefaultQuery,
+      highlightedSampleId,
+      setHighlightedSampleId,
     ]
   );
 

+ 2 - 2
static/app/views/ddm/createAlertModal.tsx

@@ -39,7 +39,7 @@ import {
   TimeWindow,
 } from 'sentry/views/alerts/rules/metric/types';
 import {AlertWizardAlertNames} from 'sentry/views/alerts/wizard/options';
-import {getChartSeries} from 'sentry/views/ddm/widget';
+import {getChartTimeseries} from 'sentry/views/ddm/widget';
 
 interface FormState {
   environment: string | null;
@@ -151,7 +151,7 @@ export function CreateAlertModal({Header, Body, Footer, metricsQuery}: Props) {
   const chartSeries = useMemo(
     () =>
       data &&
-      getChartSeries(data, {
+      getChartTimeseries(data, {
         mri: metricsQuery.mri,
         displayType: MetricDisplayType.AREA,
         focusedSeries: undefined,

+ 1 - 8
static/app/views/ddm/focusArea.tsx

@@ -19,6 +19,7 @@ import {IconClose, IconZoom} from 'sentry/icons';
 import {space} from 'sentry/styles/space';
 import {EChartBrushEndHandler, ReactEchartsRef} from 'sentry/types/echarts';
 import {MetricRange} from 'sentry/utils/metrics';
+import {isInRect} from 'sentry/views/ddm/rect';
 
 import {DateTimeObject} from '../../components/charts/utils';
 
@@ -42,14 +43,6 @@ interface UseFocusAreaOptions {
 
 type BrushEndResult = Parameters<EChartBrushEndHandler>[0];
 
-function isInRect(x: number, y: number, rect: DOMRect | undefined) {
-  if (!rect) {
-    return false;
-  }
-
-  return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
-}
-
 type UseFocusAreaProps = {
   chartRef: RefObject<ReactEchartsRef>;
   focusArea: FocusArea | null;

+ 69 - 0
static/app/views/ddm/rect.tsx

@@ -0,0 +1,69 @@
+import {RefObject} from 'react';
+import moment from 'moment';
+
+import {ReactEchartsRef} from 'sentry/types/echarts';
+
+export function isInRect(x: number, y: number, rect: DOMRect | undefined) {
+  if (!rect) {
+    return false;
+  }
+
+  return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+}
+
+export type ValueRect = {
+  xMax: number;
+  xMin: number;
+  yMax: number;
+  yMin: number;
+};
+
+const DEFAULT_VALUE_RECT = {
+  xMin: -Infinity,
+  xMax: Infinity,
+  yMin: 0,
+  yMax: Infinity,
+};
+
+export function fitToValueRect(x: number, y: number, rect: ValueRect | undefined) {
+  if (!rect) {
+    return [x, y];
+  }
+
+  const xValue = x <= rect.xMin ? rect.xMin : x >= rect.xMax ? rect.xMax : x;
+  const yValue = y <= rect.yMin ? rect.yMin : y >= rect.yMax ? rect.yMax : y;
+
+  return [xValue, yValue];
+}
+
+export function getValueRect(chartRef?: RefObject<ReactEchartsRef>): ValueRect {
+  const chartInstance = chartRef?.current?.getEchartsInstance();
+
+  if (!chartInstance) {
+    return DEFAULT_VALUE_RECT;
+  }
+
+  const finder = {xAxisId: 'xAxis', yAxisId: 'yAxis'};
+
+  const topLeft = chartInstance.convertFromPixel(finder, [0, 0]);
+  const bottomRight = chartInstance.convertFromPixel(finder, [
+    chartInstance.getWidth(),
+    chartInstance.getHeight(),
+  ]);
+
+  if (!topLeft || !bottomRight) {
+    return DEFAULT_VALUE_RECT;
+  }
+
+  const xMin = moment(topLeft[0]).valueOf();
+  const xMax = moment(bottomRight[0]).valueOf();
+  const yMin = Math.max(0, bottomRight[1]);
+  const yMax = topLeft[1];
+
+  return {
+    xMin,
+    xMax,
+    yMin,
+    yMax,
+  };
+}

+ 106 - 37
static/app/views/ddm/sampleTable.tsx

@@ -1,4 +1,4 @@
-import {Fragment} from 'react';
+import {Fragment, useCallback} from 'react';
 import {Link} from 'react-router';
 import styled from '@emotion/styled';
 import {PlatformIcon} from 'platformicons';
@@ -52,7 +52,9 @@ function sortAndLimitSpans(samples: MetricCorrelation['spansSummary'], limit: nu
 }
 
 export type SamplesTableProps = MetricRange & {
+  highlightedRow?: string | null;
   mri?: MRI;
+  onRowHover?: (sampleId?: string) => void;
   query?: string;
 };
 
@@ -68,7 +70,12 @@ const columnOrder: GridColumnOrder<keyof MetricCorrelation>[] = [
   {key: 'profileId', width: COL_WIDTH_UNDEFINED, name: 'Profile'},
 ];
 
-export function SampleTable({mri, ...metricMetaOptions}: SamplesTableProps) {
+export function SampleTable({
+  mri,
+  highlightedRow,
+  onRowHover,
+  ...metricMetaOptions
+}: SamplesTableProps) {
   const location = useLocation();
   const organization = useOrganization();
   const {projects} = useProjects();
@@ -102,15 +109,22 @@ export function SampleTable({mri, ...metricMetaOptions}: SamplesTableProps) {
     if (!row[key]) {
       return <AlignCenter>{'\u2014'}</AlignCenter>;
     }
+
     const project = projects.find(p => parseInt(p.id, 10) === row.projectId);
     const eventSlug = generateEventSlug({
       id: row.transactionId,
       project: project?.slug,
     });
 
+    const highlighted = row.transactionId === highlightedRow;
+
     if (key === 'transactionId') {
       return (
-        <span>
+        <BodyCell
+          rowId={row.transactionId}
+          onHover={onRowHover}
+          highlighted={highlighted}
+        >
           <Link
             to={getTransactionDetailsUrl(
               organization.slug,
@@ -123,37 +137,55 @@ export function SampleTable({mri, ...metricMetaOptions}: SamplesTableProps) {
           >
             {row.transactionId.slice(0, 8)}
           </Link>
-        </span>
+        </BodyCell>
       );
     }
     if (key === 'segmentName') {
       return (
-        <TextOverflow>
-          <Tooltip title={project?.slug}>
-            <StyledPlatformIcon platform={project?.platform || 'default'} />
-          </Tooltip>
-          <Link
-            to={normalizeUrl(
-              `/organizations/${organization.slug}/performance/summary/?${qs.stringify({
-                ...extractSelectionParameters(location.query),
-                project: project?.id,
-                transaction: row.segmentName,
-                referrer: 'metrics',
-              })}`
-            )}
-          >
-            {row.segmentName}
-          </Link>
-        </TextOverflow>
+        <BodyCell
+          rowId={row.transactionId}
+          onHover={onRowHover}
+          highlighted={highlighted}
+        >
+          <TextOverflow>
+            <Tooltip title={project?.slug}>
+              <StyledPlatformIcon platform={project?.platform || 'default'} />
+            </Tooltip>
+            <Link
+              to={normalizeUrl(
+                `/organizations/${organization.slug}/performance/summary/?${qs.stringify({
+                  ...extractSelectionParameters(location.query),
+                  project: project?.id,
+                  transaction: row.segmentName,
+                  referrer: 'metrics',
+                })}`
+              )}
+            >
+              {row.segmentName}
+            </Link>
+          </TextOverflow>
+        </BodyCell>
       );
     }
     if (key === 'duration') {
       // We get duration in miliseconds, but getDuration expects seconds
-      return <span>{getDuration(row.duration / 1000, 2, true)}</span>;
+      return (
+        <BodyCell
+          rowId={row.transactionId}
+          onHover={onRowHover}
+          highlighted={highlighted}
+        >
+          {getDuration(row.duration / 1000, 2, true)}
+        </BodyCell>
+      );
     }
     if (key === 'traceId') {
       return (
-        <span>
+        <BodyCell
+          rowId={row.transactionId}
+          onHover={onRowHover}
+          highlighted={highlighted}
+        >
           <Link
             to={normalizeUrl(
               `/organizations/${organization.slug}/performance/trace/${row.traceId}/`
@@ -161,7 +193,7 @@ export function SampleTable({mri, ...metricMetaOptions}: SamplesTableProps) {
           >
             {row.traceId.slice(0, 8)}
           </Link>
-        </span>
+        </BodyCell>
       );
     }
     if (key === 'spansSummary') {
@@ -224,25 +256,62 @@ export function SampleTable({mri, ...metricMetaOptions}: SamplesTableProps) {
         </AlignCenter>
       );
     }
-    return <span>{row[col.key]}</span>;
+
+    return (
+      <BodyCell rowId={row.transactionId} onHover={onRowHover} highlighted={highlighted}>
+        {row[col.key]}
+      </BodyCell>
+    );
   }
 
   return (
-    <GridEditable
-      isLoading={isFetching}
-      columnOrder={columnOrder}
-      columnSortBy={[]}
-      data={rows}
-      grid={{
-        renderHeadCell,
-        renderBodyCell,
-      }}
-      emptyMessage={mri ? t('No samples found') : t('Choose a metric to display data.')}
-      location={location}
-    />
+    <Wrapper>
+      <GridEditable
+        isLoading={isFetching}
+        columnOrder={columnOrder}
+        columnSortBy={[]}
+        data={rows}
+        grid={{
+          renderHeadCell,
+          renderBodyCell,
+        }}
+        emptyMessage={mri ? t('No samples found') : t('Choose a metric to display data.')}
+        location={location}
+      />
+    </Wrapper>
+  );
+}
+
+function BodyCell({children, rowId, highlighted, onHover}: any) {
+  const handleMouseOver = useCallback(() => {
+    onHover(rowId);
+  }, [onHover, rowId]);
+
+  const handleMouseOut = useCallback(() => {
+    onHover(null);
+  }, [onHover]);
+
+  return (
+    <BodyCellWrapper
+      onMouseOver={handleMouseOver}
+      onMouseOut={handleMouseOut}
+      highlighted={highlighted}
+    >
+      {children}
+    </BodyCellWrapper>
   );
 }
 
+const Wrapper = styled('div')`
+  tr:hover {
+    td {
+      background: ${p => p.theme.backgroundSecondary};
+    }
+  }
+`;
+
+const BodyCellWrapper = styled('span')<{highlighted?: boolean}>``;
+
 const AlignCenter = styled('span')`
   display: block;
   margin: auto;

+ 34 - 1
static/app/views/ddm/scratchpad.tsx

@@ -3,12 +3,17 @@ import styled from '@emotion/styled';
 import * as echarts from 'echarts/core';
 
 import {space} from 'sentry/styles/space';
+import {generateEventSlug} from 'sentry/utils/discover/urls';
 import {MetricWidgetQueryParams} from 'sentry/utils/metrics';
+import {getTransactionDetailsUrl} from 'sentry/utils/performance/urls';
+import useOrganization from 'sentry/utils/useOrganization';
 import usePageFilters from 'sentry/utils/usePageFilters';
+import useProjects from 'sentry/utils/useProjects';
+import useRouter from 'sentry/utils/useRouter';
 import {DDM_CHART_GROUP, MIN_WIDGET_WIDTH} from 'sentry/views/ddm/constants';
 import {useDDMContext} from 'sentry/views/ddm/context';
 
-import {MetricWidget} from './widget';
+import {MetricWidget, Sample} from './widget';
 
 export function MetricScratchpad() {
   const {
@@ -20,9 +25,14 @@ export function MetricScratchpad() {
     addFocusArea,
     removeFocusArea,
     showQuerySymbols,
+    highlightedSampleId,
   } = useDDMContext();
   const {selection} = usePageFilters();
 
+  const router = useRouter();
+  const organization = useOrganization();
+  const {projects} = useProjects();
+
   // Make sure all charts are connected to the same group whenever the widgets definition changes
   useLayoutEffect(() => {
     echarts.connect(DDM_CHART_GROUP);
@@ -35,6 +45,27 @@ export function MetricScratchpad() {
     [updateWidget]
   );
 
+  const handleSampleClick = useCallback(
+    (sample: Sample) => {
+      const project = projects.find(p => parseInt(p.id, 10) === sample.projectId);
+      const eventSlug = generateEventSlug({
+        id: sample.transactionId,
+        project: project?.slug,
+      });
+
+      router.push(
+        getTransactionDetailsUrl(
+          organization.slug,
+          eventSlug,
+          undefined,
+          {referrer: 'metrics'},
+          sample.spanId
+        )
+      );
+    },
+    [router, organization.slug, projects]
+  );
+
   const Wrapper =
     widgets.length === 1 ? StyledSingleWidgetWrapper : StyledMetricDashboard;
 
@@ -56,6 +87,8 @@ export function MetricScratchpad() {
           removeFocusArea={removeFocusArea}
           showQuerySymbols={showQuerySymbols}
           focusArea={focusArea}
+          onSampleClick={handleSampleClick}
+          highlightedSampleId={highlightedSampleId}
         />
       ))}
     </Wrapper>

Some files were not shown because too many files changed in this diff