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

feat(starfish): span sample list sidebar highlighting (#51634)

This PR makes a bunch of changes to the span sample list, and some other
supporting changes as well.
1. Adds highlighting between the duration chart and span sample table
(video of this behaviour in slack)
2. Adds ability to click into a sample event from the graph
3. Change table column to Compared to baseline
4. Right align `at baseline` in the compared to baseline column
5. Right align `span summary` table header

There is one bug that I'm aware of, when you try to hover on the graph,
and there's two events at the exact same time, only one will be
highlighted. This is something i'll fix in another PR!
Dominik Buszowiecki 1 год назад
Родитель
Сommit
5ea50bf095

+ 3 - 0
static/app/types/echarts.tsx

@@ -44,6 +44,9 @@ export type EChartHighlightHandler = EChartEventHandler<any>;
 interface EChartMouseEventParam {
   // color of component (make sense when componentType is 'series')
   color: string;
+  // subtype of the component to which the clicked glyph belongs
+  // i.e. 'scatter', 'line', etc
+  componentSubType: string;
   // type of the component to which the clicked glyph belongs
   // i.e., 'series', 'markLine', 'markPoint', 'timeLine'
   componentType: string;

+ 17 - 1
static/app/views/starfish/components/chart.tsx

@@ -29,7 +29,14 @@ import TransparentLoadingMask from 'sentry/components/charts/transparentLoadingM
 import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {IconWarning} from 'sentry/icons';
 import {DateString} from 'sentry/types';
-import {EChartClickHandler, ReactEchartsRef, Series} from 'sentry/types/echarts';
+import {
+  EChartClickHandler,
+  EChartHighlightHandler,
+  EChartMouseOutHandler,
+  EChartMouseOverHandler,
+  ReactEchartsRef,
+  Series,
+} from 'sentry/types/echarts';
 import {
   axisLabelFormatter,
   getDurationUnit,
@@ -80,6 +87,9 @@ type Props = {
   isLineChart?: boolean;
   log?: boolean;
   onClick?: EChartClickHandler;
+  onHighlight?: EChartHighlightHandler;
+  onMouseOut?: EChartMouseOutHandler;
+  onMouseOver?: EChartMouseOverHandler;
   previousData?: Series[];
   scatterPlot?: Series[];
   showLegend?: boolean;
@@ -153,6 +163,9 @@ function Chart({
   throughput,
   aggregateOutputFormat,
   onClick,
+  onMouseOver,
+  onMouseOut,
+  onHighlight,
   forwardedRef,
   chartGroup,
   tooltipFormatterOptions = {},
@@ -369,6 +382,9 @@ function Chart({
                 grid={grid}
                 legend={showLegend ? {top: 0, right: 0} : undefined}
                 onClick={onClick}
+                onMouseOut={onMouseOut}
+                onMouseOver={onMouseOver}
+                onHighlight={onHighlight}
                 series={[
                   ...series.map(({seriesName, data: seriesData, ...options}) =>
                     LineSeries({

+ 12 - 3
static/app/views/starfish/components/samplesTable/common.tsx

@@ -2,23 +2,32 @@ import styled from '@emotion/styled';
 
 import {t} from 'sentry/locale';
 import {getDuration} from 'sentry/utils/formatters';
+import {TextAlignRight} from 'sentry/views/starfish/components/textAlign';
 
 type Props = {
   duration: number;
   p95: number;
+  containerProps?: React.DetailedHTMLProps<
+    React.HTMLAttributes<HTMLSpanElement>,
+    HTMLSpanElement
+  >;
 };
 
-export function DurationComparisonCell({duration, p95}: Props) {
+export function DurationComparisonCell({duration, p95, containerProps}: Props) {
   const diff = duration - p95;
 
   if (Math.floor(duration) === Math.floor(p95)) {
-    return <PlaintextLabel>{t('At baseline')}</PlaintextLabel>;
+    return <TextAlignRight>{t('At baseline')}</TextAlignRight>;
   }
 
   const readableDiff = getDuration(diff / 1000, 2, true, true);
   const labelString = diff > 0 ? `+${readableDiff} above` : `${readableDiff} below`;
 
-  return <ComparisonLabel value={diff}>{labelString}</ComparisonLabel>;
+  return (
+    <ComparisonLabel {...containerProps} value={diff}>
+      {labelString}
+    </ComparisonLabel>
+  );
 }
 
 export const PlaintextLabel = styled('div')``;

+ 61 - 28
static/app/views/starfish/components/samplesTable/spanSamplesTable.tsx

@@ -1,6 +1,6 @@
+import {CSSProperties} from 'react';
 import {Link} from 'react-router';
 
-import DateTime from 'sentry/components/dateTime';
 import GridEditable, {GridColumnHeader} from 'sentry/components/gridEditable';
 import {useLocation} from 'sentry/utils/useLocation';
 import {DurationComparisonCell} from 'sentry/views/starfish/components/samplesTable/common';
@@ -9,6 +9,7 @@ import {
   OverflowEllipsisTextContainer,
   TextAlignRight,
 } from 'sentry/views/starfish/components/textAlign';
+import {SpanSample} from 'sentry/views/starfish/queries/useSpanSamples';
 
 type Keys = 'transaction_id' | 'timestamp' | 'duration' | 'p95_comparison';
 type TableColumnHeader = GridColumnHeader<Keys>;
@@ -26,36 +27,54 @@ const COLUMN_ORDER: TableColumnHeader[] = [
   },
   {
     key: 'p95_comparison',
-    name: 'Compared to P95',
+    name: 'Compared to baseline',
     width: 200,
   },
 ];
 
 type SpanTableRow = {
   op: string;
-  'span.self_time': number;
-  span_id: string;
-  timestamp: string;
   transaction: {
     id: string;
     'project.name': string;
     timestamp: string;
     'transaction.duration': number;
   };
-  'transaction.id': string;
-};
+} & SpanSample;
 
 type Props = {
   data: SpanTableRow[];
   isLoading: boolean;
   p95: number;
+  highlightedSpanId?: string;
+  onMouseLeaveSample?: () => void;
+  onMouseOverSample?: (sample: SpanSample) => void;
 };
 
-export function SpanSamplesTable({isLoading, data, p95}: Props) {
+export function SpanSamplesTable({
+  isLoading,
+  data,
+  p95,
+  highlightedSpanId,
+  onMouseLeaveSample,
+  onMouseOverSample,
+}: Props) {
   const location = useLocation();
 
+  function handleMouseOverBodyCell(row: SpanTableRow) {
+    if (onMouseOverSample) {
+      onMouseOverSample(row);
+    }
+  }
+
+  function handleMouseLeave() {
+    if (onMouseLeaveSample) {
+      onMouseLeaveSample();
+    }
+  }
+
   function renderHeadCell(column: GridColumnHeader): React.ReactNode {
-    if (column.key === 'p95_comparison') {
+    if (column.key === 'p95_comparison' || column.key === 'duration') {
       return (
         <TextAlignRight>
           <OverflowEllipsisTextContainer>{column.name}</OverflowEllipsisTextContainer>
@@ -67,10 +86,18 @@ export function SpanSamplesTable({isLoading, data, p95}: Props) {
   }
 
   function renderBodyCell(column: GridColumnHeader, row: SpanTableRow): React.ReactNode {
+    const shouldHighlight = row.span_id === highlightedSpanId;
+
+    const commonProps = {
+      style: (shouldHighlight ? {fontWeight: 'bold'} : {}) satisfies CSSProperties,
+      onMouseEnter: () => handleMouseOverBodyCell(row),
+    };
+
     if (column.key === 'transaction_id') {
       return (
         <Link
-          to={`/performance/${row.transaction?.['project.name']}:${row['transaction.id']}#span-${row.span_id}`}
+          to={`/performance/${row.project}:${row['transaction.id']}#span-${row.span_id}`}
+          {...commonProps}
         >
           {row['transaction.id'].slice(0, 8)}
         </Link>
@@ -78,31 +105,37 @@ export function SpanSamplesTable({isLoading, data, p95}: Props) {
     }
 
     if (column.key === 'duration') {
-      return <DurationCell milliseconds={row['span.self_time']} />;
+      return (
+        <DurationCell containerProps={commonProps} milliseconds={row['span.self_time']} />
+      );
     }
 
     if (column.key === 'p95_comparison') {
-      return <DurationComparisonCell duration={row['span.self_time']} p95={p95} />;
-    }
-
-    if (column.key === 'timestamp') {
-      return <DateTime date={row['span.timestamp']} year timeZone seconds />;
+      return (
+        <DurationComparisonCell
+          containerProps={commonProps}
+          duration={row['span.self_time']}
+          p95={p95}
+        />
+      );
     }
 
-    return <span>{row[column.key]}</span>;
+    return <span {...commonProps}>{row[column.key]}</span>;
   }
 
   return (
-    <GridEditable
-      isLoading={isLoading}
-      data={data}
-      columnOrder={COLUMN_ORDER}
-      columnSortBy={[]}
-      grid={{
-        renderHeadCell,
-        renderBodyCell,
-      }}
-      location={location}
-    />
+    <div onMouseLeave={handleMouseLeave}>
+      <GridEditable
+        isLoading={isLoading}
+        data={data}
+        columnOrder={COLUMN_ORDER}
+        columnSortBy={[]}
+        grid={{
+          renderHeadCell,
+          renderBodyCell,
+        }}
+        location={location}
+      />
+    </div>
   );
 }

+ 6 - 2
static/app/views/starfish/components/tableCells/durationCell.tsx

@@ -3,11 +3,15 @@ import {NumberContainer} from 'sentry/utils/discover/styles';
 
 type Props = {
   milliseconds: number;
+  containerProps?: React.DetailedHTMLProps<
+    React.HTMLAttributes<HTMLDivElement>,
+    HTMLDivElement
+  >;
 };
 
-export default function DurationCell({milliseconds}: Props) {
+export default function DurationCell({milliseconds, containerProps}: Props) {
   return (
-    <NumberContainer>
+    <NumberContainer {...containerProps}>
       <Duration seconds={milliseconds / 1000} fixedDigits={2} abbreviation />
     </NumberContainer>
   );

+ 1 - 1
static/app/views/starfish/queries/useSpanSamples.tsx

@@ -16,7 +16,7 @@ type Options = {
   transactionName?: string;
 };
 
-type SpanSample = Pick<
+export type SpanSample = Pick<
   SpanIndexedFieldTypes,
   | SpanIndexedFields.SPAN_SELF_TIME
   | SpanIndexedFields.TRANSACTION_ID

+ 63 - 8
static/app/views/starfish/views/spanSummaryPage/sampleList/durationChart/index.tsx

@@ -1,12 +1,11 @@
-import {Fragment} from 'react';
 import {useTheme} from '@emotion/react';
 
-import {Series} from 'sentry/types/echarts';
+import {EChartClickHandler, EChartHighlightHandler, Series} from 'sentry/types/echarts';
 import {P95_COLOR} from 'sentry/views/starfish/colours';
 import Chart from 'sentry/views/starfish/components/chart';
 import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
 import {useSpanMetricsSeries} from 'sentry/views/starfish/queries/useSpanMetricsSeries';
-import {useSpanSamples} from 'sentry/views/starfish/queries/useSpanSamples';
+import {SpanSample, useSpanSamples} from 'sentry/views/starfish/queries/useSpanSamples';
 import {SpanMetricsFields} from 'sentry/views/starfish/types';
 import {DataTitles} from 'sentry/views/starfish/views/spans/types';
 
@@ -16,10 +15,22 @@ type Props = {
   groupId: string;
   transactionMethod: string;
   transactionName: string;
+  highlightedSpanId?: string;
+  onClickSample?: (sample: SpanSample) => void;
+  onMouseLeaveSample?: () => void;
+  onMouseOverSample?: (sample: SpanSample) => void;
   spanDescription?: string;
 };
 
-function DurationChart({groupId, transactionName, transactionMethod}: Props) {
+function DurationChart({
+  groupId,
+  transactionName,
+  onClickSample,
+  onMouseLeaveSample,
+  onMouseOverSample,
+  highlightedSpanId,
+  transactionMethod,
+}: Props) {
   const theme = useTheme();
 
   const getSampleSymbol = (duration: number, p95: number) => {
@@ -79,7 +90,12 @@ function DurationChart({groupId, transactionName, transactionMethod}: Props) {
   };
 
   const sampledSpanDataSeries: Series[] = spans.map(
-    ({timestamp, 'span.self_time': duration, 'transaction.id': transaction_id}) => ({
+    ({
+      timestamp,
+      'span.self_time': duration,
+      'transaction.id': transaction_id,
+      span_id,
+    }) => ({
       data: [
         {
           name: timestamp,
@@ -88,17 +104,56 @@ function DurationChart({groupId, transactionName, transactionMethod}: Props) {
       ],
       symbol: getSampleSymbol(duration, p95).symbol,
       color: getSampleSymbol(duration, p95).color,
-      symbolSize: 10,
+      symbolSize: span_id === highlightedSpanId ? 15 : 10,
       seriesName: transaction_id,
     })
   );
 
+  const getSample = (timestamp: string, duration: number) => {
+    return spans.find(s => s.timestamp === timestamp && s['span.self_time'] === duration);
+  };
+
+  const handleChartClick: EChartClickHandler = e => {
+    const isSpanSample = e?.componentSubType === 'scatter';
+    if (isSpanSample && onClickSample) {
+      const [timestamp, duration] = e.value as [string, number];
+      const sample = getSample(timestamp, duration);
+      if (sample) {
+        onClickSample(sample);
+      }
+    }
+  };
+
+  const handleChartHighlight: EChartHighlightHandler = e => {
+    const {seriesIndex} = e.batch[0];
+    const isSpanSample = seriesIndex > 1;
+    if (isSpanSample && onMouseOverSample) {
+      const spanSampleData = sampledSpanDataSeries?.[seriesIndex - 2]?.data[0];
+      const {name: timestamp, value: duration} = spanSampleData;
+      const sample = getSample(timestamp as string, duration);
+      if (sample) {
+        onMouseOverSample(sample);
+      }
+    }
+    if (!isSpanSample && onMouseLeaveSample) {
+      onMouseLeaveSample();
+    }
+  };
+
+  const handleMouseLeave = () => {
+    if (onMouseLeaveSample) {
+      onMouseLeaveSample();
+    }
+  };
+
   return (
-    <Fragment>
+    <div onMouseLeave={handleMouseLeave}>
       <h5>{DataTitles.p95}</h5>
       <Chart
         statsPeriod="24h"
         height={140}
+        onClick={handleChartClick}
+        onHighlight={handleChartHighlight}
         data={[spanMetricsSeriesData?.[`p95(${SPAN_SELF_TIME})`], baselineP95Series]}
         start=""
         end=""
@@ -113,7 +168,7 @@ function DurationChart({groupId, transactionName, transactionMethod}: Props) {
         isLineChart
         definedAxisTicks={4}
       />
-    </Fragment>
+    </div>
   );
 }
 

+ 16 - 1
static/app/views/starfish/views/spanSummaryPage/sampleList/index.tsx

@@ -1,3 +1,4 @@
+import {useState} from 'react';
 import omit from 'lodash/omit';
 
 import useRouter from 'sentry/utils/useRouter';
@@ -14,6 +15,9 @@ type Props = {
 
 export function SampleList({groupId, transactionName, transactionMethod}: Props) {
   const router = useRouter();
+  const [highlightedSpanId, setHighlightedSpanId] = useState<string | undefined>(
+    undefined
+  );
 
   return (
     <DetailPanel
@@ -37,12 +41,23 @@ export function SampleList({groupId, transactionName, transactionMethod}: Props)
         groupId={groupId}
         transactionName={transactionName}
         transactionMethod={transactionMethod}
+        onClickSample={span => {
+          router.push(
+            `/performance/${span.project}:${span['transaction.id']}/#span-${span.span_id}`
+          );
+        }}
+        onMouseOverSample={sample => setHighlightedSpanId(sample.span_id)}
+        onMouseLeaveSample={() => setHighlightedSpanId(undefined)}
+        highlightedSpanId={highlightedSpanId}
       />
 
       <SampleTable
+        highlightedSpanId={highlightedSpanId}
+        transactionMethod={transactionMethod}
+        onMouseLeaveSample={() => setHighlightedSpanId(undefined)}
+        onMouseOverSample={sample => setHighlightedSpanId(sample.span_id)}
         groupId={groupId}
         transactionName={transactionName}
-        transactionMethod={transactionMethod}
       />
     </DetailPanel>
   );

+ 15 - 3
static/app/views/starfish/views/spanSummaryPage/sampleList/sampleTable/sampleTable.tsx

@@ -5,7 +5,7 @@ import {Button} from 'sentry/components/button';
 import {t} from 'sentry/locale';
 import {SpanSamplesTable} from 'sentry/views/starfish/components/samplesTable/spanSamplesTable';
 import {useSpanMetrics} from 'sentry/views/starfish/queries/useSpanMetrics';
-import {useSpanSamples} from 'sentry/views/starfish/queries/useSpanSamples';
+import {SpanSample, useSpanSamples} from 'sentry/views/starfish/queries/useSpanSamples';
 import {useTransactions} from 'sentry/views/starfish/queries/useTransactions';
 import {SpanMetricsFields} from 'sentry/views/starfish/types';
 
@@ -15,10 +15,19 @@ type Props = {
   groupId: string;
   transactionMethod: string;
   transactionName: string;
-  user?: string;
+  highlightedSpanId?: string;
+  onMouseLeaveSample?: () => void;
+  onMouseOverSample?: (sample: SpanSample) => void;
 };
 
-function SampleTable({groupId, transactionName, transactionMethod}: Props) {
+function SampleTable({
+  groupId,
+  transactionName,
+  highlightedSpanId,
+  onMouseLeaveSample,
+  onMouseOverSample,
+  transactionMethod,
+}: Props) {
   const {data: spanMetrics} = useSpanMetrics(
     {group: groupId},
     {transactionName, 'transaction.method': transactionMethod},
@@ -49,6 +58,9 @@ function SampleTable({groupId, transactionName, transactionMethod}: Props) {
   return (
     <Fragment>
       <SpanSamplesTable
+        onMouseLeaveSample={onMouseLeaveSample}
+        onMouseOverSample={onMouseOverSample}
+        highlightedSpanId={highlightedSpanId}
         data={spans.map(sample => {
           return {
             ...sample,