mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[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:
parent
3075dc5bfd
commit
edf2ef6373
8 changed files with 722 additions and 233 deletions
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}, {});
|
||||
|
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
@ -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] } : {}),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue