Browse Source

feat(starfish): Add UI to display aggregate span waterfall (#56628)

This PR gets in an initial UI for the aggregate span waterfall.
It's currently nested under the 'Aggregate Waterfall' tab in the
transaction overview while we clean up the UI.

Note, this feature is tied to indexed span ingestion, so only
works for `sentry/sentry` and `sentry/javascript` projects
Shruthi 1 year ago
parent
commit
57d653f4b9

+ 125 - 0
static/app/components/events/interfaces/spans/aggregateSpanDetail.tsx

@@ -0,0 +1,125 @@
+import styled from '@emotion/styled';
+
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import {Organization} from 'sentry/types';
+import {AggregateEventTransaction} from 'sentry/types/event';
+import {formatPercentage, getDuration} from 'sentry/utils/formatters';
+import {QuickTraceEvent, TraceError} from 'sentry/utils/performance/quickTrace/types';
+
+import {AggregateSpanType, ParsedTraceType} from './types';
+
+type Props = {
+  childTransactions: QuickTraceEvent[] | null;
+  event: Readonly<AggregateEventTransaction>;
+  isRoot: boolean;
+  organization: Organization;
+  relatedErrors: TraceError[] | null;
+  resetCellMeasureCache: () => void;
+  scrollToHash: (hash: string) => void;
+  span: AggregateSpanType;
+  trace: Readonly<ParsedTraceType>;
+};
+
+function AggregateSpanDetail({span}: Props) {
+  const frequency = span?.frequency;
+  const avgDuration = span?.timestamp - span?.start_timestamp;
+  return (
+    <SpanDetailContainer
+      data-component="span-detail"
+      onClick={event => {
+        // prevent toggling the span detail
+        event.stopPropagation();
+      }}
+    >
+      <SpanDetails>
+        <table className="table key-value">
+          <tbody>
+            <Row title={t('avg(duration)')}>{getDuration(avgDuration)}</Row>
+            <Row title={t('frequency')}>{frequency && formatPercentage(frequency)}</Row>
+          </tbody>
+        </table>
+      </SpanDetails>
+    </SpanDetailContainer>
+  );
+}
+
+export default AggregateSpanDetail;
+
+export function Row({
+  title,
+  keep,
+  children,
+  prefix,
+  extra = null,
+}: {
+  children: React.ReactNode;
+  title: JSX.Element | string | null;
+  extra?: React.ReactNode;
+  keep?: boolean;
+  prefix?: JSX.Element;
+}) {
+  if (!keep && !children) {
+    return null;
+  }
+
+  return (
+    <tr>
+      <td className="key">
+        <Flex>
+          {prefix}
+          {title}
+        </Flex>
+      </td>
+      <ValueTd className="value">
+        <ValueRow>
+          <StyledPre>
+            <span className="val-string">{children}</span>
+          </StyledPre>
+          <ButtonContainer>{extra}</ButtonContainer>
+        </ValueRow>
+      </ValueTd>
+    </tr>
+  );
+}
+
+export const SpanDetailContainer = styled('div')`
+  border-bottom: 1px solid ${p => p.theme.border};
+  cursor: auto;
+`;
+
+const ValueTd = styled('td')`
+  position: relative;
+`;
+
+const Flex = styled('div')`
+  display: flex;
+  align-items: center;
+`;
+
+const ValueRow = styled('div')`
+  display: grid;
+  grid-template-columns: auto min-content;
+  gap: ${space(1)};
+
+  border-radius: 4px;
+  background-color: ${p => p.theme.surface200};
+  margin: 2px;
+`;
+
+const StyledPre = styled('pre')`
+  margin: 0 !important;
+  background-color: transparent !important;
+`;
+
+const ButtonContainer = styled('div')`
+  padding: 8px 10px;
+`;
+
+export const SpanDetails = styled('div')`
+  padding: ${space(2)};
+
+  table.table.key-value td.key {
+    max-width: 280px;
+  }
+`;

+ 153 - 0
static/app/components/events/interfaces/spans/aggregateSpans.tsx

@@ -0,0 +1,153 @@
+import {useMemo} from 'react';
+import omit from 'lodash/omit';
+
+import TraceView from 'sentry/components/events/interfaces/spans/traceView';
+import {AggregateSpanType} from 'sentry/components/events/interfaces/spans/types';
+import WaterfallModel from 'sentry/components/events/interfaces/spans/waterfallModel';
+import {normalizeDateTimeParams} from 'sentry/components/organizations/pageFilters/parse';
+import Panel from 'sentry/components/panels/panel';
+import {AggregateEventTransaction, EntryType, EventOrGroupType} from 'sentry/types/event';
+import {defined} from 'sentry/utils';
+import {useApiQuery} from 'sentry/utils/queryClient';
+import useOrganization from 'sentry/utils/useOrganization';
+import usePageFilters from 'sentry/utils/usePageFilters';
+
+type AggregateSpanRow = {
+  'avg(absolute_offset)': number;
+  'avg(duration)': number;
+  'avg(exclusive_time)': number;
+  'count()': number;
+  description: string;
+  group: string;
+  is_segment: number;
+  node_fingerprint: string;
+  parent_node_fingerprint: string;
+  start_ms: number;
+};
+
+export function useAggregateSpans({transaction}) {
+  const organization = useOrganization();
+  const {selection} = usePageFilters();
+
+  const endpointOptions = {
+    query: {
+      transaction,
+      project: selection.projects,
+      environment: selection.environments,
+      ...normalizeDateTimeParams(selection.datetime),
+    },
+  };
+
+  return useApiQuery<{
+    data: {[fingerprint: string]: AggregateSpanRow}[];
+    meta: any;
+  }>(
+    [
+      `/organizations/${organization.slug}/spans-aggregation/`,
+      {
+        query: endpointOptions.query,
+      },
+    ],
+    {
+      staleTime: Infinity,
+      enabled: true,
+    }
+  );
+}
+
+type Props = {
+  transaction: string;
+};
+
+export function AggregateSpans({transaction}: Props) {
+  const organization = useOrganization();
+  const {data} = useAggregateSpans({transaction});
+
+  function formatSpan(span, total) {
+    const {
+      node_fingerprint: span_id,
+      parent_node_fingerprint: parent_span_id,
+      description: description,
+      'avg(exclusive_time)': exclusive_time,
+      'avg(absolute_offset)': start_timestamp,
+      'count()': count,
+      'avg(duration)': duration,
+      ...rest
+    } = span;
+    return {
+      ...rest,
+      span_id,
+      parent_span_id,
+      description,
+      exclusive_time,
+      timestamp: (start_timestamp + duration) / 1000,
+      start_timestamp: start_timestamp / 1000,
+      trace_id: '1', // not actually trace_id just a placeholder
+      count,
+      duration,
+      frequency: count / total,
+      type: 'aggregate',
+    };
+  }
+
+  const totalCount: number = useMemo(() => {
+    if (defined(data)) {
+      const spans = Object.values(data);
+      for (let index = 0; index < spans.length; index++) {
+        if (spans[index].is_segment) {
+          return spans[index]['count()'];
+        }
+      }
+    }
+    return 0;
+  }, [data]);
+
+  const spanList: AggregateSpanType[] = useMemo(() => {
+    const spanList_: AggregateSpanType[] = [];
+    if (defined(data)) {
+      const spans = Object.values(data);
+      for (let index = 0; index < spans.length; index++) {
+        const node = formatSpan(spans[index], totalCount);
+        if (node.is_segment === 1) {
+          spanList_.unshift(node);
+        } else {
+          spanList_.push(node);
+        }
+      }
+    }
+    return spanList_;
+  }, [data, totalCount]);
+
+  const [parentSpan, ...flattenedSpans] = spanList;
+
+  const event: AggregateEventTransaction = useMemo(() => {
+    return {
+      contexts: {
+        trace: {
+          ...omit(parentSpan, 'type'),
+        },
+      },
+      endTimestamp: 0,
+      entries: [
+        {
+          data: flattenedSpans,
+          type: EntryType.SPANS,
+        },
+      ],
+      startTimestamp: 0,
+      type: EventOrGroupType.AGGREGATE_TRANSACTION,
+    };
+  }, [parentSpan, flattenedSpans]);
+  const waterfallModel = useMemo(() => new WaterfallModel(event, undefined), [event]);
+
+  return (
+    <Panel>
+      <TraceView
+        waterfallModel={waterfallModel}
+        organization={organization}
+        isEmbedded
+        isAggregate
+      />
+    </Panel>
+  );
+}

+ 2 - 2
static/app/components/events/interfaces/spans/header.tsx

@@ -22,7 +22,7 @@ import {
 import ConfigStore from 'sentry/stores/configStore';
 import {space} from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
-import {EventTransaction} from 'sentry/types/event';
+import {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
 import {defined} from 'sentry/utils';
 import toPercent from 'sentry/utils/number/toPercent';
 import theme from 'sentry/utils/theme';
@@ -57,7 +57,7 @@ import {
 
 type PropType = {
   dragProps: DragManagerChildrenProps;
-  event: EventTransaction;
+  event: EventTransaction | AggregateEventTransaction;
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   minimapInteractiveRef: React.RefObject<HTMLDivElement>;
   operationNameFilters: ActiveOperationFilter;

+ 51 - 5
static/app/components/events/interfaces/spans/spanBar.tsx

@@ -6,6 +6,7 @@ import styled from '@emotion/styled';
 import {withProfiler} from '@sentry/react';
 
 import Count from 'sentry/components/count';
+import AggregateSpanDetail from 'sentry/components/events/interfaces/spans/aggregateSpanDetail';
 import {ROW_HEIGHT, SpanBarType} from 'sentry/components/performance/waterfall/constants';
 import {MessageRow} from 'sentry/components/performance/waterfall/messageRow';
 import {
@@ -45,10 +46,15 @@ import {IconWarning} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import {Organization} from 'sentry/types';
-import {EventTransaction} from 'sentry/types/event';
+import {
+  AggregateEventTransaction,
+  EventOrGroupType,
+  EventTransaction,
+} from 'sentry/types/event';
 import {defined} from 'sentry/utils';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import {generateEventSlug} from 'sentry/utils/discover/urls';
+import {formatPercentage} from 'sentry/utils/formatters';
 import toPercent from 'sentry/utils/number/toPercent';
 import {
   QuickTraceContext,
@@ -73,6 +79,7 @@ import SpanBarCursorGuide from './spanBarCursorGuide';
 import SpanDetail from './spanDetail';
 import {MeasurementMarker} from './styles';
 import {
+  AggregateSpanType,
   FetchEmbeddedChildrenState,
   GroupType,
   ParsedTraceType,
@@ -122,7 +129,7 @@ export type SpanBarProps = {
   cellMeasurerCache: CellMeasurerCache;
   continuingTreeDepths: Array<TreeDepthType>;
   didAnchoredSpanMount: () => boolean;
-  event: Readonly<EventTransaction>;
+  event: Readonly<EventTransaction | AggregateEventTransaction>;
   fetchEmbeddedChildrenState: FetchEmbeddedChildrenState;
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   getCurrentLeftPos: () => number;
@@ -138,7 +145,7 @@ export type SpanBarProps = {
   resetCellMeasureCache: () => void;
   showEmbeddedChildren: boolean;
   showSpanTree: boolean;
-  span: ProcessedSpanType;
+  span: ProcessedSpanType | AggregateSpanType;
   spanNumber: number;
   storeSpanBar: (spanBar: SpanBar) => void;
   toggleEmbeddedChildren:
@@ -305,11 +312,30 @@ export class SpanBar extends Component<SpanBarProps, SpanBarState> {
       return null;
     }
 
+    const isAggregateSpan =
+      event.type === EventOrGroupType.AGGREGATE_TRANSACTION && span.type === 'aggregate';
+
+    if (isAggregateSpan) {
+      return (
+        <AggregateSpanDetail
+          span={span}
+          organization={organization}
+          event={event}
+          isRoot={!!isRoot}
+          trace={trace}
+          childTransactions={transactions}
+          relatedErrors={errors}
+          scrollToHash={this.scrollIntoView}
+          resetCellMeasureCache={this.props.resetCellMeasureCache}
+        />
+      );
+    }
+
     return (
       <SpanDetail
-        span={span}
+        span={span as ProcessedSpanType}
         organization={organization}
-        event={event}
+        event={event as EventTransaction}
         isRoot={!!isRoot}
         trace={trace}
         childTransactions={transactions}
@@ -1081,6 +1107,10 @@ export class SpanBar extends Component<SpanBarProps, SpanBarState> {
       : null;
 
     const durationDisplay = getDurationDisplay(bounds);
+    let frequency: number | undefined = undefined;
+    if (span.type === 'aggregate') {
+      frequency = span.frequency;
+    }
     return (
       <Fragment>
         <RowRectangle
@@ -1102,6 +1132,9 @@ export class SpanBar extends Component<SpanBarProps, SpanBarState> {
           </DurationPill>
         </RowRectangle>
         {subSpans}
+        <PercentageContainer>
+          <Percentage>{frequency && formatPercentage(frequency)}</Percentage>
+        </PercentageContainer>
       </Fragment>
     );
   }
@@ -1184,3 +1217,16 @@ const StyledIconWarning = styled(IconWarning)`
 const Regroup = styled('span')``;
 
 export const ProfiledSpanBar = withProfiler(SpanBar);
+
+const PercentageContainer = styled('div')`
+  position: absolute;
+  left: 100%;
+  padding-left: 10px;
+  white-space: nowrap;
+  color: ${p => p.theme.gray300};
+  font-size: ${p => p.theme.fontSizeSmall};
+`;
+
+const Percentage = styled('div')`
+  position: fixed;
+`;

+ 2 - 2
static/app/components/events/interfaces/spans/spanDescendantGroupBar.tsx

@@ -17,7 +17,7 @@ import {
   getHumanDuration,
 } from 'sentry/components/performance/waterfall/utils';
 import {t} from 'sentry/locale';
-import {EventTransaction} from 'sentry/types/event';
+import {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
 import toPercent from 'sentry/utils/number/toPercent';
 
 import {SpanGroupBar} from './spanGroupBar';
@@ -37,7 +37,7 @@ export type SpanDescendantGroupBarProps = {
   addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
   continuingTreeDepths: Array<TreeDepthType>;
   didAnchoredSpanMount: () => boolean;
-  event: Readonly<EventTransaction>;
+  event: Readonly<EventTransaction | AggregateEventTransaction>;
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   getCurrentLeftPos: () => number;
   onWheel: (deltaX: number) => void;

+ 3 - 3
static/app/components/events/interfaces/spans/spanGroupBar.tsx

@@ -34,7 +34,7 @@ import {
   TreeToggle,
   TreeToggleContainer,
 } from 'sentry/components/performance/waterfall/treeConnector';
-import {EventTransaction} from 'sentry/types/event';
+import {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
 import {defined} from 'sentry/utils';
 import toPercent from 'sentry/utils/number/toPercent';
 import {PerformanceInteraction} from 'sentry/utils/performanceForSentry';
@@ -56,7 +56,7 @@ const MARGIN_LEFT = 0;
 type Props = {
   addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
   didAnchoredSpanMount: () => boolean;
-  event: Readonly<EventTransaction>;
+  event: Readonly<EventTransaction | AggregateEventTransaction>;
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   getCurrentLeftPos: () => number;
   onWheel: (deltaX: number) => void;
@@ -129,7 +129,7 @@ function renderDivider(
 }
 
 function renderMeasurements(
-  event: Readonly<EventTransaction>,
+  event: Readonly<EventTransaction | AggregateEventTransaction>,
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType
 ) {
   const measurements = getMeasurements(event, generateBounds);

+ 2 - 2
static/app/components/events/interfaces/spans/spanSiblingGroupBar.tsx

@@ -7,7 +7,7 @@ import {
   TreeConnector,
 } from 'sentry/components/performance/waterfall/treeConnector';
 import {t} from 'sentry/locale';
-import {EventTransaction} from 'sentry/types/event';
+import {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import useOrganization from 'sentry/utils/useOrganization';
 
@@ -28,7 +28,7 @@ export type SpanSiblingGroupBarProps = {
   addContentSpanBarRef: (instance: HTMLDivElement | null) => void;
   continuingTreeDepths: Array<TreeDepthType>;
   didAnchoredSpanMount: () => boolean;
-  event: Readonly<EventTransaction>;
+  event: Readonly<EventTransaction | AggregateEventTransaction>;
   generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
   getCurrentLeftPos: () => number;
   isEmbeddedSpanTree: boolean;

+ 12 - 6
static/app/components/events/interfaces/spans/spanTreeModel.spec.tsx

@@ -1,7 +1,10 @@
 import {waitFor} from 'sentry-test/reactTestingLibrary';
 
 import SpanTreeModel from 'sentry/components/events/interfaces/spans/spanTreeModel';
-import {EnhancedProcessedSpanType} from 'sentry/components/events/interfaces/spans/types';
+import {
+  EnhancedProcessedSpanType,
+  RawSpanType,
+} from 'sentry/components/events/interfaces/spans/types';
 import {
   boundsGenerator,
   generateRootSpan,
@@ -559,8 +562,9 @@ describe('SpanTreeModel', () => {
       throw new Error('event2.entries[0].data is not an array');
     }
 
+    const data = event2.entries[0].data as RawSpanType[];
     for (let i = 0; i < 5; i++) {
-      event2.entries[0].data.push(spanTemplate);
+      data.push(spanTemplate);
     }
 
     const parsedTrace = parseTrace(event2);
@@ -640,8 +644,9 @@ describe('SpanTreeModel', () => {
       throw new Error('event2.entries[0].data is not an array');
     }
 
+    const data = event2.entries[0].data as RawSpanType[];
     for (let i = 0; i < 4; i++) {
-      event2.entries[0].data.push(spanTemplate);
+      data.push(spanTemplate);
     }
 
     const parsedTrace = parseTrace(event2);
@@ -735,15 +740,16 @@ describe('SpanTreeModel', () => {
       throw new Error('event2.entries[0].data is not an array');
     }
 
+    const data = event2.entries[0].data as RawSpanType[];
     for (let i = 0; i < 7; i++) {
-      event2.entries[0].data.push(groupableSpanTemplate);
+      data.push(groupableSpanTemplate);
     }
 
     // This span should not get grouped with the others
-    event2.entries[0].data.push(normalSpanTemplate);
+    data.push(normalSpanTemplate);
 
     for (let i = 0; i < 5; i++) {
-      event2.entries[0].data.push(groupableSpanTemplate);
+      data.push(groupableSpanTemplate);
     }
 
     const parsedTrace = parseTrace(event2);

+ 3 - 3
static/app/components/events/interfaces/spans/spanTreeModel.tsx

@@ -2,7 +2,7 @@ import {action, computed, makeObservable, observable} from 'mobx';
 
 import {Client} from 'sentry/api';
 import {t} from 'sentry/locale';
-import {EventTransaction} from 'sentry/types/event';
+import {AggregateEventTransaction, EventTransaction} from 'sentry/types/event';
 
 import {ActiveOperationFilter} from './filter';
 import {
@@ -145,7 +145,7 @@ class SpanTreeModel {
   };
 
   generateSpanGap(
-    event: Readonly<EventTransaction>,
+    event: Readonly<EventTransaction | AggregateEventTransaction>,
     previousSiblingEndTimestamp: number | undefined,
     treeDepth: number,
     continuingTreeDepths: Array<TreeDepthType>
@@ -189,7 +189,7 @@ class SpanTreeModel {
     addTraceBounds: (bounds: TraceBound) => void;
     continuingTreeDepths: Array<TreeDepthType>;
     directParent: SpanTreeModel | null;
-    event: Readonly<EventTransaction>;
+    event: Readonly<EventTransaction | AggregateEventTransaction>;
     filterSpans: FilterSpans | undefined;
     generateBounds: (bounds: SpanBoundsType) => SpanGeneratedBoundsType;
     hiddenSpanSubTrees: Set<string>;

+ 1 - 0
static/app/components/events/interfaces/spans/traceView.tsx

@@ -19,6 +19,7 @@ import WaterfallModel from './waterfallModel';
 type Props = {
   organization: Organization;
   waterfallModel: WaterfallModel;
+  isAggregate?: boolean;
   isEmbedded?: boolean;
   performanceIssues?: TracePerformanceIssue[];
 };

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