[8.7] Combine rule tags with source tags in alert context and AAD for infra rules (#150502) (#153463)

# Backport

This will backport the following commits from `main` to `8.7`:
- [Combine rule tags with source tags in alert context and AAD for infra
rules (#150502)](https://github.com/elastic/kibana/pull/150502)

<!--- Backport version: 8.9.7 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sqren/backport)

<!--BACKPORT [{"author":{"name":"Bena
Kansara","email":"69037875+benakansara@users.noreply.github.com"},"sourceCommit":{"committedDate":"2023-02-28T12:39:01Z","message":"Combine
rule tags with source tags in alert context and AAD for infra rules
(#150502)\n\nPart of
https://github.com/elastic/kibana/issues/150110\r\n\r\nCombines rule
tags with source tags in alert's context for Inventory\r\nthreshold and
Metric threshold rules. The same combination of tags is\r\nalso indexed
to AAD.\r\n\r\n### Manual Testing\r\n1. Create Inventory rule with some
tags\r\n2. Add connector for active and recovered alerts with
`{{context}}` in\r\naction template\r\n3. Ensure that both rule tags and
tags from source are available in\r\n`context.tags` in alert
notifications\r\n4. Ensure that the combination of tags is also indexed
to AAD\r\n\r\nRepeat the same steps for Metric threshold
rule.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"28ab5c73798cbd3c72e520b80738d6fe22d15a21","branchLabelMapping":{"^v8.8.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Team:
Actionable
Observability","backport:prev-minor","v8.8.0"],"number":150502,"url":"https://github.com/elastic/kibana/pull/150502","mergeCommit":{"message":"Combine
rule tags with source tags in alert context and AAD for infra rules
(#150502)\n\nPart of
https://github.com/elastic/kibana/issues/150110\r\n\r\nCombines rule
tags with source tags in alert's context for Inventory\r\nthreshold and
Metric threshold rules. The same combination of tags is\r\nalso indexed
to AAD.\r\n\r\n### Manual Testing\r\n1. Create Inventory rule with some
tags\r\n2. Add connector for active and recovered alerts with
`{{context}}` in\r\naction template\r\n3. Ensure that both rule tags and
tags from source are available in\r\n`context.tags` in alert
notifications\r\n4. Ensure that the combination of tags is also indexed
to AAD\r\n\r\nRepeat the same steps for Metric threshold
rule.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"28ab5c73798cbd3c72e520b80738d6fe22d15a21"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v8.8.0","labelRegex":"^v8.8.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/150502","number":150502,"mergeCommit":{"message":"Combine
rule tags with source tags in alert context and AAD for infra rules
(#150502)\n\nPart of
https://github.com/elastic/kibana/issues/150110\r\n\r\nCombines rule
tags with source tags in alert's context for Inventory\r\nthreshold and
Metric threshold rules. The same combination of tags is\r\nalso indexed
to AAD.\r\n\r\n### Manual Testing\r\n1. Create Inventory rule with some
tags\r\n2. Add connector for active and recovered alerts with
`{{context}}` in\r\naction template\r\n3. Ensure that both rule tags and
tags from source are available in\r\n`context.tags` in alert
notifications\r\n4. Ensure that the combination of tags is also indexed
to AAD\r\n\r\nRepeat the same steps for Metric threshold
rule.\r\n\r\n---------\r\n\r\nCo-authored-by: kibanamachine
<42973632+kibanamachine@users.noreply.github.com>","sha":"28ab5c73798cbd3c72e520b80738d6fe22d15a21"}},{"url":"https://github.com/elastic/kibana/pull/153460","number":153460,"branch":"8.7","state":"OPEN"}]}]
BACKPORT-->
This commit is contained in:
Bena Kansara 2023-03-22 19:08:09 +01:00 committed by GitHub
parent 3075dc5bfd
commit edf2ef6373
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 722 additions and 233 deletions

View file

@ -0,0 +1,58 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { flattenObject } from './utils';
describe('FlattenObject', () => {
it('flattens multi level item', () => {
const data = {
key1: {
item1: 'value 1',
item2: { itemA: 'value 2' },
},
key2: {
item3: { itemA: { itemAB: 'value AB' } },
item4: 'value 4',
},
};
const flatten = flattenObject(data);
expect(flatten).toEqual({
'key2.item3.itemA.itemAB': 'value AB',
'key2.item4': 'value 4',
'key1.item1': 'value 1',
'key1.item2.itemA': 'value 2',
});
});
it('does not flatten an array item', () => {
const data = {
key1: {
item1: 'value 1',
item2: { itemA: 'value 2' },
},
key2: {
item3: { itemA: { itemAB: 'value AB' } },
item4: 'value 4',
item5: [1],
item6: { itemA: [1, 2, 3] },
},
key3: ['item7', 'item8'],
};
const flatten = flattenObject(data);
expect(flatten).toEqual({
key3: ['item7', 'item8'],
'key2.item3.itemA.itemAB': 'value AB',
'key2.item4': 'value 4',
'key2.item5': [1],
'key2.item6.itemA': [1, 2, 3],
'key1.item1': 'value 1',
'key1.item2.itemA': 'value 2',
});
});
});

View file

@ -226,18 +226,7 @@ export const shouldTermsAggOnContainer = (groupBy: string | string[] | undefined
export const flattenAdditionalContext = (
additionalContext: AdditionalContext | undefined | null
): AdditionalContext => {
let flattenedContext: AdditionalContext = {};
if (additionalContext) {
Object.keys(additionalContext).forEach((context: string) => {
if (additionalContext[context]) {
flattenedContext = {
...flattenedContext,
...flattenObject(additionalContext[context], [context + '.']),
};
}
});
}
return flattenedContext;
return additionalContext ? flattenObject(additionalContext) : {};
};
export const getContextForRecoveredAlerts = (
@ -261,39 +250,24 @@ export const unflattenObject = <T extends object = AdditionalContext>(object: ob
return acc;
}, {} as T);
/**
* Wrap the key with [] if it is a key from an Array
* @param key The object key
* @param isArrayItem Flag to indicate if it is the key of an Array
*/
const renderKey = (key: string, isArrayItem: boolean): string => (isArrayItem ? `[${key}]` : key);
export const flattenObject = (obj: AdditionalContext, prefix: string = ''): AdditionalContext =>
Object.keys(obj).reduce<AdditionalContext>((acc, key) => {
const nextValue = obj[key];
export const flattenObject = (
obj: AdditionalContext,
prefix: string[] = [],
isArrayItem = false
): AdditionalContext =>
Object.keys(obj).reduce<AdditionalContext>((acc, k) => {
const nextValue = obj[k];
if (typeof nextValue === 'object' && nextValue !== null) {
const isNextValueArray = Array.isArray(nextValue);
const dotSuffix = isNextValueArray ? '' : '.';
if (Object.keys(nextValue).length > 0) {
return {
...acc,
...flattenObject(
nextValue,
[...prefix, `${renderKey(k, isArrayItem)}${dotSuffix}`],
isNextValueArray
),
};
if (nextValue) {
if (typeof nextValue === 'object' && !Array.isArray(nextValue)) {
const dotSuffix = '.';
if (Object.keys(nextValue).length > 0) {
return {
...acc,
...flattenObject(nextValue, `${prefix}${key}${dotSuffix}`),
};
}
}
}
const fullPath = `${prefix.join('')}${renderKey(k, isArrayItem)}`;
acc[fullPath] = nextValue;
const fullPath = `${prefix}${key}`;
acc[fullPath] = nextValue;
}
return acc;
}, {});

View file

@ -0,0 +1,301 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
AlertInstanceContext as AlertContext,
AlertInstanceState as AlertState,
} from '@kbn/alerting-plugin/server';
import {
AlertInstanceMock,
RuleExecutorServicesMock,
alertsMock,
} from '@kbn/alerting-plugin/server/mocks';
import { LifecycleAlertServices } from '@kbn/rule-registry-plugin/server';
import { ruleRegistryMocks } from '@kbn/rule-registry-plugin/server/mocks';
import { createLifecycleRuleExecutorMock } from '@kbn/rule-registry-plugin/server/utils/create_lifecycle_rule_executor_mock';
import {
Aggregators,
Comparator,
InventoryMetricConditions,
} from '../../../../common/alerting/metrics';
import type { LogMeta, Logger } from '@kbn/logging';
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common';
import { createInventoryMetricThresholdExecutor } from './inventory_metric_threshold_executor';
import { ConditionResult } from './evaluate_condition';
import { InfraBackendLibs } from '../../infra_types';
import { infraPluginMock } from '../../../mocks';
jest.mock('./evaluate_condition', () => ({ evaluateCondition: jest.fn() }));
interface AlertTestInstance {
instance: AlertInstanceMock;
actionQueue: any[];
state: any;
}
const persistAlertInstances = false;
const fakeLogger = <Meta extends LogMeta = LogMeta>(msg: string, meta?: Meta) => {};
const logger = {
trace: fakeLogger,
debug: fakeLogger,
info: fakeLogger,
warn: fakeLogger,
error: fakeLogger,
fatal: fakeLogger,
log: () => void 0,
get: () => logger,
} as unknown as Logger;
const mockOptions = {
executionId: '',
startedAt: new Date(),
previousStartedAt: null,
spaceId: '',
rule: {
id: '',
name: '',
tags: [],
consumer: '',
enabled: true,
schedule: {
interval: '1h',
},
actions: [],
createdBy: null,
updatedBy: null,
createdAt: new Date(),
updatedAt: new Date(),
throttle: null,
notifyWhen: null,
producer: '',
ruleTypeId: '',
ruleTypeName: '',
muteAll: false,
},
logger,
flappingSettings: DEFAULT_FLAPPING_SETTINGS,
};
const setEvaluationResults = (response: Record<string, ConditionResult>) => {
jest.requireMock('./evaluate_condition').evaluateCondition.mockImplementation(() => response);
};
const createMockStaticConfiguration = (sources: any) => ({
alerting: {
inventory_threshold: {
group_by_page_size: 100,
},
metric_threshold: {
group_by_page_size: 100,
},
},
inventory: {
compositeSize: 2000,
},
sources,
});
const mockLibs = {
sources: {
getSourceConfiguration: (savedObjectsClient: any, sourceId: string) => {
return Promise.resolve({
id: sourceId,
configuration: {
logIndices: {
type: 'index_pattern',
indexPatternId: 'some-id',
},
},
});
},
},
getStartServices: () => [
null,
infraPluginMock.createSetupContract(),
infraPluginMock.createStartContract(),
],
configuration: createMockStaticConfiguration({}),
metricsRules: {
createLifecycleRuleExecutor: createLifecycleRuleExecutorMock,
},
basePath: {
publicBaseUrl: 'http://localhost:5601',
prepend: (path: string) => path,
},
logger,
} as unknown as InfraBackendLibs;
const alertsServices = alertsMock.createRuleExecutorServices();
const services: RuleExecutorServicesMock &
LifecycleAlertServices<AlertState, AlertContext, string> = {
...alertsServices,
...ruleRegistryMocks.createLifecycleAlertServices(alertsServices),
};
const alertInstances = new Map<string, AlertTestInstance>();
services.alertFactory.create.mockImplementation((instanceID: string) => {
const newAlertInstance: AlertTestInstance = {
instance: alertsMock.createAlertFactory.create(),
actionQueue: [],
state: {},
};
const alertInstance: AlertTestInstance = persistAlertInstances
? alertInstances.get(instanceID) || newAlertInstance
: newAlertInstance;
alertInstances.set(instanceID, alertInstance);
alertInstance.instance.scheduleActions.mockImplementation((id: string, action: any) => {
alertInstance.actionQueue.push({ id, action });
return alertInstance.instance;
});
return alertInstance.instance;
});
function mostRecentAction(id: string) {
const instance = alertInstances.get(id);
if (!instance) return undefined;
return instance.actionQueue.pop();
}
function clearInstances() {
alertInstances.clear();
}
const executor = createInventoryMetricThresholdExecutor(mockLibs);
const baseCriterion = {
aggType: Aggregators.AVERAGE,
metric: 'count',
timeSize: 1,
timeUnit: 'm',
threshold: [0],
comparator: Comparator.GT,
} as InventoryMetricConditions;
describe('The inventory threshold alert type', () => {
describe('querying with Hosts and rule tags', () => {
afterAll(() => clearInstances());
const execute = (comparator: Comparator, threshold: number[], state?: any) =>
executor({
...mockOptions,
services,
params: {
nodeType: 'host',
criteria: [
{
...baseCriterion,
comparator,
threshold,
},
],
},
state: state ?? {},
rule: {
...mockOptions.rule,
tags: ['ruleTag1', 'ruleTag2'],
},
});
const instanceIdA = 'host-01';
const instanceIdB = 'host-02';
test('when tags are present in the source, rule tags and source tags are combined in alert context', async () => {
setEvaluationResults({
'host-01': {
...baseCriterion,
metric: 'count',
timeSize: 1,
timeUnit: 'm',
threshold: [0.75],
comparator: Comparator.GT,
shouldFire: true,
shouldWarn: false,
currentValue: 1.0,
isNoData: false,
isError: false,
context: {
tags: ['host-01_tag1', 'host-01_tag2'],
},
},
'host-02': {
...baseCriterion,
metric: 'count',
timeSize: 1,
timeUnit: 'm',
threshold: [0.75],
comparator: Comparator.GT,
shouldFire: true,
shouldWarn: false,
currentValue: 1.0,
isNoData: false,
isError: false,
context: {
tags: ['host-02_tag1', 'host-02_tag2'],
},
},
});
await execute(Comparator.GT, [0.75]);
expect(mostRecentAction(instanceIdA).action.tags).toStrictEqual([
'host-01_tag1',
'host-01_tag2',
'ruleTag1',
'ruleTag2',
]);
expect(mostRecentAction(instanceIdB).action.tags).toStrictEqual([
'host-02_tag1',
'host-02_tag2',
'ruleTag1',
'ruleTag2',
]);
});
test('when tags are NOT present in the source, rule tags are added in alert context', async () => {
setEvaluationResults({
'host-01': {
...baseCriterion,
metric: 'count',
timeSize: 1,
timeUnit: 'm',
threshold: [0.75],
comparator: Comparator.GT,
shouldFire: true,
shouldWarn: false,
currentValue: 1.0,
isNoData: false,
isError: false,
context: {
cloud: undefined,
},
},
'host-02': {
...baseCriterion,
metric: 'count',
timeSize: 1,
timeUnit: 'm',
threshold: [0.75],
comparator: Comparator.GT,
shouldFire: true,
shouldWarn: false,
currentValue: 1.0,
isNoData: false,
isError: false,
context: {
tags: undefined,
},
},
});
await execute(Comparator.GT, [0.75]);
expect(mostRecentAction(instanceIdA).action.tags).toStrictEqual(['ruleTag1', 'ruleTag2']);
expect(mostRecentAction(instanceIdB).action.tags).toStrictEqual(['ruleTag1', 'ruleTag2']);
});
});
});

View file

@ -76,176 +76,218 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) =
InventoryMetricThresholdAlertState,
InventoryMetricThresholdAlertContext,
InventoryMetricThresholdAllowedActionGroups
>(async ({ services, params, executionId, spaceId, startedAt, rule: { id: ruleId } }) => {
const startTime = Date.now();
const { criteria, filterQuery, sourceId = 'default', nodeType, alertOnNoData } = params;
if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions');
const logger = createScopedLogger(libs.logger, 'inventoryRule', {
alertId: ruleId,
>(
async ({
services,
params,
executionId,
});
spaceId,
startedAt,
rule: { id: ruleId, tags: ruleTags },
}) => {
const startTime = Date.now();
const esClient = services.scopedClusterClient.asCurrentUser;
const { criteria, filterQuery, sourceId = 'default', nodeType, alertOnNoData } = params;
const {
alertWithLifecycle,
savedObjectsClient,
getAlertStartedDate,
getAlertUuid,
getAlertByAlertUuid,
} = services;
const alertFactory: InventoryMetricThresholdAlertFactory = (
id,
reason,
actionGroup,
additionalContext
) =>
alertWithLifecycle({
id,
fields: {
[ALERT_REASON]: reason,
[ALERT_ACTION_GROUP]: actionGroup,
...flattenAdditionalContext(additionalContext),
},
if (criteria.length === 0) throw new Error('Cannot execute an alert with 0 conditions');
const logger = createScopedLogger(libs.logger, 'inventoryRule', {
alertId: ruleId,
executionId,
});
if (!params.filterQuery && params.filterQueryText) {
try {
const { fromKueryExpression } = await import('@kbn/es-query');
fromKueryExpression(params.filterQueryText);
} catch (e) {
logger.error(e.message);
const actionGroupId = FIRED_ACTIONS.id; // Change this to an Error action group when able
const reason = buildInvalidQueryAlertReason(params.filterQueryText);
const alert = alertFactory(UNGROUPED_FACTORY_KEY, reason, actionGroupId);
const indexedStartedDate =
getAlertStartedDate(UNGROUPED_FACTORY_KEY) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(UNGROUPED_FACTORY_KEY);
const esClient = services.scopedClusterClient.asCurrentUser;
alert.scheduleActions(actionGroupId, {
alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid),
alertState: stateToAlertMessage[AlertStates.ERROR],
group: UNGROUPED_FACTORY_KEY,
metric: mapToConditionsLookup(criteria, (c) => c.metric),
reason,
timestamp: startedAt.toISOString(),
value: null,
viewInAppUrl: getViewInInventoryAppUrl({
basePath: libs.basePath,
criteria,
nodeType,
timestamp: indexedStartedDate,
spaceId,
}),
const {
alertWithLifecycle,
savedObjectsClient,
getAlertStartedDate,
getAlertUuid,
getAlertByAlertUuid,
} = services;
const alertFactory: InventoryMetricThresholdAlertFactory = (
id,
reason,
actionGroup,
additionalContext
) =>
alertWithLifecycle({
id,
fields: {
[ALERT_REASON]: reason,
[ALERT_ACTION_GROUP]: actionGroup,
...flattenAdditionalContext(additionalContext),
},
});
return { state: {} };
}
}
const source = await libs.sources.getSourceConfiguration(savedObjectsClient, sourceId);
if (!params.filterQuery && params.filterQueryText) {
try {
const { fromKueryExpression } = await import('@kbn/es-query');
fromKueryExpression(params.filterQueryText);
} catch (e) {
logger.error(e.message);
const actionGroupId = FIRED_ACTIONS.id; // Change this to an Error action group when able
const reason = buildInvalidQueryAlertReason(params.filterQueryText);
const alert = alertFactory(UNGROUPED_FACTORY_KEY, reason, actionGroupId);
const indexedStartedDate =
getAlertStartedDate(UNGROUPED_FACTORY_KEY) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(UNGROUPED_FACTORY_KEY);
const [, , { logViews }] = await libs.getStartServices();
const logQueryFields: LogQueryFields | undefined = await logViews
.getClient(savedObjectsClient, esClient)
.getResolvedLogView(sourceId)
.then(
({ indices }) => ({ indexPattern: indices }),
() => undefined
alert.scheduleActions(actionGroupId, {
alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid),
alertState: stateToAlertMessage[AlertStates.ERROR],
group: UNGROUPED_FACTORY_KEY,
metric: mapToConditionsLookup(criteria, (c) => c.metric),
reason,
timestamp: startedAt.toISOString(),
value: null,
viewInAppUrl: getViewInInventoryAppUrl({
basePath: libs.basePath,
criteria,
nodeType,
timestamp: indexedStartedDate,
spaceId,
}),
});
return { state: {} };
}
}
const source = await libs.sources.getSourceConfiguration(savedObjectsClient, sourceId);
const [, , { logViews }] = await libs.getStartServices();
const logQueryFields: LogQueryFields | undefined = await logViews
.getClient(savedObjectsClient, esClient)
.getResolvedLogView(sourceId)
.then(
({ indices }) => ({ indexPattern: indices }),
() => undefined
);
const compositeSize = libs.configuration.alerting.inventory_threshold.group_by_page_size;
const results = await Promise.all(
criteria.map((condition) =>
evaluateCondition({
compositeSize,
condition,
esClient,
executionTimestamp: startedAt,
filterQuery,
logger,
logQueryFields,
nodeType,
source,
})
)
);
const compositeSize = libs.configuration.alerting.inventory_threshold.group_by_page_size;
const results = await Promise.all(
criteria.map((condition) =>
evaluateCondition({
compositeSize,
condition,
esClient,
executionTimestamp: startedAt,
filterQuery,
logger,
logQueryFields,
nodeType,
source,
})
)
);
let scheduledActionsCount = 0;
const inventoryItems = Object.keys(first(results)!);
for (const group of inventoryItems) {
// AND logic; all criteria must be across the threshold
const shouldAlertFire = results.every((result) => result[group]?.shouldFire);
const shouldAlertWarn = results.every((result) => result[group]?.shouldWarn);
// AND logic; because we need to evaluate all criteria, if one of them reports no data then the
// whole alert is in a No Data/Error state
const isNoData = results.some((result) => result[group]?.isNoData);
const isError = results.some((result) => result[group]?.isError);
let scheduledActionsCount = 0;
const inventoryItems = Object.keys(first(results)!);
for (const group of inventoryItems) {
// AND logic; all criteria must be across the threshold
const shouldAlertFire = results.every((result) => result[group]?.shouldFire);
const shouldAlertWarn = results.every((result) => result[group]?.shouldWarn);
// AND logic; because we need to evaluate all criteria, if one of them reports no data then the
// whole alert is in a No Data/Error state
const isNoData = results.some((result) => result[group]?.isNoData);
const isError = results.some((result) => result[group]?.isError);
const nextState = isError
? AlertStates.ERROR
: isNoData
? AlertStates.NO_DATA
: shouldAlertFire
? AlertStates.ALERT
: shouldAlertWarn
? AlertStates.WARNING
: AlertStates.OK;
let reason;
if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) {
reason = results
.map((result) =>
buildReasonWithVerboseMetricName(
group,
result[group],
buildFiredAlertReason,
nextState === AlertStates.WARNING
)
)
.join('\n');
}
if (alertOnNoData) {
if (nextState === AlertStates.NO_DATA) {
const nextState = isError
? AlertStates.ERROR
: isNoData
? AlertStates.NO_DATA
: shouldAlertFire
? AlertStates.ALERT
: shouldAlertWarn
? AlertStates.WARNING
: AlertStates.OK;
let reason;
if (nextState === AlertStates.ALERT || nextState === AlertStates.WARNING) {
reason = results
.filter((result) => result[group].isNoData)
.map((result) =>
buildReasonWithVerboseMetricName(group, result[group], buildNoDataAlertReason)
)
.join('\n');
} else if (nextState === AlertStates.ERROR) {
reason = results
.filter((result) => result[group].isError)
.map((result) =>
buildReasonWithVerboseMetricName(group, result[group], buildErrorAlertReason)
buildReasonWithVerboseMetricName(
group,
result[group],
buildFiredAlertReason,
nextState === AlertStates.WARNING
)
)
.join('\n');
}
if (alertOnNoData) {
if (nextState === AlertStates.NO_DATA) {
reason = results
.filter((result) => result[group].isNoData)
.map((result) =>
buildReasonWithVerboseMetricName(group, result[group], buildNoDataAlertReason)
)
.join('\n');
} else if (nextState === AlertStates.ERROR) {
reason = results
.filter((result) => result[group].isError)
.map((result) =>
buildReasonWithVerboseMetricName(group, result[group], buildErrorAlertReason)
)
.join('\n');
}
}
if (reason) {
const actionGroupId =
nextState === AlertStates.WARNING ? WARNING_ACTIONS_ID : FIRED_ACTIONS_ID;
const additionalContext = results && results.length > 0 ? results[0][group].context : {};
additionalContext.tags = Array.from(
new Set([...(additionalContext.tags ?? []), ...ruleTags])
);
const alert = alertFactory(group, reason, actionGroupId, additionalContext);
const indexedStartedDate = getAlertStartedDate(group) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(group);
scheduledActionsCount++;
const context = {
alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid),
alertState: stateToAlertMessage[nextState],
group,
reason,
metric: mapToConditionsLookup(criteria, (c) => c.metric),
timestamp: startedAt.toISOString(),
threshold: mapToConditionsLookup(criteria, (c) => c.threshold),
value: mapToConditionsLookup(results, (result) =>
formatMetric(result[group].metric, result[group].currentValue)
),
viewInAppUrl: getViewInInventoryAppUrl({
basePath: libs.basePath,
criteria,
nodeType,
timestamp: indexedStartedDate,
spaceId,
}),
...additionalContext,
};
alert.scheduleActions(actionGroupId, context);
}
}
if (reason) {
const actionGroupId =
nextState === AlertStates.WARNING ? WARNING_ACTIONS_ID : FIRED_ACTIONS_ID;
const additionalContext = results && results.length > 0 ? results[0][group].context : null;
const { getRecoveredAlerts } = services.alertFactory.done();
const recoveredAlerts = getRecoveredAlerts();
const alert = alertFactory(group, reason, actionGroupId, additionalContext);
const indexedStartedDate = getAlertStartedDate(group) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(group);
for (const alert of recoveredAlerts) {
const recoveredAlertId = alert.getId();
const indexedStartedDate = getAlertStartedDate(recoveredAlertId) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(recoveredAlertId);
const alertHits = alertUuid ? await getAlertByAlertUuid(alertUuid) : undefined;
const additionalContext = getContextForRecoveredAlerts(alertHits);
const originalActionGroup = getOriginalActionGroup(alertHits);
scheduledActionsCount++;
const context = {
alert.setContext({
alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid),
alertState: stateToAlertMessage[nextState],
group,
reason,
alertState: stateToAlertMessage[AlertStates.OK],
group: recoveredAlertId,
metric: mapToConditionsLookup(criteria, (c) => c.metric),
timestamp: startedAt.toISOString(),
threshold: mapToConditionsLookup(criteria, (c) => c.threshold),
value: mapToConditionsLookup(results, (result) =>
formatMetric(result[group].metric, result[group].currentValue)
),
timestamp: startedAt.toISOString(),
viewInAppUrl: getViewInInventoryAppUrl({
basePath: libs.basePath,
criteria,
@ -253,49 +295,19 @@ export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) =
timestamp: indexedStartedDate,
spaceId,
}),
originalAlertState: translateActionGroupToAlertState(originalActionGroup),
originalAlertStateWasALERT: originalActionGroup === FIRED_ACTIONS_ID,
originalAlertStateWasWARNING: originalActionGroup === WARNING_ACTIONS_ID,
...additionalContext,
};
alert.scheduleActions(actionGroupId, context);
});
}
const stopTime = Date.now();
logger.debug(`Scheduled ${scheduledActionsCount} actions in ${stopTime - startTime}ms`);
return { state: {} };
}
const { getRecoveredAlerts } = services.alertFactory.done();
const recoveredAlerts = getRecoveredAlerts();
for (const alert of recoveredAlerts) {
const recoveredAlertId = alert.getId();
const indexedStartedDate = getAlertStartedDate(recoveredAlertId) ?? startedAt.toISOString();
const alertUuid = getAlertUuid(recoveredAlertId);
const alertHits = alertUuid ? await getAlertByAlertUuid(alertUuid) : undefined;
const additionalContext = getContextForRecoveredAlerts(alertHits);
const originalActionGroup = getOriginalActionGroup(alertHits);
alert.setContext({
alertDetailsUrl: getAlertDetailsUrl(libs.basePath, spaceId, alertUuid),
alertState: stateToAlertMessage[AlertStates.OK],
group: recoveredAlertId,
metric: mapToConditionsLookup(criteria, (c) => c.metric),
threshold: mapToConditionsLookup(criteria, (c) => c.threshold),
timestamp: startedAt.toISOString(),
viewInAppUrl: getViewInInventoryAppUrl({
basePath: libs.basePath,
criteria,
nodeType,
timestamp: indexedStartedDate,
spaceId,
}),
originalAlertState: translateActionGroupToAlertState(originalActionGroup),
originalAlertStateWasALERT: originalActionGroup === FIRED_ACTIONS_ID,
originalAlertStateWasWARNING: originalActionGroup === WARNING_ACTIONS_ID,
...additionalContext,
});
}
const stopTime = Date.now();
logger.debug(`Scheduled ${scheduledActionsCount} actions in ${stopTime - startTime}ms`);
return { state: {} };
});
);
const formatThreshold = (metric: SnapshotMetricType, value: number | number[]) => {
const metricFormatter = get(METRIC_FORMATTERS, metric, METRIC_FORMATTERS.count);

View file

@ -690,6 +690,143 @@ describe('The metric threshold alert type', () => {
});
});
describe('querying with a groupBy parameter host.name and rule tags', () => {
afterAll(() => clearInstances());
const execute = (
comparator: Comparator,
threshold: number[],
groupBy: string[] = ['host.name'],
metric?: string,
state?: any
) =>
executor({
...mockOptions,
services,
params: {
groupBy,
criteria: [
{
...baseNonCountCriterion,
comparator,
threshold,
metric: metric ?? baseNonCountCriterion.metric,
},
],
},
state: state ?? mockOptions.state.wrapped,
rule: {
...mockOptions.rule,
tags: ['ruleTag1', 'ruleTag2'],
},
});
const instanceIdA = 'host-01';
const instanceIdB = 'host-02';
test('rule tags and source tags are combined in alert context', async () => {
setEvaluationResults([
{
'host-01': {
...baseNonCountCriterion,
comparator: Comparator.GT,
threshold: [0.75],
metric: 'test.metric.1',
currentValue: 1.0,
timestamp: new Date().toISOString(),
shouldFire: true,
shouldWarn: false,
isNoData: false,
bucketKey: { groupBy0: 'host-01' },
context: {
tags: ['host-01_tag1', 'host-01_tag2'],
},
},
'host-02': {
...baseNonCountCriterion,
comparator: Comparator.GT,
threshold: [0.75],
metric: 'test.metric.1',
currentValue: 3,
timestamp: new Date().toISOString(),
shouldFire: true,
shouldWarn: false,
isNoData: false,
bucketKey: { groupBy0: 'host-02' },
context: {
tags: ['host-02_tag1', 'host-02_tag2'],
},
},
},
]);
await execute(Comparator.GT, [0.75]);
expect(mostRecentAction(instanceIdA).action.tags).toStrictEqual([
'host-01_tag1',
'host-01_tag2',
'ruleTag1',
'ruleTag2',
]);
expect(mostRecentAction(instanceIdB).action.tags).toStrictEqual([
'host-02_tag1',
'host-02_tag2',
'ruleTag1',
'ruleTag2',
]);
});
});
describe('querying without a groupBy parameter and rule tags', () => {
afterAll(() => clearInstances());
const execute = (
comparator: Comparator,
threshold: number[],
groupBy: string = '',
metric?: string,
state?: any
) =>
executor({
...mockOptions,
services,
params: {
groupBy,
criteria: [
{
...baseNonCountCriterion,
comparator,
threshold,
metric: metric ?? baseNonCountCriterion.metric,
},
],
},
state: state ?? mockOptions.state.wrapped,
rule: {
...mockOptions.rule,
tags: ['ruleTag1', 'ruleTag2'],
},
});
test('rule tags are added in alert context', async () => {
setEvaluationResults([
{
'*': {
...baseNonCountCriterion,
comparator: Comparator.GT,
threshold: [0.75],
metric: 'test.metric.1',
currentValue: 1.0,
timestamp: new Date().toISOString(),
shouldFire: true,
shouldWarn: false,
isNoData: false,
bucketKey: { groupBy0: '*' },
},
},
]);
const instanceID = '*';
await execute(Comparator.GT, [0.75]);
expect(mostRecentAction(instanceID).action.tags).toStrictEqual(['ruleTag1', 'ruleTag2']);
});
});
describe('querying with multiple criteria', () => {
afterAll(() => clearInstances());
const execute = (

View file

@ -287,9 +287,13 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs) =>
const additionalContext = hasAdditionalContext(params.groupBy, validGroupByForContext)
? alertResults && alertResults.length > 0
? alertResults[0][group].context
: null
: null;
? alertResults[0][group].context ?? {}
: {}
: {};
additionalContext.tags = Array.from(
new Set([...(additionalContext.tags ?? []), ...options.rule.tags])
);
const alert = alertFactory(`${group}`, reason, actionGroupId, additionalContext);
const alertUuid = getAlertUuid(group);

View file

@ -5,11 +5,12 @@
* 2.0.
*/
import { createResolvedLogViewMock } from '../../../common/log_views/resolved_log_view.mock';
import { ILogViewsClient } from './types';
export const createLogViewsClientMock = (): jest.Mocked<ILogViewsClient> => ({
getLogView: jest.fn(),
getResolvedLogView: jest.fn(),
getResolvedLogView: jest.fn((logViewId: string) => Promise.resolve(createResolvedLogViewMock())),
putLogView: jest.fn(),
resolveLogView: jest.fn(),
});

View file

@ -307,7 +307,9 @@ export const createLifecycleExecutor =
[ALERT_WORKFLOW_STATUS]: alertData?.fields[ALERT_WORKFLOW_STATUS] ?? 'open',
[EVENT_KIND]: 'signal',
[EVENT_ACTION]: isNew ? 'open' : isActive ? 'active' : 'close',
[TAGS]: options.rule.tags,
[TAGS]: Array.from(
new Set([...(currentAlertData?.tags ?? []), ...(options.rule.tags ?? [])])
),
[VERSION]: ruleDataClient.kibanaVersion,
[ALERT_FLAPPING]: flapping,
...(isRecovered ? { [ALERT_END]: commonRuleFields[TIMESTAMP] } : {}),