Alerting: Improve rules fuzzy search (#100822)

* Add string search fallback for long and unusual search queries

* Add a test for fallback search
This commit is contained in:
Konrad Lalik
2025-02-18 11:29:39 +01:00
committed by GitHub
parent 4df398c084
commit 3bdb6ee11c
2 changed files with 73 additions and 83 deletions
@@ -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);
});
});
@@ -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<CombinedRuleGroup[]>(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<TItem>(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 => {