traceSearchEvaluator.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  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: TokenResult<Token>[],
  37. previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
  38. cb: (
  39. results: [
  40. ReadonlyArray<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: (
  108. | ProcessedTokenResult
  109. | Map<TraceTreeNode<TraceTree.NodeValue>, number>
  110. )[] = [];
  111. function search(): void {
  112. const ts = performance.now();
  113. if (!bool) {
  114. while (ti < postfix.length) {
  115. const token = postfix[ti];
  116. if (token.type === Token.LOGIC_BOOLEAN) {
  117. bool = token;
  118. if (stack.length < 2) {
  119. Sentry.captureMessage('Unbalanced tree - missing left or right token');
  120. typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
  121. cb([[], resultLookup, null]);
  122. return;
  123. }
  124. // @ts-expect-error the type guard is handled and expected
  125. rightToken = stack.pop()!;
  126. leftToken = stack.pop()!;
  127. break;
  128. } else {
  129. stack.push(token);
  130. }
  131. ti++;
  132. }
  133. }
  134. if (!bool) {
  135. Sentry.captureMessage(
  136. 'Invalid state in searchInTraceTreeTokens, missing boolean token'
  137. );
  138. typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
  139. cb([[], resultLookup, null]);
  140. return;
  141. }
  142. if (!leftToken || !rightToken) {
  143. Sentry.captureMessage(
  144. 'Invalid state in searchInTraceTreeTokens, missing left or right token'
  145. );
  146. typeof handle.id === 'number' && window.cancelAnimationFrame(handle.id);
  147. cb([[], resultLookup, null]);
  148. return;
  149. }
  150. if (li < count && !(leftToken instanceof Map)) {
  151. while (li < count && performance.now() - ts < 12) {
  152. const node = tree.list[li];
  153. if (evaluateTokenForValue(leftToken, resolveValueFromKey(node, leftToken))) {
  154. left.set(node, li);
  155. }
  156. li++;
  157. }
  158. handle.id = requestAnimationFrame(search);
  159. } else if (ri < count && !(rightToken instanceof Map)) {
  160. while (ri < count && performance.now() - ts < 12) {
  161. const node = tree.list[ri];
  162. if (evaluateTokenForValue(rightToken, resolveValueFromKey(node, rightToken))) {
  163. right.set(node, ri);
  164. }
  165. ri++;
  166. }
  167. handle.id = requestAnimationFrame(search);
  168. } else {
  169. if (
  170. (li === count || leftToken instanceof Map) &&
  171. (ri === count || rightToken instanceof Map)
  172. ) {
  173. result_map = booleanResult(
  174. leftToken instanceof Map ? leftToken : left,
  175. rightToken instanceof Map ? rightToken : right,
  176. bool.value
  177. );
  178. // Reset the state for the next iteration
  179. bool = null;
  180. leftToken = null;
  181. rightToken = null;
  182. left.clear();
  183. right.clear();
  184. li = 0;
  185. ri = 0;
  186. // Push result to stack;
  187. stack.push(result_map);
  188. ti++;
  189. }
  190. if (ti === postfix.length) {
  191. const result: TraceSearchResult[] = [];
  192. let resultIdx = -1;
  193. // @TODO We render up to 10k nodes and plan to load more, so this might be future bottleneck.
  194. for (const [node, index] of result_map) {
  195. result.push({index, value: node});
  196. resultLookup.set(node, ++resultIdx);
  197. if (previousNode === node) {
  198. previousNodeSearchResult = {
  199. resultIndex: index,
  200. resultIteratorIndex: resultIdx,
  201. };
  202. }
  203. }
  204. cb([result, resultLookup, previousNodeSearchResult]);
  205. } else {
  206. handle.id = requestAnimationFrame(search);
  207. }
  208. }
  209. }
  210. handle.id = requestAnimationFrame(search);
  211. return handle;
  212. }
  213. // Freetext search in the trace tree
  214. export function searchInTraceTreeText(
  215. tree: TraceTree,
  216. query: string,
  217. previousNode: TraceTreeNode<TraceTree.NodeValue> | null,
  218. cb: (
  219. results: [
  220. ReadonlyArray<TraceSearchResult>,
  221. Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  222. {resultIndex: number | undefined; resultIteratorIndex: number | undefined} | null,
  223. ]
  224. ) => void
  225. ): {id: number | null} {
  226. const handle: {id: number | null} = {id: 0};
  227. let previousNodeSearchResult: {
  228. resultIndex: number | undefined;
  229. resultIteratorIndex: number | undefined;
  230. } | null = null;
  231. const results: Array<TraceSearchResult> = [];
  232. const resultLookup = new Map();
  233. let i = 0;
  234. let matchCount = 0;
  235. const count = tree.list.length;
  236. function search() {
  237. const ts = performance.now();
  238. while (i < count && performance.now() - ts < 12) {
  239. const node = tree.list[i];
  240. if (evaluateNodeFreeText(query, node)) {
  241. results.push({index: i, value: node});
  242. resultLookup.set(node, matchCount);
  243. if (previousNode === node) {
  244. previousNodeSearchResult = {
  245. resultIndex: i,
  246. resultIteratorIndex: matchCount,
  247. };
  248. }
  249. matchCount++;
  250. }
  251. i++;
  252. }
  253. if (i < count) {
  254. handle.id = requestAnimationFrame(search);
  255. }
  256. if (i === count) {
  257. cb([results, resultLookup, previousNodeSearchResult]);
  258. handle.id = null;
  259. }
  260. }
  261. handle.id = requestAnimationFrame(search);
  262. return handle;
  263. }
  264. function evaluateTokenForValue(token: ProcessedTokenResult, value: any): boolean {
  265. if (token.type === Token.FILTER) {
  266. if (token.value.type === Token.VALUE_NUMBER) {
  267. const result = evaluateValueNumber(token.value, token.operator, value);
  268. return token.negated ? !result : result;
  269. }
  270. if (token.value.type === Token.VALUE_DURATION) {
  271. const result = evaluateValueNumber(token.value, token.operator, value);
  272. return token.negated ? !result : result;
  273. }
  274. if (token.value.type === Token.VALUE_TEXT) {
  275. switch (typeof value) {
  276. case 'string':
  277. return token.negated
  278. ? !value.includes(token.value.value)
  279. : value.includes(token.value.value);
  280. case 'boolean':
  281. return token.negated ? !value : !!value;
  282. default:
  283. return false;
  284. }
  285. }
  286. if (token.value.type === Token.VALUE_ISO_8601_DATE) {
  287. return (
  288. typeof value === 'number' && evaluateValueDate(token.value, token.operator, value)
  289. );
  290. }
  291. }
  292. return false;
  293. }
  294. function booleanResult(
  295. left: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  296. right: Map<TraceTreeNode<TraceTree.NodeValue>, number>,
  297. operator: BooleanOperator
  298. ): Map<TraceTreeNode<TraceTree.NodeValue>, number> {
  299. if (operator === BooleanOperator.AND) {
  300. const result = new Map();
  301. for (const [key, value] of left) {
  302. right.has(key) && result.set(key, value);
  303. }
  304. return result;
  305. }
  306. if (operator === BooleanOperator.OR) {
  307. const result = new Map(left);
  308. for (const [key, value] of right) {
  309. result.set(key, value);
  310. }
  311. return result;
  312. }
  313. throw new Error(`Unsupported boolean operator, received ${operator}`);
  314. }
  315. function evaluateValueDate<T extends Token.VALUE_ISO_8601_DATE>(
  316. token: TokenResult<T>,
  317. operator: TermOperator,
  318. value: any
  319. ): boolean {
  320. if (!token.parsed || (typeof value !== 'number' && typeof value !== 'string')) {
  321. return false;
  322. }
  323. if (typeof value === 'string') {
  324. value = new Date(value).getTime();
  325. if (isNaN(value)) {
  326. return false;
  327. }
  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. // Aliases for fields that do not exist on raw data
  455. if (key === 'project' || key === 'project.name') {
  456. // project.name and project fields do not exist on raw data and are
  457. // aliases for project_slug key that does exist.
  458. key = 'project_slug';
  459. }
  460. // Check for direct key access.
  461. if (value[key] !== undefined) {
  462. return value[key];
  463. }
  464. // @TODO perf optimization opportunity
  465. // Entity check should be preprocessed per token, not once per token per node we are evaluating, however since
  466. // we are searching <10k nodes in p99 percent of the time and the search is non blocking, we are likely fine
  467. // and can be optimized later.
  468. const [maybeEntity, ...rest] = key.split('.');
  469. switch (maybeEntity) {
  470. case 'span':
  471. if (isSpanNode(node)) {
  472. return value[rest.join('.')];
  473. }
  474. break;
  475. case 'transaction':
  476. if (isTransactionNode(node)) {
  477. return value[rest.join('.')];
  478. }
  479. break;
  480. default:
  481. break;
  482. }
  483. }
  484. return key ? value[key] ?? null : null;
  485. }
  486. return null;
  487. }
  488. /**
  489. * Evaluates the node based on freetext. This is a simple search that checks if the query
  490. * is present in a very small subset of the node's properties.
  491. */
  492. function evaluateNodeFreeText(
  493. query: string,
  494. node: TraceTreeNode<TraceTree.NodeValue>
  495. ): boolean {
  496. if (isSpanNode(node)) {
  497. if (node.value.op?.includes(query)) {
  498. return true;
  499. }
  500. if (node.value.description?.includes(query)) {
  501. return true;
  502. }
  503. if (node.value.span_id && node.value.span_id === query) {
  504. return true;
  505. }
  506. }
  507. if (isTransactionNode(node)) {
  508. if (node.value['transaction.op']?.includes(query)) {
  509. return true;
  510. }
  511. if (node.value.transaction?.includes(query)) {
  512. return true;
  513. }
  514. if (node.value.event_id && node.value.event_id === query) {
  515. return true;
  516. }
  517. }
  518. if (isAutogroupedNode(node)) {
  519. if (node.value.op?.includes(query)) {
  520. return true;
  521. }
  522. if (node.value.description?.includes(query)) {
  523. return true;
  524. }
  525. }
  526. if (isTraceErrorNode(node)) {
  527. if (node.value.level === query) {
  528. return true;
  529. }
  530. if (node.value.title?.includes(query)) {
  531. return true;
  532. }
  533. }
  534. return false;
  535. }