[BE] [Conditional actions] Add query and timeframe params to RuleAction to filter alerts. (#152360)

Resolves: #152357

With **Conditional Actions** we want to add some params to an action to
filter the alerts it gets.
~~As we are already using the persisting alerts (aka alert-as-data) for
summary actions, decided to use this filter with those actions first,
the same feature will be applied to the non-summary actions with a
follow on issue.~~
As this PR has already been waiting and the development for for-each
type alerts is done, decided to add also that change in this PR. So, any
rule that has `getSummarizedAlerts` can have an action with
alertsFilter.

This PR adds the new `alertsFilter` object to RuleAction and allows the
rule types that pass it to filter alerts for summary actions.

The filter has two groups of params: `query` and `timeframe`

`query` has two fields, `kql` and `dsl`, kql is to be entered by the
user and `dsl` is to be generated and persisted out of `kql` in BE .
We send `dsl` to ES to filter alerts.

`timeframe` also has two fields: `days` and `hours`.
`days` is an array that represents the days of the week by their index
numbers. (e.g. [1,2,3] means MON,TUE,WED)

`hours` is a time range. e.g. `{start:'08:00', end:'17:00'}` means
between 08:00 and 17:00.
As we save Datetime fileds as UTC time, we assume that this time range
is in UTC format. (So we need to tell the users that the hours they
enter are UTC times)

`{timeframe: { days: [1,2,3,4,5], hours: {start:'08:00', end:'17:00'}}`
=> Alert has to be started on : **Every weekday, at between 08:00 and
17:00**

The filter's schema is below:
```
{
  query: null | {
    kql: string;
    dsl?: string;
  };
  timeframe: null | {
    days: Array< 1 | 2 | 3 | 4 | 5 | 6 | 7>;
    hours: {
      start: string;
      end: string;
    };
    timezone: string;
  };
}
```

## To verify:

Add below code to the rule create/update call's payload in UI and create
an alert generating rule with summary action.
Then expect the alerts to be filtered out from the action's output.

Modify
2a1740d035/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/create.ts (L23)

To be:
```
const rewriteBodyRequest: RewriteResponseCase<RuleCreateBody> = ({
  ruleTypeId,
  actions,
  ...res
}): any => ({
  ...res,
  rule_type_id: ruleTypeId,
  actions: actions.map(({ group, id, params, frequency, alertsFilter }) => ({
    group,
    id,
    params,
    frequency: {
      notify_when: frequency!.notifyWhen,
      throttle: frequency!.throttle,
      summary: frequency!.summary,
    },
    alerts_filter: {
      query: {
        kql: 'kibana.alert.rule.name:<your rule name here>',
      },
      timeframe: {
        days: [1, 2, 4, 5],
        hours: { start: '08:00', end: '17:00' },
        timezone: 'UTC',
      },
    },
  })),
});
```

do the same for:
2a1740d035/x-pack/plugins/triggers_actions_ui/public/application/lib/rule_api/update.ts (L18)

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ersin Erdal 2023-03-30 14:36:46 +02:00 committed by GitHub
parent 99d7ad1fe0
commit 25d578d0ad
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
46 changed files with 2351 additions and 211 deletions

View file

@ -77,6 +77,23 @@ export interface RuleExecutionStatus {
export type RuleActionParams = SavedObjectAttributes;
export type RuleActionParam = SavedObjectAttribute;
export interface AlertsFilterTimeframe extends SavedObjectAttributes {
days: Array<1 | 2 | 3 | 4 | 5 | 6 | 7>;
timezone: string;
hours: {
start: string;
end: string;
};
}
export interface AlertsFilter extends SavedObjectAttributes {
query: null | {
kql: string;
dsl?: string; // This fields is generated in the code by using "kql", therefore it's not optional but defined as optional to avoid modifying a lot of files in different plugins
};
timeframe: null | AlertsFilterTimeframe;
}
export interface RuleAction {
uuid?: string;
group: string;
@ -88,6 +105,7 @@ export interface RuleAction {
notifyWhen: RuleNotifyWhenType;
throttle: string | null;
};
alertsFilter?: AlertsFilter;
}
export interface AggregateOptions {
@ -166,7 +184,22 @@ export interface Rule<Params extends RuleTypeParams = never> {
viewInAppRelativeUrl?: string;
}
export type SanitizedRule<Params extends RuleTypeParams = never> = Omit<Rule<Params>, 'apiKey'>;
export interface SanitizedAlertsFilter extends AlertsFilter {
query: null | {
kql: string;
};
timeframe: null | AlertsFilterTimeframe;
}
export type SanitizedRuleAction = Omit<RuleAction, 'alertsFilter'> & {
alertsFilter?: SanitizedAlertsFilter;
};
export type SanitizedRule<Params extends RuleTypeParams = never> = Omit<
Rule<Params>,
'apiKey' | 'actions'
> & { actions: SanitizedRuleAction[] };
export type ResolvedSanitizedRule<Params extends RuleTypeParams = never> = SanitizedRule<Params> &
Omit<SavedObjectsResolveResponse, 'saved_object'>;

View file

@ -621,3 +621,31 @@ describe('resetPendingRecoveredCount', () => {
expect(alert.getPendingRecoveredCount()).toEqual(0);
});
});
describe('isFilteredOut', () => {
const summarizedAlerts = {
all: { count: 1, data: [{ kibana: { alert: { instance: { id: 1 } } } }] },
new: { count: 0, data: [] },
ongoing: { count: 0, data: [] },
recovered: { count: 0, data: [] },
};
test('returns false if summarizedAlerts is null', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1', {
meta: { pendingRecoveredCount: 3 },
});
expect(alert.isFilteredOut(null)).toBe(false);
});
test('returns false if the alert is in summarizedAlerts', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1', {
meta: { pendingRecoveredCount: 3 },
});
expect(alert.isFilteredOut(null)).toBe(false);
});
test('returns true if the alert is not in summarizedAlerts', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('2', {
meta: { pendingRecoveredCount: 3 },
});
expect(alert.isFilteredOut(summarizedAlerts)).toBe(true);
});
});

View file

@ -5,7 +5,9 @@
* 2.0.
*/
import { isEmpty } from 'lodash';
import { get, isEmpty } from 'lodash';
import { ALERT_INSTANCE_ID } from '@kbn/rule-data-utils';
import { CombinedSummarizedAlerts } from '../types';
import {
AlertInstanceMeta,
AlertInstanceState,
@ -255,4 +257,14 @@ export class Alert<
resetPendingRecoveredCount() {
this.meta.pendingRecoveredCount = 0;
}
isFilteredOut(summarizedAlerts: CombinedSummarizedAlerts | null) {
if (summarizedAlerts === null) {
return false;
}
return !summarizedAlerts.all.data.some(
(alert) => get(alert, ALERT_INSTANCE_ID) === this.getId()
);
}
}

View file

