traceSearchEvaluator.tsx 16 KB

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