[ResponseOps][BE] Alert creation delay based on user definition (#174657)

Related to https://github.com/elastic/kibana/issues/173009

## Summary

This is the first of two PRs and only focuses on the backend
implementation. This PR adds a new `notificationDelay` field to the
`Rule` object. With the delay the rule will run X times and has to match
the threshold X times before triggering actions. It won't affect the
alert recovery, but it can be expanded on easily if we want to include
recovered alerts in the future.


### Checklist

- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios


### To verify

- Use [Dev Tools](http://localhost:5601/app/dev_tools#/console) to
create a rule with the `notificationDelay`

```
POST kbn:/api/alerting/rule
{
  "params": {
    "searchType": "esQuery",
    "timeWindowSize": 5,
    "timeWindowUnit": "m",
    "threshold": [
      -1
    ],
    "thresholdComparator": ">",
    "size": 100,
    "esQuery": """{
    "query":{
      "match_all" : {}
    }
  }""",
    "aggType": "count",
    "groupBy": "all",
    "termSize": 5,
    "excludeHitsFromPreviousRun": false,
    "sourceFields": [],
    "index": [
      ".kibana-event-log*"
    ],
    "timeField": "@timestamp"
  },
  "consumer": "stackAlerts",
  "schedule": {
    "interval": "1m"
  },
  "tags": [],
  "name": "test",
  "rule_type_id": ".es-query",
  "actions": [
    {
      "group": "query matched",
      "id": "${ACTION_ID}",
      "params": {
        "level": "info",
        "message": """Elasticsearch query rule '{{rule.name}}' is active:

- Value: {{context.value}}
- Conditions Met: {{context.conditions}} over {{rule.params.timeWindowSize}}{{rule.params.timeWindowUnit}}
- Timestamp: {{context.date}}
- Link: {{context.link}}"""
      },
      "frequency": {
        "notify_when": "onActionGroupChange",
        "throttle": null,
        "summary": false
      }
    }
  ],
  "notification_delay": {
    "active": 3
  }
}
```

- Verify that the rule will not trigger actions until it has matched the
delay threshold. It might be helpful to look at rule details page and
add the Triggered actions column to easily see the action was triggered
after X consecutive active alerts
<img width="1420" alt="Screen Shot 2024-01-16 at 1 18 52 PM"
src="85d8ceef-042c-4a52-950e-24492dc0e79f">
- Verify that the delay does not affect recovered alerts
This commit is contained in:
Alexi Doak 2024-01-23 12:50:57 -08:00 committed by GitHub
parent 3e9cc8d692
commit 80640cf1e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 817 additions and 11 deletions

View file

@ -17,6 +17,8 @@ const trackedAlertStateRt = t.type({
flappingHistory: t.array(t.boolean),
// flapping flag that indicates whether the alert is flapping
flapping: t.boolean,
// count of consecutive recovered alerts for flapping
// will reset if the alert is active or if equal to the statusChangeThreshold stored in the rule settings
pendingRecoveredCount: t.number,
});

View file

@ -32,8 +32,13 @@ export const metaSchema = schema.object({
// flapping flag that indicates whether the alert is flapping
flapping: schema.maybe(schema.boolean()),
maintenanceWindowIds: schema.maybe(schema.arrayOf(schema.string())),
// count of consecutive recovered alerts for flapping
// will reset if the alert is active or if equal to the statusChangeThreshold stored in the rule settings
pendingRecoveredCount: schema.maybe(schema.number()),
uuid: schema.maybe(schema.string()),
// count of consecutive active alerts
// will reset if the alert is recovered or if equal to notificationDelay.active stored in the rule
activeCount: schema.maybe(schema.number()),
});
export const rawAlertInstanceSchema = schema.object({

View file

@ -7,7 +7,7 @@
import { schema } from '@kbn/config-schema';
import { validateDurationV1, validateHoursV1, validateTimezoneV1 } from '../../../validation';
import { notifyWhenSchemaV1 } from '../../../response';
import { notifyWhenSchemaV1, notificationDelaySchemaV1 } from '../../../response';
import { alertsFilterQuerySchemaV1 } from '../../../../alerts_filter_query';
export const actionFrequencySchema = schema.object({
@ -68,6 +68,7 @@ export const createBodySchema = schema.object({
}),
actions: schema.arrayOf(actionSchema, { defaultValue: [] }),
notify_when: schema.maybe(schema.nullable(notifyWhenSchemaV1)),
notification_delay: schema.maybe(notificationDelaySchemaV1),
});
export const createParamsSchema = schema.object({

View file

@ -30,6 +30,7 @@ export interface CreateRuleRequestBody<Params extends RuleParamsV1 = never> {
schedule: CreateBodySchema['schedule'];
actions: CreateBodySchema['actions'];
notify_when?: CreateBodySchema['notify_when'];
notification_delay?: CreateBodySchema['notification_delay'];
}
export interface CreateRuleResponse<Params extends RuleParamsV1 = never> {

View file

@ -37,6 +37,7 @@ export {
ruleSnoozeScheduleSchema as ruleSnoozeScheduleSchemaV1,
notifyWhenSchema as notifyWhenSchemaV1,
scheduleIdsSchema as scheduleIdsSchemaV1,
notificationDelaySchema as notificationDelaySchemaV1,
} from './schemas/v1';
export type {

View file

@ -182,6 +182,10 @@ export const ruleSnoozeScheduleSchema = schema.object({
skipRecurrences: schema.maybe(schema.arrayOf(schema.string())),
});
export const notificationDelaySchema = schema.object({
active: schema.number(),
});
export const ruleResponseSchema = schema.object({
id: schema.string(),
enabled: schema.boolean(),
@ -214,6 +218,7 @@ export const ruleResponseSchema = schema.object({
revision: schema.number(),
running: schema.maybe(schema.nullable(schema.boolean())),
view_in_app_relative_url: schema.maybe(schema.nullable(schema.string())),
notification_delay: schema.maybe(notificationDelaySchema),
});
export const scheduleIdsSchema = schema.maybe(schema.arrayOf(schema.string()));

View file

@ -53,4 +53,5 @@ export interface RuleResponse<Params extends RuleParams = never> {
revision: RuleResponseSchemaType['revision'];
running?: RuleResponseSchemaType['running'];
view_in_app_relative_url?: RuleResponseSchemaType['view_in_app_relative_url'];
notification_delay?: RuleResponseSchemaType['notification_delay'];
}

View file

@ -141,6 +141,10 @@ export interface MappedParamsProperties {
export type MappedParams = SavedObjectAttributes & MappedParamsProperties;
export interface NotificationDelay {
active: number;
}
export interface Rule<Params extends RuleTypeParams = never> {
id: string;
enabled: boolean;
@ -174,6 +178,7 @@ export interface Rule<Params extends RuleTypeParams = never> {
revision: number;
running?: boolean | null;
viewInAppRelativeUrl?: string;
notificationDelay?: NotificationDelay;
}
export interface SanitizedAlertsFilter extends AlertsFilter {

View file

@ -543,6 +543,7 @@ describe('toRaw', () => {
},
flappingHistory: [false, true, true],
pendingRecoveredCount: 2,
activeCount: 1,
},
};
const alertInstance = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>(
@ -562,6 +563,7 @@ describe('toRaw', () => {
},
flappingHistory: [false, true, true],
flapping: false,
activeCount: 1,
},
};
const alertInstance = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>(
@ -574,6 +576,7 @@ describe('toRaw', () => {
flapping: false,
maintenanceWindowIds: [],
uuid: expect.any(String),
activeCount: 1,
},
});
});
@ -746,3 +749,43 @@ describe('isFilteredOut', () => {
expect(alert.isFilteredOut(summarizedAlerts)).toBe(true);
});
});
describe('incrementActiveCount', () => {
test('correctly increments activeCount', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1', {
meta: { activeCount: 3 },
});
alert.incrementActiveCount();
expect(alert.getActiveCount()).toEqual(4);
});
test('correctly increments activeCount when it is not already defined', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1');
alert.incrementActiveCount();
expect(alert.getActiveCount()).toEqual(1);
});
});
describe('getActiveCount', () => {
test('returns ActiveCount', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1', {
meta: { activeCount: 3 },
});
expect(alert.getActiveCount()).toEqual(3);
});
test('defines and returns activeCount when it is not already defined', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1');
expect(alert.getActiveCount()).toEqual(0);
});
});
describe('resetActiveCount', () => {
test('resets activeCount to 0', () => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, DefaultActionGroupId>('1', {
meta: { activeCount: 3 },
});
alert.resetActiveCount();
expect(alert.getActiveCount()).toEqual(0);
});
});

