Browse Source

feat(trace): evaluate tokens against trace items using search syntax (#70299)

Jonas 9 months ago
parent
commit
c6113df7f3

+ 46 - 39
static/app/views/performance/newTraceDetails/index.tsx

@@ -51,6 +51,11 @@ import {
   type ViewManagerScrollAnchor,
   VirtualizedViewManager,
 } from 'sentry/views/performance/newTraceDetails/traceRenderers/virtualizedViewManager';
+import {
+  searchInTraceTreeText,
+  searchInTraceTreeTokens,
+} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator';
+import {parseTraceSearch} from 'sentry/views/performance/newTraceDetails/traceSearch/traceTokenConverter';
 import {TraceShortcuts} from 'sentry/views/performance/newTraceDetails/traceShortcutsModal';
 import {
   loadTraceViewPreferences,
@@ -63,7 +68,6 @@ import {useTraceRootEvent} from './traceApi/useTraceRootEvent';
 import {TraceDrawer} from './traceDrawer/traceDrawer';
 import {TraceTree, type TraceTreeNode} from './traceModels/traceTree';
 import {TraceSearchInput} from './traceSearch/traceSearchInput';
-import {searchInTraceTree} from './traceState/traceSearch';
 import {isTraceNode} from './guards';
 import {Trace} from './trace';
 import {TraceMetadataHeader} from './traceMetadataHeader';
@@ -411,52 +415,55 @@ function TraceViewContent(props: TraceViewContentProps) {
         window.cancelAnimationFrame(searchingRaf.current.id);
       }
 
-      searchingRaf.current = searchInTraceTree(
-        tree,
-        query,
-        activeNode,
-        ([matches, lookup, activeNodeSearchResult]) => {
-          // If the previous node is still in the results set, we want to keep it
-          if (activeNodeSearchResult) {
-            traceDispatch({
-              type: 'set results',
-              results: matches,
-              resultsLookup: lookup,
-              resultIteratorIndex: activeNodeSearchResult?.resultIteratorIndex,
-              resultIndex: activeNodeSearchResult?.resultIndex,
-              previousNode: activeNodeSearchResult,
-              node: activeNode,
-            });
-            return;
-          }
-
-          if (activeNode && behavior === 'persist') {
-            traceDispatch({
-              type: 'set results',
-              results: matches,
-              resultsLookup: lookup,
-              resultIteratorIndex: undefined,
-              resultIndex: undefined,
-              previousNode: activeNodeSearchResult,
-              node: activeNode,
-            });
-            return;
-          }
+      function done([matches, lookup, activeNodeSearchResult]) {
+        // If the previous node is still in the results set, we want to keep it
+        if (activeNodeSearchResult) {
+          traceDispatch({
+            type: 'set results',
+            results: matches,
+            resultsLookup: lookup,
+            resultIteratorIndex: activeNodeSearchResult?.resultIteratorIndex,
+            resultIndex: activeNodeSearchResult?.resultIndex,
+            previousNode: activeNodeSearchResult,
+            node: activeNode,
+          });
+          return;
+        }
 
-          const resultIndex: number | undefined = matches?.[0]?.index;
-          const resultIteratorIndex: number | undefined = matches?.[0] ? 0 : undefined;
-          const node: TraceTreeNode<TraceTree.NodeValue> | null = matches?.[0]?.value;
+        if (activeNode && behavior === 'persist') {
           traceDispatch({
             type: 'set results',
             results: matches,
             resultsLookup: lookup,
-            resultIteratorIndex: resultIteratorIndex,
-            resultIndex: resultIndex,
+            resultIteratorIndex: undefined,
+            resultIndex: undefined,
             previousNode: activeNodeSearchResult,
-            node,
+            node: activeNode,
           });
+          return;
         }
-      );
+
+        const resultIndex: number | undefined = matches?.[0]?.index;
+        const resultIteratorIndex: number | undefined = matches?.[0] ? 0 : undefined;
+        const node: TraceTreeNode<TraceTree.NodeValue> | null = matches?.[0]?.value;
+        traceDispatch({
+          type: 'set results',
+          results: matches,
+          resultsLookup: lookup,
+          resultIteratorIndex: resultIteratorIndex,
+          resultIndex: resultIndex,
+          previousNode: activeNodeSearchResult,
+          node,
+        });
+      }
+
+      const tokens = parseTraceSearch(query);
+
+      if (tokens) {
+        searchingRaf.current = searchInTraceTreeTokens(tree, tokens, activeNode, done);
+      } else {
+        searchingRaf.current = searchInTraceTreeText(tree, query, activeNode, done);
+      }
     },
     [traceDispatch, tree]
   );

+ 0 - 82
static/app/views/performance/newTraceDetails/traceSearch/traceSearch.pegjs

@@ -1,82 +0,0 @@
-{
-    function as_undefined(){
-        return undefined;
-    }
-
-    function as_null(){
-        return null;
-    }
-
-    function as_operator_type(input){
-        switch(input){
-            case ">=":
-                return "ge";
-            case "<=":
-                return "le";
-            case "=":
-                return "eq";
-            case "<":
-                return "lt";
-            case ">":
-                return "gt";
-        }
-    }
-
-    function as_bool(input){
-        switch(input){
-        case "true": return true;
-        case "false": return false;
-        default: {
-            throw new Error("Failed to parse boolean value")
-            }
-        }
-    }
-}
-
-search_grammar
-   = t:token* {
-        return t
-   }
-
-token = search
-
-search = s:search_key separator vo:value_operator? v:value {
-    return {
-        type: "Token",
-        key: s.value,
-        value: v,
-        ...(s.negated ? {negated: true}: {}),
-        ...(vo ? {operator: vo} : {}),
-    }
-}
-
-
-search_key = space n:[!]? k:value space {
-    return {
-        value: k,
-        ...(!!n ? {negated: true} : {}),
-    }
-}
-value = space k:(bool/undefined/null/float/integer/string) space { return k }
-
-value_operator = space k:(">=" / "<=" / "=" / "<" / ">") space {
-    return as_operator_type(text())
-}
-
-// Primitives
-bool = k:("true" / "false") { return as_bool(text()) }
-undefined = "undefined" { return as_undefined() }
-null = "null" { return as_null() }
-float = ([-])? ([0-9]+)? "." ([0-9]+)? !string {
-    return parseFloat(text(), 10)
-}
-integer = ([-])?[0-9]+ !string{
-    return parseInt(text(), 10)
-}
-string = [a-zA-Z0-9\-._!$]+ !bool !undefined !null {
-    return text()
-}
-
-space = " "*
-separator = ":"
-eoi = [\t\n]

+ 85 - 0
static/app/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator.spec.tsx

@@ -0,0 +1,85 @@
+import {waitFor} from 'sentry-test/reactTestingLibrary';
+
+import {
+  type TraceTree,
+  TraceTreeNode,
+} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
+import {searchInTraceTreeTokens} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator';
+import {parseTraceSearch} from 'sentry/views/performance/newTraceDetails/traceSearch/traceTokenConverter';
+
+function makeTransaction(
+  overrides: Partial<TraceTree.Transaction> = {}
+): TraceTree.Transaction {
+  return {
+    children: [],
+    start_timestamp: 0,
+    timestamp: 1,
+    transaction: 'transaction',
+    'transaction.op': '',
+    'transaction.status': '',
+    performance_issues: [],
+    errors: [],
+    ...overrides,
+  } as TraceTree.Transaction;
+}
+
+const makeTree = (list: TraceTree.NodeValue[]): TraceTree => {
+  return {
+    list: list.map(
+      n => new TraceTreeNode(null, n, {project_slug: 'project', event_id: ''})
+    ),
+  } as unknown as TraceTree;
+};
+
+const search = (query: string, list: TraceTree.NodeValue[], cb: any) => {
+  searchInTraceTreeTokens(
+    makeTree(list),
+    // @ts-expect-error test failed parse
+    parseTraceSearch(query),
+    null,
+    cb
+  );
+};
+
+describe('TraceSearchEvaluator', () => {
+  it('empty string', async () => {
+    const list = [
+      makeTransaction({'transaction.op': 'operation'}),
+      makeTransaction({'transaction.op': 'other'}),
+    ];
+
+    const cb = jest.fn();
+    search('', list, cb);
+    await waitFor(() => {
+      expect(cb).toHaveBeenCalled();
+    });
+    expect(cb.mock.calls[0][0][0]).toEqual([]);
+    expect(cb.mock.calls[0][0][1].size).toBe(0);
+    expect(cb.mock.calls[0][0][2]).toBe(null);
+  });
+  it.each([
+    [''],
+    ['invalid_query'],
+    ['invalid_query:'],
+    ['OR'],
+    ['AND'],
+    ['('],
+    [')'],
+    ['()'],
+    ['(invalid_query)'],
+  ])('invalid grammar %s', async query => {
+    const list = [
+      makeTransaction({'transaction.op': 'operation'}),
+      makeTransaction({'transaction.op': 'other'}),
+    ];
+
+    const cb = jest.fn();
+    search(query, list, cb);
+    await waitFor(() => {
+      expect(cb).toHaveBeenCalled();
+    });
+    expect(cb.mock.calls[0][0][0]).toEqual([]);
+    expect(cb.mock.calls[0][0][1].size).toBe(0);
+    expect(cb.mock.calls[0][0][2]).toBe(null);
+  });
+});

+ 511 - 0
static/app/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator.tsx

@@ -0,0 +1,511 @@
+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 'sentry/views/performance/newTraceDetails/guards';
+import type {
+  TraceTree,
+  TraceTreeNode,
+} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
+
+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) {
+      return typeof value === 'string' && value.includes(token.value.value);
+    }
+    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;
+    }
+  }
+}
+
+// @TODO Alias self time, total time.
+const DURATION_ALIASES = new Set(['duration']);
+// 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 (DURATION_ALIASES.has(token.key.value) && node.space) {
+          return node.space[1];
+        }
+        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) {
+      // Check for direct key access.
+      if (value[key] !== undefined) {
+        return value[key];
+      }
+    }
+
+    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;
+}

