From 3bdb6ee11c3286c50e7b1182864fc9b1b5deba1d Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 18 Feb 2025 11:29:39 +0100 Subject: [PATCH] Alerting: Improve rules fuzzy search (#100822) * Add string search fallback for long and unusual search queries * Add a test for fallback search --- .../unified/hooks/useFilteredRules.test.ts | 32 +++++ .../unified/hooks/useFilteredRules.ts | 124 ++++++------------ 2 files changed, 73 insertions(+), 83 deletions(-) diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts index 728bbfaf765..ecdc4b64ac9 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.test.ts @@ -293,4 +293,36 @@ describe('filterRules', function () { expect(() => filterRules([ns], getFilter({ freeFormWords: ['.+'] }))).not.toThrow(); }); + + it.each(['[square-bracket]', '[5m]', '(bracket-test)', 'aste-risk*', 'with+ plus'])( + 'should apply filters when expression contains special characters = "%s"', + (expression) => { + const rules = [mockCombinedRule({ name: expression })]; + + const ns = mockCombinedRuleNamespace({ + name: 'namespace', + groups: [mockCombinedRuleGroup('group', rules)], + }); + + const filtered = filterRules([ns], getFilter({ freeFormWords: [expression] })); + + expect(filtered[0]?.groups[0]?.rules).toHaveLength(1); + expect(filtered[0]?.groups[0]?.rules[0]?.name).toBe(expression); + } + ); + + it('should return filtered results for long search terms', () => { + const longRuleName = 'This:is:a:very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const rules = [mockCombinedRule({ name: longRuleName }), mockCombinedRule({ name: 'Short rule name' })]; + + const ns = mockCombinedRuleNamespace({ + groups: [mockCombinedRuleGroup('group', rules)], + }); + + const longSearchTerm = 'very:long:rule:name:that:definitely:exceeds:max:needle:length'; + const filtered = filterRules([ns], getFilter({ ruleName: longSearchTerm })); + + expect(filtered[0]?.groups[0]?.rules).toHaveLength(1); + expect(filtered[0]?.groups[0]?.rules[0]?.name).toBe(longRuleName); + }); }); diff --git a/public/app/features/alerting/unified/hooks/useFilteredRules.ts b/public/app/features/alerting/unified/hooks/useFilteredRules.ts index 7789c8ec90d..e4d720cc7cb 100644 --- a/public/app/features/alerting/unified/hooks/useFilteredRules.ts +++ b/public/app/features/alerting/unified/hooks/useFilteredRules.ts @@ -28,21 +28,12 @@ import { useURLSearchParams } from './useURLSearchParams'; // if the search term is longer than MAX_NEEDLE_SIZE we disable Levenshtein distance const MAX_NEEDLE_SIZE = 25; const INFO_THRESHOLD = Infinity; - -const SEARCH_FAILED_ERR = new Error('Failed to search rules'); - -/** - * Escape query strings so that regex characters don't interfere - * with uFuzzy search methods. - * - * The fuzzy searching will take the query and generate a regex - but if the query - * contains a regex itself, then it can easily end up being split in a bad place - * and end up creating an invalid expression - */ -const escapeQueryRegex = (query: string) => { - // see https://stackoverflow.com/a/6969486 - return query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -}; +const MAX_FUZZY_TERMS = 5; +// https://catonmat.net/my-favorite-regex :) +const REGEXP_NON_ASCII = /[^ -~]/m; +// https://www.asciitable.com/ +// matches only these: `~!@#$%^&*()_+-=[]\{}|;':",./<>? +const REGEXP_ONLY_SYMBOLS = /^[\x21-\x2F\x3A-\x40\x5B-\x60\x7B-\x7E]+$/m; export function useRulesFilter() { const [queryParams, updateQueryParams] = useURLSearchParams(); @@ -147,22 +138,7 @@ export const filterRules = ( const namespaceFilter = filterState.namespace; if (namespaceFilter) { - const namespaceHaystack = filteredNamespaces.map((ns) => ns.name); - - const escapedQuery = escapeQueryRegex(namespaceFilter); - - const ufuzzy = getSearchInstance(namespaceFilter); - const [idxs, info, order] = ufuzzy.search( - namespaceHaystack, - escapedQuery, - getOutOfOrderLimit(namespaceFilter), - INFO_THRESHOLD - ); - if (info && order) { - filteredNamespaces = order.map((idx) => filteredNamespaces[info.idx[idx]]); - } else if (idxs) { - filteredNamespaces = idxs.map((idx) => filteredNamespaces[idx]); - } + filteredNamespaces = fuzzyFilter(filteredNamespaces, (ns) => ns.name, namespaceFilter); } // If a namespace and group have rules that match the rules filters then keep them. @@ -173,8 +149,8 @@ export const filterRules = ( matches.forEach((match) => { filteredRuleNamespaces.push(match); }); - } catch { - logError(SEARCH_FAILED_ERR, { + } catch (error) { + logError(new Error('Failed to filter rules', { cause: error }), { search: JSON.stringify(filterState), }); } @@ -188,21 +164,7 @@ const reduceNamespaces = (filterState: RulesFilter) => { let filteredGroups = namespace.groups; if (groupNameFilter) { - const groupsHaystack = filteredGroups.map((g) => g.name); - const ufuzzy = getSearchInstance(groupNameFilter); - - const escapedQuery = escapeQueryRegex(groupNameFilter); - const [idxs, info, order] = ufuzzy.search( - groupsHaystack, - escapedQuery, - getOutOfOrderLimit(groupNameFilter), - INFO_THRESHOLD - ); - if (info && order) { - filteredGroups = order.map((idx) => filteredGroups[info.idx[idx]]); - } else if (idxs) { - filteredGroups = idxs.map((idx) => filteredGroups[idx]); - } + filteredGroups = fuzzyFilter(filteredGroups, (g) => g.name, groupNameFilter); } filteredGroups = filteredGroups.reduce(reduceGroups(filterState), []); @@ -226,21 +188,7 @@ const reduceGroups = (filterState: RulesFilter) => { let filteredRules = group.rules; if (ruleNameQuery) { - const rulesHaystack = filteredRules.map((r) => r.name); - const ufuzzy = getSearchInstance(ruleNameQuery); - const escapedQuery = escapeQueryRegex(ruleNameQuery); - - const [idxs, info, order] = ufuzzy.search( - rulesHaystack, - escapedQuery, - getOutOfOrderLimit(ruleNameQuery), - INFO_THRESHOLD - ); - if (info && order) { - filteredRules = order.map((idx) => filteredRules[info.idx[idx]]); - } else if (idxs) { - filteredRules = idxs.map((idx) => filteredRules[idx]); - } + filteredRules = fuzzyFilter(filteredRules, (r) => r.name, ruleNameQuery); } filteredRules = filteredRules.filter((rule) => { @@ -356,15 +304,6 @@ const reduceGroups = (filterState: RulesFilter) => { }; }; -// apply an outOfOrder limit which helps to limit the number of permutations to search for -// and prevents the browser from hanging -function getOutOfOrderLimit(searchTerm: string) { - const ufuzzy = getSearchInstance(searchTerm); - - const termCount = ufuzzy.split(searchTerm).length; - return termCount < 5 ? 4 : 0; -} - function looseParseMatcher(matcherQuery: string): Matcher | undefined { try { return parseMatcher(matcherQuery); @@ -374,21 +313,40 @@ function looseParseMatcher(matcherQuery: string): Matcher | undefined { } } -// determine which search instance to use, very long search terms should match without checking for Levenshtein distance -function getSearchInstance(searchTerm: string): uFuzzy { - const searchTermExeedsMaxNeedleSize = searchTerm.length > MAX_NEEDLE_SIZE; +function fuzzyFilter(items: TItem[], filterBy: (item: TItem) => string, searchTerm: string) { + let filteredItems = items; // Options details can be found here https://github.com/leeoniya/uFuzzy#options // The following configuration complies with Damerau-Levenshtein distance // https://en.wikipedia.org/wiki/Damerau%E2%80%93Levenshtein_distance - return new uFuzzy({ - // we will disable Levenshtein distance for very long search terms – this will help with performance - // as it will avoid creating a very complex regular expression - intraMode: searchTermExeedsMaxNeedleSize ? 0 : 1, - // split search terms only on whitespace, this will significantly reduce the amount of regex permutations to test - // and is important for performance with large amount of rules and large needle - interSplit: '\\s+', - }); + const ufuzzy = new uFuzzy({ intraMode: 1 }); + const needleTermsCount = ufuzzy.split(searchTerm).length; + + // If the search term is very long or contains non-ascii characters or only special characters we don't use fuzzy search + // and need to fallback to simple string search + const fuzzySearchNotApplicable = + REGEXP_NON_ASCII.test(searchTerm) || + REGEXP_ONLY_SYMBOLS.test(searchTerm) || + searchTerm.length > MAX_NEEDLE_SIZE || + needleTermsCount > MAX_FUZZY_TERMS; + + if (fuzzySearchNotApplicable) { + return items.filter((item) => filterBy(item).toLowerCase().includes(searchTerm.toLowerCase())); + } + + const haystack = items.map(filterBy); + // apply an outOfOrder limit which helps to limit the number of permutations to search for + // and prevents the browser from hanging + const outOfOrderLimit = needleTermsCount < 5 ? 4 : 0; + + const [idxs, info, order] = ufuzzy.search(haystack, searchTerm, outOfOrderLimit, INFO_THRESHOLD); + if (info && order) { + filteredItems = order.map((idx) => filteredItems[info.idx[idx]]); + } else if (idxs) { + filteredItems = idxs.map((idx) => filteredItems[idx]); + } + + return filteredItems; } const isQueryingDataSource = (rulerRule: RulerGrafanaRuleDTO, filterState: RulesFilter): boolean => {