import isEqual from 'lodash/isEqual'; import pick from 'lodash/pick'; import {action, computed, makeObservable, observable} from 'mobx'; import {Client} from 'sentry/api'; import {EventTransaction} from 'sentry/types/event'; import {createFuzzySearch, Fuse} from 'sentry/utils/fuzzySearch'; import {ActiveOperationFilter, noFilter, toggleAllFilters, toggleFilter} from './filter'; import SpanTreeModel from './spanTreeModel'; import { FilterSpans, FocusedSpanIDMap, IndexedFusedSpan, ParsedTraceType, RawSpanType, TraceBound, } from './types'; import {boundsGenerator, generateRootSpan, getSpanID, parseTrace} from './utils'; class WaterfallModel { api: Client = new Client(); // readonly state event: Readonly; rootSpan: SpanTreeModel; parsedTrace: ParsedTraceType; fuse: Fuse | undefined = undefined; focusedSpanIds?: FocusedSpanIDMap; // readable/writable state operationNameFilters: ActiveOperationFilter = noFilter; filterSpans: FilterSpans | undefined = undefined; searchQuery: string | undefined = undefined; hiddenSpanSubTrees: Set; traceBounds: Array; constructor(event: Readonly, focusedSpanIds?: FocusedSpanIDMap) { this.event = event; this.parsedTrace = parseTrace(event); const rootSpan = generateRootSpan(this.parsedTrace); this.rootSpan = new SpanTreeModel( rootSpan, this.parsedTrace.childSpans, this.api, true ); // Track the trace bounds of the current transaction and the trace bounds of // any embedded transactions this.traceBounds = [this.rootSpan.generateTraceBounds()]; this.indexSearch(this.parsedTrace, rootSpan); // Set of span IDs whose sub-trees should be hidden. This is used for the // span tree toggling product feature. this.hiddenSpanSubTrees = new Set(); // When viewing the span waterfall from a Performance Issue, a set of span IDs may be provided this.focusedSpanIds = focusedSpanIds; makeObservable(this, { parsedTrace: observable, rootSpan: observable, // operation names filtering operationNameFilters: observable, toggleOperationNameFilter: action, toggleAllOperationNameFilters: action, operationNameCounts: computed.struct, // span search filterSpans: observable, searchQuery: observable, querySpanSearch: action, // span sub-tree toggling hiddenSpanSubTrees: observable, toggleSpanSubTree: action, // trace bounds traceBounds: observable, addTraceBounds: action, removeTraceBounds: action, }); } isEvent(otherEvent: Readonly) { return isEqual(this.event, otherEvent); } toggleOperationNameFilter = (operationName: string) => { this.operationNameFilters = toggleFilter(this.operationNameFilters, operationName); }; toggleAllOperationNameFilters = () => { const operationNames = Array.from(this.operationNameCounts.keys()); this.operationNameFilters = toggleAllFilters( this.operationNameFilters, operationNames ); }; get operationNameCounts(): Map { return this.rootSpan.operationNameCounts; } async indexSearch(parsedTrace: ParsedTraceType, rootSpan: RawSpanType) { this.filterSpans = undefined; this.searchQuery = undefined; const {spans} = parsedTrace; const transformed: IndexedFusedSpan[] = [rootSpan, ...spans].map( (span): IndexedFusedSpan => { const indexed: string[] = []; // basic properties const pickedSpan = pick(span, [ // TODO: do we want this? // 'trace_id', 'span_id', 'start_timestamp', 'timestamp', 'op', 'description', ]); const basicValues: string[] = Object.values(pickedSpan) .filter(value => !!value) .map(value => String(value)); indexed.push(...basicValues); // tags let tagKeys: string[] = []; let tagValues: string[] = []; const tags: {[tag_name: string]: string} | undefined = span?.tags; if (tags) { tagKeys = Object.keys(tags); tagValues = Object.values(tags); } const data: {[data_name: string]: any} | undefined = span?.data ?? {}; let dataKeys: string[] = []; let dataValues: string[] = []; if (data) { dataKeys = Object.keys(data); dataValues = Object.values(data).map( value => JSON.stringify(value, null, 4) || '' ); } return { span, indexed, tagKeys, tagValues, dataKeys, dataValues, }; } ); this.fuse = await createFuzzySearch(transformed, { keys: ['indexed', 'tagKeys', 'tagValues', 'dataKeys', 'dataValues'], includeMatches: false, threshold: 0.6, location: 0, distance: 100, maxPatternLength: 32, }); } querySpanSearch(searchQuery: string | undefined) { if (!searchQuery) { // reset if (this.filterSpans !== undefined) { this.filterSpans = undefined; this.searchQuery = undefined; } return; } if (!this.fuse) { return; } const results = this.fuse.search(searchQuery); const spanIDs: Set = results.reduce((setOfSpanIDs: Set, result) => { const spanID = getSpanID(result.item.span); if (spanID) { setOfSpanIDs.add(spanID); } return setOfSpanIDs; }, new Set()); this.searchQuery = searchQuery; this.filterSpans = {spanIDs, results}; } toggleSpanSubTree = (spanID: string) => { if (this.hiddenSpanSubTrees.has(spanID)) { this.hiddenSpanSubTrees.delete(spanID); return; } this.hiddenSpanSubTrees.add(spanID); }; addTraceBounds = (traceBound: TraceBound) => { this.traceBounds.push(traceBound); this.parsedTrace = { ...this.parsedTrace, ...this.getTraceBounds(), }; }; removeTraceBounds = (spanId: string) => { this.traceBounds = this.traceBounds.filter(bound => bound.spanId !== spanId); // traceBounds must always be non-empty if (this.traceBounds.length === 0) { this.traceBounds = [this.rootSpan.generateTraceBounds()]; } this.parsedTrace = { ...this.parsedTrace, ...this.getTraceBounds(), }; }; getTraceBounds = () => { // traceBounds must always be non-empty if (this.traceBounds.length === 0) { this.traceBounds = [this.rootSpan.generateTraceBounds()]; } return this.traceBounds.reduce( (acc, bounds) => { return { traceStartTimestamp: Math.min( acc.traceStartTimestamp, bounds.traceStartTimestamp ), traceEndTimestamp: Math.max(acc.traceEndTimestamp, bounds.traceEndTimestamp), }; }, { traceStartTimestamp: this.traceBounds[0].traceStartTimestamp, traceEndTimestamp: this.traceBounds[0].traceEndTimestamp, } ); }; generateBounds = ({ viewStart, viewEnd, }: { // in [0, 1] viewEnd: number; viewStart: number; // in [0, 1] }) => { return boundsGenerator({ ...this.getTraceBounds(), viewStart, viewEnd, }); }; getWaterfall = ({ viewStart, viewEnd, }: { // in [0, 1] viewEnd: number; viewStart: number; // in [0, 1] }) => { const generateBounds = this.generateBounds({ viewStart, viewEnd, }); return this.rootSpan.getSpansList({ operationNameFilters: this.operationNameFilters, generateBounds, treeDepth: 0, directParent: null, isLastSibling: true, continuingTreeDepths: [], hiddenSpanSubTrees: this.hiddenSpanSubTrees, spanAncestors: new Set(), filterSpans: this.filterSpans, focusedSpanIds: this.focusedSpanIds, previousSiblingEndTimestamp: undefined, event: this.event, isOnlySibling: true, spanNestedGrouping: undefined, toggleNestedSpanGroup: undefined, isNestedSpanGroupExpanded: false, addTraceBounds: this.addTraceBounds, removeTraceBounds: this.removeTraceBounds, }); }; } export default WaterfallModel;