[AO] Save group information in AAD for the new threshold rule (#164087)

Closes #161758

## Summary

In this PR, I am saving the groupings information for the new threshold
in AAD in a similar format as the security team does, you can check the
format in the following screenshots. (Please check this
[RFC](https://docs.google.com/document/d/1DlykydM8Hk7-VAPOcuoUXp0L_qSi2jCZabJkPdO44tQ/edit#heading=h.2b1v1tr0ep8m)
for more information)

### Alert as data document


![image](ce4d5000-3799-4dd7-9a04-d012f1cc5aca)

### Groupings action variable


![image](5a4aaff1-b9c5-44e8-86e5-9fa397b6af62)

### Alert table


![image](cfe1aaf1-475c-4d04-8726-b064c0905d55)

It is also possible to search based on these new variables:


f07b39c2-52e8-4f50-b713-577da7ab1c42
This commit is contained in:
Maryam Saeidi 2023-10-02 15:42:35 +02:00 committed by GitHub
parent 8fd6dbed55
commit 41e54e7208
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 171 additions and 28 deletions

View file

@ -11,6 +11,9 @@ import {
ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_THRESHOLD,
ALERT_EVALUATION_VALUE, ALERT_EVALUATION_VALUE,
ALERT_EVALUATION_VALUES, ALERT_EVALUATION_VALUES,
ALERT_GROUP,
ALERT_GROUP_FIELD,
ALERT_GROUP_VALUE,
} from '@kbn/rule-data-utils'; } from '@kbn/rule-data-utils';
export const legacyExperimentalFieldMap = { export const legacyExperimentalFieldMap = {
@ -27,6 +30,21 @@ export const legacyExperimentalFieldMap = {
required: false, required: false,
array: true, array: true,
}, },
[ALERT_GROUP]: {
type: 'object',
array: true,
required: false,
},
[ALERT_GROUP_FIELD]: {
type: 'keyword',
array: false,
required: false,
},
[ALERT_GROUP_VALUE]: {
type: 'keyword',
array: false,
required: false,
},
} as const; } as const;
export type ExperimentalRuleFieldMap = typeof legacyExperimentalFieldMap; export type ExperimentalRuleFieldMap = typeof legacyExperimentalFieldMap;

View file

@ -84,6 +84,12 @@ const ObservabilityApmAlertOptional = rt.partial({
value: schemaStringOrNumber, value: schemaStringOrNumber,
values: schemaStringOrNumberArray, values: schemaStringOrNumberArray,
}), }),
group: rt.array(
rt.partial({
field: schemaString,
value: schemaString,
})
),
}), }),
}), }),
processor: rt.partial({ processor: rt.partial({

View file

@ -78,6 +78,12 @@ const ObservabilityLogsAlertOptional = rt.partial({
value: schemaStringOrNumber, value: schemaStringOrNumber,
values: schemaStringOrNumberArray, values: schemaStringOrNumberArray,
}), }),
group: rt.array(
rt.partial({
field: schemaString,
value: schemaString,
})
),
}), }),
}), }),
}); });

View file

@ -78,6 +78,12 @@ const ObservabilityMetricsAlertOptional = rt.partial({
value: schemaStringOrNumber, value: schemaStringOrNumber,
values: schemaStringOrNumberArray, values: schemaStringOrNumberArray,
}), }),
group: rt.array(
rt.partial({
field: schemaString,
value: schemaString,
})
),
}), }),
}), }),
}); });

View file

