Browse Source

feat(profiling): span tree (#42436)

Create a first and most primitive version of the span tree (the idea is
that we can have multiple ways in this class to generate a tree and we
can experiment with data visualizations).

Right now, discard the notion of parent_span_id edges, sort spans by
start time and search for a parent in the tree for each insertion. We
track spans we fail to insert (because we sort by start time, it means
only ends can overlap). Next up is to create a basic renderer to render
the tree so we can see how the strategy works.

I suspect a lot of this will change with time, but the idea is to get
the boilerplates in and iterate/find best strategy there.
Jonas 2 years ago
parent
commit
ef337c8602

+ 7 - 3
static/app/components/events/interfaces/performance/utils.tsx

@@ -3,6 +3,7 @@ import keyBy from 'lodash/keyBy';
 
 import {t} from 'sentry/locale';
 import {
+  EntrySpans,
   EntryType,
   EventTransaction,
   IssueCategory,
@@ -10,7 +11,7 @@ import {
   PlatformType,
 } from 'sentry/types';
 
-import {RawSpanType, SpanEntry} from '../spans/types';
+import {RawSpanType} from '../spans/types';
 
 import {ResourceLink} from './resources';
 import {TraceContextSpanProxy} from './spanEvidence';
@@ -96,10 +97,13 @@ export function getSpanInfoFromTransactionEvent(
   }
 
   // Let's dive into the event to pick off the span evidence data by using the IDs we know
-  const spanEntry = event.entries.find((entry: SpanEntry | any): entry is SpanEntry => {
+  const spanEntry = event.entries.find((entry: EntrySpans | any): entry is EntrySpans => {
     return entry.type === EntryType.SPANS;
   });
-  const spans: Array<RawSpanType | TraceContextSpanProxy> = [...spanEntry?.data] ?? [];
+
+  const spans: Array<RawSpanType | TraceContextSpanProxy> = spanEntry?.data
+    ? [...spanEntry.data]
+    : [];
 
   if (event?.contexts?.trace && event?.contexts?.trace?.span_id) {
     // TODO: Fix this conditional and check if span_id is ever actually undefined.

+ 13 - 1
static/app/components/events/interfaces/spans/spanTreeModel.spec.tsx

@@ -85,7 +85,7 @@ describe('SpanTreeModel', () => {
         type: EntryType.SPANS,
       },
     ],
-  } as EventTransaction;
+  } as unknown as EventTransaction;
 
   MockApiClient.addMockResponse({
     url: '/organizations/sentry/events/project:19c403a10af34db2b7d93ad669bb51ed/',
@@ -550,6 +550,10 @@ describe('SpanTreeModel', () => {
       },
     };
 
+    if (!Array.isArray(event2.entries[0].data)) {
+      throw new Error('event2.entries[0].data is not an array');
+    }
+
     for (let i = 0; i < 5; i++) {
       event2.entries[0].data.push(spanTemplate);
     }
@@ -627,6 +631,10 @@ describe('SpanTreeModel', () => {
       },
     };
 
+    if (!Array.isArray(event2.entries[0].data)) {
+      throw new Error('event2.entries[0].data is not an array');
+    }
+
     for (let i = 0; i < 4; i++) {
       event2.entries[0].data.push(spanTemplate);
     }
@@ -718,6 +726,10 @@ describe('SpanTreeModel', () => {
       },
     };
 
+    if (!Array.isArray(event2.entries[0].data)) {
+      throw new Error('event2.entries[0].data is not an array');
+    }
+
     for (let i = 0; i < 7; i++) {
       event2.entries[0].data.push(groupableSpanTemplate);
     }

+ 0 - 5
static/app/components/events/interfaces/spans/types.tsx

@@ -131,11 +131,6 @@ export type EnhancedProcessedSpanType =
       type: 'span_group_siblings';
     } & SpanSiblingGroupProps);
 
-export type SpanEntry = {
-  data: Array<RawSpanType>;
-  type: 'spans';
-};
-
 // map span_id to children whose parent_span_id is equal to span_id
 export type SpanChildrenLookupType = {[span_id: string]: Array<SpanType>};
 

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

@@ -7,7 +7,7 @@ import set from 'lodash/set';
 import moment from 'moment';
 
 import {Organization} from 'sentry/types';
-import {EntryType, EventTransaction} from 'sentry/types/event';
+import {EntrySpans, EntryType, EventTransaction} from 'sentry/types/event';
 import {assert} from 'sentry/types/utils';
 import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
 import {WebVital} from 'sentry/utils/fields';
@@ -26,7 +26,6 @@ import {
   ParsedTraceType,
   ProcessedSpanType,
   RawSpanType,
-  SpanEntry,
   SpanType,
   TraceContextType,
   TreeDepthType,
@@ -308,7 +307,7 @@ export function getTraceContext(
 }
 
 export function parseTrace(event: Readonly<EventTransaction>): ParsedTraceType {
-  const spanEntry = event.entries.find((entry: SpanEntry | any): entry is SpanEntry => {
+  const spanEntry = event.entries.find((entry: EntrySpans | any): entry is EntrySpans => {
     return entry.type === EntryType.SPANS;
   });
 

+ 1 - 1
static/app/components/events/interfaces/spans/waterfallModel.spec.tsx

@@ -176,7 +176,7 @@ describe('WaterfallModel', () => {
         type: EntryType.SPANS,
       },
     ],
-  } as EventTransaction;
+  } as unknown as EventTransaction;
 
   const fullWaterfall: EnhancedProcessedSpanType[] = [
     {

+ 6 - 5
static/app/components/events/opsBreakdown.tsx

@@ -6,7 +6,6 @@ import {SectionHeading} from 'sentry/components/charts/styles';
 import {ActiveOperationFilter} from 'sentry/components/events/interfaces/spans/filter';
 import {
   RawSpanType,
-  SpanEntry,
   TraceContextType,
 } from 'sentry/components/events/interfaces/spans/types';
 import {getSpanOperation} from 'sentry/components/events/interfaces/spans/utils';
@@ -14,7 +13,7 @@ import {pickBarColor} from 'sentry/components/performance/waterfall/utils';
 import QuestionTooltip from 'sentry/components/questionTooltip';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {EntryType, Event, EventTransaction} from 'sentry/types/event';
+import {EntrySpans, EntryType, Event, EventTransaction} from 'sentry/types/event';
 
 type StartTimestamp = number;
 type EndTimestamp = number;
@@ -82,9 +81,11 @@ class OpsBreakdown extends Component<Props> {
       return [];
     }
 
-    const spanEntry = event.entries.find((entry: SpanEntry | any): entry is SpanEntry => {
-      return entry.type === EntryType.SPANS;
-    });
+    const spanEntry = event.entries.find(
+      (entry: EntrySpans | any): entry is EntrySpans => {
+        return entry.type === EntryType.SPANS;
+      }
+    );
 
     let spans: RawSpanType[] = spanEntry?.data ?? [];
 

+ 7 - 4
static/app/types/event.tsx

@@ -1,7 +1,10 @@
-import type {TraceContextType} from 'sentry/components/events/interfaces/spans/types';
+import type {
+  RawSpanType,
+  TraceContextType,
+} from 'sentry/components/events/interfaces/spans/types';
 import type {SymbolicatorStatus} from 'sentry/components/events/interfaces/types';
 import type {PlatformKey} from 'sentry/data/platformCategories';
-import {IssueType} from 'sentry/types';
+import type {IssueType} from 'sentry/types';
 
 import type {RawCrumb} from './breadcrumbs';
 import type {Image} from './debugImage';
@@ -292,8 +295,8 @@ type EntryStacktrace = {
   type: EntryType.STACKTRACE;
 };
 
-type EntrySpans = {
-  data: any;
+export type EntrySpans = {
+  data: RawSpanType[];
   type: EntryType.SPANS;
 };
 

+ 69 - 0
static/app/utils/profiling/spanTree.spec.tsx

@@ -0,0 +1,69 @@
+import {EntrySpans} from 'sentry/types/event';
+
+function s(partial: Partial<EntrySpans['data'][0]>): EntrySpans['data'][0] {
+  return {
+    timestamp: 0,
+    start_timestamp: 0,
+    exclusive_time: 0,
+    description: '',
+    op: '',
+    span_id: '',
+    parent_span_id: '',
+    trace_id: '',
+    hash: '',
+    data: {},
+    ...partial,
+  };
+}
+
+import {SpanTree} from './spanTree';
+
+describe('SpanTree', () => {
+  it('appends to parent that contains span', () => {
+    const tree = new SpanTree([
+      s({span_id: '1', timestamp: 1, start_timestamp: 0}),
+      s({span_id: '2', timestamp: 0.5, start_timestamp: 0}),
+    ]);
+    expect(tree.spanTree.children[0].span.span_id).toBe('1');
+    expect(tree.spanTree.children[0].children[0].span.span_id).toBe('2');
+  });
+  it('pushes consequtive span', () => {
+    const tree = new SpanTree([
+      s({span_id: '1', timestamp: 1, start_timestamp: 0}),
+      s({span_id: '2', timestamp: 0.5, start_timestamp: 0}),
+      s({span_id: '3', timestamp: 0.8, start_timestamp: 0.5}),
+    ]);
+    expect(tree.spanTree.children[0].children[0].span.span_id).toBe('2');
+    expect(tree.spanTree.children[0].children[1].span.span_id).toBe('3');
+  });
+  it('marks span as orphaned if end overlaps', () => {
+    const tree = new SpanTree([
+      s({span_id: '1', timestamp: 1, start_timestamp: 0}),
+      s({span_id: '2', timestamp: 1.1, start_timestamp: 0.1}),
+    ]);
+    expect(tree.orphanedSpans[0].span_id).toBe('2');
+  });
+
+  it('iterates over all spans with depth', () => {
+    const tree = new SpanTree([
+      s({span_id: '1', timestamp: 1, start_timestamp: 0}),
+      s({span_id: '2', timestamp: 0.5, start_timestamp: 0}),
+      s({span_id: '3', timestamp: 0.2, start_timestamp: 0}),
+      s({span_id: '4', timestamp: 1, start_timestamp: 0.5}),
+    ]);
+
+    expect(tree.spanTree.children[0].children[1].span.span_id).toBe('4');
+
+    tree.forEach(span => {
+      if (span.node.span.span_id === '1') {
+        expect(span.depth).toBe(0);
+      } else if (span.node.span.span_id === '2') {
+        expect(span.depth).toBe(1);
+      } else if (span.node.span.span_id === '3') {
+        expect(span.depth).toBe(2);
+      } else if (span.node.span.span_id === '4') {
+        expect(span.depth).toBe(1);
+      }
+    });
+  });
+});

+ 114 - 0
static/app/utils/profiling/spanTree.tsx

@@ -0,0 +1,114 @@
+import {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
+
+interface SpanChartNode {
+  depth: number;
+  end: number;
+  node: SpanTreeNode;
+  start: number;
+}
+
+class SpanTreeNode {
+  parent?: SpanTreeNode | null = null;
+  span: RawSpanType;
+  children: SpanTreeNode[] = [];
+
+  constructor(span: RawSpanType, parent?: SpanTreeNode | null) {
+    this.span = span;
+    this.parent = parent;
+  }
+
+  static Root() {
+    return new SpanTreeNode(
+      {
+        description: 'root',
+        op: 'root',
+        start_timestamp: 0,
+        exclusive_time: 0,
+        timestamp: Number.MAX_SAFE_INTEGER,
+        parent_span_id: '',
+        data: {},
+        span_id: '',
+        trace_id: '',
+        hash: '',
+      },
+      null
+    );
+  }
+
+  contains(span: RawSpanType) {
+    return (
+      span.start_timestamp >= this.span.start_timestamp &&
+      span.timestamp <= this.span.timestamp
+    );
+  }
+}
+
+class SpanTree {
+  spans: RawSpanType[];
+  spanTree: SpanTreeNode = SpanTreeNode.Root();
+  orphanedSpans: RawSpanType[] = [];
+
+  constructor(spans: RawSpanType[]) {
+    this.spans = spans;
+    this.buildCollapsedSpanTree();
+  }
+
+  buildCollapsedSpanTree() {
+    const spansSortedByStartTime = [...this.spans].sort((a, b) => {
+      if (a.start_timestamp < b.start_timestamp) {
+        return -1;
+      }
+      // if the start times are the same, we want to sort by end time
+      if (a.start_timestamp === b.start_timestamp) {
+        if (a.timestamp < b.timestamp) {
+          return 1; // a is a child of b
+        }
+        return -1; // b is a child of a
+      }
+      return 1;
+    });
+
+    for (const span of spansSortedByStartTime) {
+      const queue = [...this.spanTree.children];
+      let parent: SpanTreeNode | null = null;
+
+      // If this is the first span, just push it to the root
+      if (!this.spanTree.children.length) {
+        this.spanTree.children.push(new SpanTreeNode(span, this.spanTree));
+        continue;
+      }
+
+      while (queue.length > 0) {
+        const current = queue.pop()!;
+        if (current.contains(span)) {
+          parent = current;
+          queue.push(...current.children);
+        }
+      }
+
+      // if we didn't find a parent, we have an orphaned span
+      if (parent === null) {
+        this.orphanedSpans.push(span);
+        continue;
+      }
+      parent.children.push(new SpanTreeNode(span, parent));
+    }
+  }
+
+  forEach(cb: (node: SpanChartNode) => void) {
+    const queue: SpanTreeNode[] = [...this.spanTree.children];
+    let depth = 0;
+
+    while (queue.length) {
+      let children_at_depth = queue.length;
+      while (children_at_depth-- !== 0) {
+        const node = queue.pop()!;
+        queue.push(...node.children);
+        cb({start: node.span.start_timestamp, end: node.span.timestamp, node, depth});
+      }
+      depth++;
+    }
+  }
+}
+
+export {SpanTree};

+ 9 - 4
tests/js/sentry-test/performance/initializePerformanceData.ts

@@ -1,5 +1,6 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
 
+import {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
 import {EntryType, EventTransaction, Project} from 'sentry/types';
 import {defined} from 'sentry/utils';
 import EventView from 'sentry/utils/discover/eventView';
@@ -199,19 +200,19 @@ export function generateSampleEvent(): EventTransaction {
         type: EntryType.SPANS,
       },
     ],
-  } as EventTransaction;
+  } as unknown as EventTransaction;
 
   return event;
 }
 
 export function generateSampleSpan(
-  description: string | null,
-  op: string | null,
+  description: string | undefined,
+  op: string | undefined,
   span_id: string,
   parent_span_id: string,
   event: EventTransaction
 ) {
-  const span = {
+  const span: RawSpanType = {
     start_timestamp: 1000,
     timestamp: 2000,
     description,
@@ -226,6 +227,10 @@ export function generateSampleSpan(
     data: {},
   };
 
+  if (!Array.isArray(event.entries[0].data)) {
+    throw new Error('Event entries data is not an array');
+  }
+
   event.entries[0].data.push(span);
   return span;
 }