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