From ef588e0771f3ba19c0212aff40ab0f1d4b589975 Mon Sep 17 00:00:00 2001 From: Alexi Doak <109488926+doakalexi@users.noreply.github.com> Date: Tue, 23 Jan 2024 12:50:57 -0800 Subject: [PATCH] [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 Screen Shot 2024-01-16 at 1 18 52 PM - Verify that the delay does not affect recovered alerts --- .../src/lifecycle_state.ts | 2 + .../src/task_state/v1/schema.ts | 5 + .../routes/rule/apis/create/schemas/v1.ts | 3 +- .../routes/rule/apis/create/types/v1.ts | 1 + .../common/routes/rule/response/index.ts | 1 + .../common/routes/rule/response/schemas/v1.ts | 5 + .../common/routes/rule/response/types/v1.ts | 1 + x-pack/plugins/alerting/common/rule.ts | 5 + .../alerting/server/alert/alert.test.ts | 43 ++++ x-pack/plugins/alerting/server/alert/alert.ts | 16 ++ .../create/schemas/create_rule_data_schema.ts | 7 +- .../methods/create/types/create_rule_data.ts | 1 + .../server/application/rule/schemas/index.ts | 1 + .../application/rule/schemas/rule_schemas.ts | 6 + ...ransform_rule_attributes_to_rule_domain.ts | 1 + .../transform_rule_domain_to_rule.ts | 1 + ...ransform_rule_domain_to_rule_attributes.ts | 1 + .../server/application/rule/types/rule.ts | 2 + .../server/data/rule/types/rule_attributes.ts | 5 + .../lib/get_alerts_for_notification.test.ts | 162 +++++++++++++- .../server/lib/get_alerts_for_notification.ts | 2 + .../transforms/transform_create_body/v1.ts | 1 + .../transform_rule_to_rule_response/v1.ts | 1 + .../saved_objects/schemas/raw_rule/v1.ts | 5 + .../task_runner/execution_handler.test.ts | 162 ++++++++++++++ .../server/task_runner/execution_handler.ts | 16 ++ .../alerting/server/task_runner/fixtures.ts | 1 + .../server/task_runner/task_runner.test.ts | 3 + x-pack/plugins/alerting/server/types.ts | 3 + .../tests/alerting/group1/event_log.ts | 152 +++++++++++++ .../alerts_as_data/alerts_as_data_flapping.ts | 2 +- .../tests/alerting/group4/index.ts | 1 + .../alerting/group4/notification_delay.ts | 210 ++++++++++++++++++ 33 files changed, 817 insertions(+), 11 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/notification_delay.ts diff --git a/x-pack/packages/kbn-alerting-state-types/src/lifecycle_state.ts b/x-pack/packages/kbn-alerting-state-types/src/lifecycle_state.ts index 0d8fb3e5aaadae..8fa5cb111f6d91 100644 --- a/x-pack/packages/kbn-alerting-state-types/src/lifecycle_state.ts +++ b/x-pack/packages/kbn-alerting-state-types/src/lifecycle_state.ts @@ -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, }); diff --git a/x-pack/packages/kbn-alerting-state-types/src/task_state/v1/schema.ts b/x-pack/packages/kbn-alerting-state-types/src/task_state/v1/schema.ts index 62e802483dcf7e..247c0d7fc8d87f 100644 --- a/x-pack/packages/kbn-alerting-state-types/src/task_state/v1/schema.ts +++ b/x-pack/packages/kbn-alerting-state-types/src/task_state/v1/schema.ts @@ -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({ diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts index 30a294d5c0527f..bdc60a6562fe20 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/schemas/v1.ts @@ -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({ diff --git a/x-pack/plugins/alerting/common/routes/rule/apis/create/types/v1.ts b/x-pack/plugins/alerting/common/routes/rule/apis/create/types/v1.ts index 466f5d61eac466..328f44fe185bf4 100644 --- a/x-pack/plugins/alerting/common/routes/rule/apis/create/types/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/apis/create/types/v1.ts @@ -30,6 +30,7 @@ export interface CreateRuleRequestBody { schedule: CreateBodySchema['schedule']; actions: CreateBodySchema['actions']; notify_when?: CreateBodySchema['notify_when']; + notification_delay?: CreateBodySchema['notification_delay']; } export interface CreateRuleResponse { diff --git a/x-pack/plugins/alerting/common/routes/rule/response/index.ts b/x-pack/plugins/alerting/common/routes/rule/response/index.ts index ec8734240cc2bd..8e405c599e4835 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/index.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/index.ts @@ -37,6 +37,7 @@ export { ruleSnoozeScheduleSchema as ruleSnoozeScheduleSchemaV1, notifyWhenSchema as notifyWhenSchemaV1, scheduleIdsSchema as scheduleIdsSchemaV1, + notificationDelaySchema as notificationDelaySchemaV1, } from './schemas/v1'; export type { diff --git a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts index 1c7b202f59060b..67f57926d54603 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/schemas/v1.ts @@ -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())); diff --git a/x-pack/plugins/alerting/common/routes/rule/response/types/v1.ts b/x-pack/plugins/alerting/common/routes/rule/response/types/v1.ts index c6c2c7218ed886..453d1a96d24dcf 100644 --- a/x-pack/plugins/alerting/common/routes/rule/response/types/v1.ts +++ b/x-pack/plugins/alerting/common/routes/rule/response/types/v1.ts @@ -53,4 +53,5 @@ export interface RuleResponse { revision: RuleResponseSchemaType['revision']; running?: RuleResponseSchemaType['running']; view_in_app_relative_url?: RuleResponseSchemaType['view_in_app_relative_url']; + notification_delay?: RuleResponseSchemaType['notification_delay']; } diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index fdd0705629486f..590c1d4312d57b 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -141,6 +141,10 @@ export interface MappedParamsProperties { export type MappedParams = SavedObjectAttributes & MappedParamsProperties; +export interface NotificationDelay { + active: number; +} + export interface Rule { id: string; enabled: boolean; @@ -174,6 +178,7 @@ export interface Rule { revision: number; running?: boolean | null; viewInAppRelativeUrl?: string; + notificationDelay?: NotificationDelay; } export interface SanitizedAlertsFilter extends AlertsFilter { diff --git a/x-pack/plugins/alerting/server/alert/alert.test.ts b/x-pack/plugins/alerting/server/alert/alert.test.ts index a0d0ef25f9a869..6008f862891412 100644 --- a/x-pack/plugins/alerting/server/alert/alert.test.ts +++ b/x-pack/plugins/alerting/server/alert/alert.test.ts @@ -543,6 +543,7 @@ describe('toRaw', () => { }, flappingHistory: [false, true, true], pendingRecoveredCount: 2, + activeCount: 1, }, }; const alertInstance = new Alert( @@ -562,6 +563,7 @@ describe('toRaw', () => { }, flappingHistory: [false, true, true], flapping: false, + activeCount: 1, }, }; const alertInstance = new Alert( @@ -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('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('1'); + alert.incrementActiveCount(); + expect(alert.getActiveCount()).toEqual(1); + }); +}); + +describe('getActiveCount', () => { + test('returns ActiveCount', () => { + const alert = new Alert('1', { + meta: { activeCount: 3 }, + }); + expect(alert.getActiveCount()).toEqual(3); + }); + + test('defines and returns activeCount when it is not already defined', () => { + const alert = new Alert('1'); + expect(alert.getActiveCount()).toEqual(0); + }); +}); + +describe('resetActiveCount', () => { + test('resets activeCount to 0', () => { + const alert = new Alert('1', { + meta: { activeCount: 3 }, + }); + alert.resetActiveCount(); + expect(alert.getActiveCount()).toEqual(0); + }); +}); diff --git a/x-pack/plugins/alerting/server/alert/alert.ts b/x-pack/plugins/alerting/server/alert/alert.ts index 835b8db17a8930..51a9f29891bcf6 100644 --- a/x-pack/plugins/alerting/server/alert/alert.ts +++ b/x-pack/plugins/alerting/server/alert/alert.ts @@ -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; + } } diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts index b7e55919969572..44a89e05992fe3 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/schemas/create_rule_data_schema.ts @@ -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), }); diff --git a/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts b/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts index e75cbb5456c228..f99beda90e80ae 100644 --- a/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts +++ b/x-pack/plugins/alerting/server/application/rule/methods/create/types/create_rule_data.ts @@ -22,4 +22,5 @@ export interface CreateRuleData { schedule: CreateRuleDataType['schedule']; actions: CreateRuleDataType['actions']; notifyWhen?: CreateRuleDataType['notifyWhen']; + notificationDelay?: CreateRuleDataType['notificationDelay']; } diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/index.ts b/x-pack/plugins/alerting/server/application/rule/schemas/index.ts index 50cecadfe4a712..d039d190f1e96b 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/index.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/index.ts @@ -13,6 +13,7 @@ export { monitoringSchema, ruleSchema, ruleDomainSchema, + notificationDelaySchema, } from './rule_schemas'; export { diff --git a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts index ef8f1dc652bff5..b75a6e4f76aade 100644 --- a/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts +++ b/x-pack/plugins/alerting/server/application/rule/schemas/rule_schemas.ts @@ -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), }); diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts index 26831b9dff81c5..ea331cec4fcc31 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_attributes_to_rule_domain.ts @@ -216,6 +216,7 @@ export const transformRuleAttributesToRuleDomain = ( revision: ruleDomain.revision, running: ruleDomain.running, viewInAppRelativeUrl: ruleDomain.viewInAppRelativeUrl, + notificationDelay: ruleDomain.notificationDelay, }; if (isPublic) { diff --git a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts index 20eef8f851e082..2eb21c42587d3f 100644 --- a/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts +++ b/x-pack/plugins/alerting/server/application/rule/transforms/transform_rule_domain_to_rule_attributes.ts @@ -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 } : {}), }; }; diff --git a/x-pack/plugins/alerting/server/application/rule/types/rule.ts b/x-pack/plugins/alerting/server/application/rule/types/rule.ts index d8dbabb72b23b3..1a7e7e1d371182 100644 --- a/x-pack/plugins/alerting/server/application/rule/types/rule.ts +++ b/x-pack/plugins/alerting/server/application/rule/types/rule.ts @@ -85,6 +85,7 @@ export interface Rule { revision: RuleSchemaType['revision']; running?: RuleSchemaType['running']; viewInAppRelativeUrl?: RuleSchemaType['viewInAppRelativeUrl']; + notificationDelay?: RuleSchemaType['notificationDelay']; } export interface RuleDomain { @@ -120,4 +121,5 @@ export interface RuleDomain { revision: RuleDomainSchemaType['revision']; running?: RuleDomainSchemaType['running']; viewInAppRelativeUrl?: RuleDomainSchemaType['viewInAppRelativeUrl']; + notificationDelay?: RuleSchemaType['notificationDelay']; } diff --git a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts index 19a669e6bd33ee..316578149c5ca8 100644 --- a/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts +++ b/x-pack/plugins/alerting/server/data/rule/types/rule_attributes.ts @@ -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; } diff --git a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts index 04f732426cff5a..c1465d5b7a2388 100644 --- a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts +++ b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.test.ts @@ -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, + }, + "state": Object {}, + }, + "3": Object { + "meta": Object { + "activeCount": 0, + "flappingHistory": Array [], + "maintenanceWindowIds": Array [], + "uuid": Any, + }, + "state": Object {}, + }, + } + `); + expect(alertsWithAnyUUID(currentRecoveredAlerts)).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object { + "activeCount": 0, + "flappingHistory": Array [], + "maintenanceWindowIds": Array [], + "uuid": Any, + }, + "state": Object {}, + }, + "3": Object { + "meta": Object { + "activeCount": 0, + "flappingHistory": Array [], + "maintenanceWindowIds": Array [], + "uuid": Any, + }, + "state": Object {}, + }, + } + `); + }); }); diff --git a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts index 4ff8408d67e119..63e95402549a4a 100644 --- a/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts +++ b/x-pack/plugins/alerting/server/lib/get_alerts_for_notification.ts @@ -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) { diff --git a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts index 97ff8b383e4c53..fcf92b386aaa2b 100644 --- a/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/apis/create/transforms/transform_create_body/v1.ts @@ -58,5 +58,6 @@ export const transformCreateBody = ( schedule: createBody.schedule, actions: transformCreateBodyActions(createBody.actions), ...(createBody.notify_when ? { notifyWhen: createBody.notify_when } : {}), + ...(createBody.notification_delay ? { notificationDelay: createBody.notification_delay } : {}), }; }; diff --git a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts index fb6799f2b5e480..4f55401160ce9f 100644 --- a/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts +++ b/x-pack/plugins/alerting/server/routes/rule/transforms/transform_rule_to_rule_response/v1.ts @@ -119,4 +119,5 @@ export const transformRuleToRuleResponse = ( ...(rule.viewInAppRelativeUrl !== undefined ? { view_in_app_relative_url: rule.viewInAppRelativeUrl } : {}), + ...(rule.notificationDelay !== undefined ? { notification_delay: rule.notificationDelay } : {}), }); diff --git a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts index 76c6241396dc30..495c2493f2e434 100644 --- a/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts +++ b/x-pack/plugins/alerting/server/saved_objects/schemas/raw_rule/v1.ts @@ -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), }); diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts index 063e4d933f067d..8f5147ea4de309 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts @@ -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( 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, diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index ec690bb8ba0f5f..e118b4d327ce18 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -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)) { diff --git a/x-pack/plugins/alerting/server/task_runner/fixtures.ts b/x-pack/plugins/alerting/server/task_runner/fixtures.ts index b975f213040133..3d647966414f5c 100644 --- a/x-pack/plugins/alerting/server/task_runner/fixtures.ts +++ b/x-pack/plugins/alerting/server/task_runner/fixtures.ts @@ -454,6 +454,7 @@ export const generateAlertInstance = ( flapping: false, maintenanceWindowIds: maintenanceWindowIds || [], pendingRecoveredCount: 0, + activeCount: 0, }, state: { bar: false, diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts index d395ff3364cd73..d14d44010252d8 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts @@ -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', diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f0b254c7673356..b03c27d1dbe488 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -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'; diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts index 4b0bcba103e109..5228b1c76d3d92 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts @@ -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); + }); }); } }); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts index 87e2d8d91b59f6..59cc5c14730a1e 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts @@ -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'); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts index fda6c1b9c80b26..b73477cf3df302 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/index.ts @@ -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 diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/notification_delay.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/notification_delay.ts new file mode 100644 index 00000000000000..2b632686d57936 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/notification_delay.ts @@ -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; + } +}