+ 1 - 1
static/app/views/performance/newTraceDetails/traceSearch/traceSearchInput.tsx

@@ -161,7 +161,7 @@ export function TraceSearchInput(props: TraceSearchInputProps) {
         name="query"
         autoComplete="off"
         placeholder={t('Search in trace')}
-        value={props.trace_state.search.query ?? ''}
+        defaultValue={props.trace_state.search.query ?? ''}
         onChange={onChange}
         onKeyDown={onKeyDown}
         onFocus={onSearchFocus}

+ 0 - 212
static/app/views/performance/newTraceDetails/traceSearch/traceSearchTokenizer.spec.tsx

@@ -1,212 +0,0 @@
-import type {RawSpanType} from 'sentry/components/events/interfaces/spans/types';
-import {EntryType, type Event} from 'sentry/types';
-import {
-  type TraceTree,
-  TraceTreeNode,
-} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
-import {evaluateTokenForTraceNode} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchTokenizer';
-
-import grammar from './traceSearch.pegjs';
-
-const evaluate = evaluateTokenForTraceNode;
-
-const metadata = {
-  project_slug: 'project',
-  event_id: 'event_id',
-};
-
-function makeSpan(overrides: Partial<RawSpanType> = {}): TraceTree.Span {
-  return {
-    op: '',
-    description: '',
-    span_id: '',
-    start_timestamp: 0,
-    timestamp: 10,
-    event: makeEvent(),
-    errors: [],
-    performance_issues: [],
-    childTransactions: [],
-    ...overrides,
-  } as TraceTree.Span;
-}
-
-function makeEvent(overrides: Partial<Event> = {}, spans: RawSpanType[] = []): Event {
-  return {
-    entries: [{type: EntryType.SPANS, data: spans}],
-    ...overrides,
-  } as Event;
-}
-
-function makeSpanNode(span: Partial<RawSpanType>): TraceTreeNode<TraceTree.Span> {
-  return new TraceTreeNode(null, makeSpan(span), metadata);
-}
-
-describe('traceSearchTokenizer', () => {
-  it('empty value', () => {
-    expect(grammar.parse('')).toEqual([]);
-  });
-
-  test.each([
-    'key:value',
-    'key :value',
-    'key : value',
-    '  key:  value',
-    'key:  value   ',
-  ])('parses %s', input => {
-    expect(grammar.parse(input)).toEqual([{type: 'Token', key: 'key', value: 'value'}]);
-  });
-
-  describe('grammar', () => {
-    it('alphanumeric', () => {
-      expect(grammar.parse('key:1a_-.!$')[0].value).toBe('1a_-.!$');
-    });
-
-    it('integer', () => {
-      // @TODO scientific notation?
-      // @TODO should we evaluate arithmetic expressions?
-      // Support unit suffies (B, KB, MB, GB), (ms, s, m, h, d, w, y)
-      expect(grammar.parse('key:1')[0].value).toBe(1);
-      expect(grammar.parse('key:10')[0].value).toBe(10);
-      expect(grammar.parse('key:-10')[0].value).toBe(-10);
-    });
-
-    it('float', () => {
-      expect(grammar.parse('key:.5')[0].value).toBe(0.5);
-      expect(grammar.parse('key:-.5')[0].value).toBe(-0.5);
-      expect(grammar.parse('key:1.000')[0].value).toBe(1.0);
-      expect(grammar.parse('key:1.5')[0].value).toBe(1.5);
-      expect(grammar.parse('key:-1.0')[0].value).toBe(-1.0);
-    });
-
-    it('boolean', () => {
-      expect(grammar.parse('key:true')[0].value).toBe(true);
-      expect(grammar.parse('key:false')[0].value).toBe(false);
-    });
-
-    it('undefined', () => {
-      expect(grammar.parse('key:undefined')[0].value).toBe(undefined);
-    });
-
-    it('null', () => {
-      expect(grammar.parse('key:null')[0].value).toBe(null);
-    });
-
-    it('multiple expressions', () => {
-      expect(grammar.parse('key1:value key2:value')).toEqual([
-        {type: 'Token', key: 'key1', value: 'value'},
-        {type: 'Token', key: 'key2', value: 'value'},
-      ]);
-    });
-
-    it('value operator', () => {
-      expect(grammar.parse('key:>value')[0].operator).toBe('gt');
-      expect(grammar.parse('key:>=value')[0].operator).toBe('ge');
-      expect(grammar.parse('key:<value')[0].operator).toBe('lt');
-      expect(grammar.parse('key:<=value')[0].operator).toBe('le');
-      expect(grammar.parse('key:=value')[0].operator).toBe('eq');
-    });
-
-    it('negation', () => {
-      expect(grammar.parse('!key:value')[0].negated).toBe(true);
-    });
-  });
-
-  describe('transaction properties', () => {});
-  describe('autogrouped properties', () => {});
-  describe('missing instrumentation properties', () => {});
-  describe('error properties', () => {});
-  describe('perf issue', () => {});
-
-  describe('tag properties', () => {});
-  describe('measurement properties', () => {});
-  describe('vitals properties', () => {});
-});
-
-describe('lexer', () => {
-  // it.todo('checks for empty key');
-  // it.todo('checks for invalid key');
-  // it.todo('checks for unknown keys');
-  // it.todo('checks for invalid operator');
-  // it.todo('checks for invalid value');
-  // it.todo("supports OR'ing expressions");
-  // it.todo("supports AND'ing expressions");
-  // it.todo('supports operator precedence via ()');
-});
-
-describe('token evaluator', () => {
-  it('negates expression', () => {
-    const node = makeSpanNode({span_id: '1a3'});
-    expect(evaluate(node, grammar.parse('!span_id:1a3')[0])).toBe(false);
-  });
-
-  describe('string', () => {
-    const node = makeSpanNode({span_id: '1a3'});
-    function g(v: string) {
-      return grammar.parse(`span_id:${v}`)[0];
-    }
-
-    it('exact value', () => {
-      expect(evaluate(node, g('1a3'))).toBe(true);
-    });
-    it('using includes', () => {
-      expect(evaluate(node, g('1a'))).toBe(true);
-    });
-  });
-
-  describe('number', () => {
-    const node = makeSpanNode({start_timestamp: 1000});
-    function g(v: string) {
-      return grammar.parse(`start_timestamp:${v}`)[0];
-    }
-
-    it('exact value', () => {
-      expect(evaluate(node, grammar.parse('start_timestamp:1000')[0])).toBe(true);
-    });
-    it('using gt', () => {
-      expect(evaluate(node, g('>999'))).toBe(true);
-    });
-    it('using ge', () => {
-      expect(evaluate(node, g('>=1000'))).toBe(true);
-    });
-    it('using lt', () => {
-      expect(evaluate(node, g('<1001'))).toBe(true);
-    });
-    it('using le', () => {
-      expect(evaluate(node, g('<=1000'))).toBe(true);
-    });
-    it('using eq', () => {
-      expect(evaluate(node, g('=1000'))).toBe(true);
-    });
-
-    describe('comparing float to int', () => {
-      it('query is float, value is int', () => {
-        expect(evaluate(makeSpanNode({start_timestamp: 1000}), g('1000.0'))).toBe(true);
-      });
-      it('query is int, value is float', () => {
-        expect(evaluate(makeSpanNode({start_timestamp: 1000.0}), g('1000'))).toBe(true);
-      });
-    });
-  });
-
-  describe('boolean', () => {
-    it('true', () => {
-      const node = makeSpanNode({same_process_as_parent: true});
-      expect(evaluate(node, grammar.parse('same_process_as_parent:true')[0])).toBe(true);
-    });
-    it('false', () => {
-      const node = makeSpanNode({same_process_as_parent: false});
-      expect(evaluate(node, grammar.parse('same_process_as_parent:false')[0])).toBe(true);
-    });
-  });
-  it('null', () => {
-    // @ts-expect-error force null on type
-    const node = makeSpanNode({same_process_as_parent: null});
-    expect(evaluate(node, grammar.parse('same_process_as_parent:null')[0])).toBe(true);
-  });
-  it('undefined', () => {
-    const node = makeSpanNode({same_process_as_parent: undefined});
-    expect(evaluate(node, grammar.parse('same_process_as_parent:undefined')[0])).toBe(
-      true
-    );
-  });
-});

+ 0 - 127
static/app/views/performance/newTraceDetails/traceSearch/traceSearchTokenizer.tsx

@@ -1,127 +0,0 @@
-import type {
-  NoDataNode,
-  ParentAutogroupNode,
-  SiblingAutogroupNode,
-  TraceTree,
-  TraceTreeNode,
-} from '../traceModels/traceTree';
-
-import grammar from './traceSearch.pegjs';
-
-interface SearchToken {
-  key: string;
-  type: 'Token';
-  value: string | number;
-  negated?: boolean;
-  operator?: 'gt' | 'ge' | 'lt' | 'le' | 'eq';
-}
-
-type Token = SearchToken;
-
-// typeof can return one of the following string values - we ignore BigInt, Symbol as there
-// is no practical case for them + they are not supported by JSON.
-// object (special handling for arrays), number, string, boolean, undefined, null
-type Type = 'object' | 'number' | 'string' | 'boolean' | 'undefined' | 'null';
-
-// @ts-expect-error we ignore some keys on purpose, the TS error makes it helpful
-// for seeing exactly which ones we are ignoring for when we want to add support for them
-const SPAN_KEYS: Record<keyof TraceTree.Span, Type> = {
-  hash: 'string',
-  span_id: 'string',
-  start_timestamp: 'number',
-  timestamp: 'number',
-  trace_id: 'string',
-  description: 'string',
-  exclusive_time: 'number',
-
-  op: 'string',
-  origin: 'string',
-  parent_span_id: 'string',
-  same_process_as_parent: 'boolean',
-  // TODO Jonas Badalic: The response for the avg duration metrics is now an object and can return
-  // both the avg span_self time and the avg span duration. This will need to be handled differently.
-  // This one will need to be flattened
-  'span.averageResults': 'object',
-  status: 'string',
-
-  // These are both records and will need to be handled differently
-  sentry_tags: 'string',
-  tags: 'string',
-};
-
-export function traceSearchTokenizer(input: string): Token[] {
-  return grammar.parse(input);
-}
-
-export function traceSearchLexer(_input: string): string[] {
-  throw new Error('Not implemented');
-}
-
-export function evaluateTokenForTraceNode(
-  node:
-    | TraceTreeNode<TraceTree.NodeValue>
-    | ParentAutogroupNode
-    | SiblingAutogroupNode
-    | NoDataNode,
-  token: Token
-): boolean {
-  const type = SPAN_KEYS[token.key];
-
-  // @ts-expect-error ignore the lookup as the value will be dynamic
-  const value = node.value[token.key];
-
-  let match: undefined | boolean = undefined;
-  if (token.value === undefined) {
-    match = value === undefined;
-  }
-
-  if (token.value === null) {
-    match = value === null;
-  }
-
-  // @TODO check for the distinction between obj and array here as L78
-  // does not guarantee exact same primitive type in this case
-  if (typeof value !== type && token.value !== null && token.value !== undefined) {
-    // The two types are not the same.
-    return false;
-  }
-
-  // prettier-ignore
-  switch (type) {
-    case 'string': {
-      match = value === token.value || value.includes(token.value);
-      break;
-    }
-    case 'number': {
-      if (!token.operator) {
-        match = value === token.value;
-        break;
-      }
-
-      // prettier-ignore
-      switch (token.operator) {
-        case 'gt': match = value > token.value; break;
-        case 'ge': match = value >= token.value; break;
-        case 'lt': match = value < token.value; break;
-        case 'le': match = value <= token.value; break;
-        case 'eq': match = value === token.value; break;
-        default: break;
-      }
-      break;
-    }
-    case 'boolean': {
-      match = value === token.value;
-      break;
-    }
-    case 'object': {
-      return false;
-    }
-    default: break;
-  }
-
-  if (match === undefined) {
-    return false;
-  }
-
-  return token.negated ? !match : match;
-}

+ 79 - 0
static/app/views/performance/newTraceDetails/traceSearch/traceTokenConverter.tsx

@@ -0,0 +1,79 @@
+import {
+  defaultConfig,
+  parseSearch,
+  type SearchConfig,
+} from 'sentry/components/searchSyntax/parser';
+import type {TraceTree} from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
+
+// Transaction keys
+const TRANSACTION_TEXT_KEYS: (keyof TraceTree.Transaction)[] = [
+  'event_id',
+  'project_slug',
+  'parent_event_id',
+  'parent_span_id',
+  'span_id',
+  'transaction',
+  'transaction.op',
+  'transaction.status',
+];
+const TRANSACTION_NUMERIC_KEYS: (keyof TraceTree.Transaction)[] = [
+  'project_id',
+  'start_timestamp',
+  'timestamp',
+];
+const TRANSACTION_DURATION_KEYS: (keyof TraceTree.Transaction)[] = [
+  'transaction.duration',
+];
+
+// @TODO the current date parsing does not support timestamps, so we
+// exclude these keys for now and parse them as numeric keys
+const TRANSACTION_DATE_KEYS: (keyof TraceTree.Transaction)[] = [
+  //   'start_timestamp',
+  //   'timestamp',
+];
+const TRANSACTION_BOOLEAN_KEYS: (keyof TraceTree.Transaction)[] = [];
+
+// Span keys
+const SPAN_TEXT_KEYS: (keyof TraceTree.Span)[] = [
+  'hash',
+  'description',
+  'op',
+  'origin',
+  'parent_span_id',
+  'span_id',
+  'trace_id',
+  'status',
+];
+const SPAN_NUMERIC_KEYS: (keyof TraceTree.Span)[] = ['timestamp', 'start_timestamp'];
+const SPAN_DURATION_KEYS: (keyof TraceTree.Span)[] = [
+  // @TODO create aliases for self_time total_time and duration.
+  'exclusive_time',
+];
+// @TODO the current date parsing does not support timestamps, so we
+// exclude these keys for now and parse them as numeric keys
+const SPAN_DATE_KEYS: (keyof TraceTree.Span)[] = [
+  // 'timestamp', 'start_timestamp'
+];
+const SPAN_BOOLEAN_KEYS: (keyof TraceTree.Span)[] = ['same_process_as_parent'];
+
+// @TODO Issue keys
+
+const TEXT_KEYS = new Set([...TRANSACTION_TEXT_KEYS, ...SPAN_TEXT_KEYS]);
+const NUMERIC_KEYS = new Set([...TRANSACTION_NUMERIC_KEYS, ...SPAN_NUMERIC_KEYS]);
+const DURATION_KEYS = new Set([...TRANSACTION_DURATION_KEYS, ...SPAN_DURATION_KEYS]);
+const DATE_KEYS = new Set([...TRANSACTION_DATE_KEYS, ...SPAN_DATE_KEYS]);
+const BOOLEAN_KEYS = new Set([...TRANSACTION_BOOLEAN_KEYS, ...SPAN_BOOLEAN_KEYS]);
+
+export const TRACE_SEARCH_CONFIG: SearchConfig = {
+  ...defaultConfig,
+  textOperatorKeys: TEXT_KEYS,
+  durationKeys: DURATION_KEYS,
+  percentageKeys: new Set(),
+  numericKeys: NUMERIC_KEYS,
+  dateKeys: DATE_KEYS,
+  booleanKeys: BOOLEAN_KEYS,
+};
+
+export function parseTraceSearch(query: string) {
+  return parseSearch(query, {...TRACE_SEARCH_CONFIG, parse: true});
+}

+ 3 - 121
static/app/views/performance/newTraceDetails/traceState/traceSearch.tsx

@@ -1,13 +1,8 @@
-import {
-  isAutogroupedNode,
-  isSpanNode,
-  isTraceErrorNode,
-  isTransactionNode,
-} from 'sentry/views/performance/newTraceDetails/guards';
 import type {
   TraceTree,
   TraceTreeNode,
 } from 'sentry/views/performance/newTraceDetails/traceModels/traceTree';
+import type {TraceSearchResult} from 'sentry/views/performance/newTraceDetails/traceSearch/traceSearchEvaluator';
 import {traceReducerExhaustiveActionCheck} from 'sentry/views/performance/newTraceDetails/traceState';
 
 export type TraceSearchAction =
@@ -30,7 +25,7 @@ export type TraceSearchAction =
         resultIndex: number | undefined;
         resultIteratorIndex: number | undefined;
       } | null;