@ -77,6 +77,12 @@ const ObservabilitySloAlertOptional = rt.partial({
value: schemaStringOrNumber, value: schemaStringOrNumber,
values: schemaStringOrNumberArray, values: schemaStringOrNumberArray,
}), }),
group: rt.array(
rt.partial({
field: schemaString,
value: schemaString,
})
),
}), }),
}), }),
slo: rt.partial({ slo: rt.partial({

View file

@ -89,6 +89,12 @@ const ObservabilityUptimeAlertOptional = rt.partial({
value: schemaStringOrNumber, value: schemaStringOrNumber,
values: schemaStringOrNumberArray, values: schemaStringOrNumberArray,
}), }),
group: rt.array(
rt.partial({
field: schemaString,
value: schemaString,
})
),
}), }),
}), }),
monitor: rt.partial({ monitor: rt.partial({

View file

@ -88,6 +88,9 @@ const ALERT_EVALUATION_THRESHOLD = `${ALERT_NAMESPACE}.evaluation.threshold` as
const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const; const ALERT_EVALUATION_VALUE = `${ALERT_NAMESPACE}.evaluation.value` as const;
const ALERT_CONTEXT = `${ALERT_NAMESPACE}.context` as const; const ALERT_CONTEXT = `${ALERT_NAMESPACE}.context` as const;
const ALERT_EVALUATION_VALUES = `${ALERT_NAMESPACE}.evaluation.values` as const; const ALERT_EVALUATION_VALUES = `${ALERT_NAMESPACE}.evaluation.values` as const;
const ALERT_GROUP = `${ALERT_NAMESPACE}.group` as const;
const ALERT_GROUP_FIELD = `${ALERT_GROUP}.field` as const;
const ALERT_GROUP_VALUE = `${ALERT_GROUP}.value` as const;
// Fields pertaining to the rule associated with the alert // Fields pertaining to the rule associated with the alert
const ALERT_RULE_EXCEPTIONS_LIST = `${ALERT_RULE_NAMESPACE}.exceptions_list` as const; const ALERT_RULE_EXCEPTIONS_LIST = `${ALERT_RULE_NAMESPACE}.exceptions_list` as const;
@ -129,6 +132,9 @@ const fields = {
ALERT_EVALUATION_THRESHOLD, ALERT_EVALUATION_THRESHOLD,
ALERT_EVALUATION_VALUE, ALERT_EVALUATION_VALUE,
ALERT_EVALUATION_VALUES, ALERT_EVALUATION_VALUES,
ALERT_GROUP,
ALERT_GROUP_FIELD,
ALERT_GROUP_VALUE,
ALERT_FLAPPING, ALERT_FLAPPING,
ALERT_MAINTENANCE_WINDOW_IDS, ALERT_MAINTENANCE_WINDOW_IDS,
ALERT_INSTANCE_ID, ALERT_INSTANCE_ID,
@ -200,6 +206,9 @@ export {
ALERT_EVALUATION_VALUE, ALERT_EVALUATION_VALUE,
ALERT_CONTEXT, ALERT_CONTEXT,
ALERT_EVALUATION_VALUES, ALERT_EVALUATION_VALUES,
ALERT_GROUP,
ALERT_GROUP_FIELD,
ALERT_GROUP_VALUE,
ALERT_RULE_EXCEPTIONS_LIST, ALERT_RULE_EXCEPTIONS_LIST,
ALERT_RULE_NAMESPACE_FIELD, ALERT_RULE_NAMESPACE_FIELD,
ALERT_THREAT_FRAMEWORK, ALERT_THREAT_FRAMEWORK,

View file

@ -389,8 +389,12 @@ describe('The metric threshold alert type', () => {
}, },
]); ]);
await execute(Comparator.GT, [0.75]); await execute(Comparator.GT, [0.75]);
expect(mostRecentAction(instanceIdA).action.group).toEqual({ groupByField: 'a' }); expect(mostRecentAction(instanceIdA).action.group).toEqual([
expect(mostRecentAction(instanceIdB).action.group).toEqual({ groupByField: 'b' }); { field: 'groupByField', value: 'a' },
]);
expect(mostRecentAction(instanceIdB).action.group).toEqual([
{ field: 'groupByField', value: 'b' },
]);
}); });
test('persists previous groups that go missing, until the groupBy param changes', async () => { test('persists previous groups that go missing, until the groupBy param changes', async () => {
setEvaluationResults([ setEvaluationResults([

View file

@ -8,7 +8,12 @@
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
import { TypeOf } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { ALERT_ACTION_GROUP, ALERT_EVALUATION_VALUES, ALERT_REASON } from '@kbn/rule-data-utils'; import {
ALERT_ACTION_GROUP,
ALERT_EVALUATION_VALUES,
ALERT_REASON,
ALERT_GROUP,
} from '@kbn/rule-data-utils';
import { LocatorPublic } from '@kbn/share-plugin/common'; import { LocatorPublic } from '@kbn/share-plugin/common';
import { import {
ActionGroupIdsOf, ActionGroupIdsOf,
@ -37,7 +42,7 @@ import {
hasAdditionalContext, hasAdditionalContext,
validGroupByForContext, validGroupByForContext,
flattenAdditionalContext, flattenAdditionalContext,
getGroupByObject, getFormattedGroupBy,
} from './utils'; } from './utils';
import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule'; import { EvaluatedRuleParams, evaluateRule } from './lib/evaluate_rule';
@ -80,12 +85,18 @@ type MetricThresholdAlert = Alert<
MetricThresholdAllowedActionGroups MetricThresholdAllowedActionGroups
>; >;
export type Group = Array<{
field: string;
value: string;
}>;
type MetricThresholdAlertFactory = ( type MetricThresholdAlertFactory = (
id: string, id: string,
reason: string, reason: string,
actionGroup: MetricThresholdActionGroup, actionGroup: MetricThresholdActionGroup,
additionalContext?: AdditionalContext | null, additionalContext?: AdditionalContext | null,
evaluationValues?: Array<number | null> evaluationValues?: Array<number | null>,
group?: Group
) => MetricThresholdAlert; ) => MetricThresholdAlert;
export const createMetricThresholdExecutor = ({ export const createMetricThresholdExecutor = ({
@ -139,7 +150,8 @@ export const createMetricThresholdExecutor = ({
reason, reason,
actionGroup, actionGroup,
additionalContext, additionalContext,
evaluationValues evaluationValues,
group
) => ) =>
alertWithLifecycle({ alertWithLifecycle({
id, id,
@ -147,6 +159,7 @@ export const createMetricThresholdExecutor = ({
[ALERT_REASON]: reason, [ALERT_REASON]: reason,
[ALERT_ACTION_GROUP]: actionGroup, [ALERT_ACTION_GROUP]: actionGroup,
[ALERT_EVALUATION_VALUES]: evaluationValues, [ALERT_EVALUATION_VALUES]: evaluationValues,
[ALERT_GROUP]: group,
...flattenAdditionalContext(additionalContext), ...flattenAdditionalContext(additionalContext),
}, },
}); });
@ -198,7 +211,7 @@ export const createMetricThresholdExecutor = ({
} }
} }
const groupByKeysObjectMapping = getGroupByObject(params.groupBy, resultGroupSet); const groupByKeysObjectMapping = getFormattedGroupBy(params.groupBy, resultGroupSet);
const groups = [...resultGroupSet]; const groups = [...resultGroupSet];
const nextMissingGroups = new Set<MissingGroupsRecord>(); const nextMissingGroups = new Set<MissingGroupsRecord>();
const hasGroups = !isEqual(groups, [UNGROUPED_FACTORY_KEY]); const hasGroups = !isEqual(groups, [UNGROUPED_FACTORY_KEY]);
@ -292,7 +305,8 @@ export const createMetricThresholdExecutor = ({
reason, reason,
actionGroupId, actionGroupId,
additionalContext, additionalContext,
evaluationValues evaluationValues,
groupByKeysObjectMapping[group]
); );
const alertUuid = getAlertUuid(group); const alertUuid = getAlertUuid(group);
const indexedStartedAt = getAlertStartedDate(group) ?? startedAt.toISOString(); const indexedStartedAt = getAlertStartedDate(group) ?? startedAt.toISOString();
@ -325,7 +339,7 @@ export const createMetricThresholdExecutor = ({
const { getRecoveredAlerts } = services.alertFactory.done(); const { getRecoveredAlerts } = services.alertFactory.done();
const recoveredAlerts = getRecoveredAlerts(); const recoveredAlerts = getRecoveredAlerts();
const groupByKeysObjectForRecovered = getGroupByObject( const groupByKeysObjectForRecovered = getFormattedGroupBy(
params.groupBy, params.groupBy,
new Set<string>(recoveredAlerts.map((recoveredAlert) => recoveredAlert.getId())) new Set<string>(recoveredAlerts.map((recoveredAlert) => recoveredAlert.getId()))
); );

View file

@ -147,7 +147,7 @@ export function thresholdRuleType(
doesSetRecoveryContext: true, doesSetRecoveryContext: true,
actionVariables: { actionVariables: {
context: [ context: [
{ name: 'groupings', description: groupByKeysActionVariableDescription }, { name: 'group', description: groupByKeysActionVariableDescription },
{ {
name: 'alertDetailsUrl', name: 'alertDetailsUrl',
description: alertDetailUrlActionVariableDescription, description: alertDetailUrlActionVariableDescription,

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import { flattenObject, validateKQLStringFilter } from './utils'; import { flattenObject, getFormattedGroupBy, validateKQLStringFilter } from './utils';
describe('FlattenObject', () => { describe('FlattenObject', () => {
it('flattens multi level item', () => { it('flattens multi level item', () => {
@ -69,3 +69,48 @@ describe('validateKQLStringFilter', () => {
expect(validateKQLStringFilter(input)).toEqual(output); expect(validateKQLStringFilter(input)).toEqual(output);
}); });
}); });
describe('getFormattedGroupBy', () => {
it('should format groupBy correctly for empty input', () => {
expect(getFormattedGroupBy(undefined, new Set<string>())).toEqual({});
});
it('should format groupBy correctly for multiple groups', () => {
expect(
getFormattedGroupBy(
['host.name', 'host.mac', 'tags', 'container.name'],
new Set([
'host-0,00-00-5E-00-53-23,event-0,container-name',
'host-0,00-00-5E-00-53-23,group-0,container-name',
'host-0,00-00-5E-00-53-24,event-0,container-name',
'host-0,00-00-5E-00-53-24,group-0,container-name',
])
)
).toEqual({
'host-0,00-00-5E-00-53-23,event-0,container-name': [
{ field: 'host.name', value: 'host-0' },
{ field: 'host.mac', value: '00-00-5E-00-53-23' },
{ field: 'tags', value: 'event-0' },
{ field: 'container.name', value: 'container-name' },
],
'host-0,00-00-5E-00-53-23,group-0,container-name': [
{ field: 'host.name', value: 'host-0' },
{ field: 'host.mac', value: '00-00-5E-00-53-23' },
{ field: 'tags', value: 'group-0' },
{ field: 'container.name', value: 'container-name' },
],
'host-0,00-00-5E-00-53-24,event-0,container-name': [
{ field: 'host.name', value: 'host-0' },
{ field: 'host.mac', value: '00-00-5E-00-53-24' },
{ field: 'tags', value: 'event-0' },
{ field: 'container.name', value: 'container-name' },
],
'host-0,00-00-5E-00-53-24,group-0,container-name': [
{ field: 'host.name', value: 'host-0' },
{ field: 'host.mac', value: '00-00-5E-00-53-24' },
{ field: 'tags', value: 'group-0' },
{ field: 'container.name', value: 'container-name' },
],
});
});
});

View file

@ -16,6 +16,7 @@ import { ES_FIELD_TYPES } from '@kbn/field-types';
import { set } from '@kbn/safer-lodash-set'; import { set } from '@kbn/safer-lodash-set';
import { ParsedExperimentalFields } from '@kbn/rule-registry-plugin/common/parse_experimental_fields'; import { ParsedExperimentalFields } from '@kbn/rule-registry-plugin/common/parse_experimental_fields';
import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common';
import { Group } from './custom_threshold_executor';
import { ObservabilityConfig } from '../../..'; import { ObservabilityConfig } from '../../..';
import { AlertExecutionDetails } from './types'; import { AlertExecutionDetails } from './types';
@ -227,21 +228,20 @@ export const flattenObject = (obj: AdditionalContext, prefix: string = ''): Addi
return acc; return acc;
}, {}); }, {});
export const getGroupByObject = ( export const getFormattedGroupBy = (
groupBy: string | string[] | undefined, groupBy: string | string[] | undefined,
resultGroupSet: Set<string> groupSet: Set<string>
): Record<string, object> => { ): Record<string, Group> => {
const groupByKeysObjectMapping: Record<string, object> = {}; const groupByKeysObjectMapping: Record<string, Group> = {};
if (groupBy) { if (groupBy) {
resultGroupSet.forEach((groupSet) => { groupSet.forEach((group) => {
const groupSetKeys = groupSet.split(','); const groupSetKeys = group.split(',');
groupByKeysObjectMapping[groupSet] = unflattenObject( groupByKeysObjectMapping[group] = Array.isArray(groupBy)
Array.isArray(groupBy) ? groupBy.reduce((result: Group, groupByItem, index) => {
? groupBy.reduce((result, group, index) => { result.push({ field: groupByItem, value: groupSetKeys[index]?.trim() });
return { ...result, [group]: groupSetKeys[index]?.trim() }; return result;
}, {}) }, [])
: { [groupBy]: groupSet } : [{ field: groupBy, value: group }];
);
}); });
} }
return groupByKeysObjectMapping; return groupByKeysObjectMapping;

View file

@ -163,6 +163,8 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open'); expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source).not.have.property('kibana.alert.group');
expect(resp.hits.hits[0]._source) expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters') .property('kibana.alert.rule.parameters')
.eql({ .eql({

View file

@ -111,7 +111,7 @@ export default function ({ getService }: FtrProviderContext) {
}, },
index: DATA_VIEW_ID, index: DATA_VIEW_ID,
}, },
groupBy: ['host.name'], groupBy: ['host.name', 'container.id'],
}, },
actions: [ actions: [
{ {
@ -125,6 +125,7 @@ export default function ({ getService }: FtrProviderContext) {
reason: '{{context.reason}}', reason: '{{context.reason}}',
value: '{{context.value}}', value: '{{context.value}}',
host: '{{context.host}}', host: '{{context.host}}',
group: '{{context.group}}',
}, },
], ],
}, },
@ -180,7 +181,10 @@ export default function ({ getService }: FtrProviderContext) {
'custom_threshold.fired' 'custom_threshold.fired'
); );
expect(resp.hits.hits[0]._source).property('tags').contain('observability'); expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', 'host-0'); expect(resp.hits.hits[0]._source).property(
'kibana.alert.instance.id',
'host-0,container-0'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open'); expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal'); expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open'); expect(resp.hits.hits[0]._source).property('event.action', 'open');
@ -193,6 +197,19 @@ export default function ({ getService }: FtrProviderContext) {
expect(resp.hits.hits[0]._source).property('container.name', 'container-name'); expect(resp.hits.hits[0]._source).property('container.name', 'container-name');
expect(resp.hits.hits[0]._source).not.property('container.cpu'); expect(resp.hits.hits[0]._source).not.property('container.cpu');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.group')
.eql([
{
field: 'host.name',
value: 'host-0',
},
{
field: 'container.id',
value: 'container-0',
},
]);
expect(resp.hits.hits[0]._source) expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters') .property('kibana.alert.rule.parameters')
.eql({ .eql({
@ -209,7 +226,7 @@ export default function ({ getService }: FtrProviderContext) {
alertOnNoData: true, alertOnNoData: true,
alertOnGroupDisappear: true, alertOnGroupDisappear: true,
searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } }, searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } },
groupBy: ['host.name'], groupBy: ['host.name', 'container.id'],
}); });
}); });
@ -221,6 +238,7 @@ export default function ({ getService }: FtrProviderContext) {
reason: string; reason: string;
value: string; value: string;
host: string; host: string;
group: string;
}>({ }>({
esClient, esClient,
indexName: ALERT_ACTION_INDEX, indexName: ALERT_ACTION_INDEX,
@ -231,12 +249,15 @@ export default function ({ getService }: FtrProviderContext) {
`https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)` `https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)`
); );
expect(resp.hits.hits[0]._source?.reason).eql( expect(resp.hits.hits[0]._source?.reason).eql(
'Custom equation is 0.8 in the last 1 min for host-0. Alert when >= 0.2.' 'Custom equation is 0.8 in the last 1 min for host-0,container-0. Alert when >= 0.2.'
); );
expect(resp.hits.hits[0]._source?.value).eql('0.8'); expect(resp.hits.hits[0]._source?.value).eql('0.8');
expect(resp.hits.hits[0]._source?.host).eql( expect(resp.hits.hits[0]._source?.host).eql(
'{"name":"host-0","mac":["00-00-5E-00-53-23","00-00-5E-00-53-24"]}' '{"name":"host-0","mac":["00-00-5E-00-53-23","00-00-5E-00-53-24"]}'
); );
expect(resp.hits.hits[0]._source?.group).eql(
'{"field":"host.name","value":"host-0"},{"field":"container.id","value":"container-0"}'
);
}); });
}); });
}); });