import {Client} from 'sentry/api'; import SpanTreeModel from 'sentry/components/events/interfaces/spans/spanTreeModel'; import {EnhancedProcessedSpanType} from 'sentry/components/events/interfaces/spans/types'; import { boundsGenerator, generateRootSpan, parseTrace, } from 'sentry/components/events/interfaces/spans/utils'; import {EntryType, EventTransaction} from 'sentry/types/event'; import {assert} from 'sentry/types/utils'; describe('SpanTreeModel', () => { const api: Client = new Client(); const event = { id: '2b658a829a21496b87fd1f14a61abf65', eventID: '2b658a829a21496b87fd1f14a61abf65', title: '/organizations/:orgId/discover/results/', type: 'transaction', startTimestamp: 1622079935.86141, endTimestamp: 1622079940.032905, contexts: { trace: { trace_id: '8cbbc19c0f54447ab702f00263262726', span_id: 'a934857184bdf5a6', op: 'pageload', status: 'unknown', type: 'trace', }, }, entries: [ { data: [ { timestamp: 1622079937.227645, start_timestamp: 1622079936.90689, description: 'GET /api/0/organizations/?member=1', op: 'http', span_id: 'b23703998ae619e7', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/organizations/?member=1', }, }, { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'GET /api/0/internal/health/', op: 'http', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }, { timestamp: 1622079936.05839, start_timestamp: 1622079936.048125, description: '/_static/dist/sentry/sentry.541f5b.css', op: 'resource.link', span_id: 'a23f26b939d1a735', parent_span_id: 'a453cc713e5baf9c', trace_id: '8cbbc19c0f54447ab702f00263262726', data: { 'Decoded Body Size': 159248, 'Encoded Body Size': 159248, 'Transfer Size': 275, }, }, ], type: EntryType.SPANS, }, ], } as EventTransaction; MockApiClient.addMockResponse({ url: '/organizations/sentry/events/project:19c403a10af34db2b7d93ad669bb51ed/', body: { ...event, contexts: { trace: { trace_id: '61d2d7c5acf448ffa8e2f8f973e2cd36', span_id: 'a5702f287954a9ef', parent_span_id: 'b23703998ae619e7', op: 'something', status: 'unknown', type: 'trace', }, }, entries: [ { data: [ { timestamp: 1622079937.227645, start_timestamp: 1622079936.90689, description: 'something child', op: 'child', span_id: 'bcbea9f18a11e161', parent_span_id: 'a5702f287954a9ef', trace_id: '61d2d7c5acf448ffa8e2f8f973e2cd36', status: 'ok', data: {}, }, ], type: EntryType.SPANS, }, ], }, }); MockApiClient.addMockResponse({ url: '/organizations/sentry/events/project:broken/', body: { ...event, }, statusCode: 500, }); it('makes children', () => { const parsedTrace = parseTrace(event); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); expect(spanTreeModel.children).toHaveLength(2); }); it('handles recursive children', () => { const event2 = { ...event, entries: [ { data: [ { timestamp: 1622079937.227645, start_timestamp: 1622079936.90689, description: 'GET /api/0/organizations/?member=1', op: 'http', span_id: 'a934857184bdf5a6', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/organizations/?member=1', }, }, ], type: EntryType.SPANS, }, ], } as EventTransaction; const parsedTrace = parseTrace(event2); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); expect(spanTreeModel.children).toHaveLength(1); }); it('operationNameCounts', () => { const parsedTrace = parseTrace(event); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); expect(Object.fromEntries(spanTreeModel.operationNameCounts)).toMatchObject({ http: 2, pageload: 1, 'resource.link': 1, }); }); it('toggleEmbeddedChildren - happy path', async () => { const parsedTrace = parseTrace(event); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe('idle'); const fullWaterfall: EnhancedProcessedSpanType[] = [ { type: 'span', span: { trace_id: '8cbbc19c0f54447ab702f00263262726', span_id: 'a934857184bdf5a6', parent_span_id: undefined, start_timestamp: 1622079935.86141, timestamp: 1622079940.032905, op: 'pageload', description: undefined, data: {}, status: 'unknown', }, numOfSpanChildren: 2, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, }, { type: 'span', span: { timestamp: 1622079937.227645, start_timestamp: 1622079936.90689, description: 'GET /api/0/organizations/?member=1', op: 'http', span_id: 'b23703998ae619e7', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/organizations/?member=1', }, }, numOfSpanChildren: 0, treeDepth: 1, isLastSibling: false, continuingTreeDepths: [], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, }, { type: 'span', span: { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'GET /api/0/internal/health/', op: 'http', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }, numOfSpanChildren: 1, treeDepth: 1, isLastSibling: true, continuingTreeDepths: [], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, }, { type: 'span', span: { timestamp: 1622079936.05839, start_timestamp: 1622079936.048125, description: '/_static/dist/sentry/sentry.541f5b.css', op: 'resource.link', span_id: 'a23f26b939d1a735', parent_span_id: 'a453cc713e5baf9c', trace_id: '8cbbc19c0f54447ab702f00263262726', data: { 'Decoded Body Size': 159248, 'Encoded Body Size': 159248, 'Transfer Size': 275, }, }, numOfSpanChildren: 0, treeDepth: 2, isLastSibling: true, continuingTreeDepths: [], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, }, ]; const generateBounds = boundsGenerator({ traceStartTimestamp: parsedTrace.traceStartTimestamp, traceEndTimestamp: parsedTrace.traceEndTimestamp, viewStart: 0, viewEnd: 1, }); let spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); expect(spans).toEqual(fullWaterfall); let mockAddTraceBounds = jest.fn(); let mockRemoveTraceBounds = jest.fn(); // embed a child transaction let promise = spanTreeModel.toggleEmbeddedChildren({ addTraceBounds: mockAddTraceBounds, removeTraceBounds: mockRemoveTraceBounds, })({ orgSlug: 'sentry', eventSlug: 'project:19c403a10af34db2b7d93ad669bb51ed', }); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe( 'loading_embedded_transactions' ); await promise; expect(mockAddTraceBounds).toHaveBeenCalled(); expect(mockRemoveTraceBounds).not.toHaveBeenCalled(); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe('idle'); spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); const fullWaterfallExpected: EnhancedProcessedSpanType[] = [...fullWaterfall]; fullWaterfallExpected.splice( 1, 0, // Expect these spans to be embedded { type: 'span', span: { trace_id: '61d2d7c5acf448ffa8e2f8f973e2cd36', span_id: 'a5702f287954a9ef', parent_span_id: 'b23703998ae619e7', start_timestamp: 1622079935.86141, timestamp: 1622079940.032905, op: 'something', description: undefined, data: {}, status: 'unknown', }, numOfSpanChildren: 1, treeDepth: 1, isLastSibling: false, continuingTreeDepths: [], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, }, { type: 'span', span: { trace_id: '61d2d7c5acf448ffa8e2f8f973e2cd36', span_id: 'bcbea9f18a11e161', parent_span_id: 'a5702f287954a9ef', start_timestamp: 1622079936.90689, timestamp: 1622079937.227645, op: 'child', description: 'something child', data: {}, status: 'ok', }, numOfSpanChildren: 0, treeDepth: 2, isLastSibling: true, continuingTreeDepths: [1], showEmbeddedChildren: false, toggleEmbeddedChildren: expect.any(Function), fetchEmbeddedChildrenState: 'idle', toggleNestedSpanGroup: undefined, toggleSiblingSpanGroup: undefined, isEmbeddedTransactionTimeAdjusted: false, } ); fullWaterfallExpected[0] = { ...fullWaterfallExpected[0], }; assert(fullWaterfallExpected[0].type === 'span'); fullWaterfallExpected[0].numOfSpanChildren += 1; fullWaterfallExpected[0].showEmbeddedChildren = true; expect(spans).toEqual(fullWaterfallExpected); mockAddTraceBounds = jest.fn(); mockRemoveTraceBounds = jest.fn(); // un-embed a child transaction promise = spanTreeModel.toggleEmbeddedChildren({ addTraceBounds: mockAddTraceBounds, removeTraceBounds: mockRemoveTraceBounds, })({ orgSlug: 'sentry', eventSlug: 'project:19c403a10af34db2b7d93ad669bb51ed', }); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe('idle'); await promise; expect(mockAddTraceBounds).not.toHaveBeenCalled(); expect(mockRemoveTraceBounds).toHaveBeenCalled(); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe('idle'); spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); expect(spans).toEqual(fullWaterfall); }); it('toggleEmbeddedChildren - error state', async () => { const parsedTrace = parseTrace(event); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); const promise = spanTreeModel.toggleEmbeddedChildren({ addTraceBounds: () => {}, removeTraceBounds: () => {}, })({ orgSlug: 'sentry', eventSlug: 'project:broken', }); expect(spanTreeModel.fetchEmbeddedChildrenState).toBe( 'loading_embedded_transactions' ); await promise; expect(spanTreeModel.fetchEmbeddedChildrenState).toBe( 'error_fetching_embedded_transactions' ); }); it('automatically groups siblings with the same operation and description', () => { const event2 = { ...event, entries: [ { data: [], type: EntryType.SPANS, }, ], } as EventTransaction; const spanTemplate = { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'test_description', op: 'test', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }; for (let i = 0; i < 5; i++) { event2.entries[0].data.push(spanTemplate); } const parsedTrace = parseTrace(event2); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); const generateBounds = boundsGenerator({ traceStartTimestamp: parsedTrace.traceStartTimestamp, traceEndTimestamp: parsedTrace.traceEndTimestamp, viewStart: 0, viewEnd: 1, }); const spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); expect(spans.length).toEqual(2); expect(spans[1].type).toEqual('span_group_siblings'); // If statement here is required to avoid TS linting issues if (spans[1].type === 'span_group_siblings') { expect(spans[1].spanSiblingGrouping!.length).toEqual(5); } }); it('does not autogroup similar siblings if there are less than 5 in a row', () => { const event2 = { ...event, entries: [ { data: [], type: EntryType.SPANS, }, ], } as EventTransaction; const spanTemplate = { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'test_description', op: 'test', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }; for (let i = 0; i < 4; i++) { event2.entries[0].data.push(spanTemplate); } const parsedTrace = parseTrace(event2); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); const generateBounds = boundsGenerator({ traceStartTimestamp: parsedTrace.traceStartTimestamp, traceEndTimestamp: parsedTrace.traceEndTimestamp, viewStart: 0, viewEnd: 1, }); const spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); expect(spans.length).toEqual(5); spans.forEach(span => expect(span.type).toEqual('span')); }); it('properly autogroups similar siblings and leaves other siblings ungrouped', () => { const event2 = { ...event, entries: [ { data: [], type: EntryType.SPANS, }, ], } as EventTransaction; const groupableSpanTemplate = { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'test_description', op: 'test', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }; const normalSpanTemplate = { timestamp: 1622079937.20331, start_timestamp: 1622079936.907515, description: 'dont_group_me', op: 'http', span_id: 'a453cc713e5baf9c', parent_span_id: 'a934857184bdf5a6', trace_id: '8cbbc19c0f54447ab702f00263262726', status: 'ok', tags: { 'http.status_code': '200', }, data: { method: 'GET', type: 'fetch', url: '/api/0/internal/health/', }, }; for (let i = 0; i < 7; i++) { event2.entries[0].data.push(groupableSpanTemplate); } // This span should not get grouped with the others event2.entries[0].data.push(normalSpanTemplate); for (let i = 0; i < 5; i++) { event2.entries[0].data.push(groupableSpanTemplate); } const parsedTrace = parseTrace(event2); const rootSpan = generateRootSpan(parsedTrace); const spanTreeModel = new SpanTreeModel(rootSpan, parsedTrace.childSpans, api); const generateBounds = boundsGenerator({ traceStartTimestamp: parsedTrace.traceStartTimestamp, traceEndTimestamp: parsedTrace.traceEndTimestamp, viewStart: 0, viewEnd: 1, }); const spans = spanTreeModel.getSpansList({ operationNameFilters: { type: 'no_filter', }, generateBounds, treeDepth: 0, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: new Set(), spanAncestors: new Set(), filterSpans: undefined, previousSiblingEndTimestamp: undefined, event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: () => {}, removeTraceBounds: () => {}, directParent: null, }); expect(spans.length).toEqual(4); expect(spans[1].type).toEqual('span_group_siblings'); expect(spans[2].type).toEqual('span'); expect(spans[3].type).toEqual('span_group_siblings'); }); });