[ResponseOps] Schema changes for ES|QL rule type improvements - adding grouping per row (#217898)

Related to https://github.com/elastic/response-ops-team/issues/201

## Summary

Schema changes for intermediate release related to this PR,
https://github.com/elastic/kibana/pull/212135.

This PR adds a new `row` option and validation for the ES query rule
`groupBy` field.


### Checklist

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
This commit is contained in:
Alexi Doak 2025-04-17 14:15:54 -07:00 committed by GitHub
parent 5454ce5bbd
commit 5667c6cc43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 202 additions and 28 deletions

View file

@ -125,7 +125,7 @@ export function validateAggType(aggType: string): string | undefined {
}
export function validateGroupBy(groupBy: string): string | undefined {
if (groupBy === 'all' || groupBy === 'top') {
if (groupBy === 'all' || groupBy === 'top' || groupBy === 'row') {
return;
}

View file

@ -0,0 +1,168 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { validateParams } from './v1';
describe('validateParams', () => {
describe('esqlQuery', () => {
const params = {
searchType: 'esqlQuery',
threshold: [0],
thresholdComparator: '>',
timeField: '@timestamp',
};
it('if timeField is not defined should return error message', () => {
expect(
validateParams({
...params,
timeField: undefined,
})
).toBe('[timeField]: is required');
expect(
validateParams({
...params,
timeField: '',
})
).toBe('[timeField]: is required');
});
it('if thresholdComparator is not > should return error message', () => {
expect(
validateParams({
...params,
thresholdComparator: '<',
})
).toBe('[thresholdComparator]: is required to be greater than');
});
it('if threshold is not 0 should return error message', () => {
expect(
validateParams({
...params,
threshold: [8],
})
).toBe('[threshold]: is required to be 0');
});
it('if groupBy is "top" should not return error message', () => {
expect(
validateParams({
...params,
groupBy: 'top',
})
).toBeUndefined();
});
});
const esQuery = [
'esQuery',
{
aggType: 'count',
esQuery: '{"query":{"match_all":{}}}',
groupBy: 'all',
searchType: 'esQuery',
threshold: [0],
thresholdComparator: '>',
},
] as const;
const searchSource = [
'searchSource',
{
aggType: 'count',
groupBy: 'all',
searchType: 'searchSource',
threshold: [0],
thresholdComparator: '>',
},
] as const;
for (const [searchType, params] of [esQuery, searchSource]) {
describe(searchType, () => {
it('if thresholdComparator is a "betweenComparator" and threshold does not have two elements should return error message', () => {
expect(
validateParams({
...params,
thresholdComparator: 'between',
})
).toBe('[threshold]: must have two elements for the "between" comparator');
});
it('if aggType is not "count" and aggField is not defined should return error message', () => {
expect(
validateParams({
...params,
aggType: 'avg',
aggField: undefined,
})
).toBe('[aggField]: must have a value when [aggType] is "avg"');
});
it('if groupBy is "top" and termField is undefined should return error message', () => {
expect(
validateParams({
...params,
groupBy: 'top',
termField: undefined,
})
).toBe('[termField]: termField required when [groupBy] is top');
});
it('if groupBy is "top" and termSize is undefined should return error message', () => {
expect(
validateParams({
...params,
groupBy: 'top',
termField: 'test',
termSize: undefined,
})
).toBe('[termSize]: termSize required when [groupBy] is top');
});
it('if groupBy is "top" and termSize is > MAX_GROUPS should return error message', () => {
expect(
validateParams({
...params,
groupBy: 'top',
termField: 'test',
termSize: 1001,
})
).toBe('[termSize]: must be less than or equal to 1000');
});
it('if groupBy is "row" should return error message', () => {
expect(
validateParams({
...params,
groupBy: 'row',
})
).toBe('[groupBy]: groupBy should be all or top when [searchType] is not esqlQuery');
});
if (searchType === 'esQuery') {
it('if parsed esQuery does not contain query should return error message', () => {
expect(
validateParams({
...params,
esQuery: '{}',
})
).toBe('[esQuery]: must contain "query"');
});
it('if esQuery is not valid JSON should return error message', () => {
expect(
validateParams({
...params,
esQuery: '{"query":{"match_all":{}}',
})
).toBe('[esQuery]: must be valid JSON');
});
}
});
}
});

View file

@ -86,7 +86,7 @@ const EsQueryRuleParamsSchemaProperties = {
defaultValue: 'all',
meta: {
description:
'Indicates whether the aggregation is applied over all documents (`all`) or split into groups (`top`) using a grouping field (`termField`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to `termSize` number of groups) are checked.',
'Indicates whether the aggregation is applied over all documents (`all`), grouped by row (`row`), or split into groups (`top`) using a grouping field (`termField`) where only the top groups (up to `termSize` number of groups) are checked. If grouping is used, an alert will be created for each group when it exceeds the threshold.',
},
}),
termField: schema.maybe(
@ -187,7 +187,7 @@ function isEsqlQueryRule(searchType: EsQueryRuleParams['searchType']) {
}
// using direct type not allowed, circular reference, so body is typed to any
function validateParams(anyParams: unknown): string | undefined {
export function validateParams(anyParams: unknown): string | undefined {
const {
esQuery,
thresholdComparator,
@ -200,6 +200,31 @@ function validateParams(anyParams: unknown): string | undefined {
termSize,
} = anyParams as EsQueryRuleParams;
if (isEsqlQueryRule(searchType)) {
const { timeField } = anyParams as EsQueryRuleParams;
if (!timeField) {
return i18n.translate('xpack.responseOps.ruleParams.esQuery.esqlTimeFieldErrorMessage', {
defaultMessage: '[timeField]: is required',
});
}
if (thresholdComparator !== Comparator.GT) {
return i18n.translate(
'xpack.responseOps.ruleParams.esQuery.esqlThresholdComparatorErrorMessage',
{
defaultMessage: '[thresholdComparator]: is required to be greater than',
}
);
}
if (threshold && threshold[0] !== 0) {
return i18n.translate('xpack.responseOps.ruleParams.esQuery.esqlThresholdErrorMessage', {
defaultMessage: '[threshold]: is required to be 0',
});
}
// The esqlQuery type does not validate groupBy, as any groupBy other than 'row' is considered to be 'all'
return;
}
if (betweenComparators.has(thresholdComparator) && threshold.length === 1) {
return i18n.translate('responseOps.ruleParams.esQuery.invalidThreshold2ErrorMessage', {
defaultMessage:
@ -243,32 +268,13 @@ function validateParams(anyParams: unknown): string | undefined {
);
}
}
if (isSearchSourceRule(searchType)) {
return;
if (groupBy === 'row') {
return i18n.translate('xpack.responseOps.ruleParams.esQuery.invalidRowGroupByErrorMessage', {
defaultMessage: '[groupBy]: groupBy should be all or top when [searchType] is not esqlQuery',
});
}
if (isEsqlQueryRule(searchType)) {
const { timeField } = anyParams as EsQueryRuleParams;
if (!timeField) {
return i18n.translate('xpack.responseOps.ruleParams.esQuery.esqlTimeFieldErrorMessage', {
defaultMessage: '[timeField]: is required',
});
}
if (thresholdComparator !== Comparator.GT) {
return i18n.translate(
'xpack.responseOps.ruleParams.esQuery.esqlThresholdComparatorErrorMessage',
{
defaultMessage: '[thresholdComparator]: is required to be greater than',
}
);
}
if (threshold && threshold[0] !== 0) {
return i18n.translate('xpack.responseOps.ruleParams.esQuery.esqlThresholdErrorMessage', {
defaultMessage: '[threshold]: is required to be 0',
});
}
if (isSearchSourceRule(searchType)) {
return;
}

View file

@ -203,7 +203,7 @@ Object {
"groupBy": Object {
"flags": Object {
"default": "all",
"description": "Indicates whether the aggregation is applied over all documents (\`all\`) or split into groups (\`top\`) using a grouping field (\`termField\`). If grouping is used, an alert will be created for each group when it exceeds the threshold; only the top groups (up to \`termSize\` number of groups) are checked.",
"description": "Indicates whether the aggregation is applied over all documents (\`all\`), grouped by row (\`row\`), or split into groups (\`top\`) using a grouping field (\`termField\`) where only the top groups (up to \`termSize\` number of groups) are checked. If grouping is used, an alert will be created for each group when it exceeds the threshold.",
"error": [Function],
"presence": "optional",
},