123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600 |
- import * as Sentry from '@sentry/react';
- import {
- type ProcessedTokenResult,
- toPostFix,
- } from 'sentry/components/searchSyntax/evaluator';
- import {
- BooleanOperator,
- TermOperator,
- Token,
- type TokenResult,
- } from 'sentry/components/searchSyntax/parser';
- import {
- isAutogroupedNode,
- isSpanNode,
- isTraceErrorNode,
- isTransactionNode,
- } from '../traceGuards';
- import type {TraceTree} from '../traceModels/traceTree';
- import type {TraceTreeNode} from '../traceModels/traceTreeNode';
- export type TraceSearchResult = {
- index: number;
- value: TraceTreeNode<TraceTree.NodeValue>;
- };
- /**
- * Evaluates the infix token representation against the token list. The logic is the same as
- * if we were evaluating arithmetics expressions with the caveat that we have to handle and edge
- * case the first time we evaluate two operands. Because our list contains tokens and not values,
- * we need to evaluate the first two tokens, push the result back to the stack. From there on we can
- * evaluate the rest of the tokens.
- * [token, token, bool logic] -> [] -> [result, token, bool]
- * ^evaluate^ and push to stack ^ left ^ right ^ operator
- * All next evaluations will be done with the result and the next token.
- */
- export function searchInTraceTreeTokens(
- tree: TraceTree,
- tokens: TokenResult<Token>[],
- previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
- cb: (
- results: [
- ReadonlyArray<TraceSearchResult>,
- Map<TraceTreeNode<TraceTree.NodeValue>, number>,
- {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
- ]
- ) => void
- ): {id: number | null} {
- let previousNodeSearchResult: {
- resultIndex: number | undefined;
- resultIteratorIndex: number | undefined;
- } | null = null;
- if (!tokens || tokens.length === 0) {
- cb([[], new Map(), null]);
- return {id: null};
- }
- const handle: {id: number | null} = {id: 0};
- const resultLookup = new Map();
- const postfix = toPostFix(tokens);
- if (postfix.length === 0) {
- cb([[], resultLookup, null]);
- return handle;
- }
- if (postfix.length === 1 && postfix[0].type === Token.FREE_TEXT) {
- return searchInTraceTreeText(tree, postfix[0].value, previousNode, cb);
- }
- let i = 0;
- let matchCount = 0;
- const count = tree.list.length;
- const resultsForSingleToken: TraceSearchResult[] = [];
- function searchSingleToken() {
- const ts = performance.now();
- while (i < count && performance.now() - ts < 12) {
- const node = tree.list[i];
- if (evaluateTokenForValue(postfix[0], resolveValueFromKey(node, postfix[0]))) {
- resultsForSingleToken.push({index: i, value: node});
- resultLookup.set(node, matchCount);
- if (previousNode === node) {
- previousNodeSearchResult = {
- resultIndex: i,
- resultIteratorIndex: matchCount,
- };
- }
- matchCount++;
- }
- i++;
- }
- if (i < count) {
- handle.id = requestAnimationFrame(searchSingleToken);
- } else {
- cb([resultsForSingleToken, resultLookup, previousNodeSearchResult]);
- }
- }
- if (postfix.length <= 1 && postfix[0].type === Token.FILTER) {
- handle.id = requestAnimationFrame(searchSingleToken);
- return handle;
- }
- let result_map: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
- let ti = 0;
- let li = 0;
- let ri = 0;
- let bool: TokenResult<Token.LOGIC_BOOLEAN> | null = null;
- let leftToken:
- | ProcessedTokenResult
- | Map<TraceTreeNode<TraceTree.NodeValue>, number>
- | null = null;
- let rightToken: ProcessedTokenResult | null = null;
- const left: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
- const right: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
- const stack: (
- | ProcessedTokenResult
- | Map<TraceTreeNode<TraceTree.NodeValue>, number>
- )[] = [];
- function search(): void {
- const ts = performance.now();
- if (!bool) {
- while (ti < postfix.length) {
- const token = postfix[ti];
- if (token.type === Token.LOGIC_BOOLEAN) {
- bool = token;
- if (stack.length < 2) {
- Sentry.captureMessage('Unbalanced tree - missing left or right token');
- typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
- cb([[], resultLookup, null]);
- return;
- }
- // @ts-expect-error the type guard is handled and expected
- rightToken = stack.pop()!;
- leftToken = stack.pop()!;
- break;
- } else {
- stack.push(token);
- }
- ti++;
- }
- }
- if (!bool) {
- Sentry.captureMessage(
- 'Invalid state in searchInTraceTreeTokens, missing boolean token'
- );
- typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
- cb([[], resultLookup, null]);
- return;
- }
- if (!leftToken || !rightToken) {
- Sentry.captureMessage(
- 'Invalid state in searchInTraceTreeTokens, missing left or right token'
- );
- typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
- cb([[], resultLookup, null]);
- return;
- }
- if (li < count && !(leftToken instanceof Map)) {
- while (li < count && performance.now() - ts < 12) {
- const node = tree.list[li];
- if (evaluateTokenForValue(leftToken, resolveValueFromKey(node, leftToken))) {
- left.set(node, li);
- }
- li++;
- }
- handle.id = requestAnimationFrame(search);
- } else if (ri < count && !(rightToken instanceof Map)) {
- while (ri < count && performance.now() - ts < 12) {
- const node = tree.list[ri];
- if (evaluateTokenForValue(rightToken, resolveValueFromKey(node, rightToken))) {
- right.set(node, ri);
- }
- ri++;
- }
- handle.id = requestAnimationFrame(search);
- } else {
- if (
- (li === count || leftToken instanceof Map) &&
- (ri === count || rightToken instanceof Map)
- ) {
- result_map = booleanResult(
- leftToken instanceof Map ? leftToken : left,
- rightToken instanceof Map ? rightToken : right,
- bool.value
- );
- // Reset the state for the next iteration
- bool = null;
- leftToken = null;
- rightToken = null;
- left.clear();
- right.clear();
- li = 0;
- ri = 0;
- // Push result to stack;
- stack.push(result_map);
- ti++;
- }
- if (ti === postfix.length) {
- const result: TraceSearchResult[] = [];
- let resultIdx = -1;
- // @TODO We render up to 10k nodes and plan to load more, so this might be future bottleneck.
- for (const [node, index] of result_map) {
- result.push({index, value: node});
- resultLookup.set(node, ++resultIdx);
- if (previousNode === node) {
- previousNodeSearchResult = {
- resultIndex: index,
- resultIteratorIndex: resultIdx,
- };
- }
- }
- cb([result, resultLookup, previousNodeSearchResult]);
- } else {
- handle.id = requestAnimationFrame(search);
- }
- }
- }
- handle.id = requestAnimationFrame(search);
- return handle;
- }
- // Freetext search in the trace tree
- export function searchInTraceTreeText(
- tree: TraceTree,
- query: string,
- previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
- cb: (
- results: [
- ReadonlyArray<TraceSearchResult>,
- Map<TraceTreeNode<TraceTree.NodeValue>, number>,
- {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
- ]
- ) => void
- ): {id: number | null} {
- const handle: {id: number | null} = {id: 0};
- let previousNodeSearchResult: {
- resultIndex: number | undefined;
- resultIteratorIndex: number | undefined;
- } | null = null;
- const results: Array<TraceSearchResult> = [];
- const resultLookup = new Map();
- let i = 0;
- let matchCount = 0;
- const count = tree.list.length;
- function search() {
- const ts = performance.now();
- while (i < count && performance.now() - ts < 12) {
- const node = tree.list[i];
- if (evaluateNodeFreeText(query, node)) {
- results.push({index: i, value: node});
- resultLookup.set(node, matchCount);
- if (previousNode === node) {
- previousNodeSearchResult = {
- resultIndex: i,
- resultIteratorIndex: matchCount,
- };
- }
- matchCount++;
- }
- i++;
- }
- if (i < count) {
- handle.id = requestAnimationFrame(search);
- }
- if (i === count) {
- cb([results, resultLookup, previousNodeSearchResult]);
- handle.id = null;
- }
- }
- handle.id = requestAnimationFrame(search);
- return handle;
- }
- function evaluateTokenForValue(token: ProcessedTokenResult, value: any): boolean {
- if (token.type === Token.FILTER) {
- if (token.value.type === Token.VALUE_NUMBER) {
- const result = evaluateValueNumber(token.value, token.operator, value);
- return token.negated ? !result : result;
- }
- if (token.value.type === Token.VALUE_DURATION) {
- const result = evaluateValueNumber(token.value, token.operator, value);
- return token.negated ? !result : result;
- }
- if (token.value.type === Token.VALUE_TEXT) {
- switch (typeof value) {
- case 'string':
- return token.negated
- ? !value.includes(token.value.value)
- : value.includes(token.value.value);
- case 'boolean':
- return token.negated ? !value : !!value;
- default:
- return false;
- }
- }
- if (token.value.type === Token.VALUE_ISO_8601_DATE) {
- return (
- typeof value === 'number' && evaluateValueDate(token.value, token.operator, value)
- );
- }
- }
- return false;
- }
- function booleanResult(
- left: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
- right: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
- operator: BooleanOperator
- ): Map<TraceTreeNode<TraceTree.NodeValue>, number> {
- if (operator === BooleanOperator.AND) {
- const result = new Map();
- for (const [key, value] of left) {
- right.has(key) && result.set(key, value);
- }
- return result;
- }
- if (operator === BooleanOperator.OR) {
- const result = new Map(left);
- for (const [key, value] of right) {
- result.set(key, value);
- }
- return result;
- }
- throw new Error(`Unsupported boolean operator, received ${operator}`);
- }
- function evaluateValueDate<T extends Token.VALUE_ISO_8601_DATE>(
- token: TokenResult<T>,
- operator: TermOperator,
- value: any
- ): boolean {
- if (!token.parsed || (typeof value !== 'number' && typeof value !== 'string')) {
- return false;
- }
- if (typeof value === 'string') {
- value = new Date(value).getTime();
- if (isNaN(value)) {
- return false;
- }
- }
- const query = token.parsed.value.getTime();
- switch (operator) {
- case TermOperator.GREATER_THAN:
- return value > query;
- case TermOperator.GREATER_THAN_EQUAL:
- return value >= query;
- case TermOperator.LESS_THAN:
- return value < query;
- case TermOperator.LESS_THAN_EQUAL:
- return value <= query;
- case TermOperator.EQUAL:
- case TermOperator.DEFAULT: {
- return value === query;
- }
- default: {
- Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
- return false;
- }
- }
- }
- function evaluateValueNumber<T extends Token.VALUE_DURATION | Token.VALUE_NUMBER>(
- token: TokenResult<T>,
- operator: TermOperator,
- value: any
- ): boolean {
- // @TODO Figure out if it's possible that we receive NaN/Infinity values
- // and how we should handle them.
- if (!token.parsed || typeof value !== 'number') {
- return false;
- }
- const query = token.parsed.value;
- switch (operator) {
- case TermOperator.GREATER_THAN:
- return value > query;
- case TermOperator.GREATER_THAN_EQUAL:
- return value >= query;
- case TermOperator.LESS_THAN:
- return value < query;
- case TermOperator.LESS_THAN_EQUAL:
- return value <= query;
- case TermOperator.EQUAL:
- case TermOperator.DEFAULT: {
- return value === query;
- }
- default: {
- Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
- return false;
- }
- }
- }
- const TRANSACTION_DURATION_ALIASES = new Set([
- 'duration',
- // 'transaction.duration', <-- this is an actual key
- 'transaction.total_time',
- ]);
- const SPAN_DURATION_ALIASES = new Set(['duration', 'span.duration', 'span.total_time']);
- const SPAN_SELF_TIME_ALIASES = new Set(['span.self_time', 'span.exclusive_time']);
- // Pulls the value from the node based on the key in the token
- function resolveValueFromKey(
- node: TraceTreeNode<TraceTree.NodeValue>,
- token: ProcessedTokenResult
- ): any | null {
- const value = node.value;
- if (!value) {
- return null;
- }
- if (token.type === Token.FILTER) {
- let key: string | null = null;
- switch (token.key.type) {
- case Token.KEY_SIMPLE: {
- if (
- TRANSACTION_DURATION_ALIASES.has(token.key.value) &&
- isTransactionNode(node) &&
- node.space
- ) {
- return node.space[1];
- }
- if (
- SPAN_DURATION_ALIASES.has(token.key.value) &&
- isSpanNode(node) &&
- node.space
- ) {
- return node.space[1];
- }
- if (
- SPAN_SELF_TIME_ALIASES.has(token.key.value) &&
- isSpanNode(node) &&
- node.space
- ) {
- return node.value.exclusive_time;
- }
- key = token.key.value;
- break;
- }
- case Token.KEY_AGGREGATE:
- case Token.KEY_EXPLICIT_TAG:
- default: {
- Sentry.captureMessage(`Unsupported key type for filter, got ${token.key.type}`);
- }
- }
- if (key !== null) {
- // If the value can be accessed directly, do so,
- // else check if the key is an entity key, sanitize it and try direct access again.
- // @TODO support deep nested keys with dot notation
- if (
- key === 'has' &&
- token.type === Token.FILTER &&
- token.value.type === Token.VALUE_TEXT
- ) {
- switch (token.value.text) {
- case 'error':
- case 'errors': {
- return node.errors.size > 0;
- }
- case 'issue':
- case 'issues':
- return node.errors.size > 0 || node.performance_issues.size > 0;
- case 'profile':
- case 'profiles':
- return node.profiles.length > 0;
- default: {
- break;
- }
- }
- }
- // Aliases for fields that do not exist on raw data
- if (key === 'project' || key === 'project.name') {
- // project.name and project fields do not exist on raw data and are
- // aliases for project_slug key that does exist.
- key = 'project_slug';
- }
- // Check for direct key access.
- if (value[key] !== undefined) {
- return value[key];
- }
- // @TODO perf optimization opportunity
- // Entity check should be preprocessed per token, not once per token per node we are evaluating, however since
- // we are searching <10k nodes in p99 percent of the time and the search is non blocking, we are likely fine
- // and can be optimized later.
- const [maybeEntity, ...rest] = key.split('.');
- switch (maybeEntity) {
- case 'span':
- if (isSpanNode(node)) {
- return value[rest.join('.')];
- }
- break;
- case 'transaction':
- if (isTransactionNode(node)) {
- return value[rest.join('.')];
- }
- break;
- default:
- break;
- }
- }
- return key ? value[key] ?? null : null;
- }
- return null;
- }
- /**
- * Evaluates the node based on freetext. This is a simple search that checks if the query
- * is present in a very small subset of the node's properties.
- */
- function evaluateNodeFreeText(
- query: string,
- node: TraceTreeNode<TraceTree.NodeValue>
- ): boolean {
- if (isSpanNode(node)) {
- if (node.value.op?.includes(query)) {
- return true;
- }
- if (node.value.description?.includes(query)) {
- return true;
- }
- if (node.value.span_id && node.value.span_id === query) {
- return true;
- }
- }
- if (isTransactionNode(node)) {
- if (node.value['transaction.op']?.includes(query)) {
- return true;
- }
- if (node.value.transaction?.includes(query)) {
- return true;
- }
- if (node.value.event_id && node.value.event_id === query) {
- return true;
- }
- }
- if (isAutogroupedNode(node)) {
- if (node.value.op?.includes(query)) {
- return true;
- }
- if (node.value.description?.includes(query)) {
- return true;
- }
- }
- if (isTraceErrorNode(node)) {
- if (node.value.level === query) {
- return true;
- }
- if (node.value.title?.includes(query)) {
- return true;
- }
- }
- return false;
- }
|