traceSearchEvaluator.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
  1. import * as Sentry from '@sentry/react';
  2. import {
  3. type ProcessedTokenResult,
  4. toPostFix,
  5. } from 'sentry/components/searchSyntax/evaluator';
  6. import {
  7. BooleanOperator,
  8. TermOperator,
  9. Token,
  10. type TokenResult,
  11. } from 'sentry/components/searchSyntax/parser';
  12. import {
  13. isAutogroupedNode,
  14. isSpanNode,
  15. isTraceErrorNode,
  16. isTransactionNode,
  17. } from '../traceGuards';
  18. import type {TraceTree} from '../traceModels/traceTree';
  19. import type {TraceTreeNode} from '../traceModels/traceTreeNode';
  20. export type TraceSearchResult = {
  21. index: number;
  22. value: TraceTreeNode<TraceTree.NodeValue>;
  23. };
  24. /**
  25. * Evaluates the infix token representation against the token list. The logic is the same as
  26. * if we were evaluating arithmetics expressions with the caveat that we have to handle and edge
  27. * case the first time we evaluate two operands. Because our list contains tokens and not values,
  28. * we need to evaluate the first two tokens, push the result back to the stack. From there on we can
  29. * evaluate the rest of the tokens.
  30. * [token, token, bool logic] -> [] -> [result, token, bool]
  31. * ^evaluate^ and push to stack ^ left ^ right ^ operator
  32. * All next evaluations will be done with the result and the next token.
  33. */
  34. export function searchInTraceTreeTokens(
  35. tree: TraceTree,
  36. tokens: Array<TokenResult<Token>>,
  37. previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
  38. cb: (
  39. results: [
  40. readonly TraceSearchResult[],
  41. Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  42. {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
  43. ]
  44. ) => void
  45. ): {id: number | null} {
  46. let previousNodeSearchResult: {
  47. resultIndex: number | undefined;
  48. resultIteratorIndex: number | undefined;
  49. } | null = null;
  50. if (!tokens || tokens.length === 0) {
  51. cb([[], new Map(), null]);
  52. return {id: null};
  53. }
  54. const handle: {id: number | null} = {id: 0};
  55. const resultLookup = new Map();
  56. const postfix = toPostFix(tokens);
  57. if (postfix.length === 0) {
  58. cb([[], resultLookup, null]);
  59. return handle;
  60. }
  61. if (postfix.length === 1 && postfix[0]!.type === Token.FREE_TEXT) {
  62. return searchInTraceTreeText(tree, postfix[0]!.value, previousNode, cb);
  63. }
  64. let i = 0;
  65. let matchCount = 0;
  66. const count = tree.list.length;
  67. const resultsForSingleToken: TraceSearchResult[] = [];
  68. function searchSingleToken() {
  69. const ts = performance.now();
  70. while (i < count && performance.now() - ts < 12) {
  71. const node = tree.list[i]!;
  72. if (evaluateTokenForValue(postfix[0]!, resolveValueFromKey(node, postfix[0]!))) {
  73. resultsForSingleToken.push({index: i, value: node});
  74. resultLookup.set(node, matchCount);
  75. if (previousNode === node) {
  76. previousNodeSearchResult = {
  77. resultIndex: i,
  78. resultIteratorIndex: matchCount,
  79. };
  80. }
  81. matchCount++;
  82. }
  83. i++;
  84. }
  85. if (i < count) {
  86. handle.id = requestAnimationFrame(searchSingleToken);
  87. } else {
  88. cb([resultsForSingleToken, resultLookup, previousNodeSearchResult]);
  89. }
  90. }
  91. if (postfix.length <= 1 && postfix[0]!.type === Token.FILTER) {
  92. handle.id = requestAnimationFrame(searchSingleToken);
  93. return handle;
  94. }
  95. let result_map: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
  96. let ti = 0;
  97. let li = 0;
  98. let ri = 0;
  99. let bool: TokenResult<Token.LOGIC_BOOLEAN> | null = null;
  100. let leftToken:
  101. | ProcessedTokenResult
  102. | Map<TraceTreeNode<TraceTree.NodeValue>, number>
  103. | null = null;
  104. let rightToken: ProcessedTokenResult | null = null;
  105. const left: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
  106. const right: Map<TraceTreeNode<TraceTree.NodeValue>, number> = new Map();
  107. const stack: Array<
  108. ProcessedTokenResult | Map<TraceTreeNode<TraceTree.NodeValue>, number>
  109. > = [];
  110. function search(): void {
  111. const ts = performance.now();
  112. if (!bool) {
  113. while (ti < postfix.length) {
  114. const token = postfix[ti]!;
  115. if (token.type === Token.LOGIC_BOOLEAN) {
  116. bool = token;
  117. if (stack.length < 2) {
  118. Sentry.captureMessage('Unbalanced tree - missing left or right token');
  119. if (typeof handle.id === 'number') {
  120. window.cancelAnimationFrame(handle.id);
  121. }
  122. cb([[], resultLookup, null]);
  123. return;
  124. }
  125. // @ts-expect-error TS(2322): Type 'Map<TraceTreeNode<NodeValue>, number> | Proc... Remove this comment to see the full error message
  126. rightToken = stack.pop()!;
  127. leftToken = stack.pop()!;
  128. break;
  129. } else {
  130. stack.push(token);
  131. }
  132. ti++;
  133. }
  134. }
  135. if (!bool) {
  136. Sentry.captureMessage(
  137. 'Invalid state in searchInTraceTreeTokens, missing boolean token'
  138. );
  139. if (typeof handle.id === 'number') {
  140. window.cancelAnimationFrame(handle.id);
  141. }
  142. cb([[], resultLookup, null]);
  143. return;
  144. }
  145. if (!leftToken || !rightToken) {
  146. Sentry.captureMessage(
  147. 'Invalid state in searchInTraceTreeTokens, missing left or right token'
  148. );
  149. if (typeof handle.id === 'number') {
  150. window.cancelAnimationFrame(handle.id);
  151. }
  152. cb([[], resultLookup, null]);
  153. return;
  154. }
  155. if (li < count && !(leftToken instanceof Map)) {
  156. while (li < count && performance.now() - ts < 12) {
  157. const node = tree.list[li]!;
  158. if (evaluateTokenForValue(leftToken, resolveValueFromKey(node, leftToken))) {
  159. left.set(node, li);
  160. }
  161. li++;
  162. }
  163. handle.id = requestAnimationFrame(search);
  164. } else if (ri < count && !(rightToken instanceof Map)) {
  165. while (ri < count && performance.now() - ts < 12) {
  166. const node = tree.list[ri]!;
  167. if (evaluateTokenForValue(rightToken, resolveValueFromKey(node, rightToken))) {
  168. right.set(node, ri);
  169. }
  170. ri++;
  171. }
  172. handle.id = requestAnimationFrame(search);
  173. } else {
  174. if (
  175. (li === count || leftToken instanceof Map) &&
  176. (ri === count || rightToken instanceof Map)
  177. ) {
  178. result_map = booleanResult(
  179. leftToken instanceof Map ? leftToken : left,
  180. rightToken instanceof Map ? rightToken : right,
  181. bool.value
  182. );
  183. // Reset the state for the next iteration
  184. bool = null;
  185. leftToken = null;
  186. rightToken = null;
  187. left.clear();
  188. right.clear();
  189. li = 0;
  190. ri = 0;
  191. // Push result to stack;
  192. stack.push(result_map);
  193. ti++;
  194. }
  195. if (ti === postfix.length) {
  196. const result: TraceSearchResult[] = [];
  197. let resultIdx = -1;
  198. // @TODO We render up to 10k nodes and plan to load more, so this might be future bottleneck.
  199. for (const [node, index] of result_map) {
  200. result.push({index, value: node});
  201. resultLookup.set(node, ++resultIdx);
  202. if (previousNode === node) {
  203. previousNodeSearchResult = {
  204. resultIndex: index,
  205. resultIteratorIndex: resultIdx,
  206. };
  207. }
  208. }
  209. cb([result, resultLookup, previousNodeSearchResult]);
  210. } else {
  211. handle.id = requestAnimationFrame(search);
  212. }
  213. }
  214. }
  215. handle.id = requestAnimationFrame(search);
  216. return handle;
  217. }
  218. // Freetext search in the trace tree
  219. export function searchInTraceTreeText(
  220. tree: TraceTree,
  221. query: string,
  222. previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
  223. cb: (
  224. results: [
  225. readonly TraceSearchResult[],
  226. Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  227. {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
  228. ]
  229. ) => void
  230. ): {id: number | null} {
  231. const handle: {id: number | null} = {id: 0};
  232. let previousNodeSearchResult: {
  233. resultIndex: number | undefined;
  234. resultIteratorIndex: number | undefined;
  235. } | null = null;
  236. const results: TraceSearchResult[] = [];
  237. const resultLookup = new Map();
  238. let i = 0;
  239. let matchCount = 0;
  240. const count = tree.list.length;
  241. function search() {
  242. const ts = performance.now();
  243. while (i < count && performance.now() - ts < 12) {
  244. const node = tree.list[i]!;
  245. if (evaluateNodeFreeText(query, node)) {
  246. results.push({index: i, value: node});
  247. resultLookup.set(node, matchCount);
  248. if (previousNode === node) {
  249. previousNodeSearchResult = {
  250. resultIndex: i,
  251. resultIteratorIndex: matchCount,
  252. };
  253. }
  254. matchCount++;
  255. }
  256. i++;
  257. }
  258. if (i < count) {
  259. handle.id = requestAnimationFrame(search);
  260. }
  261. if (i === count) {
  262. cb([results, resultLookup, previousNodeSearchResult]);
  263. handle.id = null;
  264. }
  265. }
  266. handle.id = requestAnimationFrame(search);
  267. return handle;
  268. }
  269. function evaluateTokenForValue(token: ProcessedTokenResult, value: any): boolean {
  270. if (token.type === Token.FILTER) {
  271. if (token.value.type === Token.VALUE_NUMBER) {
  272. const result = evaluateValueNumber(token.value, token.operator, value);
  273. return token.negated ? !result : result;
  274. }
  275. if (token.value.type === Token.VALUE_DURATION) {
  276. const result = evaluateValueNumber(token.value, token.operator, value);
  277. return token.negated ? !result : result;
  278. }
  279. if (token.value.type === Token.VALUE_TEXT) {
  280. switch (typeof value) {
  281. case 'string':
  282. return token.negated
  283. ? !value.includes(token.value.value)
  284. : value.includes(token.value.value);
  285. case 'boolean':
  286. return token.negated ? !value : !!value;
  287. default:
  288. return false;
  289. }
  290. }
  291. if (token.value.type === Token.VALUE_ISO_8601_DATE) {
  292. return (
  293. typeof value === 'number' && evaluateValueDate(token.value, token.operator, value)
  294. );
  295. }
  296. }
  297. return false;
  298. }
  299. function booleanResult(
  300. left: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  301. right: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  302. operator: BooleanOperator
  303. ): Map<TraceTreeNode<TraceTree.NodeValue>, number> {
  304. if (operator === BooleanOperator.AND) {
  305. const result = new Map();
  306. for (const [key, value] of left) {
  307. if (right.has(key)) {
  308. result.set(key, value);
  309. }
  310. }
  311. return result;
  312. }
  313. if (operator === BooleanOperator.OR) {
  314. const result = new Map(left);
  315. for (const [key, value] of right) {
  316. result.set(key, value);
  317. }
  318. return result;
  319. }
  320. throw new Error(`Unsupported boolean operator, received ${operator}`);
  321. }
  322. function evaluateValueDate<T extends Token.VALUE_ISO_8601_DATE>(
  323. token: TokenResult<T>,
  324. operator: TermOperator,
  325. value: any
  326. ): boolean {
  327. if (!token.parsed || (typeof value !== 'number' && typeof value !== 'string')) {
  328. return false;
  329. }
  330. if (typeof value === 'string') {
  331. value = new Date(value).getTime();
  332. if (isNaN(value)) {
  333. return false;
  334. }
  335. }
  336. const query = token.parsed.value.getTime();
  337. switch (operator) {
  338. case TermOperator.GREATER_THAN:
  339. return value > query;
  340. case TermOperator.GREATER_THAN_EQUAL:
  341. return value >= query;
  342. case TermOperator.LESS_THAN:
  343. return value < query;
  344. case TermOperator.LESS_THAN_EQUAL:
  345. return value <= query;
  346. case TermOperator.EQUAL:
  347. case TermOperator.DEFAULT: {
  348. return value === query;
  349. }
  350. default: {
  351. Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
  352. return false;
  353. }
  354. }
  355. }
  356. function evaluateValueNumber<T extends Token.VALUE_DURATION | Token.VALUE_NUMBER>(
  357. token: TokenResult<T>,
  358. operator: TermOperator,
  359. value: any
  360. ): boolean {
  361. // @TODO Figure out if it's possible that we receive NaN/Infinity values
  362. // and how we should handle them.
  363. if (!token.parsed || typeof value !== 'number') {
  364. return false;
  365. }
  366. const query = token.parsed.value;
  367. switch (operator) {
  368. case TermOperator.GREATER_THAN:
  369. return value > query;
  370. case TermOperator.GREATER_THAN_EQUAL:
  371. return value >= query;
  372. case TermOperator.LESS_THAN:
  373. return value < query;
  374. case TermOperator.LESS_THAN_EQUAL:
  375. return value <= query;
  376. case TermOperator.EQUAL:
  377. case TermOperator.DEFAULT: {
  378. return value === query;
  379. }
  380. default: {
  381. Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
  382. return false;
  383. }
  384. }
  385. }
  386. const TRANSACTION_DURATION_ALIASES = new Set([
  387. 'duration',
  388. // 'transaction.duration', <-- this is an actual key
  389. 'transaction.total_time',
  390. ]);
  391. const SPAN_DURATION_ALIASES = new Set(['duration', 'span.duration', 'span.total_time']);
  392. const SPAN_SELF_TIME_ALIASES = new Set(['span.self_time', 'span.exclusive_time']);
  393. // Pulls the value from the node based on the key in the token
  394. function resolveValueFromKey(
  395. node: TraceTreeNode<TraceTree.NodeValue>,
  396. token: ProcessedTokenResult
  397. ): any | null {
  398. const value = node.value;
  399. if (!value) {
  400. return null;
  401. }
  402. if (token.type === Token.FILTER) {
  403. let key: string | null = null;
  404. switch (token.key.type) {
  405. case Token.KEY_SIMPLE: {
  406. if (
  407. TRANSACTION_DURATION_ALIASES.has(token.key.value) &&
  408. isTransactionNode(node) &&
  409. node.space
  410. ) {
  411. return node.space[1];
  412. }
  413. if (
  414. SPAN_DURATION_ALIASES.has(token.key.value) &&
  415. isSpanNode(node) &&
  416. node.space
  417. ) {
  418. return node.space[1];
  419. }
  420. if (
  421. SPAN_SELF_TIME_ALIASES.has(token.key.value) &&
  422. isSpanNode(node) &&
  423. node.space
  424. ) {
  425. return node.value.exclusive_time;
  426. }
  427. key = token.key.value;
  428. break;
  429. }
  430. case Token.KEY_AGGREGATE:
  431. case Token.KEY_EXPLICIT_TAG:
  432. default: {
  433. Sentry.captureMessage(`Unsupported key type for filter, got ${token.key.type}`);
  434. }
  435. }
  436. if (key !== null) {
  437. // If the value can be accessed directly, do so,
  438. // else check if the key is an entity key, sanitize it and try direct access again.
  439. // @TODO support deep nested keys with dot notation
  440. if (
  441. key === 'has' &&
  442. token.type === Token.FILTER &&
  443. token.value.type === Token.VALUE_TEXT
  444. ) {
  445. switch (token.value.text) {
  446. case 'error':
  447. case 'errors': {
  448. return node.errors.size > 0;
  449. }
  450. case 'issue':
  451. case 'issues':
  452. return node.errors.size > 0 || node.performance_issues.size > 0;
  453. case 'profile':
  454. case 'profiles':
  455. return node.profiles.length > 0;
  456. default: {
  457. break;
  458. }
  459. }
  460. }
  461. // Aliases for fields that do not exist on raw data
  462. if (key === 'project' || key === 'project.name') {
  463. // project.name and project fields do not exist on raw data and are
  464. // aliases for project_slug key that does exist.
  465. key = 'project_slug';
  466. }
  467. // Check for direct key access.
  468. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  469. if (value[key] !== undefined) {
  470. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  471. return value[key];
  472. }
  473. // @TODO perf optimization opportunity
  474. // Entity check should be preprocessed per token, not once per token per node we are evaluating, however since
  475. // we are searching <10k nodes in p99 percent of the time and the search is non blocking, we are likely fine
  476. // and can be optimized later.
  477. const [maybeEntity, ...rest] = key.split('.');
  478. switch (maybeEntity) {
  479. case 'span':
  480. if (isSpanNode(node)) {
  481. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  482. return value[rest.join('.')];
  483. }
  484. break;
  485. case 'transaction':
  486. if (isTransactionNode(node)) {
  487. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  488. return value[rest.join('.')];
  489. }
  490. break;
  491. default:
  492. break;
  493. }
  494. }
  495. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  496. return key ? value[key] ?? null : null;
  497. }
  498. return null;
  499. }
  500. /**
  501. * Evaluates the node based on freetext. This is a simple search that checks if the query
  502. * is present in a very small subset of the node's properties.
  503. */
  504. function evaluateNodeFreeText(
  505. query: string,
  506. node: TraceTreeNode<TraceTree.NodeValue>
  507. ): boolean {
  508. if (isSpanNode(node)) {
  509. if (node.value.op?.includes(query)) {
  510. return true;
  511. }
  512. if (node.value.description?.includes(query)) {
  513. return true;
  514. }
  515. if (node.value.span_id && node.value.span_id === query) {
  516. return true;
  517. }
  518. }
  519. if (isTransactionNode(node)) {
  520. if (node.value['transaction.op']?.includes(query)) {
  521. return true;
  522. }
  523. if (node.value.transaction?.includes(query)) {
  524. return true;
  525. }
  526. if (node.value.event_id && node.value.event_id === query) {
  527. return true;
  528. }
  529. }
  530. if (isAutogroupedNode(node)) {
  531. if (node.value.op?.includes(query)) {
  532. return true;
  533. }
  534. if (node.value.description?.includes(query)) {
  535. return true;
  536. }
  537. }
  538. if (isTraceErrorNode(node)) {
  539. if (node.value.level === query) {
  540. return true;
  541. }
  542. if (node.value.title?.includes(query)) {
  543. return true;
  544. }
  545. }
  546. return false;
  547. }