From 282305fd65b2a09644a511844475494fde2afdb1 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Wed, 24 May 2023 13:19:09 -0400 Subject: [PATCH] [Response Ops][Alerting] Initial implementation of FAAD `AlertsClient` for writing generic AAD documents (#156946) Resolves https://github.com/elastic/kibana/issues/156442 ## Summary 1. Adds `shouldWriteAlerts` flag to rule type registration which defaults to `false` if not set. This prevents duplicate AAD documents from being written for the rule registry rule types that had to register with the framework in order to get their resources installed on startup. 2. Initial implementation of `AlertsClient` which primarily functions as a proxy to the `LegacyAlertsClient`. It does 2 additional thing: a. When initialized with the active & recovered alerts from the previous execution (de-serialized from the task manager state), it queries the AAD index for the corresponding alert document. b. When returning the alerts to serialize into the task manager state, it builds the alert document and bulk upserts into the AAD index. This PR does not opt any rule types into writing these generic docs but adds an example functional test that does. To test it out with the ES query rule type, add the following ``` diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index 214d2ee4b76..0439a576b03 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -187,5 +187,12 @@ export function getRuleType( }, producer: STACK_ALERTS_FEATURE_ID, doesSetRecoveryContext: true, + alerts: { + context: 'stack', + shouldWrite: true, + mappings: { + fieldMap: {}, + }, + }, }; } ``` ## To Verify - Verify that rule registry rule types still work as expected - Verify that non rule-registry rule types still work as expected - Modify a rule type to register with FAAD and write alerts and verify that the alert documents look as expected. --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/alerting/common/rule.ts | 3 + .../server/alert/create_alert_factory.test.ts | 19 - .../server/alert/create_alert_factory.ts | 5 +- .../alerts_client/alerts_client.mock.ts | 23 + .../alerts_client/alerts_client.test.ts | 897 ++++++++++++++++++ .../server/alerts_client/alerts_client.ts | 296 ++++++ .../alerting/server/alerts_client/index.ts | 10 + .../legacy_alerts_client.mock.ts | 24 + .../legacy_alerts_client.test.ts | 95 +- .../alerts_client/legacy_alerts_client.ts | 146 +-- .../alerts_client/lib/build_new_alert.test.ts | 131 +++ .../alerts_client/lib/build_new_alert.ts | 65 ++ .../lib/build_ongoing_alert.test.ts | 240 +++++ .../alerts_client/lib/build_ongoing_alert.ts | 84 ++ .../lib/build_recovered_alert.test.ts | 216 +++++ .../lib/build_recovered_alert.ts | 97 ++ .../alerts_client/lib/format_rule.test.ts | 76 ++ .../server/alerts_client/lib/format_rule.ts | 37 + .../server/alerts_client/lib/index.ts | 11 + .../alerting/server/alerts_client/types.ts | 82 ++ .../alerts_service/alerts_service.mock.ts | 1 + .../alerts_service/alerts_service.test.ts | 192 +++- .../server/alerts_service/alerts_service.ts | 75 +- x-pack/plugins/alerting/server/config.ts | 3 +- .../alerting/server/lib/license_state.test.ts | 4 +- .../alerting/server/lib/license_state.ts | 7 +- x-pack/plugins/alerting/server/plugin.test.ts | 10 +- x-pack/plugins/alerting/server/plugin.ts | 14 +- .../server/rule_type_registry.test.ts | 124 ++- .../alerting/server/rule_type_registry.ts | 43 +- .../task_runner/execution_handler.test.ts | 3 +- .../server/task_runner/execution_handler.ts | 10 +- .../server/task_runner/task_runner.test.ts | 18 +- .../server/task_runner/task_runner.ts | 111 ++- .../server/task_runner/task_runner_factory.ts | 10 +- .../alerting/server/task_runner/types.ts | 7 +- x-pack/plugins/alerting/server/types.ts | 36 +- .../plugins/alerts/server/alert_types.ts | 101 +- .../group4/alerts_as_data/alerts_as_data.ts | 378 ++++++++ .../alerts_as_data/alerts_as_data_flapping.ts | 493 ++++++++++ .../alerting/group4/alerts_as_data/index.ts | 17 + .../install_resources.ts} | 36 +- .../tests/alerting/group4/index.ts | 2 +- 43 files changed, 3932 insertions(+), 320 deletions(-) create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/alerts_client.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/index.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/lib/index.ts create mode 100644 x-pack/plugins/alerting/server/alerts_client/types.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts rename x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/{alerts_as_data.ts => alerts_as_data/install_resources.ts} (88%) diff --git a/x-pack/plugins/alerting/common/rule.ts b/x-pack/plugins/alerting/common/rule.ts index 2f8c50cf27f84..63342b9340306 100644 --- a/x-pack/plugins/alerting/common/rule.ts +++ b/x-pack/plugins/alerting/common/rule.ts @@ -18,6 +18,9 @@ import { RuleSnooze } from './rule_snooze_type'; export type RuleTypeState = Record; export type RuleTypeParams = Record; +// rule type defined alert fields to persist in alerts index +export type RuleAlertData = Record; + export interface IntervalSchedule extends SavedObjectAttributes { interval: string; } diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts index fda036dabad32..fbd693fa4fed3 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts @@ -31,7 +31,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -59,7 +58,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -84,7 +82,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); alertFactory.create('1'); expect(alerts).toMatchObject({ @@ -106,7 +103,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 3, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); expect(alertFactory.hasReachedAlertLimit()).toBe(false); @@ -127,7 +123,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -171,7 +166,6 @@ describe('createAlertFactory()', () => { canSetRecoveryContext: true, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: ['test-id-1'], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -190,11 +184,6 @@ describe('createAlertFactory()', () => { const recoveredAlerts = getRecoveredAlertsFn!(); expect(Array.isArray(recoveredAlerts)).toBe(true); expect(recoveredAlerts.length).toEqual(2); - expect(processAlerts).toHaveBeenLastCalledWith( - expect.objectContaining({ - maintenanceWindowIds: ['test-id-1'], - }) - ); }); test('returns empty array if no recovered alerts', () => { @@ -205,7 +194,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -233,7 +221,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -260,7 +247,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: false, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toMatchObject({ @@ -289,7 +275,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -308,7 +293,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -324,7 +308,6 @@ describe('createAlertFactory()', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); const limit = alertFactory.alertLimit.getValue(); @@ -341,7 +324,6 @@ describe('createAlertFactory()', () => { maxAlerts: 1000, canSetRecoveryContext: true, autoRecoverAlerts: false, - maintenanceWindowIds: [], }); const result = alertFactory.create('1'); expect(result).toEqual({ @@ -373,7 +355,6 @@ describe('getPublicAlertFactory', () => { logger, maxAlerts: 1000, autoRecoverAlerts: true, - maintenanceWindowIds: [], }); expect(alertFactory.create).toBeDefined(); diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts index 0ac2c207ed103..87598c0a9a0fd 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts @@ -54,7 +54,6 @@ export interface CreateAlertFactoryOpts< logger: Logger; maxAlerts: number; autoRecoverAlerts: boolean; - maintenanceWindowIds: string[]; canSetRecoveryContext?: boolean; } @@ -67,7 +66,6 @@ export function createAlertFactory< logger, maxAlerts, autoRecoverAlerts, - maintenanceWindowIds, canSetRecoveryContext = false, }: CreateAlertFactoryOpts): AlertFactory { // Keep track of which alerts we started with so we can determine which have recovered @@ -154,7 +152,8 @@ export function createAlertFactory< autoRecoverAlerts, // flappingSettings.enabled is false, as we only want to use this function to get the recovered alerts flappingSettings: DISABLE_FLAPPING_SETTINGS, - maintenanceWindowIds, + // no maintenance window IDs are passed as we only want to use this function to get recovered alerts + maintenanceWindowIds: [], }); return Object.keys(currentRecoveredAlerts ?? {}).map( (alertId: string) => currentRecoveredAlerts[alertId] diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts new file mode 100644 index 0000000000000..20bf955359d3b --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts @@ -0,0 +1,23 @@ +/* + * 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. + */ +const createAlertsClientMock = () => { + return jest.fn().mockImplementation(() => { + return { + processAndLogAlerts: jest.fn(), + getTrackedAlerts: jest.fn(), + getProcessedAlerts: jest.fn(), + getAlertsToSerialize: jest.fn(), + hasReachedAlertLimit: jest.fn(), + checkLimitUsage: jest.fn(), + getExecutorServices: jest.fn(), + }; + }); +}; + +export const alertsClientMock = { + create: createAlertsClientMock(), +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts new file mode 100644 index 0000000000000..970cccbaf5e7e --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.test.ts @@ -0,0 +1,897 @@ +/* + * 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 { elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { DEFAULT_FLAPPING_SETTINGS, RecoveredActionGroup, RuleNotifyWhen } from '../types'; +import * as LegacyAlertsClientModule from './legacy_alerts_client'; +import { Alert } from '../alert/alert'; +import { AlertsClient } from './alerts_client'; +import { AlertRuleData } from './types'; +import { legacyAlertsClientMock } from './legacy_alerts_client.mock'; +import { range } from 'lodash'; +import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; + +const date = '2023-03-28T22:27:28.159Z'; +const maxAlerts = 1000; +let logger: ReturnType; +const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; +const alertingEventLogger = alertingEventLoggerMock.create(); +const ruleRunMetricsStore = ruleRunMetricsStoreMock.create(); + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + 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', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +const mockLegacyAlertsClient = legacyAlertsClientMock.create(); + +const alertRuleData: AlertRuleData = { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], +}; + +describe('Alerts Client', () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(date)); + }); + + beforeEach(() => { + jest.resetAllMocks(); + logger = loggingSystemMock.createLogger(); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + describe('initializeExecution()', () => { + test('should initialize LegacyAlertsClient', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: {}, + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + // no alerts to query for + expect(clusterClient.search).not.toHaveBeenCalled(); + + spy.mockRestore(); + }); + + test('should query for alert UUIDs if they exist', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: { + '1': new Alert('1', { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }), + '2': new Alert('2', { + state: { foo: false }, + meta: { + flapping: false, + flappingHistory: [true, false, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'def', + }, + }), + }, + recovered: { + '3': new Alert('3', { + state: { foo: false }, + meta: { + flapping: false, + flappingHistory: [true, false, false], + uuid: 'xyz', + }, + }), + }, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledWith({ + body: { + query: { + bool: { + filter: [ + { term: { 'kibana.alert.rule.uuid': '1' } }, + { terms: { 'kibana.alert.uuid': ['abc', 'def', 'xyz'] } }, + ], + }, + }, + size: 3, + }, + index: '.internal.alerts-test.alerts-default-*', + }); + + spy.mockRestore(); + }); + + test('should split queries into chunks when there are greater than 10,000 alert UUIDs', async () => { + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: range(15000).reduce((acc: Record>, value: number) => { + const id: string = `${value}`; + acc[id] = new Alert(id, { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: id, + }, + }); + return acc; + }, {}), + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledTimes(2); + + spy.mockRestore(); + }); + + test('should log but not throw if query returns error', async () => { + clusterClient.search.mockImplementation(() => { + throw new Error('search failed!'); + }); + mockLegacyAlertsClient.getTrackedAlerts.mockImplementation(() => ({ + active: { + '1': new Alert('1', { + state: { foo: true }, + meta: { + flapping: false, + flappingHistory: [true, false], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }), + }, + recovered: {}, + })); + const spy = jest + .spyOn(LegacyAlertsClientModule, 'LegacyAlertsClient') + .mockImplementation(() => mockLegacyAlertsClient); + + const alertsClient = new AlertsClient({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + const opts = { + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }; + await alertsClient.initializeExecution(opts); + expect(mockLegacyAlertsClient.initializeExecution).toHaveBeenCalledWith(opts); + + expect(clusterClient.search).toHaveBeenCalledWith({ + body: { + query: { + bool: { + filter: [ + { term: { 'kibana.alert.rule.uuid': '1' } }, + { terms: { 'kibana.alert.uuid': ['abc'] } }, + ], + }, + }, + size: 1, + }, + index: '.internal.alerts-test.alerts-default-*', + }); + + expect(logger.error).toHaveBeenCalledWith( + `Error searching for tracked alerts by UUID - search failed!` + ); + + spy.mockRestore(); + }); + }); + + describe('test getAlertsToSerialize()', () => { + test('should index new alerts', async () => { + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: {}, + recoveredAlertsFromState: {}, + }); + + // Report 2 new alerts + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('1').scheduleActions('default'); + alertExecutorService.create('2').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid1 = alertsToReturn['1'].meta?.uuid; + const uuid2 = alertsToReturn['2'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { index: { _id: uuid1 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid1, + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid2 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid2, + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + + test('should update ongoing alerts in existing index', async () => { + clusterClient.search.mockResolvedValue({ + took: 10, + timed_out: false, + _shards: { failed: 0, successful: 1, total: 1, skipped: 0 }, + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + }, + ], + }, + }); + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { + '1': { + state: { foo: true, start: '2023-03-28T12:27:28.159Z', duration: '0' }, + meta: { + flapping: false, + flappingHistory: [true], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }, + }, + recoveredAlertsFromState: {}, + }); + + // Report 1 new alert and 1 active alert + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('1').scheduleActions('default'); + alertExecutorService.create('2').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid2 = alertsToReturn['2'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { + index: { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + require_alias: false, + }, + }, + // ongoing alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '36000000000000', + }, + flapping: false, + flapping_history: [true, false], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid2 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid2, + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + + test('should recover recovered alerts in existing index', async () => { + clusterClient.search.mockResolvedValue({ + took: 10, + timed_out: false, + _shards: { failed: 0, successful: 1, total: 1, skipped: 0 }, + hits: { + total: { + relation: 'eq', + value: 1, + }, + hits: [ + { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '1', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'active', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + }, + { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + _source: { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '36000000000000', + }, + flapping: false, + flapping_history: [true, false], + instance: { + id: '2', + }, + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T02:27:28.159Z', + status: 'active', + uuid: 'def', + }, + space_ids: ['default'], + }, + }, + }, + ], + }, + }); + const alertsClient = new AlertsClient<{}, {}, {}, 'default', 'recovered'>({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType, + namespace: 'default', + rule: alertRuleData, + }); + + await alertsClient.initializeExecution({ + maxAlerts, + ruleLabel: `test: rule-name`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { + '1': { + state: { foo: true, start: '2023-03-28T12:27:28.159Z', duration: '0' }, + meta: { + flapping: false, + flappingHistory: [true], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'abc', + }, + }, + '2': { + state: { foo: true, start: '2023-03-28T02:27:28.159Z', duration: '36000000000000' }, + meta: { + flapping: false, + flappingHistory: [true, false], + maintenanceWindowIds: [], + lastScheduledActions: { group: 'default', date: new Date() }, + uuid: 'def', + }, + }, + }, + recoveredAlertsFromState: {}, + }); + + // Report 1 new alert and 1 active alert, recover 1 alert + const alertExecutorService = alertsClient.getExecutorServices(); + alertExecutorService.create('2').scheduleActions('default'); + alertExecutorService.create('3').scheduleActions('default'); + + alertsClient.processAndLogAlerts({ + eventLogger: alertingEventLogger, + ruleRunMetricsStore, + shouldLogAlerts: false, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + notifyWhen: RuleNotifyWhen.CHANGE, + maintenanceWindowIds: [], + }); + + const { alertsToReturn } = await alertsClient.getAlertsToSerialize(); + + const uuid3 = alertsToReturn['3'].meta?.uuid; + + expect(clusterClient.bulk).toHaveBeenCalledWith({ + index: '.alerts-test.alerts-default', + refresh: 'wait_for', + require_alias: true, + body: [ + { + index: { + _id: 'def', + _index: '.internal.alerts-test.alerts-default-000002', + require_alias: false, + }, + }, + // ongoing alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '72000000000000', + }, + flapping: false, + flapping_history: [true, false, false], + instance: { + id: '2', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T02:27:28.159Z', + status: 'active', + uuid: 'def', + }, + space_ids: ['default'], + }, + }, + { index: { _id: uuid3 } }, + // new alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [true], + instance: { + id: '3', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: date, + status: 'active', + uuid: uuid3, + }, + space_ids: ['default'], + }, + }, + { + index: { + _id: 'abc', + _index: '.internal.alerts-test.alerts-default-000001', + require_alias: false, + }, + }, + // recovered alert doc + { + '@timestamp': date, + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '36000000000000', + }, + end: date, + flapping: false, + flapping_history: [true, true], + instance: { + id: '1', + }, + maintenance_window_ids: [], + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + start: '2023-03-28T12:27:28.159Z', + status: 'recovered', + uuid: 'abc', + }, + space_ids: ['default'], + }, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts new file mode 100644 index 0000000000000..b5d6e08d0857b --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.ts @@ -0,0 +1,296 @@ +/* + * 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 { ElasticsearchClient } from '@kbn/core/server'; +import { ALERT_RULE_UUID, ALERT_UUID } from '@kbn/rule-data-utils'; +import { chunk, flatMap, keys } from 'lodash'; +import { SearchRequest } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../types'; +import { LegacyAlertsClient } from './legacy_alerts_client'; +import { getIndexTemplateAndPattern } from '../alerts_service/resource_installer_utils'; +import { CreateAlertsClientParams } from '../alerts_service/alerts_service'; +import { + type AlertRule, + IAlertsClient, + InitializeExecutionOpts, + ProcessAndLogAlertsOpts, + TrackedAlerts, +} from './types'; +import { buildNewAlert, buildOngoingAlert, buildRecoveredAlert, formatRule } from './lib'; + +// Term queries can take up to 10,000 terms +const CHUNK_SIZE = 10000; + +export interface AlertsClientParams extends CreateAlertsClientParams { + elasticsearchClientPromise: Promise; +} + +export class AlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> implements IAlertsClient +{ + private legacyAlertsClient: LegacyAlertsClient< + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >; + + // Query for alerts from the previous execution in order to identify the + // correct index to use if and when we need to make updates to existing active or + // recovered alerts + private fetchedAlerts: { + indices: Record; + data: Record; + }; + + private rule: AlertRule = {}; + + constructor(private readonly options: AlertsClientParams) { + this.legacyAlertsClient = new LegacyAlertsClient< + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ logger: this.options.logger, ruleType: this.options.ruleType }); + this.fetchedAlerts = { indices: {}, data: {} }; + this.rule = formatRule({ rule: this.options.rule, ruleType: this.options.ruleType }); + } + + public async initializeExecution(opts: InitializeExecutionOpts) { + await this.legacyAlertsClient.initializeExecution(opts); + + // Get tracked alert UUIDs to query for + // TODO - we can consider refactoring to store the previous execution UUID and query + // for active and recovered alerts from the previous execution using that UUID + const trackedAlerts = this.legacyAlertsClient.getTrackedAlerts(); + + const uuidsToFetch: string[] = []; + keys(trackedAlerts).forEach((key) => { + const tkey = key as keyof TrackedAlerts; + keys(trackedAlerts[tkey]).forEach((alertId: string) => { + uuidsToFetch.push(trackedAlerts[tkey][alertId].getUuid()); + }); + }); + + if (!uuidsToFetch.length) { + return; + } + + const queryByUuid = async (uuids: string[]) => { + return await this.search({ + size: uuids.length, + query: { + bool: { + filter: [ + { + term: { + [ALERT_RULE_UUID]: this.options.rule.id, + }, + }, + { + terms: { + [ALERT_UUID]: uuids, + }, + }, + ], + }, + }, + }); + }; + + try { + const results = await Promise.all( + chunk(uuidsToFetch, CHUNK_SIZE).map((uuidChunk: string[]) => queryByUuid(uuidChunk)) + ); + + for (const hit of results.flat()) { + const alertHit: Alert & AlertData = hit._source as Alert & AlertData; + const alertUuid = alertHit.kibana.alert.uuid; + const alertId = alertHit.kibana.alert.instance.id; + + // Keep track of existing alert document so we can copy over data if alert is ongoing + this.fetchedAlerts.data[alertId] = alertHit; + + // Keep track of index so we can update the correct document + this.fetchedAlerts.indices[alertUuid] = hit._index; + } + } catch (err) { + this.options.logger.error(`Error searching for tracked alerts by UUID - ${err.message}`); + } + } + + public async search(queryBody: SearchRequest['body']) { + const context = this.options.ruleType.alerts?.context; + const esClient = await this.options.elasticsearchClientPromise; + + const indexTemplateAndPattern = getIndexTemplateAndPattern({ + context: context!, + namespace: this.options.ruleType.alerts?.isSpaceAware + ? this.options.namespace + : DEFAULT_NAMESPACE_STRING, + }); + + const { + hits: { hits }, + } = await esClient.search({ + index: indexTemplateAndPattern.pattern, + body: queryBody, + }); + + return hits; + } + + public hasReachedAlertLimit(): boolean { + return this.legacyAlertsClient.hasReachedAlertLimit(); + } + + public checkLimitUsage() { + return this.legacyAlertsClient.checkLimitUsage(); + } + + public processAndLogAlerts(opts: ProcessAndLogAlertsOpts) { + this.legacyAlertsClient.processAndLogAlerts(opts); + } + + public getProcessedAlerts( + type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' + ) { + return this.legacyAlertsClient.getProcessedAlerts(type); + } + + public async getAlertsToSerialize() { + const currentTime = new Date().toISOString(); + const context = this.options.ruleType.alerts?.context; + const esClient = await this.options.elasticsearchClientPromise; + + const indexTemplateAndPattern = getIndexTemplateAndPattern({ + context: context!, + namespace: this.options.ruleType.alerts?.isSpaceAware + ? this.options.namespace + : DEFAULT_NAMESPACE_STRING, + }); + + const { alertsToReturn, recoveredAlertsToReturn } = + await this.legacyAlertsClient.getAlertsToSerialize(false); + + const activeAlerts = this.legacyAlertsClient.getProcessedAlerts('active'); + const recoveredAlerts = this.legacyAlertsClient.getProcessedAlerts('recovered'); + + // TODO - Lifecycle alerts set some other fields based on alert status + // Example: workflow status - default to 'open' if not set + // event action: new alert = 'new', active alert: 'active', otherwise 'close' + + const activeAlertsToIndex: Array = []; + for (const id of keys(alertsToReturn)) { + // See if there's an existing active alert document + if ( + this.fetchedAlerts.data.hasOwnProperty(id) && + this.fetchedAlerts.data[id].kibana.alert.status === 'active' + ) { + activeAlertsToIndex.push( + buildOngoingAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + alert: this.fetchedAlerts.data[id], + legacyAlert: activeAlerts[id], + rule: this.rule, + timestamp: currentTime, + }) + ); + } else { + activeAlertsToIndex.push( + buildNewAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ legacyAlert: activeAlerts[id], rule: this.rule, timestamp: currentTime }) + ); + } + } + + const recoveredAlertsToIndex: Array = []; + for (const id of keys(recoveredAlertsToReturn)) { + // See if there's an existing alert document + // If there is not, log an error because there should be + if (this.fetchedAlerts.data.hasOwnProperty(id)) { + recoveredAlertsToIndex.push( + buildRecoveredAlert< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + alert: this.fetchedAlerts.data[id], + legacyAlert: recoveredAlerts[id], + rule: this.rule, + timestamp: currentTime, + recoveryActionGroup: this.options.ruleType.recoveryActionGroup.id, + }) + ); + } else { + this.options.logger.warn( + `Could not find alert document to update for recovered alert with id ${id} and uuid ${recoveredAlerts[ + id + ].getUuid()}` + ); + } + } + + const alertsToIndex = [...activeAlertsToIndex, ...recoveredAlertsToIndex]; + if (alertsToIndex.length > 0) { + await esClient.bulk({ + refresh: 'wait_for', + index: indexTemplateAndPattern.alias, + require_alias: true, + body: flatMap( + [...activeAlertsToIndex, ...recoveredAlertsToIndex].map((alert: Alert & AlertData) => [ + { + index: { + _id: alert.kibana.alert.uuid, + // If we know the concrete index for this alert, specify it + ...(this.fetchedAlerts.indices[alert.kibana.alert.uuid] + ? { + _index: this.fetchedAlerts.indices[alert.kibana.alert.uuid], + require_alias: false, + } + : {}), + }, + }, + alert, + ]) + ), + }); + } + + // The flapping value that is persisted inside the task manager state (and used in the next execution) + // is different than the value that should be written to the alert document. For this reason, we call + // getAlertsToSerialize() twice, once before building and bulk indexing alert docs and once after to return + // the value for task state serialization + + // This will be a blocker if ever we want to stop serializing alert data inside the task state and just use + // the fetched alert document. + return await this.legacyAlertsClient.getAlertsToSerialize(); + } + + public getExecutorServices() { + return this.legacyAlertsClient.getExecutorServices(); + } +} diff --git a/x-pack/plugins/alerting/server/alerts_client/index.ts b/x-pack/plugins/alerting/server/alerts_client/index.ts new file mode 100644 index 0000000000000..442f8935650f5 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/index.ts @@ -0,0 +1,10 @@ +/* + * 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 { type LegacyAlertsClientParams, LegacyAlertsClient } from './legacy_alerts_client'; +export { AlertsClient } from './alerts_client'; +export type { AlertRuleData } from './types'; diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts new file mode 100644 index 0000000000000..5154761a716a2 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.mock.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ +const createLegacyAlertsClientMock = () => { + return jest.fn().mockImplementation(() => { + return { + initializeExecution: jest.fn(), + processAndLogAlerts: jest.fn(), + getTrackedAlerts: jest.fn(), + getProcessedAlerts: jest.fn(), + getAlertsToSerialize: jest.fn(), + hasReachedAlertLimit: jest.fn(), + checkLimitUsage: jest.fn(), + getExecutorServices: jest.fn(), + }; + }); +}; + +export const legacyAlertsClientMock = { + create: createLegacyAlertsClientMock(), +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts index 4df56107bda94..b510a06f2817a 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.test.ts @@ -13,6 +13,7 @@ import { Alert } from '../alert/alert'; import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; import { ruleRunMetricsStoreMock } from '../lib/rule_run_metrics_store.mock'; import { getAlertsForNotification, processAlerts } from '../lib'; +import { trimRecoveredAlerts } from '../lib/trim_recovered_alerts'; import { logAlerts } from '../task_runner/log_alerts'; import { DEFAULT_FLAPPING_SETTINGS } from '../../common/rules_settings'; import { schema } from '@kbn/config-schema'; @@ -63,6 +64,12 @@ jest.mock('../lib', () => { }; }); +jest.mock('../lib/trim_recovered_alerts', () => { + return { + trimRecoveredAlerts: jest.fn(), + }; +}); + jest.mock('../lib/get_alerts_for_notification', () => { return { getAlertsForNotification: jest.fn(), @@ -94,7 +101,7 @@ const ruleType: jest.Mocked = { const testAlert1 = { state: { foo: 'bar' }, - meta: { flapping: false, flappingHistory: [false, false] }, + meta: { flapping: false, flappingHistory: [false, false], uuid: 'abc' }, }; const testAlert2 = { state: { any: 'value' }, @@ -103,6 +110,7 @@ const testAlert2 = { group: 'default', date: new Date(), }, + uuid: 'def', }, }; @@ -112,21 +120,22 @@ describe('Legacy Alerts Client', () => { logger = loggingSystemMock.createLogger(); }); - test('initialize() should create alert factory with given alerts', () => { + test('initializeExecution() should create alert factory with given alerts', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - ['test-id-1'] - ); + recoveredAlertsFromState: {}, + }); expect(createAlertFactory).toHaveBeenCalledWith({ alerts: { @@ -137,71 +146,73 @@ describe('Legacy Alerts Client', () => { maxAlerts: 1000, canSetRecoveryContext: false, autoRecoverAlerts: true, - maintenanceWindowIds: ['test-id-1'], }); }); - test('getExecutorServices() should call getPublicAlertFactory on alert factory', () => { + test('getExecutorServices() should call getPublicAlertFactory on alert factory', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.getExecutorServices(); expect(getPublicAlertFactory).toHaveBeenCalledWith(mockCreateAlertFactory); }); - test('checkLimitUsage() should pass through to alert factory function', () => { + test('checkLimitUsage() should pass through to alert factory function', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.checkLimitUsage(); expect(mockCreateAlertFactory.alertLimit.checkLimitUsage).toHaveBeenCalled(); }); - test('hasReachedAlertLimit() should pass through to alert factory function', () => { + test('hasReachedAlertLimit() should pass through to alert factory function', async () => { const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `test: my-test-rule`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.hasReachedAlertLimit(); expect(mockCreateAlertFactory.hasReachedAlertLimit).toHaveBeenCalled(); }); - test('processAndLogAlerts() should call processAlerts, setFlapping and logAlerts and store results', () => { + test('processAndLogAlerts() should call processAlerts, trimRecoveredAlerts, getAlertsForNotification and logAlerts and store results', async () => { (processAlerts as jest.Mock).mockReturnValue({ newAlerts: {}, activeAlerts: { @@ -211,6 +222,10 @@ describe('Legacy Alerts Client', () => { currentRecoveredAlerts: {}, recoveredAlerts: {}, }); + (trimRecoveredAlerts as jest.Mock).mockReturnValue({ + trimmedAlertsRecovered: {}, + earlyRecoveredAlerts: {}, + }); (getAlertsForNotification as jest.Mock).mockReturnValue({ newAlerts: {}, activeAlerts: { @@ -226,24 +241,24 @@ describe('Legacy Alerts Client', () => { }); const alertsClient = new LegacyAlertsClient({ logger, - maxAlerts: 1000, ruleType, }); - alertsClient.initialize( - { + await alertsClient.initializeExecution({ + maxAlerts: 1000, + ruleLabel: `ruleLogPrefix`, + flappingSettings: DEFAULT_FLAPPING_SETTINGS, + activeAlertsFromState: { '1': testAlert1, '2': testAlert2, }, - {}, - [] - ); + recoveredAlertsFromState: {}, + }); alertsClient.processAndLogAlerts({ eventLogger: alertingEventLogger, - ruleLabel: `ruleLogPrefix`, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts: true, + shouldLogAlerts: true, flappingSettings: DEFAULT_FLAPPING_SETTINGS, notifyWhen: RuleNotifyWhen.CHANGE, maintenanceWindowIds: ['window-id1', 'window-id2'], @@ -266,6 +281,8 @@ describe('Legacy Alerts Client', () => { maintenanceWindowIds: ['window-id1', 'window-id2'], }); + expect(trimRecoveredAlerts).toHaveBeenCalledWith(logger, {}, 1000); + expect(getAlertsForNotification).toHaveBeenCalledWith( { enabled: true, diff --git a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts index c432748da59c1..7542785dd3a6e 100644 --- a/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts +++ b/x-pack/plugins/alerting/server/alerts_client/legacy_alerts_client.ts @@ -5,7 +5,7 @@ * 2.0. */ import { Logger } from '@kbn/core/server'; -import { cloneDeep, merge } from 'lodash'; +import { cloneDeep, keys, merge } from 'lodash'; import { Alert } from '../alert/alert'; import { AlertFactory, @@ -18,23 +18,24 @@ import { setFlapping, getAlertsForNotification, } from '../lib'; -import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; import { trimRecoveredAlerts } from '../lib/trim_recovered_alerts'; -import { UntypedNormalizedRuleType } from '../rule_type_registry'; import { logAlerts } from '../task_runner/log_alerts'; +import { AlertInstanceContext, AlertInstanceState, WithoutReservedActionGroups } from '../types'; +import { + DEFAULT_FLAPPING_SETTINGS, + RulesSettingsFlappingProperties, +} from '../../common/rules_settings'; import { - AlertInstanceContext, - AlertInstanceState, - RawAlertInstance, - WithoutReservedActionGroups, - RuleNotifyWhenType, -} from '../types'; -import { RulesSettingsFlappingProperties } from '../../common/rules_settings'; - -interface ConstructorOpts { + IAlertsClient, + InitializeExecutionOpts, + ProcessAndLogAlertsOpts, + TrackedAlerts, +} from './types'; +import { DEFAULT_MAX_ALERTS } from '../config'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; + +export interface LegacyAlertsClientParams { logger: Logger; - maxAlerts: number; ruleType: UntypedNormalizedRuleType; } @@ -43,10 +44,21 @@ export class LegacyAlertsClient< Context extends AlertInstanceContext, ActionGroupIds extends string, RecoveryActionGroupId extends string -> { - private activeAlertsFromPreviousExecution: Record>; - private recoveredAlertsFromPreviousExecution: Record>; - private alerts: Record>; +> implements IAlertsClient +{ + private maxAlerts: number = DEFAULT_MAX_ALERTS; + private flappingSettings: RulesSettingsFlappingProperties = DEFAULT_FLAPPING_SETTINGS; + private ruleLogPrefix: string = ''; + + // Alerts from the previous execution that are deserialized from the task state + private trackedAlerts: TrackedAlerts = { + active: {}, + recovered: {}, + }; + + // Alerts reported from the rule executor using the alert factory + private reportedAlerts: Record> = {}; + private processedAlerts: { new: Record>; active: Record>; @@ -60,10 +72,8 @@ export class LegacyAlertsClient< Context, WithoutReservedActionGroups >; - constructor(private readonly options: ConstructorOpts) { - this.alerts = {}; - this.activeAlertsFromPreviousExecution = {}; - this.recoveredAlertsFromPreviousExecution = {}; + + constructor(private readonly options: LegacyAlertsClientParams) { this.processedAlerts = { new: {}, active: {}, @@ -73,77 +83,70 @@ export class LegacyAlertsClient< }; } - public initialize( - activeAlertsFromState: Record, - recoveredAlertsFromState: Record, - maintenanceWindowIds: string[] - ) { - for (const id in activeAlertsFromState) { - if (activeAlertsFromState.hasOwnProperty(id)) { - this.activeAlertsFromPreviousExecution[id] = new Alert( - id, - activeAlertsFromState[id] - ); - } + public async initializeExecution({ + maxAlerts, + ruleLabel, + flappingSettings, + activeAlertsFromState, + recoveredAlertsFromState, + }: InitializeExecutionOpts) { + this.maxAlerts = maxAlerts; + this.flappingSettings = flappingSettings; + this.ruleLogPrefix = ruleLabel; + + for (const id of keys(activeAlertsFromState)) { + this.trackedAlerts.active[id] = new Alert(id, activeAlertsFromState[id]); } - for (const id in recoveredAlertsFromState) { - if (recoveredAlertsFromState.hasOwnProperty(id)) { - this.recoveredAlertsFromPreviousExecution[id] = new Alert( - id, - recoveredAlertsFromState[id] - ); - } + for (const id of keys(recoveredAlertsFromState)) { + this.trackedAlerts.recovered[id] = new Alert( + id, + recoveredAlertsFromState[id] + ); } - this.alerts = cloneDeep(this.activeAlertsFromPreviousExecution); + // Legacy alerts client creates a copy of the active tracked alerts + // This copy is updated when rule executors report alerts back to the framework + // while the original alert is preserved + this.reportedAlerts = cloneDeep(this.trackedAlerts.active); this.alertFactory = createAlertFactory< State, Context, WithoutReservedActionGroups >({ - alerts: this.alerts, + alerts: this.reportedAlerts, logger: this.options.logger, - maxAlerts: this.options.maxAlerts, + maxAlerts: this.maxAlerts, autoRecoverAlerts: this.options.ruleType.autoRecoverAlerts ?? true, canSetRecoveryContext: this.options.ruleType.doesSetRecoveryContext ?? false, - maintenanceWindowIds, }); } + public getTrackedAlerts() { + return this.trackedAlerts; + } + public processAndLogAlerts({ eventLogger, - ruleLabel, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts, + shouldLogAlerts, flappingSettings, notifyWhen, maintenanceWindowIds, - }: { - eventLogger: AlertingEventLogger; - ruleLabel: string; - shouldLogAndScheduleActionsForAlerts: boolean; - ruleRunMetricsStore: RuleRunMetricsStore; - flappingSettings: RulesSettingsFlappingProperties; - notifyWhen: RuleNotifyWhenType | null; - maintenanceWindowIds: string[]; - }) { + }: ProcessAndLogAlertsOpts) { const { newAlerts: processedAlertsNew, activeAlerts: processedAlertsActive, currentRecoveredAlerts: processedAlertsRecoveredCurrent, recoveredAlerts: processedAlertsRecovered, } = processAlerts({ - alerts: this.alerts, - existingAlerts: this.activeAlertsFromPreviousExecution, - previouslyRecoveredAlerts: this.recoveredAlertsFromPreviousExecution, + alerts: this.reportedAlerts, + existingAlerts: this.trackedAlerts.active, + previouslyRecoveredAlerts: this.trackedAlerts.recovered, hasReachedAlertLimit: this.alertFactory!.hasReachedAlertLimit(), - alertLimit: this.options.maxAlerts, - autoRecoverAlerts: - this.options.ruleType.autoRecoverAlerts !== undefined - ? this.options.ruleType.autoRecoverAlerts - : true, + alertLimit: this.maxAlerts, + autoRecoverAlerts: this.options.ruleType.autoRecoverAlerts ?? true, flappingSettings, maintenanceWindowIds, }); @@ -151,7 +154,7 @@ export class LegacyAlertsClient< const { trimmedAlertsRecovered, earlyRecoveredAlerts } = trimRecoveredAlerts( this.options.logger, processedAlertsRecovered, - this.options.maxAlerts + this.maxAlerts ); const alerts = getAlertsForNotification( @@ -177,10 +180,10 @@ export class LegacyAlertsClient< newAlerts: alerts.newAlerts, activeAlerts: alerts.currentActiveAlerts, recoveredAlerts: alerts.currentRecoveredAlerts, - ruleLogPrefix: ruleLabel, + ruleLogPrefix: this.ruleLogPrefix, ruleRunMetricsStore, canSetRecoveryContext: this.options.ruleType.doesSetRecoveryContext ?? false, - shouldPersistAlerts: shouldLogAndScheduleActionsForAlerts, + shouldPersistAlerts: shouldLogAlerts, }); } @@ -194,7 +197,10 @@ export class LegacyAlertsClient< return {}; } - public getAlertsToSerialize() { + public async getAlertsToSerialize(shouldSetFlapping: boolean = true) { + if (shouldSetFlapping) { + this.setFlapping(); + } return determineAlertsToReturn( this.processedAlerts.active, this.processedAlerts.recovered @@ -213,9 +219,9 @@ export class LegacyAlertsClient< return getPublicAlertFactory(this.alertFactory!); } - public setFlapping(flappingSettings: RulesSettingsFlappingProperties) { + public setFlapping() { setFlapping( - flappingSettings, + this.flappingSettings, this.processedAlerts.active, this.processedAlerts.recovered ); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts new file mode 100644 index 0000000000000..f479c821e098f --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.test.ts @@ -0,0 +1,131 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildNewAlert } from './build_new_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; + +describe('buildNewAlert', () => { + test('should build alert document with info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); + + test('should include start and duration if set', () => { + const now = Date.now(); + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default').replaceState({ start: now, duration: '0' }); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: now, + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); + + test('should include flapping history and maintenance window ids if set', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + legacyAlert.setFlappingHistory([true, false, false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildNewAlert<{}, {}, {}, 'default', 'recovered'>({ + legacyAlert, + rule: alertRule, + timestamp: '2023-03-28T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + flapping: false, + flapping_history: [true, false, false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-1', 'maint-321'], + rule, + status: 'active', + uuid: legacyAlert.getUuid(), + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts new file mode 100644 index 0000000000000..d54f241db91ab --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_new_alert.ts @@ -0,0 +1,65 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildNewAlertOpts< + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + legacyAlert: LegacyAlert; + rule: AlertRule; + timestamp: string; +} + +/** + * Builds a new alert document from the LegacyAlert class + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildNewAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + legacyAlert, + rule, + timestamp, +}: BuildNewAlertOpts): Alert & + AlertData => { + return { + '@timestamp': timestamp, + kibana: { + alert: { + action_group: legacyAlert.getScheduledActionOptions()?.actionGroup, + flapping: legacyAlert.getFlapping(), + instance: { + id: legacyAlert.getId(), + }, + maintenance_window_ids: legacyAlert.getMaintenanceWindowIds(), + rule: rule.kibana?.alert.rule, + status: 'active', + uuid: legacyAlert.getUuid(), + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + ...(legacyAlert.getState().start ? { start: legacyAlert.getState().start } : {}), + }, + space_ids: rule.kibana?.space_ids, + }, + } as Alert & AlertData; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts new file mode 100644 index 0000000000000..c7ae9eb092ff5 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.test.ts @@ -0,0 +1,240 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildOngoingAlert } from './build_ongoing_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; +const existingAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +describe('buildOngoingAlert', () => { + test('should update alert document with updated info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A'); + legacyAlert + .scheduleActions('warning') + .replaceState({ start: '0000-00-00T00:00:00.000Z', duration: '36000000' }); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: existingAlert, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'warning', + duration: { + us: '36000000', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with updated rule data if rule definition has changed', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('alert-A'); + legacyAlert + .scheduleActions('warning') + .replaceState({ start: '0000-00-00T00:00:00.000Z', duration: '36000000' }); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: existingAlert, + legacyAlert, + rule: { + kibana: { + alert: { + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + }, + space_ids: ['default'], + }, + }, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'warning', + duration: { + us: '36000000', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with updated flapping history and maintenance window ids if set', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('1'); + legacyAlert.scheduleActions('error'); + legacyAlert.setFlappingHistory([false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-xyz']); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: { + ...existingAlert, + kibana: { + ...existingAlert.kibana, + alert: { + ...existingAlert.kibana.alert, + flapping_history: [true, false, false, false, true, true], + maintenance_window_ids: ['maint-1', 'maint-321'], + }, + }, + }, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-xyz'], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update alert document with latest maintenance window ids', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'error' | 'warning'>('1'); + legacyAlert.scheduleActions('error'); + legacyAlert.setFlappingHistory([false, false, true, true]); + + expect( + buildOngoingAlert<{}, {}, {}, 'error' | 'warning', 'recovered'>({ + alert: { + ...existingAlert, + kibana: { + ...existingAlert.kibana, + alert: { + ...existingAlert.kibana.alert, + flapping_history: [true, false, false, false, true, true], + maintenance_window_ids: ['maint-1', 'maint-321'], + }, + }, + }, + legacyAlert, + rule: alertRule, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'error', + duration: { + us: '0', + }, + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts new file mode 100644 index 0000000000000..65d18834d3755 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_ongoing_alert.ts @@ -0,0 +1,84 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildOngoingAlertOpts< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert & AlertData; + legacyAlert: LegacyAlert; + rule: AlertRule; + timestamp: string; +} + +/** + * Updates an existing alert document with data from the LegacyAlert class + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildOngoingAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + alert, + legacyAlert, + rule, + timestamp, +}: BuildOngoingAlertOpts< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId +>): Alert & AlertData => { + return { + ...alert, + // Update the timestamp to reflect latest update time + '@timestamp': timestamp, + kibana: { + ...alert.kibana, + alert: { + ...alert.kibana.alert, + // Set latest action group as this may have changed during execution (ex: error -> warning) + action_group: legacyAlert.getScheduledActionOptions()?.actionGroup, + // Set latest flapping state + flapping: legacyAlert.getFlapping(), + // Set latest rule configuration + rule: rule.kibana?.alert.rule, + // Set latest maintenance window IDs + maintenance_window_ids: legacyAlert.getMaintenanceWindowIds(), + // Set latest duration as ongoing alerts should have updated duration + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + // Set latest flapping history + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + + // Fields that are explicitly not updated: + // instance.id + // status - ongoing alerts should maintain 'active' status + // uuid - ongoing alerts should carry over previous UUID + // start - ongoing alerts should keep the initial start time + }, + space_ids: rule.kibana?.space_ids, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts new file mode 100644 index 0000000000000..22ac7cfd38136 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.test.ts @@ -0,0 +1,216 @@ +/* + * 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 { Alert as LegacyAlert } from '../../alert/alert'; +import { buildRecoveredAlert } from './build_recovered_alert'; +import type { AlertRule } from '../types'; + +const rule = { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', +}; +const alertRule: AlertRule = { + kibana: { + alert: { + rule, + }, + space_ids: ['default'], + }, +}; +const existingActiveAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'active', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +const existingRecoveredAlert = { + '@timestamp': '2023-03-28T12:27:28.159Z', + kibana: { + alert: { + action_group: 'default', + duration: { + us: '0', + }, + end: '2023-03-28T12:27:28.159Z', + flapping: false, + flapping_history: [true, false, false], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-27T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, +}; + +describe('buildRecoveredAlert', () => { + test('should update active alert document with recovered status and info from legacy alert', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert + .scheduleActions('default') + .replaceState({ end: '2023-03-30T12:27:28.159Z', duration: '36000000' }); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingActiveAlert, + legacyAlert, + rule: alertRule, + recoveryActionGroup: 'recovered', + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '36000000', + }, + end: '2023-03-30T12:27:28.159Z', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: [], + start: '2023-03-28T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update active alert document with recovery status and updated rule data if rule definition has changed', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert + .scheduleActions('default') + .replaceState({ end: '2023-03-30T12:27:28.159Z', duration: '36000000' }); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingActiveAlert, + legacyAlert, + recoveryActionGroup: 'NoLongerActive', + rule: { + kibana: { + alert: { + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + }, + space_ids: ['default'], + }, + }, + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'NoLongerActive', + duration: { + us: '36000000', + }, + end: '2023-03-30T12:27:28.159Z', + flapping: false, + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-1', 'maint-321'], + start: '2023-03-28T12:27:28.159Z', + rule: { + ...rule, + name: 'updated-rule-name', + parameters: { + bar: false, + }, + }, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); + + test('should update already recovered alert document with updated flapping history but not maintenance window ids', () => { + const legacyAlert = new LegacyAlert<{}, {}, 'default'>('alert-A'); + legacyAlert.scheduleActions('default'); + legacyAlert.setFlappingHistory([false, false, true, true]); + legacyAlert.setMaintenanceWindowIds(['maint-1', 'maint-321']); + + expect( + buildRecoveredAlert<{}, {}, {}, 'default', 'recovered'>({ + alert: existingRecoveredAlert, + legacyAlert, + rule: alertRule, + recoveryActionGroup: 'recovered', + timestamp: '2023-03-29T12:27:28.159Z', + }) + ).toEqual({ + '@timestamp': '2023-03-29T12:27:28.159Z', + kibana: { + alert: { + action_group: 'recovered', + duration: { + us: '0', + }, + end: '2023-03-28T12:27:28.159Z', + flapping: false, + flapping_history: [false, false, true, true], + instance: { + id: 'alert-A', + }, + maintenance_window_ids: ['maint-x'], + start: '2023-03-27T12:27:28.159Z', + rule, + status: 'recovered', + uuid: 'abcdefg', + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts new file mode 100644 index 0000000000000..07533e2fe763c --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/build_recovered_alert.ts @@ -0,0 +1,97 @@ +/* + * 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 { isEmpty } from 'lodash'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../../alert/alert'; +import { AlertInstanceContext, AlertInstanceState, RuleAlertData } from '../../types'; +import type { AlertRule } from '../types'; + +interface BuildRecoveredAlertOpts< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alert: Alert & AlertData; + legacyAlert: LegacyAlert; + rule: AlertRule; + recoveryActionGroup: string; + timestamp: string; +} + +/** + * Updates an existing alert document with data from the LegacyAlert class + * This could be a currently active alert that is now recovered or a previously + * recovered alert that has updates to its flapping history + * Currently only populates framework fields and not any rule type specific fields + */ + +export const buildRecoveredAlert = < + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +>({ + alert, + legacyAlert, + rule, + timestamp, + recoveryActionGroup, +}: BuildRecoveredAlertOpts< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId +>): Alert & AlertData => { + // If we're updating an active alert to be recovered, + // persist any maintenance window IDs on the alert, otherwise + // we should only be changing fields related to flapping + const maintenanceWindowIds = + alert.kibana.alert.status === 'active' ? legacyAlert.getMaintenanceWindowIds() : null; + return { + ...alert, + // Update the timestamp to reflect latest update time + '@timestamp': timestamp, + kibana: { + ...alert.kibana, + alert: { + ...alert.kibana.alert, + // Set the recovery action group + action_group: recoveryActionGroup, + // Set latest flapping state + flapping: legacyAlert.getFlapping(), + // Set latest rule configuration + rule: rule.kibana?.alert.rule, + // Set status to 'recovered' + status: 'recovered', + // Set latest duration as recovered alerts should have updated duration + ...(legacyAlert.getState().duration + ? { duration: { us: legacyAlert.getState().duration } } + : {}), + // Set end time + ...(legacyAlert.getState().end ? { end: legacyAlert.getState().end } : {}), + // Set latest flapping history + ...(!isEmpty(legacyAlert.getFlappingHistory()) + ? { flapping_history: legacyAlert.getFlappingHistory() } + : {}), + // Set maintenance window IDs if defined + ...(maintenanceWindowIds ? { maintenance_window_ids: maintenanceWindowIds } : {}), + + // Fields that are explicitly not updated: + // instance.id + // action_group + // uuid - recovered alerts should carry over previous UUID + // start - recovered alerts should keep the initial start time + }, + space_ids: rule.kibana?.space_ids, + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts new file mode 100644 index 0000000000000..cb90b75d2c16d --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.test.ts @@ -0,0 +1,76 @@ +/* + * 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 { formatRule } from './format_rule'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; +import { RecoveredActionGroup } from '../../types'; + +const ruleType: jest.Mocked = { + id: 'test.rule-type', + 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', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, + }, +}; + +describe('formatRule', () => { + test('should format rule data', () => { + expect( + formatRule({ + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + ruleType, + }) + ).toEqual({ + kibana: { + alert: { + rule: { + category: 'My test rule', + consumer: 'bar', + execution: { + uuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + }, + name: 'rule-name', + parameters: { + bar: true, + }, + producer: 'alerts', + revision: 0, + rule_type_id: 'test.rule-type', + tags: ['rule-', '-tags'], + uuid: '1', + }, + }, + space_ids: ['default'], + }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts new file mode 100644 index 0000000000000..70588fc4cb665 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/format_rule.ts @@ -0,0 +1,37 @@ +/* + * 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 type { AlertRule, AlertRuleData } from '../types'; +import { UntypedNormalizedRuleType } from '../../rule_type_registry'; + +interface FormatRuleOpts { + rule: AlertRuleData; + ruleType: UntypedNormalizedRuleType; +} + +export const formatRule = ({ rule, ruleType }: FormatRuleOpts): AlertRule => { + return { + kibana: { + alert: { + rule: { + category: ruleType.name, + consumer: rule.consumer, + execution: { + uuid: rule.executionId, + }, + name: rule.name, + parameters: rule.parameters, + producer: ruleType.producer, + revision: rule.revision, + rule_type_id: ruleType.id, + tags: rule.tags, + uuid: rule.id, + }, + }, + space_ids: [rule.spaceId], + }, + }; +}; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts new file mode 100644 index 0000000000000..9c1fd2d665ae9 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts @@ -0,0 +1,11 @@ +/* + * 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 { buildNewAlert } from './build_new_alert'; +export { buildOngoingAlert } from './build_ongoing_alert'; +export { buildRecoveredAlert } from './build_recovered_alert'; +export { formatRule } from './format_rule'; diff --git a/x-pack/plugins/alerting/server/alerts_client/types.ts b/x-pack/plugins/alerting/server/alerts_client/types.ts new file mode 100644 index 0000000000000..672e1eccd2118 --- /dev/null +++ b/x-pack/plugins/alerting/server/alerts_client/types.ts @@ -0,0 +1,82 @@ +/* + * 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 type { Alert } from '@kbn/alerts-as-data-utils'; +import { Alert as LegacyAlert } from '../alert/alert'; +import { + AlertInstanceContext, + AlertInstanceState, + RawAlertInstance, + RuleNotifyWhenType, +} from '../types'; +import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; +import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; +import { RulesSettingsFlappingProperties } from '../../common/rules_settings'; + +export interface AlertRuleData { + consumer: string; + executionId: string; + id: string; + name: string; + parameters: unknown; + revision: number; + spaceId: string; + tags: string[]; +} + +export interface AlertRule { + kibana?: { + alert: { + rule: Alert['kibana']['alert']['rule']; + }; + space_ids: Alert['kibana']['space_ids']; + }; +} + +export interface IAlertsClient< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + initializeExecution(opts: InitializeExecutionOpts): Promise; + hasReachedAlertLimit(): boolean; + checkLimitUsage(): void; + processAndLogAlerts(opts: ProcessAndLogAlertsOpts): void; + getProcessedAlerts( + type: 'new' | 'active' | 'activeCurrent' | 'recovered' | 'recoveredCurrent' + ): Record>; + getAlertsToSerialize(): Promise<{ + alertsToReturn: Record; + recoveredAlertsToReturn: Record; + }>; +} + +export interface ProcessAndLogAlertsOpts { + eventLogger: AlertingEventLogger; + shouldLogAlerts: boolean; + ruleRunMetricsStore: RuleRunMetricsStore; + flappingSettings: RulesSettingsFlappingProperties; + notifyWhen: RuleNotifyWhenType | null; + maintenanceWindowIds: string[]; +} + +export interface InitializeExecutionOpts { + maxAlerts: number; + ruleLabel: string; + flappingSettings: RulesSettingsFlappingProperties; + activeAlertsFromState: Record; + recoveredAlertsFromState: Record; +} + +export interface TrackedAlerts< + State extends AlertInstanceState, + Context extends AlertInstanceContext +> { + active: Record>; + recovered: Record>; +} diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts index a3754d66e1cad..1df9e8b2ff67a 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.mock.ts @@ -11,6 +11,7 @@ const creatAlertsServiceMock = () => { register: jest.fn(), isInitialized: jest.fn(), getContextInitializationPromise: jest.fn(), + createAlertsClient: jest.fn(), }; }); }; diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts index 2bc7245df4e6d..b6e102350c32d 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.test.ts @@ -10,9 +10,14 @@ import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-m import { errors as EsErrors } from '@elastic/elasticsearch'; import { ReplaySubject, Subject } from 'rxjs'; import { AlertsService } from './alerts_service'; -import { IRuleTypeAlerts } from '../types'; +import { IRuleTypeAlerts, RecoveredActionGroup } from '../types'; import { retryUntil } from './test_utils'; import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import { UntypedNormalizedRuleType } from '../rule_type_registry'; +import { AlertsClient } from '../alerts_client'; +import { alertsClientMock } from '../alerts_client/alerts_client.mock'; + +jest.mock('../alerts_client'); let logger: ReturnType; const clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser; @@ -141,6 +146,7 @@ const getIndexTemplatePutBody = (opts?: GetIndexTemplatePutBodyOpts) => { const TestRegistrationContext: IRuleTypeAlerts = { context: 'test', mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: true, }; const getContextInitialized = async ( @@ -152,6 +158,30 @@ const getContextInitialized = async ( return result; }; +const alertsClient = alertsClientMock.create(); +const ruleType: jest.Mocked = { + id: 'test.rule-type', + 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', + autoRecoverAlerts: true, + validate: { + params: { validate: (params) => params }, + }, +}; + +const ruleTypeWithAlertDefinition: jest.Mocked = { + ...ruleType, + alerts: TestRegistrationContext, +}; + describe('Alerts Service', () => { let pluginStop$: Subject; @@ -1100,6 +1130,166 @@ describe('Alerts Service', () => { }); }); + describe('createAlertsClient()', () => { + let alertsService: AlertsService; + beforeEach(async () => { + (AlertsClient as jest.Mock).mockImplementation(() => alertsClient); + alertsService = new AlertsService({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + pluginStop$, + kibanaVersion: '8.8.0', + }); + + await retryUntil( + 'alert service initialized', + async () => alertsService.isInitialized() === true + ); + }); + + test('should create new AlertsClient', async () => { + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + + await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(AlertsClient).toHaveBeenCalledWith({ + logger, + elasticsearchClientPromise: Promise.resolve(clusterClient), + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + }); + + test('should return null if rule type has no alert definition', async () => { + const result = await alertsService.createAlertsClient({ + logger, + ruleType, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + + test('should return null if context initialization has errored', async () => { + clusterClient.indices.simulateTemplate.mockImplementationOnce(async () => ({ + ...SimulateTemplateResponse, + template: { + ...SimulateTemplateResponse.template, + mappings: {}, + }, + })); + + alertsService.register(TestRegistrationContext); + await retryUntil('error logger called', async () => logger.error.mock.calls.length > 0); + const result = await alertsService.createAlertsClient({ + logger, + ruleType: ruleTypeWithAlertDefinition, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(logger.warn).toHaveBeenCalledWith( + `There was an error in the framework installing namespace-level resources and creating concrete indices for - Failure during installation. No mappings would be generated for .alerts-test.alerts-default-index-template, possibly due to failed/misconfigured bootstrapping` + ); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + + test('should return null if shouldWrite is false', async () => { + alertsService.register(TestRegistrationContext); + await retryUntil( + 'context initialized', + async () => (await getContextInitialized(alertsService)) === true + ); + const result = await alertsService.createAlertsClient({ + logger, + ruleType: { + ...ruleType, + alerts: { + context: 'test', + mappings: { fieldMap: { field: { type: 'keyword', required: false } } }, + shouldWrite: false, + }, + }, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); + + expect(result).toBe(null); + expect(logger.debug).toHaveBeenCalledWith( + `Resources registered and installed for test context but "shouldWrite" is set to false.` + ); + expect(AlertsClient).not.toHaveBeenCalled(); + }); + }); + describe('retries', () => { test('should retry adding ILM policy for transient ES errors', async () => { clusterClient.ilm.putLifecycle diff --git a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts index 3142b67c82d4d..3c9bc36353406 100644 --- a/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts +++ b/x-pack/plugins/alerting/server/alerts_service/alerts_service.ts @@ -19,7 +19,7 @@ import { getComponentTemplateName, getIndexTemplateAndPattern, } from './resource_installer_utils'; -import { IRuleTypeAlerts } from '../types'; +import { AlertInstanceContext, AlertInstanceState, IRuleTypeAlerts, RuleAlertData } from '../types'; import { createResourceInstallationHelper, errorResult, @@ -35,6 +35,7 @@ import { createConcreteWriteIndex, installWithTimeout, } from './lib'; +import { type LegacyAlertsClientParams, type AlertRuleData, AlertsClient } from '../alerts_client'; export const TOTAL_FIELDS_LIMIT = 2500; const LEGACY_ALERT_CONTEXT = 'legacy-alert'; @@ -48,6 +49,10 @@ interface AlertsServiceParams { timeoutMs?: number; } +export interface CreateAlertsClientParams extends LegacyAlertsClientParams { + namespace: string; + rule: AlertRuleData; +} interface IAlertsService { /** * Register solution specific resources. If common resource initialization is @@ -73,6 +78,27 @@ interface IAlertsService { context: string, namespace: string ): Promise; + + /** + * If the rule type has registered an alert context, initialize and return an AlertsClient, + * otherwise return null. Currently registering an alert context is optional but in the future + * we will make it a requirement for all rule types and this function should not return null. + */ + createAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >( + opts: CreateAlertsClientParams + ): Promise | null>; } export type PublicAlertsService = Pick; @@ -104,6 +130,53 @@ export class AlertsService implements IAlertsService { return this.initialized; } + public async createAlertsClient< + AlertData extends RuleAlertData, + LegacyState extends AlertInstanceState, + LegacyContext extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string + >(opts: CreateAlertsClientParams) { + if (!opts.ruleType.alerts) { + return null; + } + + // Check if context specific installation has succeeded + const { result: initialized, error } = await this.getContextInitializationPromise( + opts.ruleType.alerts.context, + opts.namespace + ); + + if (!initialized) { + // TODO - retry initialization here + this.options.logger.warn( + `There was an error in the framework installing namespace-level resources and creating concrete indices for - ${error}` + ); + return null; + } + + if (!opts.ruleType.alerts.shouldWrite) { + this.options.logger.debug( + `Resources registered and installed for ${opts.ruleType.alerts.context} context but "shouldWrite" is set to false.` + ); + return null; + } + + return new AlertsClient< + AlertData, + LegacyState, + LegacyContext, + ActionGroupIds, + RecoveryActionGroupId + >({ + logger: this.options.logger, + elasticsearchClientPromise: this.options.elasticsearchClientPromise, + ruleType: opts.ruleType, + namespace: opts.namespace, + rule: opts.rule, + }); + } + public async getContextInitializationPromise( context: string, namespace: string diff --git a/x-pack/plugins/alerting/server/config.ts b/x-pack/plugins/alerting/server/config.ts index 383143f527623..b1b9817ce1b9f 100644 --- a/x-pack/plugins/alerting/server/config.ts +++ b/x-pack/plugins/alerting/server/config.ts @@ -8,6 +8,7 @@ import { schema, TypeOf } from '@kbn/config-schema'; import { validateDurationSchema, parseDuration } from './lib'; +export const DEFAULT_MAX_ALERTS = 1000; const ONE_DAY_IN_MS = 24 * 60 * 60 * 1000; const ruleTypeSchema = schema.object({ id: schema.string(), @@ -44,7 +45,7 @@ const rulesSchema = schema.object({ connectorTypeOverrides: schema.maybe(schema.arrayOf(connectorTypeSchema)), }), alerts: schema.object({ - max: schema.number({ defaultValue: 1000 }), + max: schema.number({ defaultValue: DEFAULT_MAX_ALERTS }), }), ruleTypeOverrides: schema.maybe(schema.arrayOf(ruleTypeSchema)), }), diff --git a/x-pack/plugins/alerting/server/lib/license_state.test.ts b/x-pack/plugins/alerting/server/lib/license_state.test.ts index 27e89108b77e7..a0e2c31b07903 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.test.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.test.ts @@ -57,7 +57,7 @@ describe('getLicenseCheckForRuleType', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -195,7 +195,7 @@ describe('ensureLicenseForRuleType()', () => { let license: Subject; let licenseState: ILicenseState; const mockNotifyUsage = jest.fn(); - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ diff --git a/x-pack/plugins/alerting/server/lib/license_state.ts b/x-pack/plugins/alerting/server/lib/license_state.ts index efeed9cc6409f..c774673934ebf 100644 --- a/x-pack/plugins/alerting/server/lib/license_state.ts +++ b/x-pack/plugins/alerting/server/lib/license_state.ts @@ -21,6 +21,7 @@ import { RuleTypeState, AlertInstanceState, AlertInstanceContext, + RuleAlertData, } from '../types'; import { RuleTypeDisabledError } from './errors/rule_type_disabled'; @@ -179,7 +180,8 @@ export class LicenseState { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -188,7 +190,8 @@ export class LicenseState { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) { this.notifyUsage(ruleType.name, ruleType.minimumLicenseRequired); diff --git a/x-pack/plugins/alerting/server/plugin.test.ts b/x-pack/plugins/alerting/server/plugin.test.ts index 5101ec8609108..42a2f2074a6f5 100644 --- a/x-pack/plugins/alerting/server/plugin.test.ts +++ b/x-pack/plugins/alerting/server/plugin.test.ts @@ -60,7 +60,7 @@ const generateAlertingConfig = (): AlertingConfig => ({ }, }); -const sampleRuleType: RuleType = { +const sampleRuleType: RuleType = { id: 'test', name: 'test', minimumLicenseRequired: 'basic', @@ -196,7 +196,7 @@ describe('Alerting Plugin', () => { const ruleType = { ...sampleRuleType, minimumLicenseRequired: 'basic', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('5m'); }); @@ -206,7 +206,7 @@ describe('Alerting Plugin', () => { ...sampleRuleType, minimumLicenseRequired: 'basic', ruleTaskTimeout: '20h', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.ruleTaskTimeout).toBe('20h'); }); @@ -215,7 +215,7 @@ describe('Alerting Plugin', () => { const ruleType = { ...sampleRuleType, minimumLicenseRequired: 'basic', - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(true); }); @@ -225,7 +225,7 @@ describe('Alerting Plugin', () => { ...sampleRuleType, minimumLicenseRequired: 'basic', cancelAlertsOnRuleTimeout: false, - } as RuleType; + } as RuleType; await setup.registerType(ruleType); expect(ruleType.cancelAlertsOnRuleTimeout).toBe(false); }); diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index f73b7070ce55b..aa779f2e4e7f5 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -61,7 +61,7 @@ import { RulesClientFactory } from './rules_client_factory'; import { RulesSettingsClientFactory } from './rules_settings_client_factory'; import { MaintenanceWindowClientFactory } from './maintenance_window_client_factory'; import { ILicenseState, LicenseState } from './lib/license_state'; -import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID } from './types'; +import { AlertingRequestHandlerContext, ALERTS_FEATURE_ID, RuleAlertData } from './types'; import { defineRoutes } from './routes'; import { AlertInstanceContext, @@ -119,7 +119,8 @@ export interface PluginSetupContract { InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never >( ruleType: RuleType< Params, @@ -128,7 +129,8 @@ export interface PluginSetupContract { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ): void; @@ -351,7 +353,8 @@ export class AlertingPlugin { InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never >( ruleType: RuleType< Params, @@ -360,7 +363,8 @@ export class AlertingPlugin { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) => { if (!(ruleType.minimumLicenseRequired in LICENSE_TYPE)) { diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index e0fcc6af28a63..272232767ae98 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -16,6 +16,7 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { inMemoryMetricsMock } from './monitoring/in_memory_metrics.mock'; import { alertsServiceMock } from './alerts_service/alerts_service.mock'; import { schema } from '@kbn/config-schema'; +import { RecoveredActionGroupId } from '../common'; const logger = loggingSystemMock.create().get(); let mockedLicenseState: jest.Mocked; @@ -73,7 +74,7 @@ describe('Create Lifecycle', () => { describe('register()', () => { test('throws if RuleType Id contains invalid characters', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -109,7 +110,7 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType Id isnt a string', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 123 as unknown as string, name: 'Test', actionGroups: [ @@ -135,7 +136,7 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType ruleTaskTimeout is not a valid duration', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -164,7 +165,7 @@ describe('Create Lifecycle', () => { }); test('throws if defaultScheduleInterval isnt valid', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -194,7 +195,7 @@ describe('Create Lifecycle', () => { }); test('logs warning if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = false', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -222,7 +223,7 @@ describe('Create Lifecycle', () => { }); test('logs warning and updates default if defaultScheduleInterval is less than configured minimumScheduleInterval and enforce = true', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: '123', name: 'Test', actionGroups: [ @@ -255,7 +256,16 @@ describe('Create Lifecycle', () => { }); test('throws if RuleType action groups contains reserved group id', () => { - const ruleType: RuleType = { + const ruleType: RuleType< + never, + never, + never, + never, + never, + 'default' | 'NotReserved', + 'recovered', + {} + > = { id: 'test', name: 'Test', actionGroups: [ @@ -291,28 +301,29 @@ describe('Create Lifecycle', () => { }); test('allows an RuleType to specify a custom recovery group', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + const ruleType: RuleType = + { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + recoveryActionGroup: { + id: 'backToAwesome', + name: 'Back To Awesome', }, - ], - defaultActionGroupId: 'default', - recoveryActionGroup: { - id: 'backToAwesome', - name: 'Back To Awesome', - }, - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - validate: { - params: { validate: (params) => params }, - }, - }; + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: { validate: (params) => params }, + }, + }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); expect(registry.get('test').actionGroups).toMatchInlineSnapshot(` @@ -330,25 +341,26 @@ describe('Create Lifecycle', () => { }); test('allows an RuleType to specify a custom rule task timeout', () => { - const ruleType: RuleType = { - id: 'test', - name: 'Test', - actionGroups: [ - { - id: 'default', - name: 'Default', + const ruleType: RuleType = + { + id: 'test', + name: 'Test', + actionGroups: [ + { + id: 'default', + name: 'Default', + }, + ], + defaultActionGroupId: 'default', + ruleTaskTimeout: '13m', + executor: jest.fn(), + producer: 'alerts', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: { validate: (params) => params }, }, - ], - defaultActionGroupId: 'default', - ruleTaskTimeout: '13m', - executor: jest.fn(), - producer: 'alerts', - minimumLicenseRequired: 'basic', - isExportable: true, - validate: { - params: { validate: (params) => params }, - }, - }; + }; const registry = new RuleTypeRegistry(ruleTypeRegistryParams); registry.register(ruleType); expect(registry.get('test').ruleTaskTimeout).toBe('13m'); @@ -362,7 +374,8 @@ describe('Create Lifecycle', () => { never, never, 'default' | 'backToAwesome', - 'backToAwesome' + 'backToAwesome', + {} > = { id: 'test', name: 'Test', @@ -399,7 +412,7 @@ describe('Create Lifecycle', () => { }); test('registers the executor with the task manager', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -435,7 +448,7 @@ describe('Create Lifecycle', () => { }); test('shallow clones the given rule type', () => { - const ruleType: RuleType = { + const ruleType: RuleType = { id: 'test', name: 'Test', actionGroups: [ @@ -822,8 +835,17 @@ function ruleTypeWithVariables( id: ActionGroupIds, context: string, state: string -): RuleType { - const baseAlert: RuleType = { +): RuleType { + const baseAlert: RuleType< + never, + never, + {}, + never, + never, + ActionGroupIds, + RecoveredActionGroupId, + {} + > = { id, name: `${id}-name`, actionGroups: [], diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index fc3e2c89e16b4..abf0223a50c08 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -28,6 +28,7 @@ import { ActionGroup, validateDurationSchema, parseDuration, + RuleAlertData, } from '../common'; import { ILicenseState } from './lib/license_state'; import { getRuleTypeFeatureUsageName } from './lib/get_rule_type_feature_usage_name'; @@ -92,7 +93,8 @@ export type NormalizedRuleType< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > = { actionGroups: Array>; } & Omit< @@ -103,7 +105,8 @@ export type NormalizedRuleType< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, 'recoveryActionGroup' | 'actionGroups' > & @@ -116,7 +119,8 @@ export type NormalizedRuleType< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > >, 'recoveryActionGroup' @@ -129,7 +133,8 @@ export type UntypedNormalizedRuleType = NormalizedRuleType< AlertInstanceState, AlertInstanceContext, string, - string + string, + RuleAlertData >; export class RuleTypeRegistry { @@ -178,7 +183,8 @@ export class RuleTypeRegistry { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -187,7 +193,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ) { if (this.has(ruleType.id)) { @@ -258,7 +265,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >(ruleType); this.ruleTypes.set( @@ -278,7 +286,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId | RecoveredActionGroupId + RecoveryActionGroupId | RecoveredActionGroupId, + AlertData >(normalizedRuleType, context, this.inMemoryMetrics), }, }); @@ -303,7 +312,8 @@ export class RuleTypeRegistry { InstanceState extends AlertInstanceState = AlertInstanceState, InstanceContext extends AlertInstanceContext = AlertInstanceContext, ActionGroupIds extends string = string, - RecoveryActionGroupId extends string = string + RecoveryActionGroupId extends string = string, + AlertData extends RuleAlertData = RuleAlertData >( id: string ): NormalizedRuleType< @@ -313,7 +323,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > { if (!this.has(id)) { throw Boom.badRequest( @@ -337,7 +348,8 @@ export class RuleTypeRegistry { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; } @@ -406,7 +418,8 @@ function augmentActionGroupsWithReserved< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: RuleType< Params, @@ -415,7 +428,8 @@ function augmentActionGroupsWithReserved< InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData > ): NormalizedRuleType< Params, @@ -424,7 +438,8 @@ function augmentActionGroupsWithReserved< InstanceState, InstanceContext, ActionGroupIds, - RecoveredActionGroupId | RecoveryActionGroupId + RecoveredActionGroupId | RecoveryActionGroupId, + AlertData > { const reservedActionGroups = getBuiltinActionGroups(ruleType.recoveryActionGroup); const { id, actionGroups, recoveryActionGroup } = ruleType; 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 e80cefc9d13d8..5f507d0d14ae8 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 @@ -52,7 +52,8 @@ const ruleType: NormalizedRuleType< AlertInstanceState, AlertInstanceContext, 'default' | 'other-group', - 'recovered' + 'recovered', + {} > = { id: 'test', name: 'Test', 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 11512901e977c..f37c97de908a8 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -36,6 +36,7 @@ import { RuleTypeParams, RuleTypeState, SanitizedRule, + RuleAlertData, } from '../../common'; import { generateActionHash, @@ -64,7 +65,8 @@ export class ExecutionHandler< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { private logger: Logger; private alertingEventLogger: PublicMethodsOf; @@ -76,7 +78,8 @@ export class ExecutionHandler< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; private taskRunnerContext: TaskRunnerContext; private taskInstance: RuleTaskInstance; @@ -116,7 +119,8 @@ export class ExecutionHandler< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >) { this.logger = logger; this.alertingEventLogger = alertingEventLogger; 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 d9a4e5d08ba60..1fc307f36ef8d 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 @@ -346,7 +346,23 @@ describe('Task Runner', () => { expect(runnerResult).toEqual(generateRunnerResult({ state: true, history: [true] })); expect(ruleType.executor).toHaveBeenCalledTimes(1); - expect(alertsService.getContextInitializationPromise).toHaveBeenCalledWith('test', 'default'); + expect(alertsService.createAlertsClient).toHaveBeenCalledWith({ + logger, + ruleType: ruleTypeWithAlerts, + namespace: 'default', + rule: { + consumer: 'bar', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + id: '1', + name: 'rule-name', + parameters: { + bar: true, + }, + revision: 0, + spaceId: 'default', + tags: ['rule-', '-tags'], + }, + }); }); test.each(ephemeralTestParams)( diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.ts index 511ec9fbed8ee..e9932a0f75848 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -48,6 +48,8 @@ import { RawAlertInstance, RuleLastRunOutcomeOrderMap, MaintenanceWindow, + RuleAlertData, + SanitizedRule, } from '../../common'; import { NormalizedRuleType, UntypedNormalizedRuleType } from '../rule_type_registry'; import { getEsErrorMessage } from '../lib/errors'; @@ -69,7 +71,7 @@ import { RuleMonitoringService } from '../monitoring/rule_monitoring_service'; import { ILastRun, lastRunFromState, lastRunToRaw } from '../lib/last_run_status'; import { RunningHandler } from './running_handler'; import { RuleResultService } from '../monitoring/rule_result_service'; -import { LegacyAlertsClient } from '../alerts_client/legacy_alerts_client'; +import { LegacyAlertsClient } from '../alerts_client'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -86,7 +88,8 @@ export class TaskRunner< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { private context: TaskRunnerContext; private logger: Logger; @@ -99,7 +102,8 @@ export class TaskRunner< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; private readonly executionId: string; private readonly ruleTypeRegistry: RuleTypeRegistry; @@ -114,12 +118,6 @@ export class TaskRunner< private ruleMonitoring: RuleMonitoringService; private ruleRunning: RunningHandler; private ruleResult: RuleResultService; - private legacyAlertsClient: LegacyAlertsClient< - State, - Context, - ActionGroupIds, - RecoveryActionGroupId - >; constructor( ruleType: NormalizedRuleType< @@ -129,7 +127,8 @@ export class TaskRunner< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, taskInstance: ConcreteTaskInstance, context: TaskRunnerContext, @@ -158,11 +157,6 @@ export class TaskRunner< loggerId ); this.ruleResult = new RuleResultService(); - this.legacyAlertsClient = new LegacyAlertsClient({ - logger: this.logger, - maxAlerts: context.maxAlerts, - ruleType: this.ruleType as UntypedNormalizedRuleType, - }); } private async updateRuleSavedObjectPostRun( @@ -211,6 +205,19 @@ export class TaskRunner< return !this.context.cancelAlertsOnRuleTimeout || !this.ruleType.cancelAlertsOnRuleTimeout; } + private getAADRuleData(rule: SanitizedRule, spaceId: string) { + return { + consumer: rule.consumer, + executionId: this.executionId, + id: rule.id, + name: rule.name, + parameters: rule.params, + revision: rule.revision, + spaceId, + tags: rule.tags, + }; + } + // Usage counter for telemetry // This keeps track of how many times action executions were skipped after rule // execution completed successfully after the execution timeout @@ -281,18 +288,42 @@ export class TaskRunner< const ruleLabel = `${this.ruleType.id}:${ruleId}: '${name}'`; - if (this.context.alertsService && ruleType.alerts) { - // Wait for alerts-as-data resources to be installed - // Since this occurs at the beginning of rule execution, we can be - // assured that all resources will be ready for reading/writing when - // the rule type executors are called + const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); + const flappingSettings = await rulesSettingsClient.flapping().get(); - // TODO - add retry if any initialization steps have failed - await this.context.alertsService.getContextInitializationPromise( - ruleType.alerts.context, - namespace ?? DEFAULT_NAMESPACE_STRING + const alertsClientParams = { + logger: this.logger, + ruleType: this.ruleType as UntypedNormalizedRuleType, + }; + + // Create AlertsClient if rule type has registered an alerts context + // with the framework. The AlertsClient will handle reading and + // writing from alerts-as-data indices and eventually + // we will want to migrate all the processing of alerts out + // of the LegacyAlertsClient and into the AlertsClient. + const alertsClient = + (await this.context.alertsService?.createAlertsClient< + AlertData, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId + >({ + ...alertsClientParams, + namespace: namespace ?? DEFAULT_NAMESPACE_STRING, + rule: this.getAADRuleData(rule, spaceId), + })) ?? + new LegacyAlertsClient( + alertsClientParams ); - } + + await alertsClient.initializeExecution({ + maxAlerts: this.maxAlerts, + ruleLabel, + flappingSettings, + activeAlertsFromState: alertRawInstances, + recoveredAlertsFromState: alertRecoveredRawInstances, + }); const wrappedClientOptions = { rule: { @@ -314,8 +345,6 @@ export class TaskRunner< ...wrappedClientOptions, searchSourceClient, }); - const rulesSettingsClient = this.context.getRulesSettingsClientWithRequest(fakeRequest); - const flappingSettings = await rulesSettingsClient.flapping().get(); const maintenanceWindowClient = this.context.getMaintenanceWindowClientWithRequest(fakeRequest); let activeMaintenanceWindows: MaintenanceWindow[] = []; @@ -337,14 +366,8 @@ export class TaskRunner< const { updatedRuleTypeState } = await this.timer.runWithTimer( TaskRunnerTimerSpan.RuleTypeRun, async () => { - this.legacyAlertsClient.initialize( - alertRawInstances, - alertRecoveredRawInstances, - maintenanceWindowIds - ); - const checkHasReachedAlertLimit = () => { - const reachedLimit = this.legacyAlertsClient.hasReachedAlertLimit(); + const reachedLimit = alertsClient.hasReachedAlertLimit() || false; if (reachedLimit) { this.logger.warn( `rule execution generated greater than ${this.maxAlerts} alerts: ${ruleLabel}` @@ -382,7 +405,7 @@ export class TaskRunner< searchSourceClient: wrappedSearchSourceClient.searchSourceClient, uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient), scopedClusterClient: wrappedScopedClusterClient.client(), - alertFactory: this.legacyAlertsClient.getExecutorServices(), + alertFactory: alertsClient.getExecutorServices(), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), shouldStopExecution: () => this.cancelled, ruleMonitoringService: this.ruleMonitoring.getLastRunMetricsSetters(), @@ -428,7 +451,7 @@ export class TaskRunner< // or requested it and then reported back whether it exceeded the limit // If neither of these apply, this check will throw an error // These errors should show up during rule type development - this.legacyAlertsClient.checkLimitUsage(); + alertsClient.checkLimitUsage(); } catch (err) { // Check if this error is due to reaching the alert limit if (!checkHasReachedAlertLimit()) { @@ -460,11 +483,10 @@ export class TaskRunner< ); await this.timer.runWithTimer(TaskRunnerTimerSpan.ProcessAlerts, async () => { - this.legacyAlertsClient.processAndLogAlerts({ + alertsClient.processAndLogAlerts({ eventLogger: this.alertingEventLogger, - ruleLabel, ruleRunMetricsStore, - shouldLogAndScheduleActionsForAlerts: this.shouldLogAndScheduleActionsForAlerts(), + shouldLogAlerts: this.shouldLogAndScheduleActionsForAlerts(), flappingSettings, notifyWhen, maintenanceWindowIds, @@ -502,21 +524,20 @@ export class TaskRunner< this.countUsageOfActionExecutionAfterRuleCancellation(); } else { executionHandlerRunResult = await executionHandler.run({ - ...this.legacyAlertsClient.getProcessedAlerts('activeCurrent'), - ...this.legacyAlertsClient.getProcessedAlerts('recoveredCurrent'), + ...alertsClient.getProcessedAlerts('activeCurrent'), + ...alertsClient.getProcessedAlerts('recoveredCurrent'), }); } }); - this.legacyAlertsClient.setFlapping(flappingSettings); - let alertsToReturn: Record = {}; let recoveredAlertsToReturn: Record = {}; + const { alertsToReturn: alerts, recoveredAlertsToReturn: recovered } = + await alertsClient.getAlertsToSerialize(); + // Only serialize alerts into task state if we're auto-recovering, otherwise // we don't need to keep this information around. if (this.ruleType.autoRecoverAlerts) { - const { alertsToReturn: alerts, recoveredAlertsToReturn: recovered } = - this.legacyAlertsClient.getAlertsToSerialize(); alertsToReturn = alerts; recoveredAlertsToReturn = recovered; } diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts index 0299f07ab8de4..dc305322d1c9a 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -24,6 +24,7 @@ import { IEventLogger } from '@kbn/event-log-plugin/server'; import { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import { SharePluginStart } from '@kbn/share-plugin/server'; import { + RuleAlertData, RuleTypeParams, RuleTypeRegistry, SpaceIdToNamespaceFunction, @@ -88,7 +89,8 @@ export class TaskRunnerFactory { InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData >( ruleType: NormalizedRuleType< Params, @@ -97,7 +99,8 @@ export class TaskRunnerFactory { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >, { taskInstance }: RunContext, inMemoryMetrics: InMemoryMetrics @@ -113,7 +116,8 @@ export class TaskRunnerFactory { InstanceState, InstanceContext, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >(ruleType, taskInstance, this.taskRunnerContext!, inMemoryMetrics); } } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index fec0e53330f7f..c218104a4b4aa 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -21,6 +21,7 @@ import { SanitizedRule, RuleTypeState, RuleAction, + RuleAlertData, } from '../../common'; import { NormalizedRuleType } from '../rule_type_registry'; import { RawRule, RulesClientApi, CombinedSummarizedAlerts } from '../types'; @@ -64,7 +65,8 @@ export interface ExecutionHandlerOptions< State extends AlertInstanceState, Context extends AlertInstanceContext, ActionGroupIds extends string, - RecoveryActionGroupId extends string + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData > { ruleType: NormalizedRuleType< Params, @@ -73,7 +75,8 @@ export interface ExecutionHandlerOptions< State, Context, ActionGroupIds, - RecoveryActionGroupId + RecoveryActionGroupId, + AlertData >; logger: Logger; alertingEventLogger: PublicMethodsOf; diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index f769fdf7a1100..ff11363b29796 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -54,6 +54,7 @@ import { SanitizedRule, AlertsFilter, AlertsFilterTimeframe, + RuleAlertData, } from '../common'; import { PublicAlertFactory } from './alert/create_alert_factory'; import { RulesSettingsFlappingProperties } from '../common/rules_settings'; @@ -86,7 +87,8 @@ export type AlertingRouter = IRouter; export interface RuleExecutorServices< State extends AlertInstanceState = AlertInstanceState, Context extends AlertInstanceContext = AlertInstanceContext, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = RuleAlertData > { searchSourceClient: ISearchStartSearchSource; savedObjectsClient: SavedObjectsClientContract; @@ -106,14 +108,15 @@ export interface RuleExecutorOptions< State extends RuleTypeState = never, InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = never > { executionId: string; logger: Logger; params: Params; previousStartedAt: Date | null; rule: SanitizedRuleConfig; - services: RuleExecutorServices; + services: RuleExecutorServices; spaceId: string; startedAt: Date; state: State; @@ -132,9 +135,17 @@ export type ExecutorType< State extends RuleTypeState = never, InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, - ActionGroupIds extends string = never + ActionGroupIds extends string = never, + AlertData extends RuleAlertData = never > = ( - options: RuleExecutorOptions + options: RuleExecutorOptions< + Params, + State, + InstanceState, + InstanceContext, + ActionGroupIds, + AlertData + > ) => Promise<{ state: State }>; export interface RuleTypeParamsValidator { @@ -202,6 +213,15 @@ export interface IRuleTypeAlerts { */ mappings: ComponentTemplateSpec; + /** + * Optional flag to opt into writing alerts as data. When not specified + * defaults to false. We need this because we needed all previous rule + * registry rules to register with the framework in order to install + * Elasticsearch assets but we don't want to migrate them to using + * the framework for writing alerts as data until all the pieces are ready + */ + shouldWrite?: boolean; + /** * Optional flag to include a reference to the ECS component template. */ @@ -234,7 +254,8 @@ export interface RuleType< InstanceState extends AlertInstanceState = never, InstanceContext extends AlertInstanceContext = never, ActionGroupIds extends string = never, - RecoveryActionGroupId extends string = never + RecoveryActionGroupId extends string = never, + AlertData extends RuleAlertData = never > { id: string; name: string; @@ -253,7 +274,8 @@ export interface RuleType< * Ensure that the reserved ActionGroups (such as `Recovered`) are not * available for scheduling in the Executor */ - WithoutReservedActionGroups + WithoutReservedActionGroups, + AlertData >; producer: string; actionVariables?: { diff --git a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts index 6d716b5d3c235..ab0346aa8966e 100644 --- a/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/plugins/alerts/server/alert_types.ts @@ -93,29 +93,6 @@ function getAlwaysFiringAlertType() { context: [{ name: 'instanceContextValue', description: 'the instance context value' }], }, executor: curry(alwaysFiringExecutor)(), - alerts: { - context: 'test.always-firing', - mappings: { - fieldMap: { - instance_state_value: { - required: false, - type: 'boolean', - }, - instance_params_value: { - required: false, - type: 'boolean', - }, - instance_context_value: { - required: false, - type: 'boolean', - }, - group_in_series_index: { - required: false, - type: 'long', - }, - }, - }, - }, }; return result; } @@ -542,6 +519,83 @@ function getPatternFiringAlertType() { return result; } +function getPatternFiringAlertsAsDataRuleType() { + const paramsSchema = schema.object({ + pattern: schema.recordOf( + schema.string(), + schema.arrayOf(schema.oneOf([schema.boolean(), schema.string()])) + ), + }); + type ParamsType = TypeOf; + interface State extends RuleTypeState { + patternIndex?: number; + } + const result: RuleType = { + id: 'test.patternFiringAad', + name: 'Test: Firing on a Pattern and writing Alerts as Data', + actionGroups: [{ id: 'default', name: 'Default' }], + producer: 'alertsFixture', + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + validate: { + params: paramsSchema, + }, + async executor(alertExecutorOptions) { + const { services, state, params } = alertExecutorOptions; + const pattern = params.pattern; + if (typeof pattern !== 'object') throw new Error('pattern is not an object'); + let maxPatternLength = 0; + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (!Array.isArray(instancePattern)) { + throw new Error(`pattern for instance ${instanceId} is not an array`); + } + maxPatternLength = Math.max(maxPatternLength, instancePattern.length); + } + + // get the pattern index, return if past it + const patternIndex = state.patternIndex ?? 0; + if (patternIndex >= maxPatternLength) { + return { state: { patternIndex } }; + } + + // fire if pattern says to + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + const scheduleByPattern = instancePattern[patternIndex]; + if (scheduleByPattern === true) { + services.alertFactory.create(instanceId).scheduleActions('default'); + } else if (typeof scheduleByPattern === 'string') { + services.alertFactory.create(instanceId).scheduleActions('default', scheduleByPattern); + } + } + + return { + state: { + patternIndex: patternIndex + 1, + }, + }; + }, + alerts: { + context: 'test.patternfiring', + shouldWrite: true, + mappings: { + fieldMap: { + patternIndex: { + required: false, + type: 'long', + }, + instancePattern: { + required: false, + type: 'boolean', + array: true, + }, + }, + }, + }, + }; + return result; +} + function getPatternSuccessOrFailureAlertType() { const paramsSchema = schema.object({ pattern: schema.arrayOf(schema.oneOf([schema.boolean(), schema.string()])), @@ -1074,4 +1128,5 @@ export function defineAlertTypes( alerting.registerType(getExceedsAlertLimitRuleType()); alerting.registerType(getAlwaysFiringAlertAsDataRuleType(logger, { ruleRegistry })); alerting.registerType(getPatternFiringAutoRecoverFalseAlertType()); + alerting.registerType(getPatternFiringAlertsAsDataRuleType()); } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts new file mode 100644 index 0000000000000..b53a710d2c316 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data.ts @@ -0,0 +1,378 @@ +/* + * 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 { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import { IValidatedEvent } from '@kbn/event-log-plugin/server'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { omit } from 'lodash'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { Spaces } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertestWithoutAuth); + + type PatternFiringAlert = Alert; + + const alertsAsDataIndex = '.alerts-test.patternfiring.alerts-default'; + const timestampPattern = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + const fieldsToOmitInComparison = [ + '@timestamp', + 'kibana.alert.flapping_history', + 'kibana.alert.rule.execution.uuid', + ]; + + describe('alerts as data', () => { + afterEach(() => objectRemover.removeAll()); + after(async () => { + await es.deleteByQuery({ index: alertsAsDataIndex, query: { match_all: {} } }); + }); + + it('should write alert docs during rule execution', async () => { + const pattern = { + alertA: [true, true, true], // stays active across executions + alertB: [true, false, false], // active then recovers + alertC: [true, false, true], // active twice + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // -------------------------- + // RUN 1 - 3 new alerts + // -------------------------- + // Wait for the event log execute doc so we can get the execution UUID + let events: IValidatedEvent[] = await waitForEventLogDocs( + ruleId, + new Map([['execute', { equal: 1 }]]) + ); + let executeEvent = events[0]; + let executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun1 = await queryForAlertDocs(); + + // After the first run, we should have 3 alert docs for the 3 active alerts + expect(alertDocsRun1.length).to.equal(3); + + testExpectRuleData(alertDocsRun1, ruleId, ruleParameters, executionUuid!); + for (let i = 0; i < alertDocsRun1.length; ++i) { + const source: PatternFiringAlert = alertDocsRun1[i]._source!; + + // Each doc should have active status and default action group id + expect(source.kibana.alert.action_group).to.equal('default'); + + // alert UUID should equal doc id + expect(source.kibana.alert.uuid).to.equal(alertDocsRun1[i]._id); + + // duration should be '0' since this is a new alert + expect(source.kibana.alert.duration?.us).to.equal('0'); + + // start should be defined + expect(source.kibana.alert.start).to.match(timestampPattern); + + // timestamp should be defined + expect(source['@timestamp']).to.match(timestampPattern); + + // status should be active + expect(source.kibana.alert.status).to.equal('active'); + + // flapping information for new alert + expect(source.kibana.alert.flapping).to.equal(false); + expect(source.kibana.alert.flapping_history).to.eql([true]); + } + + let alertDoc: SearchHit | undefined = alertDocsRun1.find( + (doc) => doc._source!.kibana.alert.instance.id === 'alertA' + ); + const alertADocRun1 = alertDoc!._source!; + + alertDoc = alertDocsRun1.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun1 = alertDoc!._source!; + + alertDoc = alertDocsRun1.find((doc) => doc._source!.kibana.alert.instance.id === 'alertC'); + const alertCDocRun1 = alertDoc!._source!; + + // -------------------------- + // RUN 2 - 2 recovered (alertB, alertC), 1 ongoing (alertA) + // -------------------------- + let response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Wait for the event log execute doc so we can get the execution UUID + events = await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 2 }]])); + executeEvent = events[1]; + executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun2 = await queryForAlertDocs(); + + // After the second run, we should have 3 alert docs + expect(alertDocsRun2.length).to.equal(3); + + testExpectRuleData(alertDocsRun2, ruleId, ruleParameters, executionUuid!); + for (let i = 0; i < alertDocsRun2.length; ++i) { + const source: PatternFiringAlert = alertDocsRun2[i]._source!; + + // alert UUID should equal doc id + expect(source.kibana.alert.uuid).to.equal(alertDocsRun2[i]._id); + + // duration should be greater than 0 since these are not new alerts + const durationAsNumber = Number(source.kibana.alert.duration?.us); + expect(durationAsNumber).to.be.greaterThan(0); + } + + // alertA, run2 + // status is still active; duration is updated; no end time + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertA'); + const alertADocRun2 = alertDoc!._source!; + // uuid is the same + expect(alertADocRun2.kibana.alert.uuid).to.equal(alertADocRun1.kibana.alert.uuid); + expect(alertADocRun2.kibana.alert.action_group).to.equal('default'); + // start time should be defined and the same as prior run + expect(alertADocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertADocRun2.kibana.alert.start).to.equal(alertADocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertADocRun2['@timestamp']).to.match(timestampPattern); + expect(alertADocRun2['@timestamp']).not.to.equal(alertADocRun1['@timestamp']); + // status should still be active + expect(alertADocRun2.kibana.alert.status).to.equal('active'); + // flapping false, flapping history updated with additional entry + expect(alertADocRun2.kibana.alert.flapping).to.equal(false); + expect(alertADocRun2.kibana.alert.flapping_history).to.eql([ + ...alertADocRun1.kibana.alert.flapping_history!, + false, + ]); + + // alertB, run 2 + // status is updated to recovered, duration is updated, end time is set + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun2 = alertDoc!._source!; + // action group should be set to recovered + expect(alertBDocRun2.kibana.alert.action_group).to.be('recovered'); + // uuid is the same + expect(alertBDocRun2.kibana.alert.uuid).to.equal(alertBDocRun1.kibana.alert.uuid); + // start time should be defined and the same as before + expect(alertBDocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertBDocRun2.kibana.alert.start).to.equal(alertBDocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertBDocRun2['@timestamp']).to.match(timestampPattern); + expect(alertBDocRun2['@timestamp']).not.to.equal(alertBDocRun1['@timestamp']); + // end time should be defined + expect(alertBDocRun2.kibana.alert.end).to.match(timestampPattern); + // status should be set to recovered + expect(alertBDocRun2.kibana.alert.status).to.equal('recovered'); + // flapping false, flapping history updated with additional entry + expect(alertBDocRun2.kibana.alert.flapping).to.equal(false); + expect(alertBDocRun2.kibana.alert.flapping_history).to.eql([ + ...alertBDocRun1.kibana.alert.flapping_history!, + true, + ]); + + // alertB, run 2 + // status is updated to recovered, duration is updated, end time is set + alertDoc = alertDocsRun2.find((doc) => doc._source!.kibana.alert.instance.id === 'alertC'); + const alertCDocRun2 = alertDoc!._source!; + // action group should be set to recovered + expect(alertCDocRun2.kibana.alert.action_group).to.be('recovered'); + // uuid is the same + expect(alertCDocRun2.kibana.alert.uuid).to.equal(alertCDocRun1.kibana.alert.uuid); + // start time should be defined and the same as before + expect(alertCDocRun2.kibana.alert.start).to.match(timestampPattern); + expect(alertCDocRun2.kibana.alert.start).to.equal(alertCDocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertCDocRun2['@timestamp']).to.match(timestampPattern); + expect(alertCDocRun2['@timestamp']).not.to.equal(alertCDocRun1['@timestamp']); + // end time should be defined + expect(alertCDocRun2.kibana.alert.end).to.match(timestampPattern); + // status should be set to recovered + expect(alertCDocRun2.kibana.alert.status).to.equal('recovered'); + // flapping false, flapping history updated with additional entry + expect(alertCDocRun2.kibana.alert.flapping).to.equal(false); + expect(alertCDocRun2.kibana.alert.flapping_history).to.eql([ + ...alertCDocRun1.kibana.alert.flapping_history!, + true, + ]); + + // -------------------------- + // RUN 3 - 1 re-active (alertC), 1 still recovered (alertB), 1 ongoing (alertA) + // -------------------------- + response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + + // Wait for the event log execute doc so we can get the execution UUID + events = await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 3 }]])); + executeEvent = events[2]; + executionUuid = executeEvent?.kibana?.alert?.rule?.execution?.uuid; + expect(executionUuid).not.to.be(undefined); + + // Query for alerts + const alertDocsRun3 = await queryForAlertDocs(); + + // After the third run, we should have 4 alert docs + // The docs for "alertA" and "alertB" should not have been updated + // There should be two docs for "alertC", one for the first active -> recovered span + // the second for the new active span + expect(alertDocsRun3.length).to.equal(4); + + testExpectRuleData(alertDocsRun3, ruleId, ruleParameters); + + // alertA, run3 + // status is still active; duration is updated; no end time + alertDoc = alertDocsRun3.find((doc) => doc._source!.kibana.alert.instance.id === 'alertA'); + const alertADocRun3 = alertDoc!._source!; + // uuid is the same as previous runs + expect(alertADocRun3.kibana.alert.uuid).to.equal(alertADocRun2.kibana.alert.uuid); + expect(alertADocRun3.kibana.alert.uuid).to.equal(alertADocRun1.kibana.alert.uuid); + expect(alertADocRun3.kibana.alert.action_group).to.equal('default'); + // start time should be defined and the same as prior runs + expect(alertADocRun3.kibana.alert.start).to.match(timestampPattern); + expect(alertADocRun3.kibana.alert.start).to.equal(alertADocRun2.kibana.alert.start); + expect(alertADocRun3.kibana.alert.start).to.equal(alertADocRun1.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertADocRun3['@timestamp']).to.match(timestampPattern); + expect(alertADocRun3['@timestamp']).not.to.equal(alertADocRun2['@timestamp']); + // status should still be active + expect(alertADocRun3.kibana.alert.status).to.equal('active'); + // flapping false, flapping history updated with additional entry + expect(alertADocRun3.kibana.alert.flapping).to.equal(false); + expect(alertADocRun3.kibana.alert.flapping_history).to.eql([ + ...alertADocRun2.kibana.alert.flapping_history!, + false, + ]); + + // alertB doc should be unchanged from prior run because it is still recovered + // but its flapping history should be updated + alertDoc = alertDocsRun3.find((doc) => doc._source!.kibana.alert.instance.id === 'alertB'); + const alertBDocRun3 = alertDoc!._source!; + expect(omit(alertBDocRun3, fieldsToOmitInComparison)).to.eql( + omit(alertBDocRun2, fieldsToOmitInComparison) + ); + // execution uuid should be current one + expect(alertBDocRun3.kibana.alert.rule.execution?.uuid).to.equal(executionUuid); + // flapping history should be history from prior run with additional entry + expect(alertBDocRun3.kibana.alert.flapping_history).to.eql([ + ...alertBDocRun2.kibana.alert.flapping_history!, + false, + ]); + + // alertC should have 2 docs + const alertCDocs = alertDocsRun3.filter( + (doc) => doc._source!.kibana.alert.instance.id === 'alertC' + ); + // alertC recovered doc should be exactly the same as the alertC doc from prior run + const recoveredAlertCDoc = alertCDocs.find( + (doc) => doc._source!.kibana.alert.rule.execution?.uuid !== executionUuid + )!._source!; + expect(recoveredAlertCDoc).to.eql(alertCDocRun2); + + // alertC doc from current execution + const alertCDocRun3 = alertCDocs.find( + (doc) => doc._source!.kibana.alert.rule.execution?.uuid === executionUuid + )!._source!; + // uuid is the different from prior run] + expect(alertCDocRun3.kibana.alert.uuid).not.to.equal(alertCDocRun2.kibana.alert.uuid); + expect(alertCDocRun3.kibana.alert.action_group).to.equal('default'); + // start time should be defined and different from the prior run + expect(alertCDocRun3.kibana.alert.start).to.match(timestampPattern); + expect(alertCDocRun3.kibana.alert.start).not.to.equal(alertCDocRun2.kibana.alert.start); + // timestamp should be defined and not the same as prior run + expect(alertCDocRun3['@timestamp']).to.match(timestampPattern); + // duration should be '0' since this is a new alert + expect(alertCDocRun3.kibana.alert.duration?.us).to.equal('0'); + // flapping false, flapping history should be history from prior run with additional entry + expect(alertCDocRun3.kibana.alert.flapping).to.equal(false); + expect(alertCDocRun3.kibana.alert.flapping_history).to.eql([ + ...alertCDocRun2.kibana.alert.flapping_history!, + true, + ]); + }); + }); + + function testExpectRuleData( + alertDocs: Array>, + ruleId: string, + ruleParameters: unknown, + executionUuid?: string + ) { + for (let i = 0; i < alertDocs.length; ++i) { + const source: PatternFiringAlert = alertDocs[i]._source!; + + // Each doc should have a copy of the rule data + expect(source.kibana.alert.rule.category).to.equal( + 'Test: Firing on a Pattern and writing Alerts as Data' + ); + expect(source.kibana.alert.rule.consumer).to.equal('alertsFixture'); + expect(source.kibana.alert.rule.name).to.equal('abc'); + expect(source.kibana.alert.rule.producer).to.equal('alertsFixture'); + expect(source.kibana.alert.rule.tags).to.eql(['foo']); + expect(source.kibana.alert.rule.rule_type_id).to.equal('test.patternFiringAad'); + expect(source.kibana.alert.rule.uuid).to.equal(ruleId); + expect(source.kibana.alert.rule.parameters).to.eql(ruleParameters); + expect(source.kibana.space_ids).to.eql(['space1']); + + if (executionUuid) { + expect(source.kibana.alert.rule.execution?.uuid).to.equal(executionUuid); + } + } + } + + async function queryForAlertDocs(): Promise>> { + const searchResult = await es.search({ + index: alertsAsDataIndex, + body: { query: { match_all: {} } }, + }); + return searchResult.hits.hits as Array>; + } + + async function waitForEventLogDocs( + id: string, + actions: Map + ) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} 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 new file mode 100644 index 0000000000000..62d45f50a07c9 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/alerts_as_data_flapping.ts @@ -0,0 +1,493 @@ +/* + * 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 { SearchHit } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Alert } from '@kbn/alerts-as-data-utils'; +import { RuleNotifyWhen } from '@kbn/alerting-plugin/common'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { Spaces } from '../../../../scenarios'; +import { + getEventLog, + getTestRuleData, + getUrlPrefix, + ObjectRemover, + TaskManagerDoc, +} from '../../../../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { + const es = getService('es'); + const retry = getService('retry'); + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const objectRemover = new ObjectRemover(supertestWithoutAuth); + + type PatternFiringAlert = Alert; + + const alertsAsDataIndex = '.alerts-test.patternfiring.alerts-default'; + + describe('alerts as data flapping', () => { + afterEach(async () => { + await es.deleteByQuery({ index: alertsAsDataIndex, query: { match_all: {} } }); + objectRemover.removeAll(); + }); + + // These are the same tests from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group1/event_log.ts + // but testing that flapping status & flapping history is updated as expected for AAD docs + + it('should set flapping and flapping_history for flapping alerts that settle on active', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 6, + status_change_threshold: 4, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false].concat( + ...new Array(8).fill(true), + false + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 4 more times + for (let i = 0; i < 4; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 2 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered + expect(alertDocs.length).to.equal(2); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 3 alert docs now because alert became active again + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert is flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 7 more times + for (let i = 0; i < 7; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 3 alert docs + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be false because alert was active for long + // enough to reset the flapping state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(false); + }); + + it('should set flapping and flapping_history for flapping alerts that settle on recovered', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 6, + status_change_threshold: 4, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false, true].concat( + new Array(11).fill(false) + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 4 more times + for (let i = 0; i < 4; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 2 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered + expect(alertDocs.length).to.equal(2); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 3 alert docs now because alert became active again + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert is flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 3 more times + for (let i = 0; i < 3; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 3 alert docs + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because alert recovered while flapping + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(true); + }); + + it('should set flapping and flapping_history for flapping alerts over a period of time longer than the lookback', async () => { + await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rules/settings/_flapping`) + .set('kbn-xsrf', 'foo') + .auth('superuser', 'superuser') + .send({ + enabled: true, + look_back_window: 5, + status_change_threshold: 5, + }) + .expect(200); + + const pattern = { + alertA: [true, false, false, true, false, true, false, true, false].concat( + ...new Array(8).fill(true), + false + ), + }; + const ruleParameters = { pattern }; + const createdRule = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) + .set('kbn-xsrf', 'foo') + .send( + getTestRuleData({ + rule_type_id: 'test.patternFiringAad', + // set the schedule long so we can use "runSoon" to specify rule runs + schedule: { interval: '1d' }, + throttle: null, + params: ruleParameters, + actions: [], + notify_when: RuleNotifyWhen.CHANGE, + }) + ); + + expect(createdRule.status).to.eql(200); + const ruleId = createdRule.body.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); + + // Wait for the rule to run once + let run = 1; + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: 1 }]])); + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + let alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + let state: any = await getRuleState(ruleId); + + // Should be 3 alert docs because alert pattern was: + // active, recovered, recovered, active, recovered, active, recovered + expect(alertDocs.length).to.equal(3); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertRecoveredInstances.alertA.meta.flappingHistory + ); + + // Alert shouldn't be flapping because the status change threshold hasn't been exceeded + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertRecoveredInstances.alertA.meta.flapping).to.equal(false); + + // Run the rule 1 more time + for (let i = 0; i < 1; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should be 4 alert docs now because alert became active again + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be false while flapping value for state should be true + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertInstances.alertA.meta.flapping).to.equal(true); + + // Run the rule 6 more times + for (let i = 0; i < 6; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 4 alert docs + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc should be true while flapping value for state should be false + // This is because we write out the alert doc BEFORE calculating the latest flapping state and + // persisting into task state + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(true); + expect(state.alertInstances.alertA.meta.flapping).to.equal(false); + + // Run the rule 3 more times + for (let i = 0; i < 3; i++) { + const response = await supertestWithoutAuth + .post(`${getUrlPrefix(Spaces.space1.id)}/internal/alerting/rule/${ruleId}/_run_soon`) + .set('kbn-xsrf', 'foo'); + expect(response.status).to.eql(204); + await waitForEventLogDocs(ruleId, new Map([['execute', { equal: ++run }]])); + } + + // Query for alerts + alertDocs = await queryForAlertDocs(); + + // Get rule state from task document + state = await getRuleState(ruleId); + + // Should still be 4 alert docs + expect(alertDocs.length).to.equal(4); + + // Newest alert doc is first + // Flapping history for newest alert doc should match flapping history in state + expect(alertDocs[0]._source!.kibana.alert.flapping_history).to.eql( + state.alertInstances.alertA.meta.flappingHistory + ); + + // Flapping value for alert doc and task state should be true because lookback threshold exceeded + expect(alertDocs[0]._source!.kibana.alert.flapping).to.equal(false); + expect(state.alertInstances.alertA.meta.flapping).to.equal(false); + }); + }); + + async function getRuleState(ruleId: string) { + const task = await es.get({ + id: `task:${ruleId}`, + index: '.kibana_task_manager', + }); + + return JSON.parse(task._source!.task.state); + } + + async function queryForAlertDocs(): Promise>> { + const searchResult = await es.search({ + index: alertsAsDataIndex, + body: { query: { match_all: {} } }, + }); + return searchResult.hits.hits as Array>; + } + + async function waitForEventLogDocs( + id: string, + actions: Map + ) { + return await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts new file mode 100644 index 0000000000000..9156fb9e8ec37 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertsAsDataTests({ loadTestFile }: FtrProviderContext) { + describe('alerts_as_data', () => { + loadTestFile(require.resolve('./install_resources')); + loadTestFile(require.resolve('./alerts_as_data')); + loadTestFile(require.resolve('./alerts_as_data_flapping')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts similarity index 88% rename from x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts rename to x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts index c65af87d39aa7..b6c86b49c7fba 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/alerts_as_data/install_resources.ts @@ -8,16 +8,16 @@ import { alertFieldMap, ecsFieldMap, legacyAlertFieldMap } from '@kbn/alerts-as-data-utils'; import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; // eslint-disable-next-line import/no-default-export -export default function createAlertsAsDataTest({ getService }: FtrProviderContext) { +export default function createAlertsAsDataInstallResourcesTest({ getService }: FtrProviderContext) { const es = getService('es'); const frameworkMappings = mappingFromFieldMap(alertFieldMap, 'strict'); const legacyAlertMappings = mappingFromFieldMap(legacyAlertFieldMap, 'strict'); const ecsMappings = mappingFromFieldMap(ecsFieldMap, 'strict'); - describe('alerts as data', () => { + describe('install alerts as data resources', () => { it('should install common alerts as data resources on startup', async () => { const ilmPolicyName = '.alerts-ilm-policy'; const frameworkComponentTemplateName = '.alerts-framework-mappings'; @@ -111,22 +111,16 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex }); it('should install context specific alerts as data resources on startup', async () => { - const componentTemplateName = '.alerts-test.always-firing.alerts-mappings'; - const indexTemplateName = '.alerts-test.always-firing.alerts-default-index-template'; - const indexName = '.internal.alerts-test.always-firing.alerts-default-000001'; + const componentTemplateName = '.alerts-test.patternfiring.alerts-mappings'; + const indexTemplateName = '.alerts-test.patternfiring.alerts-default-index-template'; + const indexName = '.internal.alerts-test.patternfiring.alerts-default-000001'; const contextSpecificMappings = { - instance_params_value: { - type: 'boolean', - }, - instance_state_value: { - type: 'boolean', + patternIndex: { + type: 'long', }, - instance_context_value: { + instancePattern: { type: 'boolean', }, - group_in_series_index: { - type: 'long', - }, }; const { component_templates: componentTemplates } = await es.cluster.getComponentTemplate({ @@ -148,10 +142,10 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex const contextIndexTemplate = indexTemplates[0]; expect(contextIndexTemplate.name).to.eql(indexTemplateName); expect(contextIndexTemplate.index_template.index_patterns).to.eql([ - '.internal.alerts-test.always-firing.alerts-default-*', + '.internal.alerts-test.patternfiring.alerts-default-*', ]); expect(contextIndexTemplate.index_template.composed_of).to.eql([ - '.alerts-test.always-firing.alerts-mappings', + '.alerts-test.patternfiring.alerts-mappings', '.alerts-framework-mappings', ]); expect(contextIndexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); @@ -166,7 +160,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex index: { lifecycle: { name: '.alerts-ilm-policy', - rollover_alias: '.alerts-test.always-firing.alerts-default', + rollover_alias: '.alerts-test.patternfiring.alerts-default', }, mapping: { total_fields: { @@ -183,7 +177,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex }); expect(contextIndex[indexName].aliases).to.eql({ - '.alerts-test.always-firing.alerts-default': { + '.alerts-test.patternfiring.alerts-default': { is_write_index: true, }, }); @@ -198,7 +192,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex expect(contextIndex[indexName].settings?.index?.lifecycle).to.eql({ name: '.alerts-ilm-policy', - rollover_alias: '.alerts-test.always-firing.alerts-default', + rollover_alias: '.alerts-test.patternfiring.alerts-default', }); expect(contextIndex[indexName].settings?.index?.mapping).to.eql({ @@ -211,7 +205,7 @@ export default function createAlertsAsDataTest({ getService }: FtrProviderContex expect(contextIndex[indexName].settings?.index?.number_of_shards).to.eql(1); expect(contextIndex[indexName].settings?.index?.auto_expand_replicas).to.eql('0-1'); expect(contextIndex[indexName].settings?.index?.provided_name).to.eql( - '.internal.alerts-test.always-firing.alerts-default-000001' + '.internal.alerts-test.patternfiring.alerts-default-000001' ); }); }); 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 ec02b00593fe8..0adcfe10009ca 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 @@ -14,6 +14,7 @@ export default function alertingTests({ loadTestFile, getService }: FtrProviderC before(async () => await buildUp(getService)); after(async () => await tearDown(getService)); + loadTestFile(require.resolve('./alerts_as_data')); loadTestFile(require.resolve('./builtin_alert_types')); loadTestFile(require.resolve('./mustache_templates.ts')); loadTestFile(require.resolve('./notify_when')); @@ -26,7 +27,6 @@ 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('./alerts_as_data')); loadTestFile(require.resolve('./generate_alert_schemas')); // Do not place test files here, due to https://github.com/elastic/kibana/issues/123059