-      results: ReadonlyArray<TraceResult>;
+      results: ReadonlyArray<TraceSearchResult>;
       resultsLookup: Map<TraceTreeNode<TraceTree.NodeValue>, number>;
       type: 'set results';
       resultIndex?: number;
@@ -44,7 +39,7 @@ export type TraceSearchState = {
   resultIndex: number | null;
   // Index in the results array
   resultIteratorIndex: number | null;
-  results: ReadonlyArray<TraceResult> | null;
+  results: ReadonlyArray<TraceSearchResult> | null;
   resultsLookup: Map<TraceTreeNode<TraceTree.NodeValue>, number>;
   status: [ts: number, 'loading' | 'success' | 'error'] | undefined;
 };
@@ -193,116 +188,3 @@ export function traceSearchReducer(
     }
   }
 }
-
-type TraceResult = {
-  index: number;
-  value: TraceTreeNode<TraceTree.NodeValue>;
-};
-
-export function searchInTraceTree(
-  tree: TraceTree,
-  query: string,
-  previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
-  cb: (
-    results: [
-      ReadonlyArray<TraceResult>,
-      Map<TraceTreeNode<TraceTree.NodeValue>, number>,
-      {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
-    ]
-  ) => void
-): {id: number | null} {
-  const raf: {id: number | null} = {id: 0};
-  let previousNodeSearchResult: {
-    resultIndex: number | undefined;
-    resultIteratorIndex: number | undefined;
-  } | null = null;
-  const results: Array<TraceResult> = [];
-  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 (searchInTraceSubset(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) {
-      raf.id = requestAnimationFrame(search);
-    }
-
-    if (i === count) {
-      cb([results, resultLookup, previousNodeSearchResult]);
-      raf.id = null;
-    }
-  }
-
-  raf.id = requestAnimationFrame(search);
-  return raf;
-}
-
-function searchInTraceSubset(
-  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;
-}