traceSearchEvaluator.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  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. switch (typeof value) {
  278. case 'string':
  279. return token.negated
  280. ? !value.includes(token.value.value)
  281. : value.includes(token.value.value);
  282. case 'boolean':
  283. return token.negated ? !value : !!value;
  284. default:
  285. return false;
  286. }
  287. }
  288. if (token.value.type === Token.VALUE_ISO_8601_DATE) {
  289. return (
  290. typeof value === 'number' && evaluateValueDate(token.value, token.operator, value)
  291. );
  292. }
  293. }
  294. return false;
  295. }
  296. function booleanResult(
  297. left: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  298. right: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  299. operator: BooleanOperator
  300. ): Map<TraceTreeNode<TraceTree.NodeValue>, number> {
  301. if (operator === BooleanOperator.AND) {
  302. const result = new Map();
  303. for (const [key, value] of left) {
  304. right.has(key) && result.set(key, value);
  305. }
  306. return result;
  307. }
  308. if (operator === BooleanOperator.OR) {
  309. const result = new Map(left);
  310. for (const [key, value] of right) {
  311. result.set(key, value);
  312. }
  313. return result;
  314. }
  315. throw new Error(`Unsupported boolean operator, received ${operator}`);
  316. }
  317. function evaluateValueDate<T extends Token.VALUE_ISO_8601_DATE>(
  318. token: TokenResult<T>,
  319. operator: TermOperator,
  320. value: any
  321. ): boolean {
  322. if (!token.parsed || (typeof value !== 'number' && typeof value !== 'string')) {
  323. return false;
  324. }
  325. if (typeof value === 'string') {
  326. value = new Date(value).getTime();
  327. if (isNaN(value)) return false;
  328. }
  329. const query = token.parsed.value.getTime();
  330. switch (operator) {
  331. case TermOperator.GREATER_THAN:
  332. return value > query;
  333. case TermOperator.GREATER_THAN_EQUAL:
  334. return value >= query;
  335. case TermOperator.LESS_THAN:
  336. return value < query;
  337. case TermOperator.LESS_THAN_EQUAL:
  338. return value <= query;
  339. case TermOperator.EQUAL:
  340. case TermOperator.DEFAULT: {
  341. return value === query;
  342. }
  343. default: {
  344. Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
  345. return false;
  346. }
  347. }
  348. }
  349. function evaluateValueNumber<T extends Token.VALUE_DURATION | Token.VALUE_NUMBER>(
  350. token: TokenResult<T>,
  351. operator: TermOperator,
  352. value: any
  353. ): boolean {
  354. // @TODO Figure out if it's possible that we receive NaN/Infinity values
  355. // and how we should handle them.
  356. if (!token.parsed || typeof value !== 'number') {
  357. return false;
  358. }
  359. const query = token.parsed.value;
  360. switch (operator) {
  361. case TermOperator.GREATER_THAN:
  362. return value > query;
  363. case TermOperator.GREATER_THAN_EQUAL:
  364. return value >= query;
  365. case TermOperator.LESS_THAN:
  366. return value < query;
  367. case TermOperator.LESS_THAN_EQUAL:
  368. return value <= query;
  369. case TermOperator.EQUAL:
  370. case TermOperator.DEFAULT: {
  371. return value === query;
  372. }
  373. default: {
  374. Sentry.captureMessage('Unsupported operator for number filter, got ' + operator);
  375. return false;
  376. }
  377. }
  378. }
  379. const TRANSACTION_DURATION_ALIASES = new Set([
  380. 'duration',
  381. // 'transaction.duration', <-- this is an actual key
  382. 'transaction.total_time',
  383. ]);
  384. const SPAN_DURATION_ALIASES = new Set(['duration', 'span.duration', 'span.total_time']);
  385. const SPAN_SELF_TIME_ALIASES = new Set(['span.self_time', 'span.exclusive_time']);
  386. // Pulls the value from the node based on the key in the token
  387. function resolveValueFromKey(
  388. node: TraceTreeNode<TraceTree.NodeValue>,
  389. token: ProcessedTokenResult
  390. ): any | null {
  391. const value = node.value;
  392. if (!value) {
  393. return null;
  394. }
  395. if (token.type === Token.FILTER) {
  396. let key: string | null = null;
  397. switch (token.key.type) {
  398. case Token.KEY_SIMPLE: {
  399. if (
  400. TRANSACTION_DURATION_ALIASES.has(token.key.value) &&
  401. isTransactionNode(node) &&
  402. node.space
  403. ) {
  404. return node.space[1];
  405. }
  406. if (
  407. SPAN_DURATION_ALIASES.has(token.key.value) &&
  408. isSpanNode(node) &&
  409. node.space
  410. ) {
  411. return node.space[1];
  412. }
  413. if (
  414. SPAN_SELF_TIME_ALIASES.has(token.key.value) &&
  415. isSpanNode(node) &&
  416. node.space
  417. ) {
  418. return node.value.exclusive_time;
  419. }
  420. key = token.key.value;
  421. break;
  422. }
  423. case Token.KEY_AGGREGATE:
  424. case Token.KEY_EXPLICIT_TAG:
  425. default: {
  426. Sentry.captureMessage(`Unsupported key type for filter, got ${token.key.type}`);
  427. }
  428. }
  429. if (key !== null) {
  430. // If the value can be accessed directly, do so,
  431. // else check if the key is an entity key, sanitize it and try direct access again.
  432. // @TODO support deep nested keys with dot notation
  433. if (
  434. key === 'has' &&
  435. token.type === Token.FILTER &&
  436. token.value.type === Token.VALUE_TEXT
  437. ) {
  438. switch (token.value.text) {
  439. case 'error':
  440. case 'errors': {
  441. return node.errors.size > 0;
  442. }
  443. case 'issue':
  444. case 'issues':
  445. return node.errors.size > 0 || node.performance_issues.size > 0;
  446. case 'profile':
  447. case 'profiles':
  448. return node.profiles.length > 0;
  449. default: {
  450. break;
  451. }
  452. }
  453. }
  454. // Check for direct key access.
  455. if (value[key] !== undefined) {
  456. return value[key];
  457. }
  458. // @TODO perf optimization opportunity
  459. // Entity check should be preprocessed per token, not once per token per node we are evaluating, however since
  460. // we are searching <10k nodes in p99 percent of the time and the search is non blocking, we are likely fine
  461. // and can be optimized later.
  462. const [maybeEntity, ...rest] = key.split('.');
  463. switch (maybeEntity) {
  464. case 'span':
  465. if (isSpanNode(node)) {
  466. return value[rest.join('.')];
  467. }
  468. break;
  469. case 'transaction':
  470. if (isTransactionNode(node)) {
  471. return value[rest.join('.')];
  472. }
  473. break;
  474. default:
  475. break;
  476. }
  477. }
  478. return key ? value[key] ?? null : null;
  479. }
  480. return null;
  481. }
  482. /**
  483. * Evaluates the node based on freetext. This is a simple search that checks if the query
  484. * is present in a very small subset of the node's properties.
  485. */
  486. function evaluateNodeFreeText(
  487. query: string,
  488. node: TraceTreeNode<TraceTree.NodeValue>
  489. ): boolean {
  490. if (isSpanNode(node)) {
  491. if (node.value.op?.includes(query)) {
  492. return true;
  493. }
  494. if (node.value.description?.includes(query)) {
  495. return true;
  496. }
  497. if (node.value.span_id && node.value.span_id === query) {
  498. return true;
  499. }
  500. }
  501. if (isTransactionNode(node)) {
  502. if (node.value['transaction.op']?.includes(query)) {
  503. return true;
  504. }
  505. if (node.value.transaction?.includes(query)) {
  506. return true;
  507. }
  508. if (node.value.event_id && node.value.event_id === query) {
  509. return true;
  510. }
  511. }
  512. if (isAutogroupedNode(node)) {
  513. if (node.value.op?.includes(query)) {
  514. return true;
  515. }
  516. if (node.value.description?.includes(query)) {
  517. return true;
  518. }
  519. }
  520. if (isTraceErrorNode(node)) {
  521. if (node.value.level === query) {
  522. return true;
  523. }
  524. if (node.value.title?.includes(query)) {
  525. return true;
  526. }
  527. }
  528. return false;
  529. }