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:
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user