@ -52,6 +52,17 @@ describe('createRuleRoute', () => {
foo: true,
},
uuid: '123-456',
alertsFilter: {
query: {
kql: 'name:test',
dsl: '{"must": {"term": { "name": "test" }}}',
},
timeframe: {
days: [1],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
},
],
enabled: true,
@ -80,6 +91,10 @@ describe('createRuleRoute', () => {
group: mockedAlert.actions[0].group,
id: mockedAlert.actions[0].id,
params: mockedAlert.actions[0].params,
alerts_filter: {
query: { kql: mockedAlert.actions[0].alertsFilter!.query!.kql },
timeframe: mockedAlert.actions[0].alertsFilter?.timeframe!,
},
},
],
};
@ -102,6 +117,10 @@ describe('createRuleRoute', () => {
actions: [
{
...ruleToCreate.actions[0],
alerts_filter: {
query: mockedAlert.actions[0].alertsFilter?.query!,
timeframe: mockedAlert.actions[0].alertsFilter!.timeframe!,
},
connector_type_id: 'test',
uuid: '123-456',
},
@ -146,6 +165,21 @@ describe('createRuleRoute', () => {
"data": Object {
"actions": Array [
Object {
"alertsFilter": Object {
"query": Object {
"kql": "name:test",
},
"timeframe": Object {
"days": Array [
1,
],
"hours": Object {
"end": "17:00",
"start": "08:00",
},
"timezone": "UTC",
},
},
"group": "default",
"id": "2",
"params": Object {
@ -227,6 +261,21 @@ describe('createRuleRoute', () => {
"data": Object {
"actions": Array [
Object {
"alertsFilter": Object {
"query": Object {
"kql": "name:test",
},
"timeframe": Object {
"days": Array [
1,
],
"hours": Object {
"end": "17:00",
"start": "08:00",
},
"timezone": "UTC",
},
},
"group": "default",
"id": "2",
"params": Object {
@ -309,6 +358,21 @@ describe('createRuleRoute', () => {
"data": Object {
"actions": Array [
Object {
"alertsFilter": Object {
"query": Object {
"kql": "name:test",
},
"timeframe": Object {
"days": Array [
1,
],
"hours": Object {
"end": "17:00",
"start": "08:00",
},
"timezone": "UTC",
},
},
"group": "default",
"id": "2",
"params": Object {
@ -391,6 +455,21 @@ describe('createRuleRoute', () => {
"data": Object {
"actions": Array [
Object {
"alertsFilter": Object {
"query": Object {
"kql": "name:test",
},
"timeframe": Object {
"days": Array [
1,
],
"hours": Object {
"end": "17:00",
"start": "08:00",
},
"timezone": "UTC",
},
},
"group": "default",
"id": "2",
"params": Object {

View file

@ -24,7 +24,6 @@ import {
validateNotifyWhenType,
RuleTypeParams,
BASE_ALERTING_API_PATH,
RuleNotifyWhenType,
} from '../types';
import { RouteOptions } from '.';
@ -40,7 +39,18 @@ export const bodySchema = schema.object({
interval: schema.string({ validate: validateDurationSchema }),
}),
actions: actionsSchema,
notify_when: schema.maybe(schema.nullable(schema.string({ validate: validateNotifyWhenType }))),
notify_when: schema.maybe(
schema.nullable(
schema.oneOf(
[
schema.literal('onActionGroupChange'),
schema.literal('onActiveAlert'),
schema.literal('onThrottleInterval'),
],
{ validate: validateNotifyWhenType }
)
)
),
});
const rewriteBodyReq: RewriteRequestCase<CreateOptions<RuleTypeParams>['data']> = ({
@ -48,7 +58,7 @@ const rewriteBodyReq: RewriteRequestCase<CreateOptions<RuleTypeParams>['data']>
notify_when: notifyWhen,
actions,
...rest
}) => ({
}): CreateOptions<RuleTypeParams>['data'] => ({
...rest,
alertTypeId,
notifyWhen,
@ -124,10 +134,7 @@ export const createRuleRoute = ({ router, licenseState, usageCounter }: RouteOpt
try {
const createdRule: SanitizedRule<RuleTypeParams> =
await rulesClient.create<RuleTypeParams>({
data: rewriteBodyReq({
...rule,
notify_when: rule.notify_when as RuleNotifyWhenType,
}),
data: rewriteBodyReq(rule),
options: { id: params?.id },
});
return res.ok({

View file

@ -45,6 +45,17 @@ describe('getRuleRoute', () => {
foo: true,
},
uuid: '123-456',
alertsFilter: {
query: {
kql: 'name:test',
dsl: '{"must": {"term": { "name": "test" }}}',
},
timeframe: {
days: [1],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
},
],
consumer: 'bar',
@ -89,6 +100,7 @@ describe('getRuleRoute', () => {
params: mockedAlert.actions[0].params,
connector_type_id: mockedAlert.actions[0].actionTypeId,
uuid: mockedAlert.actions[0].uuid,
alerts_filter: mockedAlert.actions[0].alertsFilter,
},
],
};

View file

@ -60,7 +60,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
last_execution_date: executionStatus.lastExecutionDate,
last_duration: executionStatus.lastDuration,
},
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid }) => ({
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({
group,
id,
params,
@ -73,6 +73,7 @@ const rewriteBodyRes: RewriteResponseCase<SanitizedRule<RuleTypeParams>> = ({
}
: undefined,
...(uuid && { uuid }),
...(alertsFilter && { alerts_filter: alertsFilter }),
})),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),

View file

@ -6,7 +6,9 @@
*/
import { schema } from '@kbn/config-schema';
import { validateTimezone } from './validate_timezone';
import { validateDurationSchema } from '../../lib';
import { validateHours } from './validate_hours';
export const actionsSchema = schema.arrayOf(
schema.object({
@ -25,6 +27,40 @@ export const actionsSchema = schema.arrayOf(
})
),
uuid: schema.maybe(schema.string()),
alerts_filter: schema.maybe(
schema.object({
query: schema.nullable(
schema.object({
kql: schema.string(),
dsl: schema.maybe(schema.string()),
})
),
timeframe: schema.nullable(
schema.object({
days: schema.arrayOf(
schema.oneOf([
schema.literal(1),
schema.literal(2),
schema.literal(3),
schema.literal(4),
schema.literal(5),
schema.literal(6),
schema.literal(7),
])
),
hours: schema.object({
start: schema.string({
validate: validateHours,
}),
end: schema.string({
validate: validateHours,
}),
}),
timezone: schema.string({ validate: validateTimezone }),
})
),
})
),
}),
{ defaultValue: [] }
);

View file

@ -0,0 +1,120 @@
/*
* 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 { rewriteActionsReq, rewriteActionsRes } from './rewrite_actions';
describe('rewrite Actions', () => {
describe('rewriteActionsRes', () => {
it('rewrites the actions response correctly', () => {
expect(
rewriteActionsRes([
{
uuid: '111',
group: 'default',
id: '1',
actionTypeId: '2',
params: { foo: 'bar' },
frequency: {
summary: true,
notifyWhen: 'onThrottleInterval',
throttle: '1h',
},
alertsFilter: {
query: {
kql: 'test:1s',
dsl: '{test:1}',
},
timeframe: {
days: [1, 2, 3],
timezone: 'UTC',
hours: {
start: '00:00',
end: '15:00',
},
},
},
},
])
).toEqual([
{
alerts_filter: {
query: { dsl: '{test:1}', kql: 'test:1s' },
timeframe: {
days: [1, 2, 3],
hours: { end: '15:00', start: '00:00' },
timezone: 'UTC',
},
},
connector_type_id: '2',
frequency: { notify_when: 'onThrottleInterval', summary: true, throttle: '1h' },
group: 'default',
id: '1',
params: { foo: 'bar' },
uuid: '111',
},
]);
});
});
describe('rewriteActionsReq', () => {
expect(
rewriteActionsReq([
{
uuid: '111',
group: 'default',
id: '1',
params: { foo: 'bar' },
frequency: {
summary: true,
notify_when: 'onThrottleInterval',
throttle: '1h',
},
alerts_filter: {
query: {
kql: 'test:1s',
dsl: '{test:1}',
},
timeframe: {
days: [1, 2, 3],
timezone: 'UTC',
hours: {
start: '00:00',
end: '15:00',
},
},
},
},
])
).toEqual([
{
uuid: '111',
group: 'default',
id: '1',
params: { foo: 'bar' },
frequency: {
summary: true,
notifyWhen: 'onThrottleInterval',
throttle: '1h',
},
alertsFilter: {
query: {
kql: 'test:1s',
dsl: '{test:1}',
},
timeframe: {
days: [1, 2, 3],
timezone: 'UTC',
hours: {
start: '00:00',
end: '15:00',
},
},
},
},
]);
});
});

View file

@ -4,31 +4,31 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CamelToSnake, RewriteRequestCase } from './rewrite_request_case';
import { TypeOf } from '@kbn/config-schema/src/types/object_type';
import { omit } from 'lodash';
import { NormalizedAlertAction } from '../../rules_client';
import { RuleAction } from '../../types';
import { actionsSchema } from './actions_schema';
type ReqRuleAction = Omit<RuleAction, 'actionTypeId' | 'frequency'> & {
frequency?: {
[K in keyof NonNullable<RuleAction['frequency']> as CamelToSnake<K>]: NonNullable<
RuleAction['frequency']
>[K];
};
};
export const rewriteActionsReq: (
actions?: ReqRuleAction[]
) => Array<Omit<RuleAction, 'actionTypeId'>> = (actions) => {
const rewriteFrequency: RewriteRequestCase<NonNullable<RuleAction['frequency']>> = ({
notify_when: notifyWhen,
...rest
}) => ({ ...rest, notifyWhen });
export const rewriteActionsReq = (
actions?: TypeOf<typeof actionsSchema>
): NormalizedAlertAction[] => {
if (!actions) return [];
return actions.map(
(action) =>
({
...action,
...(action.frequency ? { frequency: rewriteFrequency(action.frequency) } : {}),
} as RuleAction)
);
return actions.map(({ frequency, alerts_filter: alertsFilter, ...action }) => {
return {
...action,
...(frequency
? {
frequency: {
...omit(frequency, 'notify_when'),
notifyWhen: frequency.notify_when,
},
}
: {}),
...(alertsFilter ? { alertsFilter } : {}),
};
});
};
export const rewriteActionsRes = (actions?: RuleAction[]) => {
@ -37,9 +37,14 @@ export const rewriteActionsRes = (actions?: RuleAction[]) => {
notify_when: notifyWhen,
});
if (!actions) return [];
return actions.map(({ actionTypeId, frequency, ...action }) => ({
return actions.map(({ actionTypeId, frequency, alertsFilter, ...action }) => ({
...action,
connector_type_id: actionTypeId,
...(frequency ? { frequency: rewriteFrequency(frequency) } : {}),
...(alertsFilter
? {
alerts_filter: alertsFilter,
}
: {}),
}));
};

View file

@ -43,6 +43,7 @@ const sampleRule: SanitizedRule<RuleTypeParams> & { activeSnoozes?: string[] } =
notifyWhen: 'onThrottleInterval',
throttle: '1m',
},
alertsFilter: { timeframe: null, query: { kql: 'test:1', dsl: '{}' } },
},
],
scheduledTaskId: 'xyz456',

View file

@ -57,7 +57,7 @@ export const rewriteRule = ({
last_execution_date: executionStatus.lastExecutionDate,
last_duration: executionStatus.lastDuration,
},
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid }) => ({
actions: actions.map(({ group, id, actionTypeId, params, frequency, uuid, alertsFilter }) => ({
group,
id,
params,
@ -72,6 +72,7 @@ export const rewriteRule = ({
}
: {}),
...(uuid && { uuid }),
...(alertsFilter && { alerts_filter: alertsFilter }),
})),
...(lastRun ? { last_run: rewriteRuleLastRun(lastRun) } : {}),
...(nextRun ? { next_run: nextRun } : {}),

View file

@ -0,0 +1,30 @@
/*
* 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 { validateHours } from './validate_hours';
describe('validateHours', () => {
it('should be void for valid HH:mm formatted times', () => {
expect(validateHours('12:15')).toBe(void 0);
});
it('should return error message if hour is not valid', () => {
expect(validateHours('30:15')).toBe('string is not a valid time in HH:mm format 30:15');
});
it('should return error message if minute is not valid', () => {
expect(validateHours('12:90')).toBe('string is not a valid time in HH:mm format 12:90');
});
it('should return error message if the string is not a time format', () => {
expect(validateHours('foo')).toBe('string is not a valid time in HH:mm format foo');
});
it('should return error message if the string has seconds as well', () => {
expect(validateHours('12:15:12')).toBe('string is not a valid time in HH:mm format 12:15:12');
});
});

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
export function validateHours(time: string) {
if (/^([0-9]|0[0-9]|1[0-9]|2[0-3]):[0-5][0-9]$/.test(time)) {
return;
}
return 'string is not a valid time in HH:mm format ' + time;
}

View file

@ -0,0 +1,22 @@
/*
* 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 { validateTimezone } from './validate_timezone';
describe('validateTimeZone', () => {
it('returns void for a valid timezone', () => {
expect(validateTimezone('Europe/Berlin')).toBe(void 0);
});
it('returns void for UTC timezone', () => {
expect(validateTimezone('UTC')).toBe(void 0);
});
it('returns an error message for an invalid timezone', () => {
expect(validateTimezone('foo')).toBe('string is not a valid timezone: foo');
});
});

View file

@ -0,0 +1,15 @@
/*
* 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 moment from 'moment';
import 'moment-timezone';
export function validateTimezone(timezone: string) {
if (moment.tz.names().includes(timezone)) {
return;
}
return 'string is not a valid timezone: ' + timezone;
}

View file

@ -49,6 +49,13 @@ describe('updateRuleRoute', () => {
params: {
baz: true,
},
alertsFilter: {
query: {
kql: 'name:test',
dsl: '{"must": {"term": { "name": "test" }}}',
},
timeframe: null,
},
},
],
notifyWhen: RuleNotifyWhen.CHANGE,
@ -63,6 +70,7 @@ describe('updateRuleRoute', () => {
group: mockedAlert.actions[0].group,
id: mockedAlert.actions[0].id,
params: mockedAlert.actions[0].params,
alerts_filter: mockedAlert.actions[0].alertsFilter,
},
],
};
@ -73,9 +81,10 @@ describe('updateRuleRoute', () => {
updated_at: mockedAlert.updatedAt,
created_at: mockedAlert.createdAt,
rule_type_id: mockedAlert.alertTypeId,
actions: mockedAlert.actions.map(({ actionTypeId, ...rest }) => ({
actions: mockedAlert.actions.map(({ actionTypeId, alertsFilter, ...rest }) => ({
...rest,
connector_type_id: actionTypeId,
alerts_filter: alertsFilter,
})),
};
@ -111,6 +120,13 @@ describe('updateRuleRoute', () => {
"data": Object {
"actions": Array [
Object {
"alertsFilter": Object {
"query": Object {
"dsl": "{\\"must\\": {\\"term\\": { \\"name\\": \\"test\\" }}}",
"kql": "name:test",
},
"timeframe": null,
},
"group": "default",
"id": "2",
"params": Object {

View file

@ -8,7 +8,6 @@
import { schema } from '@kbn/config-schema';
import { IRouter } from '@kbn/core/server';
import { ILicenseState, RuleTypeDisabledError, validateDurationSchema } from '../lib';
import { RuleNotifyWhenType } from '../../common';
import { UpdateOptions } from '../rules_client';
import {
verifyAccessAndContext,
@ -41,7 +40,18 @@ const bodySchema = schema.object({
throttle: schema.nullable(schema.maybe(schema.string({ validate: validateDurationSchema }))),
params: schema.recordOf(schema.string(), schema.any(), { defaultValue: {} }),
actions: actionsSchema,
notify_when: schema.maybe(schema.string({ validate: validateNotifyWhenType })),
notify_when: schema.maybe(
schema.nullable(
schema.oneOf(
[
schema.literal('onActionGroupChange'),
schema.literal('onActiveAlert'),
schema.literal('onThrottleInterval'),
],
{ validate: validateNotifyWhenType }
)
)
),
});
const rewriteBodyReq: RewriteRequestCase<UpdateOptions<RuleTypeParams>> = (result) => {
@ -124,15 +134,7 @@ export const updateRuleRoute = (
const { id } = req.params;
const rule = req.body;
try {
const alertRes = await rulesClient.update(
rewriteBodyReq({
id,
data: {
...rule,
notify_when: rule.notify_when as RuleNotifyWhenType,
},
})
);
const alertRes = await rulesClient.update(rewriteBodyReq({ id, data: rule }));
return res.ok({
body: rewriteBodyRes(alertRes),
});

View file

@ -0,0 +1,55 @@
/*
* 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 { addGeneratedActionValues } from './add_generated_action_values';
import { RuleAction } from '../../../common';
jest.mock('uuid', () => ({
v4: () => '111-222',
}));
describe('addGeneratedActionValues()', () => {
const mockAction: RuleAction = {
id: '1',
group: 'default',
actionTypeId: 'slack',
params: {},
frequency: {
summary: false,
notifyWhen: 'onActiveAlert',
throttle: null,
},
alertsFilter: {
query: { kql: 'test:testValue' },
timeframe: {
days: [1, 2],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
};
test('adds uuid', async () => {
const actionWithGeneratedValues = addGeneratedActionValues([mockAction]);
expect(actionWithGeneratedValues[0].uuid).toBe('111-222');
});
test('adds DSL', async () => {
const actionWithGeneratedValues = addGeneratedActionValues([mockAction]);
expect(actionWithGeneratedValues[0].alertsFilter?.query?.dsl).toBe(
'{"bool":{"should":[{"match":{"test":"testValue"}}],"minimum_should_match":1}}'
);
});
test('throws error if KQL is not valid', async () => {
expect(() =>
addGeneratedActionValues([
{ ...mockAction, alertsFilter: { query: { kql: 'foo:bar:1' }, timeframe: null } },
])
).toThrowErrorMatchingInlineSnapshot('"Error creating DSL query: invalid KQL"');
});
});

View file

@ -0,0 +1,44 @@
/*
* 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 { v4 } from 'uuid';
import { fromKueryExpression, toElasticsearchQuery } from '@kbn/es-query';
import Boom from '@hapi/boom';
import { NormalizedAlertAction, NormalizedAlertActionWithGeneratedValues } from '..';
export function addGeneratedActionValues(
actions: NormalizedAlertAction[] = []
): NormalizedAlertActionWithGeneratedValues[] {
return actions.map(({ uuid, alertsFilter, ...action }) => {
const generateDSL = (kql: string) => {
try {
return JSON.stringify(toElasticsearchQuery(fromKueryExpression(kql)));
} catch (e) {
throw Boom.badRequest(`Error creating DSL query: invalid KQL`);
}
};
return {
...action,
uuid: uuid || v4(),
...(alertsFilter
? {
alertsFilter: {
...alertsFilter,
timeframe: alertsFilter.timeframe || null,
query: !alertsFilter.query
? null
: {
kql: alertsFilter.query.kql,
dsl: generateDSL(alertsFilter.query.kql),
},
},
}
: {}),
};
});
}

View file

@ -1,16 +0,0 @@
/*
* 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 { v4 } from 'uuid';
import { NormalizedAlertAction, NormalizedAlertActionWithUuid } from '..';
export function addUuid(actions: NormalizedAlertAction[] = []): NormalizedAlertActionWithUuid[] {
return actions.map((action) => ({
...action,
uuid: action.uuid || v4(),
}));
}

View file

@ -8,11 +8,11 @@
import { SavedObjectReference } from '@kbn/core/server';
import { RawRule } from '../../types';
import { preconfiguredConnectorActionRefPrefix } from '../common/constants';
import { NormalizedAlertActionWithUuid, RulesClientContext } from '../types';
import { NormalizedAlertActionWithGeneratedValues, RulesClientContext } from '../types';
export async function denormalizeActions(
context: RulesClientContext,
alertActions: NormalizedAlertActionWithUuid[]
alertActions: NormalizedAlertActionWithGeneratedValues[]
): Promise<{ actions: RawRule['actions']; references: SavedObjectReference[] }> {
const references: SavedObjectReference[] = [];
const actions: RawRule['actions'] = [];

View file

@ -8,7 +8,7 @@
import { SavedObjectReference } from '@kbn/core/server';
import { RawRule, RuleTypeParams } from '../../types';
import { UntypedNormalizedRuleType } from '../../rule_type_registry';
import { NormalizedAlertActionWithUuid } from '../types';
import { NormalizedAlertActionWithGeneratedValues } from '../types';
import { extractedSavedObjectParamReferenceNamePrefix } from '../common/constants';
import { RulesClientContext } from '../types';
import { denormalizeActions } from './denormalize_actions';
@ -19,7 +19,7 @@ export async function extractReferences<
>(
context: RulesClientContext,
ruleType: UntypedNormalizedRuleType,
ruleActions: NormalizedAlertActionWithUuid[],
ruleActions: NormalizedAlertActionWithGeneratedValues[],
ruleParams: Params
): Promise<{
actions: RawRule['actions'];

View file

@ -34,6 +34,7 @@ export interface GetAlertFromRawParams {
includeLegacyId?: boolean;
excludeFromPublicApi?: boolean;
includeSnoozeData?: boolean;
omitGeneratedValues?: boolean;
}
export function getAlertFromRaw<Params extends RuleTypeParams>(
@ -44,7 +45,8 @@ export function getAlertFromRaw<Params extends RuleTypeParams>(
references: SavedObjectReference[] | undefined,
includeLegacyId: boolean = false,
excludeFromPublicApi: boolean = false,
includeSnoozeData: boolean = false
includeSnoozeData: boolean = false,
omitGeneratedValues: boolean = true
): Rule | RuleWithLegacyId {
const ruleType = context.ruleTypeRegistry.get(ruleTypeId);
// In order to support the partial update API of Saved Objects we have to support
@ -58,7 +60,8 @@ export function getAlertFromRaw<Params extends RuleTypeParams>(
references,
includeLegacyId,
excludeFromPublicApi,
includeSnoozeData
includeSnoozeData,
omitGeneratedValues
);
// include to result because it is for internal rules client usage
if (includeLegacyId) {
@ -92,7 +95,8 @@ export function getPartialRuleFromRaw<Params extends RuleTypeParams>(
references: SavedObjectReference[] | undefined,
includeLegacyId: boolean = false,
excludeFromPublicApi: boolean = false,
includeSnoozeData: boolean = false
includeSnoozeData: boolean = false,
omitGeneratedValues: boolean = true
): PartialRule<Params> | PartialRuleWithLegacyId<Params> {
const snoozeScheduleDates = snoozeSchedule?.map((s) => ({
...s,
@ -152,6 +156,12 @@ export function getPartialRuleFromRaw<Params extends RuleTypeParams>(
: {}),
};
if (omitGeneratedValues) {
if (rule.actions) {
rule.actions = rule.actions.map((ruleAction) => omit(ruleAction, 'alertsFilter.query.dsl'));
}
}
// Need the `rule` object to build a URL
if (!excludeFromPublicApi) {
const viewInAppRelativeUrl =

View file

@ -15,5 +15,5 @@ export { checkAuthorizationAndGetTotal } from './check_authorization_and_get_tot
export { scheduleTask } from './schedule_task';
export { createNewAPIKeySet } from './create_new_api_key_set';
export { recoverRuleAlerts } from './recover_rule_alerts';
export { addUuid } from './add_uuid';
export { addGeneratedActionValues } from './add_generated_action_values';
export { incrementRevision } from './increment_revision';

View file

@ -0,0 +1,278 @@
/*
* 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 { validateActions, ValidateActionsData } from './validate_actions';
import { UntypedNormalizedRuleType } from '../../rule_type_registry';
import { AlertsFilter, RecoveredActionGroup, RuleNotifyWhen } from '../../../common';
import { RulesClientContext } from '..';
describe('validateActions', () => {
const loggerErrorMock = jest.fn();
const getBulkMock = jest.fn();
const ruleType: jest.Mocked<UntypedNormalizedRuleType> = {
id: 'test',
name: 'My test rule',
actionGroups: [{ id: 'default', name: 'Default' }, RecoveredActionGroup],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
executor: jest.fn(),
producer: 'alerts',
cancelAlertsOnRuleTimeout: true,
ruleTaskTimeout: '5m',
getSummarizedAlerts: jest.fn(),
};
const data = {
schedule: { interval: '1m' },
actions: [
{
uuid: '111',
group: 'default',
id: '1',
params: {},
frequency: {
summary: false,
notifyWhen: RuleNotifyWhen.ACTIVE,
throttle: null,
},
alertsFilter: {
query: { kql: 'test:1' },
timeframe: { days: [1], hours: { start: '10:00', end: '17:00' }, timezone: 'UTC' },
},
},
],
} as unknown as ValidateActionsData;
const context = {
logger: { error: loggerErrorMock },
getActionsClient: () => {
return {
getBulk: getBulkMock,
};
},
};
afterEach(() => {
jest.resetAllMocks();
});
it('should return error message if actions have duplicated uuid', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
...data.actions,
{
...data.actions[0],
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Actions have duplicated UUIDs"'
);
});
it('should return error message if any action have isMissingSecrets', async () => {
getBulkMock.mockResolvedValue([{ isMissingSecrets: true, name: 'test name' }]);
await expect(
validateActions(context as unknown as RulesClientContext, ruleType, data, false)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Invalid connectors: test name"'
);
});
it('should return error message if any action have invalidActionGroups', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{ ...data, actions: [{ ...data.actions[0], group: 'invalid' }] },
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Invalid action groups: invalid"'
);
});
it('should return error message if any action have frequency when there is rule level notify_when', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{ ...data, notifyWhen: 'onActiveAlert' },
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Cannot specify per-action frequency params when notify_when or throttle are defined at the rule level: default"'
);
});
it('should return error message if any action have frequency when there is rule level throttle', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{ ...data, throttle: '1h' },
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Cannot specify per-action frequency params when notify_when or throttle are defined at the rule level: default"'
);
});
it('should return error message if any action does not have frequency', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{ ...data, actions: [{ ...data.actions[0], frequency: undefined }] },
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Actions missing frequency parameters: default"'
);
});
it('should return error message if any action have invalid throttle', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
{
...data.actions[0],
frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1s' },
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Action throttle cannot be shorter than the schedule interval of 1m: default (1s)"'
);
});
it('should return error message if any action has alertsFilter but has neither query not timeframe in it', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
{
...data.actions[0],
alertsFilter: {} as AlertsFilter,
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Action\'s alertsFilter must have either \\"query\\" or \\"timeframe\\" : 111"'
);
});
it('should return error message if any action has an invalid time range', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
{
...data.actions[0],
alertsFilter: {
query: { kql: 'test:1' },
timeframe: { days: [1], hours: { start: '30:00', end: '17:00' }, timezone: 'UTC' },
},
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Action\'s alertsFilter time range has an invalid value: 30:00-17:00"'
);
});
it('should return error message if any action has alertsFilter but the rule type does not have getSummarizedAlerts', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
{ ...ruleType, getSummarizedAlerts: undefined },
data,
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: This ruleType (My test rule) can\'t have an action with Alerts Filter. Actions: [111]"'
);
});
it('should return error message if any action has alertsFilter timeframe has missing field', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
{
...data.actions[0],
alertsFilter: {
query: { kql: 'test:1' },
// @ts-ignore
timeframe: { days: [1], hours: { start: '10:00', end: '17:00' } },
},
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Action\'s alertsFilter timeframe has missing fields: days, hours or timezone: 111"'
);
});
it('should return error message if any action has alertsFilter timeframe has invalid days', async () => {
await expect(
validateActions(
context as unknown as RulesClientContext,
ruleType,
{
...data,
actions: [
{
...data.actions[0],
alertsFilter: {
query: { kql: 'test:1' },
timeframe: {
// @ts-ignore
days: [0, 8],
hours: { start: '10:00', end: '17:00' },
timezone: 'UTC',
},
},
},
],
},
false
)
).rejects.toThrowErrorMatchingInlineSnapshot(
'"Failed to validate actions due to the following error: Action\'s alertsFilter days has invalid values: (111:[0,8]) "'
);
});
});

View file

@ -8,18 +8,21 @@
import Boom from '@hapi/boom';
import { map } from 'lodash';
import { i18n } from '@kbn/i18n';
import { validateHours } from '../../routes/lib/validate_hours';
import { RawRule, RuleNotifyWhen } from '../../types';
import { UntypedNormalizedRuleType } from '../../rule_type_registry';
import { NormalizedAlertAction } from '../types';
import { RulesClientContext } from '../types';
import { parseDuration } from '../../lib';
export type ValidateActionsData = Pick<RawRule, 'notifyWhen' | 'throttle' | 'schedule'> & {
actions: NormalizedAlertAction[];
};
export async function validateActions(
context: RulesClientContext,
alertType: UntypedNormalizedRuleType,
data: Pick<RawRule, 'notifyWhen' | 'throttle' | 'schedule'> & {
actions: NormalizedAlertAction[];
},
ruleType: UntypedNormalizedRuleType,
data: ValidateActionsData,
allowMissingConnectorSecrets?: boolean
): Promise<void> {
const { actions, notifyWhen, throttle } = data;
@ -68,7 +71,7 @@ export async function validateActions(
}
}
// check for actions with invalid action groups
const { actionGroups: alertTypeActionGroups } = alertType;
const { actionGroups: alertTypeActionGroups } = ruleType;
const usedAlertActionGroups = actions.map((action) => action.group);
const availableAlertTypeActionGroups = new Set(map(alertTypeActionGroups, 'id'));
const invalidActionGroups = usedAlertActionGroups.filter(
@ -113,14 +116,63 @@ export async function validateActions(
}
}
// check for actions throttled shorter than the rule schedule
const scheduleInterval = parseDuration(data.schedule.interval);
const actionsWithInvalidThrottles = actions.filter(
(action) =>
const actionsWithInvalidThrottles = [];
const actionWithoutQueryAndTimeframe = [];
const actionWithInvalidTimeframe = [];
const actionsWithInvalidTimeRange = [];
const actionsWithInvalidDays = [];
const actionsWithAlertsFilterWithoutSummaryGetter = [];
for (const action of actions) {
const { alertsFilter } = action;
// check for actions throttled shorter than the rule schedule
if (
action.frequency?.notifyWhen === RuleNotifyWhen.THROTTLE &&
parseDuration(action.frequency.throttle!) < scheduleInterval
);
if (actionsWithInvalidThrottles.length) {
) {
actionsWithInvalidThrottles.push(action);
}
if (alertsFilter) {
// Action has alertsFilter but the ruleType does not support AAD
if (!ruleType.getSummarizedAlerts) {
actionsWithAlertsFilterWithoutSummaryGetter.push(action);
}
// alertsFilter must have at least one of query and timeframe
if (!alertsFilter.query && !alertsFilter.timeframe) {
actionWithoutQueryAndTimeframe.push(action);
}
if (alertsFilter.timeframe) {
// hours, days and timezone fields are required
if (
!alertsFilter.timeframe.hours ||
!alertsFilter.timeframe.days ||
!alertsFilter.timeframe.timezone
) {
actionWithInvalidTimeframe.push(action);
}
// alertsFilter time range filter's start time can't be before end time
if (alertsFilter.timeframe.hours) {
if (
validateHours(alertsFilter.timeframe.hours.start) ||
validateHours(alertsFilter.timeframe.hours.end)
) {
actionsWithInvalidTimeRange.push(action);
}
}
if (alertsFilter.timeframe.days) {
if (alertsFilter.timeframe.days.some((day) => ![1, 2, 3, 4, 5, 6, 7].includes(day))) {
actionsWithInvalidDays.push(action);
}
}
}
}
}
if (actionsWithInvalidThrottles.length > 0) {
errors.push(
i18n.translate('xpack.alerting.rulesClient.validateActions.actionsWithInvalidThrottles', {
defaultMessage:
@ -135,6 +187,72 @@ export async function validateActions(
);
}
if (actionWithoutQueryAndTimeframe.length > 0) {
errors.push(
i18n.translate('xpack.alerting.rulesClient.validateActions.actionsWithInvalidAlertsFilter', {
defaultMessage: `Action's alertsFilter must have either "query" or "timeframe" : {uuids}`,
values: {
uuids: actionWithoutQueryAndTimeframe.map((a) => `${a.uuid}`).join(', '),
},
})
);
}
if (actionWithInvalidTimeframe.length > 0) {
errors.push(
i18n.translate('xpack.alerting.rulesClient.validateActions.actionWithInvalidTimeframe', {
defaultMessage: `Action's alertsFilter timeframe has missing fields: days, hours or timezone: {uuids}`,
values: {
uuids: actionWithInvalidTimeframe.map((a) => a.uuid).join(', '),
},
})
);
}
if (actionsWithInvalidDays.length > 0) {
errors.push(
i18n.translate('xpack.alerting.rulesClient.validateActions.actionsWithInvalidDays', {
defaultMessage: `Action's alertsFilter days has invalid values: {uuidAndDays}`,
values: {
uuidAndDays: actionsWithInvalidDays
.map((a) => `(${a.uuid}:[${a.alertsFilter!.timeframe!.days}]) `)
.join(', '),
},
})
);
}
if (actionsWithInvalidTimeRange.length > 0) {
errors.push(
i18n.translate('xpack.alerting.rulesClient.validateActions.actionsWithInvalidTimeRange', {
defaultMessage: `Action's alertsFilter time range has an invalid value: {hours}`,
values: {
hours: actionsWithInvalidTimeRange
.map(
(a) =>
`${a.alertsFilter!.timeframe!.hours.start}-${a.alertsFilter!.timeframe!.hours.end}`
)
.join(', '),
},
})
);
}
if (actionsWithAlertsFilterWithoutSummaryGetter.length > 0) {
errors.push(
i18n.translate(
'xpack.alerting.rulesClient.validateActions.actionsWithAlertsFilterWithoutSummaryGetter',
{
defaultMessage: `This ruleType ({ruleType}) can't have an action with Alerts Filter. Actions: [{uuids}]`,
values: {
uuids: actionsWithAlertsFilterWithoutSummaryGetter.map((a) => a.uuid).join(', '),
ruleType: ruleType.name,
},
}
)
);
}
// Finalize and throw any errors present
if (errors.length) {
throw Boom.badRequest(

View file

@ -55,14 +55,20 @@ import {
API_KEY_GENERATE_CONCURRENCY,
} from '../common/constants';
import { getMappedParams } from '../common/mapped_params_utils';
import { getAlertFromRaw, extractReferences, validateActions, updateMeta, addUuid } from '../lib';
import {
getAlertFromRaw,
extractReferences,
validateActions,
updateMeta,
addGeneratedActionValues,
} from '../lib';
import {
NormalizedAlertAction,
BulkOperationError,
RuleBulkOperationAggregation,
RulesClientContext,
CreateAPIKeyResult,
NormalizedAlertActionWithUuid,
NormalizedAlertActionWithGeneratedValues,
} from '../types';
export type BulkEditFields = keyof Pick<
@ -280,6 +286,9 @@ export async function bulkEdit<Params extends RuleTypeParams>(
attributes.alertTypeId as string,
attributes as RawRule,
references,
false,
false,
false,
false
);
});
@ -484,7 +493,7 @@ async function updateRuleAttributesAndParamsInMemory<Params extends RuleTypePara
} = await extractReferences(
context,
ruleType,
ruleActions.actions as NormalizedAlertActionWithUuid[],
ruleActions.actions as NormalizedAlertActionWithGeneratedValues[],
validatedMutatedAlertTypeParams
);
@ -578,7 +587,7 @@ async function getUpdatedAttributesFromOperations(
case 'actions': {
const updatedOperation = {
...operation,
value: addUuid(operation.value),
value: addGeneratedActionValues(operation.value),
};
try {

View file

@ -15,7 +15,12 @@ import { RawRule, SanitizedRule, RuleTypeParams, Rule } from '../../types';
import { WriteOperations, AlertingAuthorizationEntity } from '../../authorization';
import { validateRuleTypeParams, getRuleNotifyWhenType, getDefaultMonitoring } from '../../lib';
import { getRuleExecutionStatusPending } from '../../lib/rule_execution_status';
import { createRuleSavedObject, extractReferences, validateActions, addUuid } from '../lib';
import {
createRuleSavedObject,
extractReferences,
validateActions,
addGeneratedActionValues,
} from '../lib';
import { generateAPIKeyName, getMappedParams, apiKeyAsAlertAttributes } from '../common';
import { ruleAuditEvent, RuleAuditAction } from '../common/audit_events';
import { NormalizedAlertAction, RulesClientContext } from '../types';
@ -53,7 +58,7 @@ export async function create<Params extends RuleTypeParams = never>(
context: RulesClientContext,
{ data: initialData, options, allowMissingConnectorSecrets }: CreateOptions<Params>
): Promise<SanitizedRule<Params>> {
const data = { ...initialData, actions: addUuid(initialData.actions) };
const data = { ...initialData, actions: addGeneratedActionValues(initialData.actions) };
const id = options?.id || SavedObjectsUtils.generateId();

View file

@ -30,7 +30,7 @@ import {
extractReferences,
updateMeta,
getPartialRuleFromRaw,
addUuid,
addGeneratedActionValues,
incrementRevision,
} from '../lib';
import { generateAPIKeyName, apiKeyAsAlertAttributes } from '../common';
@ -167,7 +167,7 @@ async function updateAlert<Params extends RuleTypeParams>(
currentRule: SavedObject<RawRule>
): Promise<PartialRule<Params>> {
const { attributes, version } = currentRule;
const data = { ...initialData, actions: addUuid(initialData.actions) };
const data = { ...initialData, actions: addGeneratedActionValues(initialData.actions) };
const ruleType = context.ruleTypeRegistry.get(attributes.alertTypeId);

View file

@ -171,6 +171,7 @@ export class RulesClient {
params.references,
params.includeLegacyId,
params.excludeFromPublicApi,
params.includeSnoozeData
params.includeSnoozeData,
params.omitGeneratedValues
);
}

View file

@ -644,6 +644,175 @@ describe('bulkEdit()', () => {
expect(result.rules[0]).toHaveProperty('revision', 1);
});
test("should set timeframe in alertsFilter null if doesn't exist", async () => {
ruleTypeRegistry.get.mockReturnValue({
id: 'myType',
name: 'Test',
actionGroups: [
{ id: 'default', name: 'Default' },
{ id: 'custom', name: 'Not the Default' },
],
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
recoveryActionGroup: RecoveredActionGroup,
async executor() {
return { state: {} };
},
producer: 'alerts',
getSummarizedAlerts: jest.fn().mockResolvedValue({}),
});
const existingAction = {
frequency: {
notifyWhen: 'onActiveAlert',
summary: false,
throttle: null,
},
group: 'default',
id: '1',
params: {},
uuid: '111',
alertsFilter: {
query: {
kql: 'name:test',
dsl: '{"bool":{"should":[{"match":{"name":"test"}}],"minimum_should_match":1}}',
},
timeframe: {
days: [1],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
};
const newAction = {
frequency: {
notifyWhen: 'onActiveAlert',
summary: false,
throttle: null,
},
group: 'default',
id: '2',
params: {},
uuid: '222',
alertsFilter: { query: { kql: 'test:1', dsl: 'test' } },
};
unsecuredSavedObjectsClient.bulkCreate.mockResolvedValue({
saved_objects: [
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
...existingAction,
actionRef: 'action_0',
},
{
...newAction,
actionRef: 'action_1',
uuid: '222',
alertsFilter: {
query: { kql: 'test:1', dsl: 'test' },
timeframe: null,
},
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
{
name: 'action_1',
type: 'action',
id: '2',
},
],
},
],
});
const result = await rulesClient.bulkEdit({
filter: '',
operations: [
{
field: 'actions',
operation: 'add',
value: [existingAction, newAction] as NormalizedAlertAction[],
},
],
});
expect(unsecuredSavedObjectsClient.bulkCreate).toHaveBeenCalledWith(
[
{
...existingRule,
attributes: {
...existingRule.attributes,
actions: [
{
actionRef: 'action_0',
actionTypeId: 'test',
frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null },
group: 'default',
params: {},
uuid: '111',
alertsFilter: existingAction.alertsFilter,
},
{
actionRef: '',
actionTypeId: '',
frequency: { notifyWhen: 'onActiveAlert', summary: false, throttle: null },
group: 'default',
params: {},
uuid: '222',
alertsFilter: {
query: {
dsl: '{"bool":{"should":[{"match":{"test":"1"}}],"minimum_should_match":1}}',
kql: 'test:1',
},
timeframe: null,
},
},
],
apiKey: null,
apiKeyOwner: null,
meta: { versionApiKeyLastmodified: 'v8.2.0' },
name: 'my rule name',
enabled: false,
updatedAt: '2019-02-12T21:01:22.479Z',
updatedBy: 'elastic',
tags: ['foo'],
revision: 1,
},
references: [{ id: '1', name: 'action_0', type: 'action' }],
},
],
{ overwrite: true }
);
expect(result.rules[0]).toEqual({
...existingRule.attributes,
actions: [
existingAction,
{
...newAction,
alertsFilter: {
query: {
dsl: 'test',
kql: 'test:1',
},
timeframe: null,
},
},
],
id: existingRule.id,
snoozeSchedule: [],
});
});
});
describe('index pattern operations', () => {

View file

@ -23,7 +23,6 @@ import { getBeforeSetup, setGlobalDate } from './lib';
import { RecoveredActionGroup } from '../../../common';
import { getDefaultMonitoring } from '../../lib/monitoring';
import { bulkMarkApiKeysForInvalidation } from '../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation';
jest.mock('../../invalidate_pending_api_keys/bulk_mark_api_keys_for_invalidation', () => ({
bulkMarkApiKeysForInvalidation: jest.fn(),
}));
@ -3205,4 +3204,109 @@ describe('create()', () => {
}
`);
});
test('throws error when some actions have alertsFilter but neither timeframe nor query', async () => {
rulesClient = new RulesClient({
...rulesClientParams,
minimumScheduleInterval: { value: '1m', enforce: true },
});
ruleTypeRegistry.get.mockImplementation(() => ({
id: '123',
name: 'Test',
actionGroups: [
{ id: 'default', name: 'Default' },
{ id: 'group2', name: 'Action Group 2' },
{ id: 'group3', name: 'Action Group 3' },
],
recoveryActionGroup: RecoveredActionGroup,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
async executor() {
return { state: {} };
},
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
injectReferences: jest.fn(),
},
getSummarizedAlerts: jest.fn().mockResolvedValue({}),
}));
const data = getMockData({
notifyWhen: undefined,
throttle: undefined,
actions: [
{
group: 'default',
id: '1',
params: {
foo: true,
},
frequency: {
summary: false,
notifyWhen: 'onThrottleInterval',
throttle: '10h',
},
alertsFilter: {},
},
],
});
await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to validate actions due to the following error: Action's alertsFilter must have either \\"query\\" or \\"timeframe\\" : 149"`
);
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(taskManager.schedule).not.toHaveBeenCalled();
});
test('throws error when some actions have alertsFilter but the rule type does not support it', async () => {
rulesClient = new RulesClient({
...rulesClientParams,
minimumScheduleInterval: { value: '1m', enforce: true },
});
ruleTypeRegistry.get.mockImplementation(() => ({
id: '123',
name: 'Test',
actionGroups: [{ id: 'default', name: 'Default' }],
recoveryActionGroup: RecoveredActionGroup,
defaultActionGroupId: 'default',
minimumLicenseRequired: 'basic',
isExportable: true,
async executor() {
return { state: {} };
},
producer: 'alerts',
useSavedObjectReferences: {
extractReferences: jest.fn(),
injectReferences: jest.fn(),
},
}));
const data = getMockData({
notifyWhen: undefined,
throttle: undefined,
actions: [
{
group: 'default',
id: '1',
params: {
foo: true,
},
frequency: {
summary: true,
notifyWhen: 'onActiveAlert',
throttle: null,
},
alertsFilter: {
query: { kql: 'test:1' },
},
},
],
});
await expect(rulesClient.create({ data })).rejects.toThrowErrorMatchingInlineSnapshot(
`"Failed to validate actions due to the following error: This ruleType (Test) can't have an action with Alerts Filter. Actions: [150]"`
);
expect(unsecuredSavedObjectsClient.create).not.toHaveBeenCalled();
expect(taskManager.schedule).not.toHaveBeenCalled();
});
});

View file

@ -23,6 +23,7 @@ import {
IntervalSchedule,
SanitizedRule,
RuleSnoozeSchedule,
RawAlertsFilter,
} from '../types';
import { AlertingAuthorization } from '../authorization';
import { AlertingRulesConfig } from '../config';
@ -72,8 +73,12 @@ export interface RulesClientContext {
export type NormalizedAlertAction = Omit<RuleAction, 'actionTypeId'>;
export type NormalizedAlertActionWithUuid = Omit<RuleAction, 'actionTypeId' | 'uuid'> & {
export type NormalizedAlertActionWithGeneratedValues = Omit<
NormalizedAlertAction,
'uuid' | 'alertsFilter'
> & {
uuid: string;
alertsFilter?: RawAlertsFilter;
};
export interface RegistryAlertTypeWithAuth extends RegistryRuleType {

View file

@ -917,10 +917,7 @@ describe('Execution Handler', () => {
test('skips summary actions (per rule run) when there is no alerts', async () => {
getSummarizedAlertsMock.mockResolvedValue({
new: {
count: 1,
data: [mockAAD],
},
new: { count: 0, data: [] },
ongoing: { count: 0, data: [] },
recovered: { count: 0, data: [] },
});
@ -942,6 +939,7 @@ describe('Execution Handler', () => {
message:
'New: {{alerts.new.count}} Ongoing: {{alerts.ongoing.count}} Recovered: {{alerts.recovered.count}}',
},
alertsFilter: { query: { kql: 'test:1', dsl: '{}' } },
},
],
},
@ -950,7 +948,6 @@ describe('Execution Handler', () => {
await executionHandler.run({});
expect(getSummarizedAlertsMock).not.toHaveBeenCalled();
expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled();
expect(alertingEventLogger.logAction).not.toHaveBeenCalled();
});
@ -1074,6 +1071,7 @@ describe('Execution Handler', () => {
],
},
taskInstance: {
...defaultExecutionParams.taskInstance,
state: {
...defaultExecutionParams.taskInstance.state,
summaryActions: { '111-111': { date: new Date() } },
@ -1131,6 +1129,7 @@ describe('Execution Handler', () => {
],
},
taskInstance: {
...defaultExecutionParams.taskInstance,
state: {
...defaultExecutionParams.taskInstance.state,
summaryActions: {
@ -1292,6 +1291,146 @@ describe('Execution Handler', () => {
`);
});
test('does not schedule actions for the summarized alerts that are filtered out', async () => {
getSummarizedAlertsMock.mockResolvedValue({
new: {
count: 0,
data: [],
},
ongoing: {
count: 0,
data: [],
},
recovered: { count: 0, data: [] },
});
const executionHandler = new ExecutionHandler(
generateExecutionParams({
rule: {
...defaultExecutionParams.rule,
mutedInstanceIds: ['foo'],
actions: [
{
id: '1',
uuid: '111',
group: null,
actionTypeId: 'testActionTypeId',
frequency: {
summary: true,
notifyWhen: 'onActiveAlert',
throttle: null,
},
params: {
message:
'New: {{alerts.new.count}} Ongoing: {{alerts.ongoing.count}} Recovered: {{alerts.recovered.count}}',
},
alertsFilter: {
query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}' },
},
},
],
},
})
);
await executionHandler.run({
...generateAlert({ id: 1 }),
...generateAlert({ id: 2 }),
});
expect(getSummarizedAlertsMock).toHaveBeenCalledWith({
executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
ruleId: '1',
spaceId: 'test1',
excludedAlertInstanceIds: ['foo'],
alertsFilter: {
query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}' },
},
});
expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled();
expect(alertingEventLogger.logAction).not.toHaveBeenCalled();
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1);
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith(
'(2) alerts have been filtered out for: testActionTypeId:111'
);
});
test('does not schedule actions for the for-each type alerts that are filtered out', async () => {
getSummarizedAlertsMock.mockResolvedValue({
new: {
count: 1,
data: [{ ...mockAAD, kibana: { alert: { instance: { id: '1' } } } }],
},
ongoing: {
count: 0,
data: [],
},
recovered: { count: 0, data: [] },
});
const executionHandler = new ExecutionHandler(
generateExecutionParams({
rule: {
...defaultExecutionParams.rule,
mutedInstanceIds: ['foo'],
actions: [
{
id: '1',
uuid: '111',
group: 'default',
actionTypeId: 'testActionTypeId',
frequency: {
summary: false,
notifyWhen: 'onActiveAlert',
throttle: null,
},
params: {},
alertsFilter: {
query: { kql: 'kibana.alert.instance.id:1', dsl: '{}' },
},
},
],
},
})
);
await executionHandler.run({
...generateAlert({ id: 1 }),
...generateAlert({ id: 2 }),
...generateAlert({ id: 3 }),
});
expect(getSummarizedAlertsMock).toHaveBeenCalledWith({
executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
ruleId: '1',
spaceId: 'test1',
excludedAlertInstanceIds: ['foo'],
alertsFilter: {
query: { kql: 'kibana.alert.instance.id:1', dsl: '{}' },
},
});
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledWith([
{
apiKey: 'MTIzOmFiYw==',
consumer: 'rule-consumer',
executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
id: '1',
params: {},
relatedSavedObjects: [{ id: '1', namespace: 'test1', type: 'alert', typeId: 'test' }],
source: { source: { id: '1', type: 'alert' }, type: 'SAVED_OBJECT' },
spaceId: 'test1',
},
]);
expect(alertingEventLogger.logAction).toHaveBeenCalledWith({
alertGroup: 'default',
alertId: '1',
id: '1',
typeId: 'testActionTypeId',
});
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1);
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith(
'(2) alerts have been filtered out for: testActionTypeId:111'
);
});
describe('rule url', () => {
const ruleWithUrl = {
...rule,

View file

@ -14,10 +14,16 @@ import { ExecuteOptions as EnqueueExecutionOptions } from '@kbn/actions-plugin/s
import { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import { chunk } from 'lodash';
import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger';
import { parseDuration, RawRule, ThrottledActions } from '../types';
import {
GetSummarizedAlertsFnOpts,
parseDuration,
RawRule,
CombinedSummarizedAlerts,
ThrottledActions,
} from '../types';
import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store';
import { injectActionParams } from './inject_action_params';
import { ExecutionHandlerOptions, RuleTaskInstance } from './types';
import { Executable, ExecutionHandlerOptions, RuleTaskInstance } from './types';
import { TaskRunnerContext } from './task_runner_factory';
import { transformActionParams, transformSummaryActionParams } from './transform_action_params';
import { Alert } from '../alert';
@ -36,8 +42,9 @@ import {
getSummaryActionsFromTaskState,
isActionOnInterval,
isSummaryAction,
isSummaryActionThrottled,
isSummaryActionOnInterval,
isSummaryActionPerRuleRun,
isSummaryActionThrottled,
} from './rule_action_helper';
enum Reasons {
@ -133,7 +140,7 @@ export class ExecutionHandler<
actions: this.rule.actions,
summaryActions: this.taskInstance.state?.summaryActions,
});
const executables = this.generateExecutables(alerts, throttledSummaryActions);
const executables = await this.generateExecutables(alerts, throttledSummaryActions);
if (!!executables.length) {
const {
@ -152,7 +159,7 @@ export class ExecutionHandler<
this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length);
for (const { action, alert } of executables) {
for (const { action, alert, summarizedAlerts } of executables) {
const { actionTypeId } = action;
const actionGroup = action.group as ActionGroupIds;
@ -197,15 +204,7 @@ export class ExecutionHandler<
ruleRunMetricsStore.incrementNumberOfTriggeredActions();
ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId);
if (isSummaryAction(action)) {
if (isSummaryActionPerRuleRun(action) && !this.hasAlerts(alerts)) {
continue;
}
const summarizedAlerts = await this.getSummarizedAlerts({
action,
spaceId,
ruleId,
});
if (isSummaryAction(action) && summarizedAlerts) {
const actionToRun = {
...action,
params: injectActionParams({
@ -318,10 +317,23 @@ export class ExecutionHandler<
return { throttledSummaryActions };
}
private hasAlerts(
alerts: Record<string, Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>>
) {
return Object.keys(alerts).length > 0;
private logNumberOfFilteredAlerts({
numberOfAlerts = 0,
numberOfSummarizedAlerts = 0,
action,
}: {
numberOfAlerts: number;
numberOfSummarizedAlerts: number;
action: RuleAction;
}) {
const count = numberOfAlerts - numberOfSummarizedAlerts;
if (count > 0) {
this.logger.debug(
`(${count}) alert${count > 1 ? 's' : ''} ${
count > 1 ? 'have' : 'has'
} been filtered out for: ${action.actionTypeId}:${action.uuid}`
);
}
}
private isAlertMuted(alertId: string) {
@ -463,27 +475,45 @@ export class ExecutionHandler<
};
}
private generateExecutables(
private async generateExecutables(
alerts: Record<string, Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>>,
summaryActions: ThrottledActions
) {
throttledSummaryActions: ThrottledActions
): Promise<Array<Executable<State, Context, ActionGroupIds, RecoveryActionGroupId>>> {
const executables = [];
for (const action of this.rule.actions) {
if (isSummaryAction(action)) {
if (
this.canFetchSummarizedAlerts(action) &&
!isSummaryActionThrottled({
const alertsArray = Object.entries(alerts);
let summarizedAlerts = null;
if (this.shouldGetSummarizedAlerts({ action, throttledSummaryActions })) {
summarizedAlerts = await this.getSummarizedAlerts({
action,
spaceId: this.taskInstance.params.spaceId,
ruleId: this.taskInstance.params.alertId,
});
if (!isSummaryActionOnInterval(action)) {
this.logNumberOfFilteredAlerts({
numberOfAlerts: alertsArray.length,
numberOfSummarizedAlerts: summarizedAlerts.all.count,
action,
summaryActions,
logger: this.logger,
})
) {
executables.push({ action });
});
}
}
if (isSummaryAction(action)) {
if (summarizedAlerts) {
if (isSummaryActionPerRuleRun(action) && summarizedAlerts.all.count === 0) {
continue;
}
executables.push({ action, summarizedAlerts });
}
continue;
}
for (const [alertId, alert] of Object.entries(alerts)) {
for (const [alertId, alert] of alertsArray) {
if (alert.isFilteredOut(summarizedAlerts)) {
continue;
}
const actionGroup = this.getActionGroup(alert);
if (!this.ruleTypeActionGroups!.has(actionGroup)) {
@ -509,7 +539,7 @@ export class ExecutionHandler<
private canFetchSummarizedAlerts(action: RuleAction) {
const hasGetSummarizedAlerts = this.ruleType.getSummarizedAlerts !== undefined;
if (!hasGetSummarizedAlerts) {
if (action.frequency?.summary && !hasGetSummarizedAlerts) {
this.logger.error(
`Skipping action "${action.id}" for rule "${this.rule.id}" because the rule type "${this.ruleType.name}" does not support alert-as-data.`
);
@ -517,6 +547,36 @@ export class ExecutionHandler<
return hasGetSummarizedAlerts;
}
private shouldGetSummarizedAlerts({
action,
throttledSummaryActions,
}: {
action: RuleAction;
throttledSummaryActions: ThrottledActions;
}) {
if (!this.canFetchSummarizedAlerts(action)) {
return false;
}
// we fetch summarizedAlerts to filter alerts in memory as well
if (!isSummaryAction(action) && !action.alertsFilter) {
return false;
}
if (
isSummaryAction(action) &&
isSummaryActionThrottled({
action,
throttledSummaryActions,
logger: this.logger,
})
) {
return false;
}
return true;
}
private async getSummarizedAlerts({
action,
ruleId,
@ -525,8 +585,13 @@ export class ExecutionHandler<
action: RuleAction;
ruleId: string;
spaceId: string;
}) {
let options;
}): Promise<CombinedSummarizedAlerts> {
let options: GetSummarizedAlertsFnOpts = {
ruleId,
spaceId,
excludedAlertInstanceIds: this.rule.mutedInstanceIds,
alertsFilter: action.alertsFilter,
};
if (isActionOnInterval(action)) {
const throttleMills = parseDuration(action.frequency!.throttle!);
@ -535,16 +600,12 @@ export class ExecutionHandler<
options = {
start,
end: new Date(),
ruleId,
spaceId,
excludedAlertInstanceIds: this.rule.mutedInstanceIds,
...options,
};
} else {
options = {
executionUuid: this.executionId,
ruleId,
spaceId,
excludedAlertInstanceIds: this.rule.mutedInstanceIds,
...options,
};
}

View file

@ -373,26 +373,34 @@ export const generateAlertInstance = (
});
export const mockAAD = {
'kibana.alert.rule.category': 'Metric threshold',
'kibana.alert.rule.consumer': 'alerts',
'kibana.alert.rule.execution.uuid': 'c35db7cc-5bf7-46ea-b43f-b251613a5b72',
'kibana.alert.rule.name': 'test-rule',
'kibana.alert.rule.producer': 'infrastructure',
'kibana.alert.rule.rule_type_id': 'metrics.alert.threshold',
'kibana.alert.rule.uuid': '0de91960-7643-11ed-b719-bb9db8582cb6',
'kibana.space_ids': ['default'],
'kibana.alert.rule.tags': [],
'@timestamp': '2022-12-07T15:38:43.472Z',
'kibana.alert.reason': 'system.cpu is 90% in the last 1 min for all hosts. Alert when > 50%.',
'kibana.alert.duration.us': 100000,
'kibana.alert.time_range': { gte: '2022-01-01T12:00:00.000Z' },
'kibana.alert.instance.id': '*',
'kibana.alert.start': '2022-12-07T15:23:13.488Z',
'kibana.alert.uuid': '2d3e8fe5-3e8b-4361-916e-9eaab0bf2084',
'kibana.alert.status': 'active',
'kibana.alert.workflow_status': 'open',
'event.kind': 'signal',
'event.action': 'active',
'kibana.version': '8.7.0',
'kibana.alert.flapping': false,
event: {
kind: 'signal',
action: 'active',
},
kibana: {
version: '8.7.0',
space_ids: ['default'],
alert: {
instance: { id: '*' },
uuid: '2d3e8fe5-3e8b-4361-916e-9eaab0bf2084',
status: 'active',
workflow_status: 'open',
reason: 'system.cpu is 90% in the last 1 min for all hosts. Alert when > 50%.',
time_range: { gte: '2022-01-01T12:00:00.000Z' },
start: '2022-12-07T15:23:13.488Z',
duration: { us: 100000 },
flapping: false,
rule: {
category: 'Metric threshold',
consumer: 'alerts',
execution: { uuid: 'c35db7cc-5bf7-46ea-b43f-b251613a5b72' },
name: 'test-rule',
producer: 'infrastructure',
rule_type_id: 'metrics.alert.threshold',
uuid: '0de91960-7643-11ed-b719-bb9db8582cb6',
tags: [],
},
},
},
};

View file

@ -12,6 +12,7 @@ import {
getSummaryActionsFromTaskState,
isActionOnInterval,
isSummaryAction,
isSummaryActionOnInterval,
isSummaryActionThrottled,
} from './rule_action_helper';
@ -175,12 +176,12 @@ describe('rule_action_helper', () => {
jest.useRealTimers();
});
const logger = { debug: jest.fn() } as unknown as Logger;
const summaryActions = { '111-111': { date: new Date('2020-01-01T00:00:00.000Z') } };
const throttledSummaryActions = { '111-111': { date: new Date('2020-01-01T00:00:00.000Z') } };
test('should return false if the action does not have throttle filed', () => {
const result = isSummaryActionThrottled({
action: mockAction,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -189,7 +190,7 @@ describe('rule_action_helper', () => {
test('should return false if the action does not have frequency field', () => {
const result = isSummaryActionThrottled({
action: mockOldAction,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -201,7 +202,7 @@ describe('rule_action_helper', () => {
...mockSummaryAction,
frequency: { ...mockSummaryAction.frequency, notifyWhen: 'onActiveAlert' },
} as RuleAction,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -213,7 +214,7 @@ describe('rule_action_helper', () => {
...mockSummaryAction,
frequency: { ...mockSummaryAction.frequency, throttle: null },
} as RuleAction,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -222,7 +223,7 @@ describe('rule_action_helper', () => {
test('should return false if the action is not in the task instance', () => {
const result = isSummaryActionThrottled({
action: mockSummaryAction,
summaryActions: { '123-456': { date: new Date('2020-01-01T00:00:00.000Z') } },
throttledSummaryActions: { '123-456': { date: new Date('2020-01-01T00:00:00.000Z') } },
logger,
});
expect(result).toBe(false);
@ -232,7 +233,7 @@ describe('rule_action_helper', () => {
jest.advanceTimersByTime(3600000 * 2);
const result = isSummaryActionThrottled({
action: mockSummaryAction,
summaryActions: { '123-456': { date: new Date('2020-01-01T00:00:00.000Z') } },
throttledSummaryActions: { '123-456': { date: new Date('2020-01-01T00:00:00.000Z') } },
logger,
});
expect(result).toBe(false);
@ -241,7 +242,7 @@ describe('rule_action_helper', () => {
test('should return true for a throttling action', () => {
const result = isSummaryActionThrottled({
action: mockSummaryAction,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(true);
@ -250,7 +251,7 @@ describe('rule_action_helper', () => {
test('should return false if the action is broken', () => {
const result = isSummaryActionThrottled({
action: undefined,
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -259,7 +260,7 @@ describe('rule_action_helper', () => {
test('should return false if there is no summary action in the state', () => {
const result = isSummaryActionThrottled({
action: mockSummaryAction,
summaryActions: undefined,
throttledSummaryActions: undefined,
logger,
});
expect(result).toBe(false);
@ -275,7 +276,7 @@ describe('rule_action_helper', () => {
throttle: '1',
},
},
summaryActions,
throttledSummaryActions,
logger,
});
expect(result).toBe(false);
@ -284,4 +285,28 @@ describe('rule_action_helper', () => {
);
});
});
describe('isSummaryActionOnInterval', () => {
test('returns true for a summary action on interval', () => {
expect(isSummaryActionOnInterval(mockSummaryAction)).toBe(true);
});
test('returns false for a non-summary ', () => {
expect(
isSummaryActionOnInterval({
...mockAction,
frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' },
})
).toBe(false);
});
test('returns false for a summary per rule run ', () => {
expect(
isSummaryActionOnInterval({
...mockAction,
frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null },
})
).toBe(false);
});
});
});

View file

@ -31,29 +31,30 @@ export const isSummaryActionPerRuleRun = (action: RuleAction) => {
if (!action.frequency) {
return false;
}
return (
action.frequency.notifyWhen === RuleNotifyWhenTypeValues[1] &&
typeof action.frequency.throttle !== 'string'
);
return action.frequency.notifyWhen === RuleNotifyWhenTypeValues[1] && action.frequency.summary;
};
export const isSummaryActionOnInterval = (action: RuleAction) => {
return isActionOnInterval(action) && action.frequency?.summary;
};
export const isSummaryActionThrottled = ({
action,
summaryActions,
throttledSummaryActions,
logger,
}: {
action?: RuleAction;
summaryActions?: ThrottledActions;
throttledSummaryActions?: ThrottledActions;
logger: Logger;
}) => {
if (!isActionOnInterval(action)) {
return false;
}
if (!summaryActions) {
if (!throttledSummaryActions) {
return false;
}
const triggeredSummaryAction = summaryActions[action?.uuid!];
if (!triggeredSummaryAction) {
const throttledAction = throttledSummaryActions[action?.uuid!];
if (!throttledAction) {
return false;
}
let throttleMills = 0;
@ -63,7 +64,7 @@ export const isSummaryActionThrottled = ({
logger.debug(`Action'${action?.actionTypeId}:${action?.id}', has an invalid throttle interval`);
}
const throttled = triggeredSummaryAction.date.getTime() + throttleMills > Date.now();
const throttled = throttledAction.date.getTime() + throttleMills > Date.now();
if (throttled) {
logger.debug(

View file

@ -116,6 +116,7 @@ export async function getRuleAttributes<Params extends RuleTypeParams>(
rawRule: rawRule.attributes as RawRule,
references: rawRule.references,
includeLegacyId: false,
omitGeneratedValues: false,
});
return {

View file

@ -9,6 +9,7 @@ import { KibanaRequest, Logger } from '@kbn/core/server';
import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server';
import { PublicMethodsOf } from '@kbn/utility-types';
import { ActionsClient } from '@kbn/actions-plugin/server/actions_client';
import { Alert } from '../alert';
import { TaskRunnerContext } from './task_runner_factory';
import {
AlertInstanceContext,
@ -19,9 +20,10 @@ import {
RuleTaskState,
SanitizedRule,
RuleTypeState,
RuleAction,
} from '../../common';
import { NormalizedRuleType } from '../rule_type_registry';
import { RawRule, RulesClientApi } from '../types';
import { RawRule, RulesClientApi, CombinedSummarizedAlerts } from '../types';
import { RuleRunMetrics, RuleRunMetricsStore } from '../lib/rule_run_metrics_store';
import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger';
@ -85,3 +87,14 @@ export interface ExecutionHandlerOptions<
ruleLabel: string;
actionsClient: PublicMethodsOf<ActionsClient>;
}
export interface Executable<
State extends AlertInstanceState,
Context extends AlertInstanceContext,
ActionGroupIds extends string,
RecoveryActionGroupId extends string
> {
action: RuleAction;
alert?: Alert<State, Context, ActionGroupIds | RecoveryActionGroupId>;
summarizedAlerts?: CombinedSummarizedAlerts;
}

View file

@ -50,6 +50,8 @@ import {
IntervalSchedule,
RuleLastRun,
SanitizedRule,
AlertsFilter,
AlertsFilterTimeframe,
} from '../common';
import { PublicAlertFactory } from './alert/create_alert_factory';
import { RulesSettingsFlappingProperties } from '../common/rules_settings';
@ -143,23 +145,23 @@ export interface GetSummarizedAlertsFnOpts {
ruleId: string;
spaceId: string;
excludedAlertInstanceIds: string[];
alertsFilter?: AlertsFilter | null;
}
// TODO - add type for these alerts when we determine which alerts-as-data
// fields will be made available in https://github.com/elastic/kibana/issues/143741
interface SummarizedAlertsChunk {
count: number;
data: unknown[];
}
export interface SummarizedAlerts {
new: {
count: number;
data: unknown[];
};
ongoing: {
count: number;
data: unknown[];
};
recovered: {
count: number;
data: unknown[];
};
new: SummarizedAlertsChunk;
ongoing: SummarizedAlertsChunk;
recovered: SummarizedAlertsChunk;
}
export interface CombinedSummarizedAlerts extends SummarizedAlerts {
all: SummarizedAlertsChunk;
}
export type GetSummarizedAlertsFn = (opts: GetSummarizedAlertsFnOpts) => Promise<SummarizedAlerts>;
export interface GetViewInAppRelativeUrlFnOpts<Params extends RuleTypeParams> {
@ -276,6 +278,14 @@ export type UntypedRuleType = RuleType<
AlertInstanceContext
>;
export interface RawAlertsFilter extends AlertsFilter {
query: null | {
kql: string;
dsl: string;
};
timeframe: null | AlertsFilterTimeframe;
}
export interface RawRuleAction extends SavedObjectAttributes {
uuid: string;
group: string;
@ -287,6 +297,7 @@ export interface RawRuleAction extends SavedObjectAttributes {
notifyWhen: RuleNotifyWhenType;
throttle: string | null;
};
alertsFilter?: RawAlertsFilter;
}
export interface RuleMeta extends SavedObjectAttributes {

View file

@ -1983,6 +1983,386 @@ describe('createGetSummarizedAlertsFn', () => {
expect(summarizedAlerts.recovered.data).toEqual([]);
});
it('creates function that correctly returns lifecycle alerts using alerts filter', async () => {
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 2,
},
hits: [
{
_id: '1',
_index: '.alerts-default-000001',
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
},
{
_id: '2',
_index: '.alerts-default-000001',
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 3,
},
hits: [
{
_id: '3',
_index: '.alerts-default-000001',
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
},
{
_id: '4',
_index: '.alerts-default-000001',
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
},
{
_id: '5',
_index: '.alerts-default-000001',
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 1,
},
hits: [
{
_id: '6',
_index: '.alerts-default-000001',
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'close',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
},
],
},
} as any);
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: false,
isLifecycleAlert: true,
})();
await getSummarizedAlertsFn({
executionUuid: 'abc',
ruleId: 'rule-id',
spaceId: 'space-id',
excludedAlertInstanceIds: [],
alertsFilter: {
query: {
kql: 'kibana.alert.rule.name:test',
dsl: '{"bool":{"minimum_should_match":1,"should":[{"match":{"kibana.alert.rule.name":"test"}}]}}',
},
timeframe: {
days: [1, 2, 3, 4, 5],
hours: { start: '08:00', end: '17:00' },
timezone: 'UTC',
},
},
});
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith();
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledTimes(3);
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(1, {
body: {
size: 100,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'open',
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'kibana.alert.rule.name': 'test',
},
},
],
},
},
{
script: {
script: {
params: {
days: [1, 2, 3, 4, 5],
timezone: 'UTC',
},
source:
"params.days.contains(doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone)).dayOfWeek.getValue())",
},
},
},
{
script: {
script: {
params: {
end: '17:00',
start: '08:00',
timezone: 'UTC',
},
source: `
def alertsDateTime = doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone));
def alertsTime = LocalTime.of(alertsDateTime.getHour(), alertsDateTime.getMinute());
def start = LocalTime.parse(params.start);
def end = LocalTime.parse(params.end);
if (end.isBefore(start)){ // overnight
def dayEnd = LocalTime.parse("23:59:59");
def dayStart = LocalTime.parse("00:00:00");
if ((alertsTime.isAfter(start) && alertsTime.isBefore(dayEnd)) || (alertsTime.isAfter(dayStart) && alertsTime.isBefore(end))) {
return true;
} else {
return false;
}
} else {
if (alertsTime.isAfter(start) && alertsTime.isBefore(end)) {
return true;
} else {
return false;
}
}
`,
},
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(2, {
body: {
size: 100,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'active',
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'kibana.alert.rule.name': 'test',
},
},
],
},
},
{
script: {
script: {
params: {
days: [1, 2, 3, 4, 5],
timezone: 'UTC',
},
source:
"params.days.contains(doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone)).dayOfWeek.getValue())",
},
},
},
{
script: {
script: {
params: {
end: '17:00',
start: '08:00',
timezone: 'UTC',
},
source: `
def alertsDateTime = doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone));
def alertsTime = LocalTime.of(alertsDateTime.getHour(), alertsDateTime.getMinute());
def start = LocalTime.parse(params.start);
def end = LocalTime.parse(params.end);
if (end.isBefore(start)){ // overnight
def dayEnd = LocalTime.parse("23:59:59");
def dayStart = LocalTime.parse("00:00:00");
if ((alertsTime.isAfter(start) && alertsTime.isBefore(dayEnd)) || (alertsTime.isAfter(dayStart) && alertsTime.isBefore(end))) {
return true;
} else {
return false;
}
} else {
if (alertsTime.isAfter(start) && alertsTime.isBefore(end)) {
return true;
} else {
return false;
}
}
`,
},
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(3, {
body: {
size: 100,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'close',
},
},
{
bool: {
minimum_should_match: 1,
should: [
{
match: {
'kibana.alert.rule.name': 'test',
},
},
],
},
},
{
script: {
script: {
params: {
days: [1, 2, 3, 4, 5],
timezone: 'UTC',
},
source:
"params.days.contains(doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone)).dayOfWeek.getValue())",
},
},
},
{
script: {
script: {
params: {
end: '17:00',
start: '08:00',
timezone: 'UTC',
},
source: `
def alertsDateTime = doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone));
def alertsTime = LocalTime.of(alertsDateTime.getHour(), alertsDateTime.getMinute());
def start = LocalTime.parse(params.start);
def end = LocalTime.parse(params.end);
if (end.isBefore(start)){ // overnight
def dayEnd = LocalTime.parse("23:59:59");
def dayStart = LocalTime.parse("00:00:00");
if ((alertsTime.isAfter(start) && alertsTime.isBefore(dayEnd)) || (alertsTime.isAfter(dayStart) && alertsTime.isBefore(end))) {
return true;
} else {
return false;
}
} else {
if (alertsTime.isAfter(start) && alertsTime.isBefore(end)) {
return true;
} else {
return false;
}
}
`,
},
},
},
],
},
},
},
});
});
it('throws error if search throws error', async () => {
ruleDataClientMock.getReader().search.mockImplementation(() => {
throw new Error('search error');

View file

@ -22,6 +22,7 @@ import {
QueryDslQueryContainer,
SearchTotalHits,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { AlertsFilter } from '@kbn/alerting-plugin/common';
import { ParsedTechnicalFields } from '../../common';
import { ParsedExperimentalFields } from '../../common/parse_experimental_fields';
import { IRuleDataClient, IRuleDataReader } from '../rule_data_client';
@ -46,6 +47,7 @@ export const createGetSummarizedAlertsFn =
ruleId,
spaceId,
excludedAlertInstanceIds,
alertsFilter,
}: GetSummarizedAlertsFnOpts) => {
if (!ruleId || !spaceId) {
throw new Error(`Must specify both rule ID and space ID for summarized alert query.`);
@ -76,9 +78,9 @@ export const createGetSummarizedAlertsFn =
isLifecycleAlert: opts.isLifecycleAlert,
formatAlert: opts.formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
}
return await getAlertsByTimeRange({
ruleDataClientReader,
ruleId,
@ -87,6 +89,7 @@ export const createGetSummarizedAlertsFn =
isLifecycleAlert: opts.isLifecycleAlert,
formatAlert: opts.formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
};
@ -97,6 +100,7 @@ interface GetAlertsByExecutionUuidOpts {
isLifecycleAlert: boolean;
excludedAlertInstanceIds: string[];
formatAlert?: (alert: AlertDocument) => AlertDocument;
alertsFilter?: AlertsFilter | null;
}
const getAlertsByExecutionUuid = async ({
@ -106,6 +110,7 @@ const getAlertsByExecutionUuid = async ({
isLifecycleAlert,
excludedAlertInstanceIds,
formatAlert,
alertsFilter,
}: GetAlertsByExecutionUuidOpts) => {
if (isLifecycleAlert) {
return getLifecycleAlertsByExecutionUuid({
@ -114,6 +119,7 @@ const getAlertsByExecutionUuid = async ({
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
}
@ -123,6 +129,7 @@ const getAlertsByExecutionUuid = async ({
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
};
@ -132,6 +139,7 @@ interface GetAlertsByExecutionUuidHelperOpts {
ruleDataClientReader: IRuleDataReader;
excludedAlertInstanceIds: string[];
formatAlert?: (alert: AlertDocument) => AlertDocument;
alertsFilter?: AlertsFilter | null;
}
const getPersistentAlertsByExecutionUuid = async <TSearchRequest extends ESSearchRequest>({
@ -140,10 +148,16 @@ const getPersistentAlertsByExecutionUuid = async <TSearchRequest extends ESSearc
ruleDataClientReader,
excludedAlertInstanceIds,
formatAlert,
alertsFilter,
}: GetAlertsByExecutionUuidHelperOpts) => {
// persistent alerts only create new alerts so query by execution UUID to
// get all alerts created during an execution
const request = getQueryByExecutionUuid(executionUuid, ruleId, excludedAlertInstanceIds);
const request = getQueryByExecutionUuid({
executionUuid,
ruleId,
excludedAlertInstanceIds,
alertsFilter,
});
const response = await doSearch(ruleDataClientReader, request, formatAlert);
return {
@ -165,15 +179,34 @@ const getLifecycleAlertsByExecutionUuid = async ({
ruleDataClientReader,
excludedAlertInstanceIds,
formatAlert,
alertsFilter,
}: GetAlertsByExecutionUuidHelperOpts) => {
// lifecycle alerts assign a different action to an alert depending
// on whether it is new/ongoing/recovered. query for each action in order
// to get the count of each action type as well as up to the maximum number
// of each type of alert.
const requests = [
getQueryByExecutionUuid(executionUuid, ruleId, excludedAlertInstanceIds, 'open'),
getQueryByExecutionUuid(executionUuid, ruleId, excludedAlertInstanceIds, 'active'),
getQueryByExecutionUuid(executionUuid, ruleId, excludedAlertInstanceIds, 'close'),
getQueryByExecutionUuid({
executionUuid,
ruleId,
excludedAlertInstanceIds,
action: 'open',
alertsFilter,
}),
getQueryByExecutionUuid({
executionUuid,
ruleId,
excludedAlertInstanceIds,
action: 'active',
alertsFilter,
}),
getQueryByExecutionUuid({
executionUuid,
ruleId,
excludedAlertInstanceIds,
action: 'close',
alertsFilter,
}),
];
const responses = await Promise.all(
@ -233,12 +266,21 @@ const doSearch = async (
return getHitsWithCount(response, formatAlert);
};
const getQueryByExecutionUuid = (
executionUuid: string,
ruleId: string,
excludedAlertInstanceIds: string[],
action?: string
) => {
interface GetQueryByExecutionUuidParams {
executionUuid: string;
ruleId: string;
excludedAlertInstanceIds: string[];
action?: string;
alertsFilter?: AlertsFilter | null;
}
const getQueryByExecutionUuid = ({
executionUuid,
ruleId,
excludedAlertInstanceIds,
action,
alertsFilter,
}: GetQueryByExecutionUuidParams) => {
const filter: QueryDslQueryContainer[] = [
{
term: {
@ -270,6 +312,10 @@ const getQueryByExecutionUuid = (
});
}
if (alertsFilter) {
filter.push(...generateAlertsFilterDSL(alertsFilter));
}
return {
body: {
size: MAX_ALERT_DOCS_TO_RETURN,
@ -291,6 +337,7 @@ interface GetAlertsByTimeRangeOpts {
isLifecycleAlert: boolean;
excludedAlertInstanceIds: string[];
formatAlert?: (alert: AlertDocument) => AlertDocument;
alertsFilter?: AlertsFilter | null;
}
const getAlertsByTimeRange = async ({
@ -301,6 +348,7 @@ const getAlertsByTimeRange = async ({
isLifecycleAlert,
excludedAlertInstanceIds,
formatAlert,
alertsFilter,
}: GetAlertsByTimeRangeOpts) => {
if (isLifecycleAlert) {
return getLifecycleAlertsByTimeRange({
@ -310,9 +358,9 @@ const getAlertsByTimeRange = async ({
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
}
return getPersistentAlertsByTimeRange({
start,
end,
@ -320,6 +368,7 @@ const getAlertsByTimeRange = async ({
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
});
};
@ -330,6 +379,7 @@ interface GetAlertsByTimeRangeHelperOpts {
ruleDataClientReader: IRuleDataReader;
formatAlert?: (alert: AlertDocument) => AlertDocument;
excludedAlertInstanceIds: string[];
alertsFilter?: AlertsFilter | null;
}
enum AlertTypes {
@ -345,10 +395,18 @@ const getPersistentAlertsByTimeRange = async <TSearchRequest extends ESSearchReq
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
}: GetAlertsByTimeRangeHelperOpts) => {
// persistent alerts only create new alerts so query for all alerts within the time
// range and treat them as NEW
const request = getQueryByTimeRange(start, end, ruleId, excludedAlertInstanceIds);
const request = getQueryByTimeRange(
start,
end,
ruleId,
excludedAlertInstanceIds,
undefined,
alertsFilter
);
const response = await doSearch(ruleDataClientReader, request, formatAlert);
return {
@ -371,11 +429,26 @@ const getLifecycleAlertsByTimeRange = async ({
ruleDataClientReader,
formatAlert,
excludedAlertInstanceIds,
alertsFilter,
}: GetAlertsByTimeRangeHelperOpts) => {
const requests = [
getQueryByTimeRange(start, end, ruleId, excludedAlertInstanceIds, AlertTypes.NEW),
getQueryByTimeRange(start, end, ruleId, excludedAlertInstanceIds, AlertTypes.ONGOING),
getQueryByTimeRange(start, end, ruleId, excludedAlertInstanceIds, AlertTypes.RECOVERED),
getQueryByTimeRange(start, end, ruleId, excludedAlertInstanceIds, AlertTypes.NEW, alertsFilter),
getQueryByTimeRange(
start,
end,
ruleId,
excludedAlertInstanceIds,
AlertTypes.ONGOING,
alertsFilter
),
getQueryByTimeRange(
start,
end,
ruleId,
excludedAlertInstanceIds,
AlertTypes.RECOVERED,
alertsFilter
),
];
const responses = await Promise.all(
@ -394,7 +467,8 @@ const getQueryByTimeRange = (
end: Date,
ruleId: string,
excludedAlertInstanceIds: string[],
type?: AlertTypes
type?: AlertTypes,
alertsFilter?: AlertsFilter | null
) => {
// base query filters the alert documents for a rule by the given time range
let filter: QueryDslQueryContainer[] = [
@ -470,6 +544,10 @@ const getQueryByTimeRange = (
});
}
if (alertsFilter) {
filter.push(...generateAlertsFilterDSL(alertsFilter));
}
return {
body: {
size: MAX_ALERT_DOCS_TO_RETURN,
@ -482,3 +560,61 @@ const getQueryByTimeRange = (
},
};
};
const generateAlertsFilterDSL = (alertsFilter: AlertsFilter): QueryDslQueryContainer[] => {
const filter: QueryDslQueryContainer[] = [];
if (alertsFilter.query) {
filter.push(JSON.parse(alertsFilter.query.dsl!));
}
if (alertsFilter.timeframe) {
filter.push(
{
script: {
script: {
source:
"params.days.contains(doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone)).dayOfWeek.getValue())",
params: {
days: alertsFilter.timeframe.days,
timezone: alertsFilter.timeframe.timezone,
},
},
},
},
{
script: {
script: {
source: `
def alertsDateTime = doc['kibana.alert.start'].value.withZoneSameInstant(ZoneId.of(params.timezone));
def alertsTime = LocalTime.of(alertsDateTime.getHour(), alertsDateTime.getMinute());
def start = LocalTime.parse(params.start);
def end = LocalTime.parse(params.end);
if (end.isBefore(start)){ // overnight
def dayEnd = LocalTime.parse("23:59:59");
def dayStart = LocalTime.parse("00:00:00");
if ((alertsTime.isAfter(start) && alertsTime.isBefore(dayEnd)) || (alertsTime.isAfter(dayStart) && alertsTime.isBefore(end))) {
return true;
} else {
return false;
}
} else {
if (alertsTime.isAfter(start) && alertsTime.isBefore(end)) {
return true;
} else {
return false;
}
}
`,
params: {
start: alertsFilter.timeframe.hours.start,
end: alertsFilter.timeframe.hours.end,
timezone: alertsFilter.timeframe.timezone,
},
},
},
}
);
}
return filter;
};

View file

@ -6,6 +6,7 @@
*/
import { ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers';
import { AlertsFilter } from '@kbn/alerting-plugin/common/rule';
import { Space, User } from '../types';
import { ObjectRemover } from './object_remover';
import { getUrlPrefix } from './space_test_utils';
@ -27,6 +28,7 @@ export interface CreateAlertWithActionOpts {
notifyWhen?: string;
summary?: boolean;
throttle?: string | null;
alertsFilter?: AlertsFilter;
}
export interface CreateNoopAlertOpts {
objectRemover?: ObjectRemover;
@ -253,6 +255,7 @@ export class AlertUtils {
reference,
notifyWhen,
throttle,
alertsFilter,
}: CreateAlertWithActionOpts) {
const objRemover = objectRemover || this.objectRemover;
const actionId = indexRecordActionId || this.indexRecordActionId;
@ -270,7 +273,13 @@ export class AlertUtils {
if (this.user) {
request = request.auth(this.user.username, this.user.password);
}
const rule = getAlwaysFiringRuleWithSummaryAction(reference, actionId, notifyWhen, throttle);
const rule = getAlwaysFiringRuleWithSummaryAction(
reference,
actionId,
notifyWhen,
throttle,
alertsFilter
);
const response = await request.send({ ...rule, ...overwrites });
if (response.statusCode === 200) {
@ -495,7 +504,8 @@ function getAlwaysFiringRuleWithSummaryAction(
reference: string,
actionId: string,
notifyWhen = 'onActiveAlert',
throttle: string | null = '1m'
throttle: string | null = '1m',
alertsFilter?: AlertsFilter
) {
const messageTemplate =
`Alerts, ` +
@ -529,6 +539,7 @@ function getAlwaysFiringRuleWithSummaryAction(
notify_when: notifyWhen,
throttle,
},
...(alertsFilter && { alerts_filter: alertsFilter }),
},
],
};

View file

@ -6,7 +6,7 @@
*/
import expect from '@kbn/expect';
import { omit } from 'lodash';
import { omit, padStart } from 'lodash';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { IValidatedEvent, nanosToMillis } from '@kbn/event-log-plugin/server';
import { TaskRunning, TaskRunningStage } from '@kbn/task-manager-plugin/server/task_running';
@ -1243,6 +1243,10 @@ instanceStateValue: true
notifyWhen: 'onActiveAlert',
throttle: null,
summary: true,
alertsFilter: {
timeframe: null,
query: { kql: 'kibana.alert.rule.name:abc' },
},
});
switch (scenario.id) {
@ -1292,6 +1296,141 @@ instanceStateValue: true
}
});
it('should filter alerts by kql', async () => {
const reference = alertUtils.generateReference();
const response = await alertUtils.createAlwaysFiringSummaryAction({
reference,
overwrites: {
schedule: { interval: '1s' },
},
notifyWhen: 'onActiveAlert',
throttle: null,
summary: true,
alertsFilter: {
timeframe: null,
query: { kql: 'kibana.alert.instance.id:1' },
},
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'create',
'test.always-firing-alert-as-data',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: `Unauthorized to get actions`,
statusCode: 403,
});
break;
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'superuser at space1':
expect(response.statusCode).to.eql(200);
await esTestIndexTool.waitForDocs('rule:test.always-firing-alert-as-data', reference);
await esTestIndexTool.waitForDocs('action:test.index-record', reference);
const searchResult = await esTestIndexTool.search(
'action:test.index-record',
reference
);
// @ts-expect-error doesnt handle total: number
expect(searchResult.body.hits.total.value).to.eql(1);
// @ts-expect-error _source: unknown
expect(searchResult.body.hits.hits[0]._source.params.message).to.eql(
'Alerts, all:1, new:1 IDs:[1,], ongoing:0 IDs:[], recovered:0 IDs:[]'
);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should filter alerts by hours', async () => {
const now = new Date();
now.setMinutes(now.getMinutes() + 10);
const hour = padStart(now.getUTCHours().toString(), 2);
const minutesStart = padStart(now.getUTCMinutes().toString(), 2, '0');
now.setMinutes(now.getMinutes() + 1);
const minutesEnd = padStart(now.getUTCMinutes().toString(), 2, '0');
const start = `${hour}:${minutesStart}`;
const end = `${hour}:${minutesEnd}`;
const reference = alertUtils.generateReference();
const response = await alertUtils.createAlwaysFiringSummaryAction({
reference,
overwrites: {
schedule: { interval: '1s' },
},
notifyWhen: 'onActiveAlert',
throttle: null,
summary: true,
alertsFilter: {
timeframe: {
days: [1, 2, 3, 4, 5, 6, 7],
timezone: 'UTC',
hours: { start, end },
},
query: null,
},
});
switch (scenario.id) {
case 'no_kibana_privileges at space1':
case 'space_1_all at space2':
case 'global_read at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: getConsumerUnauthorizedErrorMessage(
'create',
'test.always-firing-alert-as-data',
'alertsFixture'
),
statusCode: 403,
});
break;
case 'space_1_all_alerts_none_actions at space1':
expect(response.statusCode).to.eql(403);
expect(response.body).to.eql({
error: 'Forbidden',
message: `Unauthorized to get actions`,
statusCode: 403,
});
break;
case 'space_1_all at space1':
case 'space_1_all_with_restricted_fixture at space1':
case 'superuser at space1':
expect(response.statusCode).to.eql(200);
await esTestIndexTool.waitForDocs('rule:test.always-firing-alert-as-data', reference);
const searchResult = await esTestIndexTool.search(
'action:test.index-record',
reference
);
// @ts-expect-error doesnt handle total: number
expect(searchResult.body.hits.total.value).to.eql(0);
break;
default:
throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`);
}
});
it('should schedule actions for summary of alerts on a custom interval', async () => {
const reference = alertUtils.generateReference();
const response = await alertUtils.createAlwaysFiringSummaryAction({