View file

@ -255,6 +255,7 @@ export class Alert<
flappingHistory: this.meta.flappingHistory,
flapping: this.meta.flapping,
uuid: this.meta.uuid,
activeCount: this.meta.activeCount,
},
}
: {
@ -327,4 +328,19 @@ export class Alert<
getMaintenanceWindowIds() {
return this.meta.maintenanceWindowIds ?? [];
}
incrementActiveCount() {
if (!this.meta.activeCount) {
this.meta.activeCount = 0;
}
this.meta.activeCount++;
}
getActiveCount() {
return this.meta.activeCount || 0;
}
resetActiveCount() {
this.meta.activeCount = 0;
}
}

View file

@ -7,7 +7,11 @@
import { schema } from '@kbn/config-schema';
import { validateDuration } from '../../../validation';
import { notifyWhenSchema, actionAlertsFilterSchema } from '../../../schemas';
import {
notifyWhenSchema,
actionAlertsFilterSchema,
notificationDelaySchema,
} from '../../../schemas';
export const createRuleDataSchema = schema.object({
name: schema.string(),
@ -40,4 +44,5 @@ export const createRuleDataSchema = schema.object({
{ defaultValue: [] }
),
notifyWhen: schema.maybe(schema.nullable(notifyWhenSchema)),
notificationDelay: schema.maybe(notificationDelaySchema),
});

View file

@ -22,4 +22,5 @@ export interface CreateRuleData<Params extends RuleParams = never> {
schedule: CreateRuleDataType['schedule'];
actions: CreateRuleDataType['actions'];
notifyWhen?: CreateRuleDataType['notifyWhen'];
notificationDelay?: CreateRuleDataType['notificationDelay'];
}

View file

@ -13,6 +13,7 @@ export {
monitoringSchema,
ruleSchema,
ruleDomainSchema,
notificationDelaySchema,
} from './rule_schemas';
export {

View file

@ -132,6 +132,10 @@ export const snoozeScheduleSchema = schema.object({
skipRecurrences: schema.maybe(schema.arrayOf(schema.string())),
});
export const notificationDelaySchema = schema.object({
active: schema.number(),
});
/**
* Unsanitized (domain) rule schema, used by internal rules clients
*/
@ -168,6 +172,7 @@ export const ruleDomainSchema = schema.object({
revision: schema.number(),
running: schema.maybe(schema.nullable(schema.boolean())),
viewInAppRelativeUrl: schema.maybe(schema.nullable(schema.string())),
notificationDelay: schema.maybe(notificationDelaySchema),
});
/**
@ -205,4 +210,5 @@ export const ruleSchema = schema.object({
revision: schema.number(),
running: schema.maybe(schema.nullable(schema.boolean())),
viewInAppRelativeUrl: schema.maybe(schema.nullable(schema.string())),
notificationDelay: schema.maybe(notificationDelaySchema),
});

View file

@ -216,6 +216,7 @@ export const transformRuleAttributesToRuleDomain = <Params extends RuleParams =
...(esRule.nextRun ? { nextRun: new Date(esRule.nextRun) } : {}),
revision: esRule.revision,
running: esRule.running,
...(esRule.notificationDelay ? { notificationDelay: esRule.notificationDelay } : {}),
};
// Bad casts, but will fix once we fix all rule types

View file

@ -49,6 +49,7 @@ export const transformRuleDomainToRule = <Params extends RuleParams = never>(
revision: ruleDomain.revision,
running: ruleDomain.running,
viewInAppRelativeUrl: ruleDomain.viewInAppRelativeUrl,
notificationDelay: ruleDomain.notificationDelay,
};
if (isPublic) {

View file

@ -68,5 +68,6 @@ export const transformRuleDomainToRuleAttributes = (
...(rule.nextRun !== undefined ? { nextRun: rule.nextRun?.toISOString() || null } : {}),
revision: rule.revision,
...(rule.running !== undefined ? { running: rule.running } : {}),
...(rule.notificationDelay !== undefined ? { notificationDelay: rule.notificationDelay } : {}),
};
};

View file

@ -85,6 +85,7 @@ export interface Rule<Params extends RuleParams = never> {
revision: RuleSchemaType['revision'];
running?: RuleSchemaType['running'];
viewInAppRelativeUrl?: RuleSchemaType['viewInAppRelativeUrl'];
notificationDelay?: RuleSchemaType['notificationDelay'];
}
export interface RuleDomain<Params extends RuleParams = never> {
@ -120,4 +121,5 @@ export interface RuleDomain<Params extends RuleParams = never> {
revision: RuleDomainSchemaType['revision'];
running?: RuleDomainSchemaType['running'];
viewInAppRelativeUrl?: RuleDomainSchemaType['viewInAppRelativeUrl'];
notificationDelay?: RuleSchemaType['notificationDelay'];
}

View file

@ -142,6 +142,10 @@ interface RuleMetaAttributes {
versionApiKeyLastmodified?: string;
}
interface NotificationDelayAttributes {
active: number;
}
export interface RuleAttributes {
name: string;
tags: string[];
@ -174,4 +178,5 @@ export interface RuleAttributes {
nextRun?: string | null;
revision: number;
running?: boolean | null;
notificationDelay?: NotificationDelayAttributes;
}

View file

@ -35,6 +35,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 1,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -49,6 +50,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 1,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -59,6 +61,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 1,
"flapping": false,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -105,6 +108,7 @@ describe('getAlertsForNotification', () => {
Object {
"3": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -129,6 +133,7 @@ describe('getAlertsForNotification', () => {
Object {
"3": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -141,18 +146,19 @@ describe('getAlertsForNotification', () => {
`);
expect(Object.values(currentActiveAlerts).map((a) => a.getScheduledActionOptions()))
.toMatchInlineSnapshot(`
Array [
Object {
"actionGroup": "default",
"context": Object {},
"state": Object {},
},
]
`);
Array [
Object {
"actionGroup": "default",
"context": Object {},
"state": Object {},
},
]
`);
expect(alertsWithAnyUUID(recoveredAlerts)).toMatchInlineSnapshot(`
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -163,6 +169,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -176,6 +183,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -186,6 +194,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -233,6 +242,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [
true,
@ -247,6 +257,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [
true,
@ -261,6 +272,7 @@ describe('getAlertsForNotification', () => {
},
"3": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [
true,
@ -279,6 +291,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [
true,
@ -293,6 +306,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [
true,
@ -307,6 +321,7 @@ describe('getAlertsForNotification', () => {
},
"3": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [
true,
@ -357,6 +372,7 @@ describe('getAlertsForNotification', () => {
Object {
"3": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -385,6 +401,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -395,6 +412,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -408,6 +426,7 @@ describe('getAlertsForNotification', () => {
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flapping": true,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -418,6 +437,7 @@ describe('getAlertsForNotification', () => {
},
"2": Object {
"meta": Object {
"activeCount": 0,
"flapping": false,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
@ -428,4 +448,130 @@ describe('getAlertsForNotification', () => {
}
`);
});
test('should increment activeCount for all active alerts', () => {
const alert1 = new Alert('1', {
meta: { activeCount: 1, uuid: 'uuid-1' },
});
const alert2 = new Alert('2', { meta: { uuid: 'uuid-2' } });
const { newAlerts, activeAlerts } = getAlertsForNotification(
DEFAULT_FLAPPING_SETTINGS,
true,
'default',
{
'1': alert1,
},
{
'1': alert1,
'2': alert2,
},
{},
{}
);
expect(newAlerts).toMatchInlineSnapshot(`
Object {
"1": Object {
"meta": Object {
"activeCount": 2,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"pendingRecoveredCount": 0,
"uuid": "uuid-1",
},
"state": Object {},
},
}
`);
expect(activeAlerts).toMatchInlineSnapshot(`
Object {
"1": Object {
"meta": Object {
"activeCount": 2,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"pendingRecoveredCount": 0,
"uuid": "uuid-1",
},
"state": Object {},
},
"2": Object {
"meta": Object {
"activeCount": 1,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"pendingRecoveredCount": 0,
"uuid": "uuid-2",
},
"state": Object {},
},
}
`);
});
test('should reset activeCount for all recovered alerts', () => {
const alert1 = new Alert('1', { meta: { activeCount: 3 } });
const alert3 = new Alert('3');
const { recoveredAlerts, currentRecoveredAlerts } = getAlertsForNotification(
DEFAULT_FLAPPING_SETTINGS,
true,
'default',
{},
{},
{
'1': alert1,
'3': alert3,
},
{
'1': alert1,
'3': alert3,
}
);
expect(alertsWithAnyUUID(recoveredAlerts)).toMatchInlineSnapshot(`
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"uuid": Any<String>,
},
"state": Object {},
},
"3": Object {
"meta": Object {
"activeCount": 0,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"uuid": Any<String>,
},
"state": Object {},
},
}
`);
expect(alertsWithAnyUUID(currentRecoveredAlerts)).toMatchInlineSnapshot(`
Object {
"1": Object {
"meta": Object {
"activeCount": 0,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"uuid": Any<String>,
},
"state": Object {},
},
"3": Object {
"meta": Object {
"activeCount": 0,
"flappingHistory": Array [],
"maintenanceWindowIds": Array [],
"uuid": Any<String>,
},
"state": Object {},
},
}
`);
});
});

View file

@ -28,12 +28,14 @@ export function getAlertsForNotification<
for (const id of keys(activeAlerts)) {
const alert = activeAlerts[id];
alert.incrementActiveCount();
alert.resetPendingRecoveredCount();
currentActiveAlerts[id] = alert;
}
for (const id of keys(currentRecoveredAlerts)) {
const alert = recoveredAlerts[id];
alert.resetActiveCount();
if (flappingSettings.enabled) {
const flapping = alert.getFlapping();
if (flapping) {

View file

@ -58,5 +58,6 @@ export const transformCreateBody = <Params extends RuleParams = never>(
schedule: createBody.schedule,
actions: transformCreateBodyActions(createBody.actions),
...(createBody.notify_when ? { notifyWhen: createBody.notify_when } : {}),
...(createBody.notification_delay ? { notificationDelay: createBody.notification_delay } : {}),
};
};

View file

@ -119,4 +119,5 @@ export const transformRuleToRuleResponse = <Params extends RuleParams = never>(
...(rule.viewInAppRelativeUrl !== undefined
? { view_in_app_relative_url: rule.viewInAppRelativeUrl }
: {}),
...(rule.notificationDelay !== undefined ? { notification_delay: rule.notificationDelay } : {}),
});

View file

@ -213,6 +213,10 @@ const rawRuleActionSchema = schema.object({
useAlertDataForTemplate: schema.maybe(schema.boolean()),
});
export const notificationDelaySchema = schema.object({
active: schema.number(),
});
export const rawRuleSchema = schema.object({
name: schema.string(),
enabled: schema.boolean(),
@ -270,4 +274,5 @@ export const rawRuleSchema = schema.object({
),
params: schema.recordOf(schema.string(), schema.maybe(schema.any())),
typeVersion: schema.maybe(schema.number()),
notificationDelay: schema.maybe(notificationDelaySchema),
});

View file

@ -161,6 +161,7 @@ const generateAlert = ({
lastScheduledActionsGroup = 'default',
maintenanceWindowIds,
pendingRecoveredCount,
activeCount,
}: {
id: number;
group?: ActiveActionGroup | 'recovered';
@ -171,6 +172,7 @@ const generateAlert = ({
lastScheduledActionsGroup?: string;
maintenanceWindowIds?: string[];
pendingRecoveredCount?: number;
activeCount?: number;
}) => {
const alert = new Alert<AlertInstanceState, AlertInstanceContext, 'default' | 'other-group'>(
String(id),
@ -184,6 +186,7 @@ const generateAlert = ({
actions: throttledActions,
},
pendingRecoveredCount,
activeCount,
},
}
);
@ -2049,6 +2052,165 @@ describe('Execution Handler', () => {
`);
});
test('does not schedule actions for alerts with activeCount less than the notificationDelay.active threshold', async () => {
const executionHandler = new ExecutionHandler(
generateExecutionParams({
...defaultExecutionParams,
rule: {
...defaultExecutionParams.rule,
notificationDelay: {
active: 3,
},
},
})
);
await executionHandler.run({
...generateAlert({ id: 1 }),
...generateAlert({ id: 2, activeCount: 2 }),
});
expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled();
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(2);
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith(
'no scheduling of action "1" for rule "1": the alert activeCount: 0 is less than the rule notificationDelay.active: 3 threshold.'
);
expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith(
'no scheduling of action "1" for rule "1": the alert activeCount: 2 is less than the rule notificationDelay.active: 3 threshold.'
);
});
test('schedules actions for alerts with activeCount greater than or equal the notificationDelay.active threshold', async () => {
const executionHandler = new ExecutionHandler(
generateExecutionParams({
...defaultExecutionParams,
rule: {
...defaultExecutionParams.rule,
notificationDelay: {
active: 3,
},
},
})
);
await executionHandler.run({
...generateAlert({ id: 1, activeCount: 3 }),
...generateAlert({ id: 2, activeCount: 4 }),
});
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"actionTypeId": "test",
"apiKey": "MTIzOmFiYw==",
"consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
"alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here",
"contextVal": "My goes here",
"foo": true,
"stateVal": "My goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
"type": "alert",
},
"type": "SAVED_OBJECT",
},
"spaceId": "test1",
},
Object {
"actionTypeId": "test",
"apiKey": "MTIzOmFiYw==",
"consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
"alertVal": "My 1 name-of-alert test1 tag-A,tag-B 2 goes here",
"contextVal": "My goes here",
"foo": true,
"stateVal": "My goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
"type": "alert",
},
"type": "SAVED_OBJECT",
},
"spaceId": "test1",
},
],
]
`);
});
test('schedules actions if notificationDelay.active threshold is not defined', async () => {
const executionHandler = new ExecutionHandler(generateExecutionParams());
await executionHandler.run({
...generateAlert({ id: 1, activeCount: 1 }),
});
expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1);
expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Array [
Object {
"actionTypeId": "test",
"apiKey": "MTIzOmFiYw==",
"consumer": "rule-consumer",
"executionId": "5f6aa57d-3e22-484e-bae8-cbed868f4d28",
"id": "1",
"params": Object {
"alertVal": "My 1 name-of-alert test1 tag-A,tag-B 1 goes here",
"contextVal": "My goes here",
"foo": true,
"stateVal": "My goes here",
},
"relatedSavedObjects": Array [
Object {
"id": "1",
"namespace": "test1",
"type": "alert",
"typeId": "test",
},
],
"source": Object {
"source": Object {
"id": "1",
"type": "alert",
},
"type": "SAVED_OBJECT",
},
"spaceId": "test1",
},
],
]
`);
});
describe('rule url', () => {
const ruleWithUrl = {
...rule,

View file

@ -628,6 +628,22 @@ export class ExecutionHandler<
continue;
}
if (
this.rule.notificationDelay &&
alert.getActiveCount() < this.rule.notificationDelay.active
) {
this.logger.debug(
`no scheduling of action "${action.id}" for rule "${
this.taskInstance.params.alertId
}": the alert activeCount: ${alert.getActiveCount()} is less than the rule notificationDelay.active: ${
this.rule.notificationDelay.active
} threshold.`
);
continue;
} else {
alert.resetActiveCount();
}
const actionGroup = this.getActionGroup(alert);
if (!this.ruleTypeActionGroups!.has(actionGroup)) {

View file

@ -454,6 +454,7 @@ export const generateAlertInstance = (
flapping: false,
maintenanceWindowIds: maintenanceWindowIds || [],
pendingRecoveredCount: 0,
activeCount: 0,
},
state: {
bar: false,

View file

@ -2954,6 +2954,7 @@ describe('Task Runner', () => {
maintenanceWindowIds: [],
flapping: false,
pendingRecoveredCount: 0,
activeCount: 0,
},
state: {
duration: '0',
@ -3124,6 +3125,7 @@ describe('Task Runner', () => {
maintenanceWindowIds: [],
flapping: false,
pendingRecoveredCount: 0,
activeCount: 0,
},
state: {
duration: '0',
@ -3141,6 +3143,7 @@ describe('Task Runner', () => {
maintenanceWindowIds: [],
flapping: false,
pendingRecoveredCount: 0,
activeCount: 0,
},
state: {
duration: '0',

View file

@ -60,6 +60,7 @@ import {
AlertsFilter,
AlertsFilterTimeframe,
RuleAlertData,
NotificationDelay,
} from '../common';
import { PublicAlertFactory } from './alert/create_alert_factory';
import { RulesSettingsFlappingProperties } from '../common/rules_settings';
@ -409,6 +410,7 @@ export type PublicRuleResultService = PublicLastRunSetters;
export interface RawRuleLastRun extends SavedObjectAttributes, RuleLastRun {}
export interface RawRuleMonitoring extends SavedObjectAttributes, RuleMonitoring {}
export interface RawNotificationDelay extends SavedObjectAttributes, NotificationDelay {}
export interface RawRuleAlertsFilter extends AlertsFilter {
query?: {
@ -485,6 +487,7 @@ export interface RawRule extends SavedObjectAttributes {
nextRun?: string | null;
revision: number;
running?: boolean | null;
notificationDelay?: RawNotificationDelay;
}
export type { DataStreamAdapter } from './alerts_service/lib/data_stream_adapter';

View file

@ -1848,6 +1848,158 @@ export default function eventLogTests({ getService }: FtrProviderContext) {
expect(hasActions).eql(false);
});
it('should generate expected events with a notificationDelay', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
// pattern of when the alert should fire
const pattern = {
instance: [true, true, true, false, true],
};
const response = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.patternFiring',
schedule: { interval: '1s' },
throttle: null,
params: {
pattern,
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
notification_delay: {
active: 3,
},
})
);
expect(response.status).to.eql(200);
const alertId = response.body.id;
objectRemover.add(space.id, alertId, 'rule', 'alerting');
// get the events we're expecting
const events = await retry.try(async () => {
return await getEventLog({
getService,
spaceId: space.id,
type: 'alert',
id: alertId,
provider: 'alerting',
actions: new Map([
// make sure the counts of the # of events per type are as expected
['execute-start', { gte: 5 }],
['execute', { gte: 5 }],
['new-instance', { equal: 2 }],
['active-instance', { gte: 1 }],
['recovered-instance', { equal: 1 }],
]),
});
});
const actualTriggeredActions = events
.filter((event) => event?.event?.action === 'execute')
.reduce(
(acc, event) =>
acc +
(event?.kibana?.alert?.rule?.execution?.metrics
?.number_of_triggered_actions as number),
0
);
expect(actualTriggeredActions).to.eql(1);
});
it('should generate expected events with a notificationDelay with AAD', async () => {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'MY action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
// pattern of when the alert should fire
const pattern = {
instance: [true, true, true, false, true],
};
const response = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.patternFiringAad',
schedule: { interval: '1s' },
throttle: null,
params: {
pattern,
},
actions: [
{
id: createdAction.id,
group: 'default',
params: {},
},
],
notification_delay: {
active: 3,
},
})
);
expect(response.status).to.eql(200);
const alertId = response.body.id;
objectRemover.add(space.id, alertId, 'rule', 'alerting');
// get the events we're expecting
const events = await retry.try(async () => {
return await getEventLog({
getService,
spaceId: space.id,
type: 'alert',
id: alertId,
provider: 'alerting',
actions: new Map([
// make sure the counts of the # of events per type are as expected
['execute-start', { gte: 5 }],
['execute', { gte: 5 }],
['new-instance', { equal: 2 }],
['active-instance', { gte: 1 }],
['recovered-instance', { equal: 1 }],
]),
});
});
const actualTriggeredActions = events
.filter((event) => event?.event?.action === 'execute')
.reduce(
(acc, event) =>
acc +
(event?.kibana?.alert?.rule?.execution?.metrics
?.number_of_triggered_actions as number),
0
);
expect(actualTriggeredActions).to.eql(1);
});
});
}
});

View file

@ -21,7 +21,7 @@ import {
} from '../../../../../common/lib';
// eslint-disable-next-line import/no-default-export
export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) {
export default function createAlertsAsDataFlappingTest({ getService }: FtrProviderContext) {
const es = getService('es');
const retry = getService('retry');
const supertest = getService('supertest');

View file

@ -28,6 +28,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC
loadTestFile(require.resolve('./run_soon'));
loadTestFile(require.resolve('./flapping_history'));
loadTestFile(require.resolve('./check_registered_rule_types'));
loadTestFile(require.resolve('./notification_delay'));
loadTestFile(require.resolve('./generate_alert_schemas'));
// Do not place test files here, due to https://github.com/elastic/kibana/issues/123059

View file

@ -0,0 +1,210 @@
/*
* 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 expect from '@kbn/expect';
import { get } from 'lodash';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
import { getUrlPrefix, getTestRuleData, ObjectRemover } from '../../../../common/lib';
import { Spaces } from '../../../scenarios';
// eslint-disable-next-line import/no-default-export
export default function createNotificationDelayTests({ getService }: FtrProviderContext) {
const es = getService('es');
const supertestWithoutAuth = getService('supertestWithoutAuth');
const retry = getService('retry');
const supertest = getService('supertest');
const space = Spaces.default;
const ACTIVE_PATH = 'alertInstances.instance.meta.activeCount';
const RECOVERED_PATH = 'alertRecoveredInstances.instance.meta.activeCount';
describe('Notification Delay', () => {
let actionId: string;
const objectRemover = new ObjectRemover(supertestWithoutAuth);
before(async () => {
actionId = await createAction();
});
after(async () => {
objectRemover.add(space.id, actionId, 'connector', 'actions');
await objectRemover.removeAll();
});
afterEach(() => objectRemover.removeAll());
it('should clear the activeCount if the notificationDelay is not configured for the rule', async () => {
const start = new Date().toISOString();
const pattern = {
instance: [true],
};
const ruleId = await createRule(actionId, pattern);
objectRemover.add(space.id, ruleId, 'rule', 'alerting');
const state = await getAlertState(start, ruleId, 0);
expect(get(state, ACTIVE_PATH)).to.eql(0);
});
it('should update the activeCount when alert is active and clear when recovered if the notificationDelay is configured for the rule', async () => {
let start = new Date().toISOString();
const pattern = {
instance: [true, true, true, false, true],
};
const ruleId = await createRule(actionId, pattern, 20);
objectRemover.add(space.id, ruleId, 'rule', 'alerting');
let state = await getAlertState(start, ruleId);
expect(get(state, ACTIVE_PATH)).to.eql(1);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 2, true);
expect(get(state, ACTIVE_PATH)).to.eql(2);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 3, true);
expect(get(state, ACTIVE_PATH)).to.eql(3);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 0, true, true);
expect(get(state, RECOVERED_PATH)).to.eql(0);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 1, true);
expect(get(state, ACTIVE_PATH)).to.eql(1);
});
it('should reset the activeCount when count of consecutive active alerts exceeds the notificationDelay count', async () => {
let start = new Date().toISOString();
const pattern = {
instance: [true, true, true, true, true],
};
const ruleId = await createRule(actionId, pattern, 3);
objectRemover.add(space.id, ruleId, 'rule', 'alerting');
let state = await getAlertState(start, ruleId);
expect(get(state, ACTIVE_PATH)).to.eql(1);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 2, true);
expect(get(state, ACTIVE_PATH)).to.eql(2);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 0, true);
expect(get(state, ACTIVE_PATH)).to.eql(0);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 1, true);
expect(get(state, ACTIVE_PATH)).to.eql(1);
start = new Date().toISOString();
state = await getAlertState(start, ruleId, 2, true);
expect(get(state, ACTIVE_PATH)).to.eql(2);
});
});
async function getState(start: string, count: number, recovered: boolean) {
const result: any = await retry.try(async () => {
const searchResult = await es.search({
index: '.kibana_task_manager',
body: {
query: {
bool: {
must: [
{
term: {
'task.taskType': 'alerting:test.patternFiring',
},
},
{
range: {
'task.scheduledAt': {
gte: start,
},
},
},
],
},
},
},
});
const taskDoc: any = searchResult.hits.hits[0];
const state = JSON.parse(taskDoc._source.task.state);
const activeCount = recovered ? get(state, RECOVERED_PATH) : get(state, ACTIVE_PATH);
if (activeCount !== count) {
throw new Error(`Expected ${count} rule executions but received ${activeCount}.`);
}
return state;
});
return result;
}
async function getAlertState(
start: string,
ruleId: string,
count: number = 1,
runRule: boolean = false,
recovered: boolean = false
) {
if (runRule) {
const response = await supertest
.post(`${getUrlPrefix(space.id)}/internal/alerting/rule/${ruleId}/_run_soon`)
.set('kbn-xsrf', 'foo');
expect(response.status).to.eql(204);
}
return await getState(start, count, recovered);
}
async function createRule(
actionId: string,
pattern: { instance: boolean[] },
activeCount?: number
) {
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(space.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send(
getTestRuleData({
rule_type_id: 'test.patternFiring',
schedule: { interval: '24h' },
throttle: null,
params: {
pattern,
},
actions: [
{
id: actionId,
group: 'default',
params: {},
},
],
...(activeCount ? { notification_delay: { active: activeCount } } : {}),
})
)
.expect(200);
return createdRule.id;
}
async function createAction() {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(space.id)}/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'My action',
connector_type_id: 'test.noop',
config: {},
secrets: {},
})
.expect(200);
return createdAction.id;
}
}