diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts similarity index 79% rename from x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts index b22d7b70a9d49e..600f6aedbe039f 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.test.ts @@ -5,42 +5,38 @@ * 2.0. */ -import { ExecutionHandler } from './execution_handler'; +import { ActionScheduler } from './action_scheduler'; import { loggingSystemMock } from '@kbn/core/server/mocks'; import { actionsClientMock, actionsMock, renderActionParameterTemplatesDefault, } from '@kbn/actions-plugin/server/mocks'; -import { KibanaRequest } from '@kbn/core/server'; import { ActionsCompletion } from '@kbn/alerting-state-types'; import { ALERT_UUID } from '@kbn/rule-data-utils'; -import { InjectActionParamsOpts, injectActionParams } from './inject_action_params'; -import { NormalizedRuleType } from '../rule_type_registry'; -import { - ThrottledActions, - RuleTypeParams, - RuleTypeState, - SanitizedRule, - GetViewInAppRelativeUrlFnOpts, -} from '../types'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock'; +import { InjectActionParamsOpts, injectActionParams } from '../inject_action_params'; +import { RuleTypeParams, SanitizedRule, GetViewInAppRelativeUrlFnOpts } from '../../types'; +import { RuleRunMetricsStore } from '../../lib/rule_run_metrics_store'; +import { alertingEventLoggerMock } from '../../lib/alerting_event_logger/alerting_event_logger.mock'; import { ConcreteTaskInstance, TaskErrorSource } from '@kbn/task-manager-plugin/server'; -import { Alert } from '../alert'; -import { AlertInstanceState, AlertInstanceContext, RuleNotifyWhen } from '../../common'; +import { RuleNotifyWhen } from '../../../common'; import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; import sinon from 'sinon'; -import { mockAAD } from './fixtures'; +import { mockAAD } from '../fixtures'; import { schema } from '@kbn/config-schema'; -import { ConnectorAdapterRegistry } from '../connector_adapters/connector_adapter_registry'; -import { alertsClientMock } from '../alerts_client/alerts_client.mock'; +import { alertsClientMock } from '../../alerts_client/alerts_client.mock'; import { ExecutionResponseType } from '@kbn/actions-plugin/server/create_execute_function'; -import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; -import { TaskRunnerContext } from './types'; - -jest.mock('./inject_action_params', () => ({ +import { + generateAlert, + generateRecoveredAlert, + getDefaultSchedulerContext, + getRule, + getRuleType, +} from './test_fixtures'; + +jest.mock('../inject_action_params', () => ({ injectActionParams: jest.fn(), })); @@ -51,100 +47,16 @@ const actionsClient = actionsClientMock.create(); const alertsClient = alertsClientMock.create(); const mockActionsPlugin = actionsMock.createStart(); const apiKey = Buffer.from('123:abc').toString('base64'); -const ruleType: NormalizedRuleType< - RuleTypeParams, - RuleTypeParams, - RuleTypeState, - AlertInstanceState, - AlertInstanceContext, - 'default' | 'other-group', - 'recovered', - {} -> = { - id: 'test', - name: 'Test', - actionGroups: [ - { id: 'default', name: 'Default' }, - { id: 'recovered', name: 'Recovered' }, - { id: 'other-group', name: 'Other Group' }, - ], - defaultActionGroupId: 'default', - minimumLicenseRequired: 'basic', - isExportable: true, - recoveryActionGroup: { - id: 'recovered', - name: 'Recovered', - }, - executor: jest.fn(), - category: 'test', - producer: 'alerts', - validate: { - params: schema.any(), - }, - alerts: { - context: 'context', - mappings: { fieldMap: { field: { type: 'fieldType', required: false } } }, - }, - autoRecoverAlerts: false, - validLegacyConsumers: [], -}; -const rule = { - id: '1', - name: 'name-of-alert', - tags: ['tag-A', 'tag-B'], - mutedInstanceIds: [], - params: { - foo: true, - contextVal: 'My other {{context.value}} goes here', - stateVal: 'My other {{state.value}} goes here', - }, - schedule: { interval: '1m' }, - notifyWhen: 'onActiveAlert', - actions: [ - { - id: '1', - group: 'default', - actionTypeId: 'test', - params: { - foo: true, - contextVal: 'My {{context.value}} goes here', - stateVal: 'My {{state.value}} goes here', - alertVal: - 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', - }, - uuid: '111-111', - }, - ], - consumer: 'test-consumer', -} as unknown as SanitizedRule; - -const defaultExecutionParams = { - rule, - ruleType, - logger: loggingSystemMock.create().get(), - taskRunnerContext: { - actionsConfigMap: { - default: { - max: 1000, - }, - }, - actionsPlugin: mockActionsPlugin, - connectorAdapterRegistry: new ConnectorAdapterRegistry(), - } as unknown as TaskRunnerContext, - apiKey, - ruleConsumer: 'rule-consumer', - executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', - alertUuid: 'uuid-1', - ruleLabel: 'rule-label', - request: {} as KibanaRequest, + +const rule = getRule(); +const ruleType = getRuleType(); +const defaultSchedulerContext = getDefaultSchedulerContext( + loggingSystemMock.create().get(), + mockActionsPlugin, alertingEventLogger, - previousStartedAt: null, - taskInstance: { - params: { spaceId: 'test1', alertId: '1' }, - } as unknown as ConcreteTaskInstance, actionsClient, - alertsClient, -}; + alertsClient +); const defaultExecutionResponse = { errors: false, @@ -153,74 +65,11 @@ const defaultExecutionResponse = { let ruleRunMetricsStore: RuleRunMetricsStore; let clock: sinon.SinonFakeTimers; -type ActiveActionGroup = 'default' | 'other-group'; -const generateAlert = ({ - id, - group = 'default', - context, - state, - scheduleActions = true, - throttledActions = {}, - lastScheduledActionsGroup = 'default', - maintenanceWindowIds, - pendingRecoveredCount, - activeCount, -}: { - id: number; - group?: ActiveActionGroup | 'recovered'; - context?: AlertInstanceContext; - state?: AlertInstanceState; - scheduleActions?: boolean; - throttledActions?: ThrottledActions; - lastScheduledActionsGroup?: string; - maintenanceWindowIds?: string[]; - pendingRecoveredCount?: number; - activeCount?: number; -}) => { - const alert = new Alert( - String(id), - { - state: state || { test: true }, - meta: { - maintenanceWindowIds, - lastScheduledActions: { - date: new Date().toISOString(), - group: lastScheduledActionsGroup, - actions: throttledActions, - }, - pendingRecoveredCount, - activeCount, - }, - } - ); - if (scheduleActions) { - alert.scheduleActions(group as ActiveActionGroup); - } - if (context) { - alert.setContext(context); - } - - return { [id]: alert }; -}; - -const generateRecoveredAlert = ({ id, state }: { id: number; state?: AlertInstanceState }) => { - const alert = new Alert(String(id), { - state: state || { test: true }, - meta: { - lastScheduledActions: { - date: new Date().toISOString(), - group: 'recovered', - actions: {}, - }, - }, - }); - return { [id]: alert }; -}; // @ts-ignore -const generateExecutionParams = (params = {}) => { +const getSchedulerContext = (params = {}) => { return { - ...defaultExecutionParams, + ...defaultSchedulerContext, ...params, ruleRunMetricsStore, }; @@ -228,11 +77,11 @@ const generateExecutionParams = (params = {}) => { const DATE_1970 = new Date('1970-01-01T00:00:00.000Z'); -describe('Execution Handler', () => { +describe('Action Scheduler', () => { beforeEach(() => { jest.resetAllMocks(); jest - .requireMock('./inject_action_params') + .requireMock('../inject_action_params') .injectActionParams.mockImplementation( ({ actionParams }: InjectActionParamsOpts) => actionParams ); @@ -252,8 +101,8 @@ describe('Execution Handler', () => { test('enqueues execution per selected action', async () => { const alerts = generateAlert({ id: 1 }); - const executionHandler = new ExecutionHandler(generateExecutionParams()); - await executionHandler.run(alerts); + const actionScheduler = new ActionScheduler(getSchedulerContext()); + await actionScheduler.run(alerts); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); @@ -302,7 +151,7 @@ describe('Execution Handler', () => { alertGroup: 'default', }); - expect(jest.requireMock('./inject_action_params').injectActionParams).toHaveBeenCalledWith({ + expect(jest.requireMock('../inject_action_params').injectActionParams).toHaveBeenCalledWith({ actionTypeId: 'test', actionParams: { alertVal: 'My 1 name-of-alert test1 tag-A,tag-B 1 goes here', @@ -321,10 +170,10 @@ describe('Execution Handler', () => { mockActionsPlugin.isActionExecutable.mockReturnValueOnce(false); mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(false); mockActionsPlugin.isActionTypeEnabled.mockReturnValueOnce(true); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { id: '2', @@ -351,7 +200,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run(generateAlert({ id: 1 })); + await actionScheduler.run(generateAlert({ id: 1 })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); @@ -388,10 +237,10 @@ describe('Execution Handler', () => { mockActionsPlugin.inMemoryConnectors = []; mockActionsPlugin.isActionExecutable.mockReturnValue(false); mockActionsPlugin.isActionTypeEnabled.mockReturnValue(false); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { id: '2', @@ -416,19 +265,19 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run(generateAlert({ id: 2 })); + await actionScheduler.run(generateAlert({ id: 2 })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(2); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); mockActionsPlugin.isActionExecutable.mockImplementation(() => true); - const executionHandlerForPreconfiguredAction = new ExecutionHandler({ - ...defaultExecutionParams, + const actionSchedulerForPreconfiguredAction = new ActionScheduler({ + ...defaultSchedulerContext, ruleRunMetricsStore, }); - await executionHandlerForPreconfiguredAction.run(generateAlert({ id: 2 })); + await actionSchedulerForPreconfiguredAction.run(generateAlert({ id: 2 })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); @@ -449,11 +298,11 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, actionsConfigMap: { default: { max: 2, @@ -461,30 +310,30 @@ describe('Execution Handler', () => { }, }, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); try { - await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); } catch (err) { expect(getErrorSource(err)).toBe(TaskErrorSource.USER); } }); test('limits actionsPlugin.execute per action group', async () => { - const executionHandler = new ExecutionHandler(generateExecutionParams()); - await executionHandler.run(generateAlert({ id: 2, group: 'other-group' })); + const actionScheduler = new ActionScheduler(getSchedulerContext()); + await actionScheduler.run(generateAlert({ id: 2, group: 'other-group' })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(0); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(0); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); }); test('context attribute gets parameterized', async () => { - const executionHandler = new ExecutionHandler(generateExecutionParams()); - await executionHandler.run(generateAlert({ id: 2, context: { value: 'context-val' } })); + const actionScheduler = new ActionScheduler(getSchedulerContext()); + await actionScheduler.run(generateAlert({ id: 2, context: { value: 'context-val' } })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(1); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); @@ -526,8 +375,8 @@ describe('Execution Handler', () => { }); test('state attribute gets parameterized', async () => { - const executionHandler = new ExecutionHandler(generateExecutionParams()); - await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + const actionScheduler = new ActionScheduler(getSchedulerContext()); + await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -567,12 +416,12 @@ describe('Execution Handler', () => { }); test(`logs an error when action group isn't part of actionGroups available for the ruleType`, async () => { - const executionHandler = new ExecutionHandler(generateExecutionParams()); - await executionHandler.run( + const actionScheduler = new ActionScheduler(getSchedulerContext()); + await actionScheduler.run( generateAlert({ id: 2, group: 'invalid-group' as 'default' | 'other-group' }) ); - expect(defaultExecutionParams.logger.error).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.error).toHaveBeenCalledWith( 'Invalid action group "invalid-group" for rule "test".' ); @@ -629,11 +478,11 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, actionsConfigMap: { default: { max: 2, @@ -641,17 +490,17 @@ describe('Execution Handler', () => { }, }, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); - await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); @@ -678,7 +527,7 @@ describe('Execution Handler', () => { ], }); const actions = [ - ...defaultExecutionParams.rule.actions, + ...defaultSchedulerContext.rule.actions, { id: '2', group: 'default', @@ -720,11 +569,11 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, actionsConfigMap: { default: { max: 4, @@ -735,12 +584,12 @@ describe('Execution Handler', () => { }, }, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); - await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(4); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(5); @@ -809,21 +658,21 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); - await executionHandler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); + await actionScheduler.run(generateAlert({ id: 2, state: { value: 'state-val' } })); expect(ruleRunMetricsStore.getNumberOfTriggeredActions()).toBe(2); expect(ruleRunMetricsStore.getNumberOfGeneratedActions()).toBe(3); expect(ruleRunMetricsStore.getTriggeredActionsStatus()).toBe(ActionsCompletion.PARTIAL); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); }); @@ -842,16 +691,16 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); - await executionHandler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run(generateRecoveredAlert({ id: 1 })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -892,11 +741,11 @@ describe('Execution Handler', () => { }); test('does not schedule alerts with recovered actions that are muted', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['1'], actions: [ { @@ -915,46 +764,46 @@ describe('Execution Handler', () => { }, }) ); - await executionHandler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run(generateRecoveredAlert({ id: 1 })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - expect(defaultExecutionParams.logger.debug).nthCalledWith( + expect(defaultSchedulerContext.logger.debug).nthCalledWith( 1, - `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + `skipping scheduling of actions for '1' in rule ${defaultSchedulerContext.ruleLabel}: rule is muted` ); }); test('does not schedule active alerts that are throttled', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, notifyWhen: 'onThrottleInterval', throttle: '1m', }, }) ); - await executionHandler.run(generateAlert({ id: 1 })); + await actionScheduler.run(generateAlert({ id: 1 })); clock.tick(30000); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - expect(defaultExecutionParams.logger.debug).nthCalledWith( + expect(defaultSchedulerContext.logger.debug).nthCalledWith( 1, - `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is throttled` + `skipping scheduling of actions for '1' in rule ${defaultSchedulerContext.ruleLabel}: rule is throttled` ); }); test('does not schedule actions that are throttled', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { - ...defaultExecutionParams.rule.actions[0], + ...defaultSchedulerContext.rule.actions[0], frequency: { summary: false, notifyWhen: 'onThrottleInterval', @@ -965,7 +814,7 @@ describe('Execution Handler', () => { }, }) ); - await executionHandler.run( + await actionScheduler.run( generateAlert({ id: 1, throttledActions: { '111-111': { date: new Date(DATE_1970).toISOString() } }, @@ -975,21 +824,21 @@ describe('Execution Handler', () => { clock.tick(30000); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - expect(defaultExecutionParams.logger.debug).nthCalledWith( + expect(defaultSchedulerContext.logger.debug).nthCalledWith( 1, - `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is throttled` + `skipping scheduling of actions for '1' in rule ${defaultSchedulerContext.ruleLabel}: rule is throttled` ); }); test('schedule actions that are throttled but alert has a changed action group', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { - ...defaultExecutionParams.rule.actions[0], + ...defaultSchedulerContext.rule.actions[0], frequency: { summary: false, notifyWhen: 'onThrottleInterval', @@ -1000,7 +849,7 @@ describe('Execution Handler', () => { }, }) ); - await executionHandler.run(generateAlert({ id: 1, lastScheduledActionsGroup: 'recovered' })); + await actionScheduler.run(generateAlert({ id: 1, lastScheduledActionsGroup: 'recovered' })); clock.tick(30000); @@ -1009,21 +858,21 @@ describe('Execution Handler', () => { }); test('does not schedule active alerts that are muted', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['1'], }, }) ); - await executionHandler.run(generateAlert({ id: 1 })); + await actionScheduler.run(generateAlert({ id: 1 })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(0); - expect(defaultExecutionParams.logger.debug).nthCalledWith( + expect(defaultSchedulerContext.logger.debug).nthCalledWith( 1, - `skipping scheduling of actions for '1' in rule ${defaultExecutionParams.ruleLabel}: rule is muted` + `skipping scheduling of actions for '1' in rule ${defaultSchedulerContext.ruleLabel}: rule is muted` ); }); @@ -1046,10 +895,10 @@ describe('Execution Handler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1071,7 +920,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run(generateAlert({ id: 1 })); + await actionScheduler.run(generateAlert({ id: 1 })); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ executionUuid: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', @@ -1125,10 +974,10 @@ describe('Execution Handler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { id: '1', @@ -1150,7 +999,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({}); + await actionScheduler.run({}); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); @@ -1175,10 +1024,10 @@ describe('Execution Handler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1201,7 +1050,7 @@ describe('Execution Handler', () => { }) ); - const result = await executionHandler.run({}); + const result = await actionScheduler.run({}); expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ start: new Date('1969-12-31T00:01:30.000Z'), @@ -1263,10 +1112,10 @@ describe('Execution Handler', () => { ongoing: { count: 0, alerts: [] }, recovered: { count: 0, alerts: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { id: '1', @@ -1286,18 +1135,18 @@ describe('Execution Handler', () => { ], }, taskInstance: { - ...defaultExecutionParams.taskInstance, + ...defaultSchedulerContext.taskInstance, state: { - ...defaultExecutionParams.taskInstance.state, + ...defaultSchedulerContext.taskInstance.state, summaryActions: { '111-111': { date: new Date() } }, }, } as unknown as ConcreteTaskInstance, }) ); - await executionHandler.run({}); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + await actionScheduler.run({}); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( "skipping scheduling the action 'testActionTypeId:1', summary action is still being throttled" ); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); @@ -1311,10 +1160,10 @@ describe('Execution Handler', () => { ongoing: { count: 0, data: [] }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { id: '1', @@ -1344,9 +1193,9 @@ describe('Execution Handler', () => { ], }, taskInstance: { - ...defaultExecutionParams.taskInstance, + ...defaultSchedulerContext.taskInstance, state: { - ...defaultExecutionParams.taskInstance.state, + ...defaultSchedulerContext.taskInstance.state, summaryActions: { '111-111': { date: new Date() }, '222-222': { date: new Date() }, @@ -1357,7 +1206,7 @@ describe('Execution Handler', () => { }) ); - const result = await executionHandler.run({}); + const result = await actionScheduler.run({}); expect(result).toEqual({ throttledSummaryActions: { '111-111': { @@ -1373,15 +1222,15 @@ describe('Execution Handler', () => { test(`skips scheduling actions if the ruleType doesn't have alerts mapping`, async () => { const { alerts, ...ruleTypeWithoutAlertsMapping } = ruleType; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, ruleType: ruleTypeWithoutAlertsMapping, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { - ...defaultExecutionParams.rule.actions[0], + ...defaultSchedulerContext.rule.actions[0], frequency: { summary: true, notifyWhen: 'onThrottleInterval', @@ -1392,9 +1241,9 @@ describe('Execution Handler', () => { }, }) ); - await executionHandler.run(generateAlert({ id: 2 })); + await actionScheduler.run(generateAlert({ id: 2 })); - expect(defaultExecutionParams.logger.error).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.error).toHaveBeenCalledWith( 'Skipping action "1" for rule "1" because the rule type "Test" does not support alert-as-data.' ); @@ -1441,16 +1290,16 @@ describe('Execution Handler', () => { }, }, ]; - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions, }, }) ); - await executionHandler.run(generateRecoveredAlert({ id: 1 })); + await actionScheduler.run(generateRecoveredAlert({ id: 1 })); expect(actionsClient.bulkEnqueueExecution).toHaveBeenCalledTimes(1); expect(actionsClient.bulkEnqueueExecution.mock.calls[0]).toMatchInlineSnapshot(` @@ -1541,10 +1390,10 @@ describe('Execution Handler', () => { }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1570,7 +1419,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1 }), ...generateAlert({ id: 2 }), }); @@ -1586,8 +1435,8 @@ describe('Execution Handler', () => { }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); expect(alertingEventLogger.logAction).not.toHaveBeenCalled(); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( '(2) alerts have been filtered out for: testActionTypeId:111' ); }); @@ -1614,10 +1463,10 @@ describe('Execution Handler', () => { }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1643,7 +1492,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1 }), ...generateAlert({ id: 2 }), }); @@ -1684,10 +1533,10 @@ describe('Execution Handler', () => { }, recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1710,7 +1559,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1 }), ...generateAlert({ id: 2 }), ...generateAlert({ id: 3 }), @@ -1746,8 +1595,8 @@ describe('Execution Handler', () => { id: '1', typeId: 'testActionTypeId', }); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( '(2) alerts have been filtered out for: testActionTypeId:111' ); }); @@ -1790,10 +1639,10 @@ describe('Execution Handler', () => { '2': newAlert2[2], }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1817,16 +1666,16 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); - expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenNthCalledWith( 1, '(3) alerts have been filtered out for: testActionTypeId:1' ); @@ -1839,10 +1688,10 @@ describe('Execution Handler', () => { recovered: { count: 0, data: [] }, }); - const executionHandler = new ExecutionHandler( - generateExecutionParams({ + const actionScheduler = new ActionScheduler( + getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, mutedInstanceIds: ['foo'], actions: [ { @@ -1866,53 +1715,53 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(1); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(1); - expect(defaultExecutionParams.logger.debug).toHaveBeenNthCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenNthCalledWith( 1, '(3) alerts have been filtered out for: testActionTypeId:1' ); }); test('does not schedule actions for alerts with maintenance window IDs', async () => { - const executionHandler = new ExecutionHandler(generateExecutionParams()); + const actionScheduler = new ActionScheduler(getSchedulerContext()); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1, maintenanceWindowIds: ['test-id-1'] }), ...generateAlert({ id: 2, maintenanceWindowIds: ['test-id-2'] }), ...generateAlert({ id: 3, maintenanceWindowIds: ['test-id-3'] }), }); expect(actionsClient.bulkEnqueueExecution).not.toHaveBeenCalled(); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledTimes(3); + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledTimes(3); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-1.' ); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-2.' ); - expect(defaultExecutionParams.logger.debug).toHaveBeenCalledWith( + expect(defaultSchedulerContext.logger.debug).toHaveBeenCalledWith( 'no scheduling of summary actions "1" for rule "1": has active maintenance windows test-id-3.' ); }); test('does not schedule actions with notifyWhen not set to "on status change" for alerts that are flapping', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { - ...defaultExecutionParams.rule.actions[0], + ...defaultSchedulerContext.rule.actions[0], frequency: { summary: false, notifyWhen: RuleNotifyWhen.ACTIVE, @@ -1924,7 +1773,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), ...generateAlert({ id: 2, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), ...generateAlert({ id: 3, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), @@ -1934,14 +1783,14 @@ describe('Execution Handler', () => { }); test('does schedule actions with notifyWhen is set to "on status change" for alerts that are flapping', async () => { - const executionHandler = new ExecutionHandler( - generateExecutionParams({ - ...defaultExecutionParams, + const actionScheduler = new ActionScheduler( + getSchedulerContext({ + ...defaultSchedulerContext, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, actions: [ { - ...defaultExecutionParams.rule.actions[0], + ...defaultSchedulerContext.rule.actions[0], frequency: { summary: false, notifyWhen: RuleNotifyWhen.CHANGE, @@ -1953,7 +1802,7 @@ describe('Execution Handler', () => { }) ); - await executionHandler.run({ + await actionScheduler.run({ ...generateAlert({ id: 1, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), ...generateAlert({ id: 2, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), ...generateAlert({ id: 3, pendingRecoveredCount: 1, lastScheduledActionsGroup: 'recovered' }), @@ -2091,16 +1940,16 @@ describe('Execution Handler', () => { it('populates the rule.url in the action params when the base url and rule id are specified', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345', }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2124,16 +1973,16 @@ describe('Execution Handler', () => { it('populates the rule.url in the action params when the base url contains pathname', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345/kbn', }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0][0].actionParams).toEqual({ val: 'rule url: http://localhost:12345/kbn/s/test1/app/management/insightsAndAlerting/triggersActions/rule/1', @@ -2159,7 +2008,7 @@ describe('Execution Handler', () => { recovered: { count: 0, data: [] }, }); const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, ruleType: { ...ruleType, getViewInAppRelativeUrl: (opts: GetViewInAppRelativeUrlFnOpts) => @@ -2167,13 +2016,13 @@ describe('Execution Handler', () => { }, rule: summaryRuleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345/basePath', }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2197,10 +2046,10 @@ describe('Execution Handler', () => { it('populates the rule.url without the space specifier when the spaceId is the string "default"', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345', }, taskInstance: { @@ -2208,8 +2057,8 @@ describe('Execution Handler', () => { } as unknown as ConcreteTaskInstance, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2233,16 +2082,16 @@ describe('Execution Handler', () => { it('populates the rule.url in the action params when the base url has a trailing slash and removes the trailing slash', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345/', }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2266,16 +2115,16 @@ describe('Execution Handler', () => { it('does not populate the rule.url when the base url is not specified', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: undefined, }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2293,10 +2142,10 @@ describe('Execution Handler', () => { it('does not populate the rule.url when base url is not a valid url', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'localhost12345', }, taskInstance: { @@ -2304,8 +2153,8 @@ describe('Execution Handler', () => { } as unknown as ConcreteTaskInstance, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2323,10 +2172,10 @@ describe('Execution Handler', () => { it('does not populate the rule.url when base url is a number', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 1, }, taskInstance: { @@ -2334,8 +2183,8 @@ describe('Execution Handler', () => { } as unknown as ConcreteTaskInstance, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2353,10 +2202,10 @@ describe('Execution Handler', () => { it('sets the rule.url to the value from getViewInAppRelativeUrl when the rule type has it defined', async () => { const execParams = { - ...defaultExecutionParams, + ...defaultSchedulerContext, rule: ruleWithUrl, taskRunnerContext: { - ...defaultExecutionParams.taskRunnerContext, + ...defaultSchedulerContext.taskRunnerContext, kibanaBaseUrl: 'http://localhost:12345', }, ruleType: { @@ -2367,8 +2216,8 @@ describe('Execution Handler', () => { }, }; - const executionHandler = new ExecutionHandler(generateExecutionParams(execParams)); - await executionHandler.run(generateAlert({ id: 1 })); + const actionScheduler = new ActionScheduler(getSchedulerContext(execParams)); + await actionScheduler.run(generateAlert({ id: 1 })); expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -2409,9 +2258,9 @@ describe('Execution Handler', () => { recovered: { count: 0, data: [] }, }); - const executorParams = generateExecutionParams({ + const executorParams = getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, systemActions: [ { id: '1', @@ -2434,9 +2283,9 @@ describe('Execution Handler', () => { executorParams.actionsClient.isSystemAction.mockReturnValue(true); executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; - const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await executionHandler.run(generateAlert({ id: 1 })); + const res = await actionScheduler.run(generateAlert({ id: 1 })); /** * Verifies that system actions are not throttled @@ -2535,9 +2384,9 @@ describe('Execution Handler', () => { recovered: { count: 0, data: [] }, }); - const executorParams = generateExecutionParams({ + const executorParams = getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, systemActions: [ { id: 'action-id', @@ -2554,9 +2403,9 @@ describe('Execution Handler', () => { executorParams.actionsClient.isSystemAction.mockReturnValue(true); executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; - const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await executionHandler.run(generateAlert({ id: 1 })); + const res = await actionScheduler.run(generateAlert({ id: 1 })); /** * Verifies that system actions are not throttled @@ -2587,13 +2436,13 @@ describe('Execution Handler', () => { test('do not execute if the rule type does not support summarized alerts', async () => { const actionsParams = { myParams: 'test' }; - const executorParams = generateExecutionParams({ + const executorParams = getSchedulerContext({ ruleType: { ...ruleType, alerts: undefined, }, rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, systemActions: [ { id: 'action-id', @@ -2610,9 +2459,9 @@ describe('Execution Handler', () => { executorParams.actionsClient.isSystemAction.mockReturnValue(true); executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; - const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - const res = await executionHandler.run(generateAlert({ id: 1 })); + const res = await actionScheduler.run(generateAlert({ id: 1 })); expect(res).toEqual({ throttledSummaryActions: {} }); expect(buildActionParams).not.toHaveBeenCalled(); @@ -2625,9 +2474,9 @@ describe('Execution Handler', () => { test('do not execute system actions if the rule type does not support summarized alerts', async () => { const actionsParams = { myParams: 'test' }; - const executorParams = generateExecutionParams({ + const executorParams = getSchedulerContext({ rule: { - ...defaultExecutionParams.rule, + ...defaultSchedulerContext.rule, systemActions: [ { id: '1', @@ -2638,7 +2487,7 @@ describe('Execution Handler', () => { ], }, ruleType: { - ...defaultExecutionParams.ruleType, + ...defaultSchedulerContext.ruleType, alerts: undefined, }, }); @@ -2648,9 +2497,9 @@ describe('Execution Handler', () => { executorParams.actionsClient.isSystemAction.mockReturnValue(true); executorParams.taskRunnerContext.kibanaBaseUrl = 'https://example.com'; - const executionHandler = new ExecutionHandler(generateExecutionParams(executorParams)); + const actionScheduler = new ActionScheduler(getSchedulerContext(executorParams)); - await executionHandler.run(generateAlert({ id: 1 })); + await actionScheduler.run(generateAlert({ id: 1 })); expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); expect(buildActionParams).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts new file mode 100644 index 00000000000000..3b804ce3da4139 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/action_scheduler.ts @@ -0,0 +1,605 @@ +/* + * 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 { getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; +import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; +import { + createTaskRunError, + isEphemeralTaskRejectedDueToCapacityError, + TaskErrorSource, +} from '@kbn/task-manager-plugin/server'; +import { + ExecuteOptions as EnqueueExecutionOptions, + ExecutionResponseItem, + ExecutionResponseType, +} from '@kbn/actions-plugin/server/create_execute_function'; +import { ActionsCompletion } from '@kbn/alerting-state-types'; +import { chunk } from 'lodash'; +import { CombinedSummarizedAlerts, ThrottledActions } from '../../types'; +import { injectActionParams } from '../inject_action_params'; +import { ActionSchedulerOptions, IActionScheduler, RuleUrl } from './types'; +import { + transformActionParams, + TransformActionParamsOptions, + transformSummaryActionParams, +} from '../transform_action_params'; +import { Alert } from '../../alert'; +import { + AlertInstanceContext, + AlertInstanceState, + RuleAction, + RuleTypeParams, + RuleTypeState, + SanitizedRule, + RuleAlertData, + RuleSystemAction, +} from '../../../common'; +import { + generateActionHash, + getSummaryActionsFromTaskState, + getSummaryActionTimeBounds, + isActionOnInterval, +} from './rule_action_helper'; +import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { ConnectorAdapter } from '../../connector_adapters/types'; +import { withAlertingSpan } from '../lib'; +import * as schedulers from './schedulers'; + +interface LogAction { + id: string; + typeId: string; + alertId?: string; + alertGroup?: string; + alertSummary?: { + new: number; + ongoing: number; + recovered: number; + }; +} + +interface RunSummarizedActionArgs { + action: RuleAction; + summarizedAlerts: CombinedSummarizedAlerts; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + +interface RunSystemActionArgs { + action: RuleSystemAction; + connectorAdapter: ConnectorAdapter; + summarizedAlerts: CombinedSummarizedAlerts; + rule: SanitizedRule; + ruleProducer: string; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + +interface RunActionArgs< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + action: RuleAction; + alert: Alert; + ruleId: string; + spaceId: string; + bulkActions: EnqueueExecutionOptions[]; +} + +export interface RunResult { + throttledSummaryActions: ThrottledActions; +} + +export class ActionScheduler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> { + private readonly schedulers: Array< + IActionScheduler + > = []; + + private ephemeralActionsToSchedule: number; + private CHUNK_SIZE = 1000; + private ruleTypeActionGroups?: Map; + private previousStartedAt: Date | null; + + constructor( + private readonly context: ActionSchedulerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + > + ) { + this.ephemeralActionsToSchedule = context.taskRunnerContext.maxEphemeralActionsPerRule; + this.ruleTypeActionGroups = new Map( + context.ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) + ); + this.previousStartedAt = context.previousStartedAt; + + for (const [_, scheduler] of Object.entries(schedulers)) { + this.schedulers.push(new scheduler(context)); + } + + // sort schedulers by priority + this.schedulers.sort((a, b) => a.priority - b.priority); + } + + public async run( + alerts: Record> + ): Promise { + const throttledSummaryActions: ThrottledActions = getSummaryActionsFromTaskState({ + actions: this.context.rule.actions, + summaryActions: this.context.taskInstance.state?.summaryActions, + }); + + const executables = []; + for (const scheduler of this.schedulers) { + executables.push( + ...(await scheduler.generateExecutables({ alerts, throttledSummaryActions })) + ); + } + + if (executables.length === 0) { + return { throttledSummaryActions }; + } + + const { + CHUNK_SIZE, + context: { + logger, + alertingEventLogger, + ruleRunMetricsStore, + taskRunnerContext: { actionsConfigMap }, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + }, + } = this; + + const logActions: Record = {}; + const bulkActions: EnqueueExecutionOptions[] = []; + let bulkActionsResponse: ExecutionResponseItem[] = []; + + this.context.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); + + for (const { action, alert, summarizedAlerts } of executables) { + const { actionTypeId } = action; + + ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); + if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + logger.debug( + `Rule "${this.context.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` + ); + break; + } + + if ( + ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ + actionTypeId, + actionsConfigMap, + }) + ) { + if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { + logger.debug( + `Rule "${this.context.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` + ); + } + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + continue; + } + + if (!this.isExecutableAction(action)) { + this.context.logger.warn( + `Rule "${this.context.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` + ); + continue; + } + + ruleRunMetricsStore.incrementNumberOfTriggeredActions(); + ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); + + if (!this.isSystemAction(action) && summarizedAlerts) { + const defaultAction = action as RuleAction; + if (isActionOnInterval(action)) { + throttledSummaryActions[defaultAction.uuid!] = { date: new Date().toISOString() }; + } + + logActions[defaultAction.id] = await this.runSummarizedAction({ + action, + summarizedAlerts, + spaceId, + bulkActions, + }); + } else if (summarizedAlerts && this.isSystemAction(action)) { + const hasConnectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.has( + action.actionTypeId + ); + /** + * System actions without an adapter + * cannot be executed + * + */ + if (!hasConnectorAdapter) { + this.context.logger.warn( + `Rule "${this.context.taskInstance.params.alertId}" skipped scheduling system action "${action.id}" because no connector adapter is configured` + ); + + continue; + } + + const connectorAdapter = this.context.taskRunnerContext.connectorAdapterRegistry.get( + action.actionTypeId + ); + logActions[action.id] = await this.runSystemAction({ + action, + connectorAdapter, + summarizedAlerts, + rule: this.context.rule, + ruleProducer: this.context.ruleType.producer, + spaceId, + bulkActions, + }); + } else if (!this.isSystemAction(action) && alert) { + const defaultAction = action as RuleAction; + logActions[defaultAction.id] = await this.runAction({ + action, + spaceId, + alert, + ruleId, + bulkActions, + }); + + const actionGroup = defaultAction.group; + if (!this.isRecoveredAlert(actionGroup)) { + if (isActionOnInterval(action)) { + alert.updateLastScheduledActions( + defaultAction.group as ActionGroupIds, + generateActionHash(action), + defaultAction.uuid + ); + } else { + alert.updateLastScheduledActions(defaultAction.group as ActionGroupIds); + } + alert.unscheduleActions(); + } + } + } + + if (!!bulkActions.length) { + for (const c of chunk(bulkActions, CHUNK_SIZE)) { + let enqueueResponse; + try { + enqueueResponse = await withAlertingSpan('alerting:bulk-enqueue-actions', () => + this.context.actionsClient!.bulkEnqueueExecution(c) + ); + } catch (e) { + if (e.statusCode === 404) { + throw createTaskRunError(e, TaskErrorSource.USER); + } + throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); + } + if (enqueueResponse.errors) { + bulkActionsResponse = bulkActionsResponse.concat( + enqueueResponse.items.filter( + (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR + ) + ); + } + } + } + + if (!!bulkActionsResponse.length) { + for (const r of bulkActionsResponse) { + if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { + ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); + ruleRunMetricsStore.decrementNumberOfTriggeredActions(); + ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); + ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ + actionTypeId: r.actionTypeId, + status: ActionsCompletion.PARTIAL, + }); + + logger.debug( + `Rule "${this.context.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` + ); + + delete logActions[r.id]; + } + } + } + + const logActionsValues = Object.values(logActions); + if (!!logActionsValues.length) { + for (const action of logActionsValues) { + alertingEventLogger.logAction(action); + } + } + + return { throttledSummaryActions }; + } + + private async runSummarizedAction({ + action, + summarizedAlerts, + spaceId, + bulkActions, + }: RunSummarizedActionArgs): Promise { + const { start, end } = getSummaryActionTimeBounds( + action, + this.context.rule.schedule, + this.previousStartedAt + ); + const ruleUrl = this.buildRuleUrl(spaceId, start, end); + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.context.rule.name, + actionParams: transformSummaryActionParams({ + alerts: summarizedAlerts, + rule: this.context.rule, + ruleTypeId: this.context.ruleType.id, + actionId: action.id, + actionParams: action.params, + spaceId, + actionsPlugin: this.context.taskRunnerContext.actionsPlugin, + actionTypeId: action.actionTypeId, + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + ruleUrl: ruleUrl?.absoluteUrl, + }), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }; + } + + private async runSystemAction({ + action, + spaceId, + connectorAdapter, + summarizedAlerts, + rule, + ruleProducer, + bulkActions, + }: RunSystemActionArgs): Promise { + const ruleUrl = this.buildRuleUrl(spaceId); + + const connectorAdapterActionParams = connectorAdapter.buildActionParams({ + alerts: summarizedAlerts, + rule: { + id: rule.id, + tags: rule.tags, + name: rule.name, + consumer: rule.consumer, + producer: ruleProducer, + }, + ruleUrl: ruleUrl?.absoluteUrl, + spaceId, + params: action.params, + }); + + const actionToRun = Object.assign(action, { params: connectorAdapterActionParams }); + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertSummary: { + new: summarizedAlerts.new.count, + ongoing: summarizedAlerts.ongoing.count, + recovered: summarizedAlerts.recovered.count, + }, + }; + } + + private async runAction({ + action, + spaceId, + alert, + ruleId, + bulkActions, + }: RunActionArgs): Promise { + const ruleUrl = this.buildRuleUrl(spaceId); + const executableAlert = alert!; + const actionGroup = action.group as ActionGroupIds; + const transformActionParamsOptions: TransformActionParamsOptions = { + actionsPlugin: this.context.taskRunnerContext.actionsPlugin, + alertId: ruleId, + alertType: this.context.ruleType.id, + actionTypeId: action.actionTypeId, + alertName: this.context.rule.name, + spaceId, + tags: this.context.rule.tags, + alertInstanceId: executableAlert.getId(), + alertUuid: executableAlert.getUuid(), + alertActionGroup: actionGroup, + alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, + context: executableAlert.getContext(), + actionId: action.id, + state: executableAlert.getState(), + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + alertParams: this.context.rule.params, + actionParams: action.params, + flapping: executableAlert.getFlapping(), + ruleUrl: ruleUrl?.absoluteUrl, + }; + + if (executableAlert.isAlertAsData()) { + transformActionParamsOptions.aadAlert = executableAlert.getAlertAsData(); + } + const actionToRun = { + ...action, + params: injectActionParams({ + actionTypeId: action.actionTypeId, + ruleUrl, + ruleName: this.context.rule.name, + actionParams: transformActionParams(transformActionParamsOptions), + }), + }; + + await this.actionRunOrAddToBulk({ + enqueueOptions: this.getEnqueueOptions(actionToRun), + bulkActions, + }); + + return { + id: action.id, + typeId: action.actionTypeId, + alertId: alert.getId(), + alertGroup: action.group, + }; + } + + private isExecutableAction(action: RuleAction | RuleSystemAction) { + return this.context.taskRunnerContext.actionsPlugin.isActionExecutable( + action.id, + action.actionTypeId, + { + notifyUsage: true, + } + ); + } + + private isSystemAction(action?: RuleAction | RuleSystemAction): action is RuleSystemAction { + return this.context.taskRunnerContext.actionsPlugin.isSystemActionConnector(action?.id ?? ''); + } + + private isRecoveredAlert(actionGroup: string) { + return actionGroup === this.context.ruleType.recoveryActionGroup.id; + } + + private buildRuleUrl(spaceId: string, start?: number, end?: number): RuleUrl | undefined { + if (!this.context.taskRunnerContext.kibanaBaseUrl) { + return; + } + + const relativePath = this.context.ruleType.getViewInAppRelativeUrl + ? this.context.ruleType.getViewInAppRelativeUrl({ rule: this.context.rule, start, end }) + : `${triggersActionsRoute}${getRuleDetailsRoute(this.context.rule.id)}`; + + try { + const basePathname = new URL(this.context.taskRunnerContext.kibanaBaseUrl).pathname; + const basePathnamePrefix = basePathname !== '/' ? `${basePathname}` : ''; + const spaceIdSegment = spaceId !== 'default' ? `/s/${spaceId}` : ''; + + const ruleUrl = new URL( + [basePathnamePrefix, spaceIdSegment, relativePath].join(''), + this.context.taskRunnerContext.kibanaBaseUrl + ); + + return { + absoluteUrl: ruleUrl.toString(), + kibanaBaseUrl: this.context.taskRunnerContext.kibanaBaseUrl, + basePathname: basePathnamePrefix, + spaceIdSegment, + relativePath, + }; + } catch (error) { + this.context.logger.debug( + `Rule "${this.context.rule.id}" encountered an error while constructing the rule.url variable: ${error.message}` + ); + return; + } + } + + private getEnqueueOptions(action: RuleAction | RuleSystemAction): EnqueueExecutionOptions { + const { + context: { + apiKey, + ruleConsumer, + executionId, + taskInstance: { + params: { spaceId, alertId: ruleId }, + }, + }, + } = this; + + const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; + return { + id: action.id, + params: action.params, + spaceId, + apiKey: apiKey ?? null, + consumer: ruleConsumer, + source: asSavedObjectExecutionSource({ + id: ruleId, + type: RULE_SAVED_OBJECT_TYPE, + }), + executionId, + relatedSavedObjects: [ + { + id: ruleId, + type: RULE_SAVED_OBJECT_TYPE, + namespace: namespace.namespace, + typeId: this.context.ruleType.id, + }, + ], + actionTypeId: action.actionTypeId, + }; + } + + private async actionRunOrAddToBulk({ + enqueueOptions, + bulkActions, + }: { + enqueueOptions: EnqueueExecutionOptions; + bulkActions: EnqueueExecutionOptions[]; + }) { + if ( + this.context.taskRunnerContext.supportsEphemeralTasks && + this.ephemeralActionsToSchedule > 0 + ) { + this.ephemeralActionsToSchedule--; + try { + await this.context.actionsClient!.ephemeralEnqueuedExecution(enqueueOptions); + } catch (err) { + if (isEphemeralTaskRejectedDueToCapacityError(err)) { + bulkActions.push(enqueueOptions); + } + } + } else { + bulkActions.push(enqueueOptions); + } + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts new file mode 100644 index 00000000000000..9afd0647094eb8 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.test.ts @@ -0,0 +1,127 @@ +/* + * 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 { getSummarizedAlerts } from './get_summarized_alerts'; +import { alertsClientMock } from '../../alerts_client/alerts_client.mock'; +import { mockAAD } from '../fixtures'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { generateAlert } from './test_fixtures'; +import { getErrorSource } from '@kbn/task-manager-plugin/server/task_running'; + +const alertsClient = alertsClientMock.create(); + +describe('getSummarizedAlerts', () => { + const newAlert1 = generateAlert({ id: 1 }); + const newAlert2 = generateAlert({ id: 2 }); + const alerts = { ...newAlert1, ...newAlert2 }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should call alertsClient.getSummarizedAlerts with the correct params', async () => { + const summarizedAlerts = { + new: { + count: 2, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const result = await getSummarizedAlerts({ + alertsClient, + queryOptions: { + excludedAlertInstanceIds: [], + executionUuid: '123xyz', + ruleId: '1', + spaceId: 'test1', + }, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: '123xyz', + ruleId: '1', + spaceId: 'test1', + }); + + expect(result).toEqual({ + ...summarizedAlerts, + all: summarizedAlerts.new, + }); + }); + + test('should throw error if alertsClient.getSummarizedAlerts throws error', async () => { + alertsClient.getSummarizedAlerts.mockImplementation(() => { + throw new Error('cannot get summarized alerts'); + }); + + try { + await getSummarizedAlerts({ + alertsClient, + queryOptions: { + excludedAlertInstanceIds: [], + executionUuid: '123xyz', + ruleId: '1', + spaceId: 'test1', + }, + }); + } catch (err) { + expect(getErrorSource(err)).toBe('framework'); + expect(err.message).toBe('cannot get summarized alerts'); + } + }); + + test('should remove alert from summarized alerts if it is new and has a maintenance window', async () => { + const newAlertWithMaintenanceWindow = generateAlert({ + id: 1, + maintenanceWindowIds: ['mw-1'], + }); + const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; + + const newAADAlerts = [ + { ...mockAAD, [ALERT_UUID]: newAlertWithMaintenanceWindow[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ]; + const summarizedAlerts = { + new: { count: 2, data: newAADAlerts }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + alertsClient.getProcessedAlerts.mockReturnValue(alertsWithMaintenanceWindow); + + const result = await getSummarizedAlerts({ + alertsClient, + queryOptions: { + excludedAlertInstanceIds: [], + executionUuid: '123xyz', + ruleId: '1', + spaceId: 'test1', + }, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: '123xyz', + ruleId: '1', + spaceId: 'test1', + }); + + expect(result).toEqual({ + new: { count: 1, data: [newAADAlerts[1]] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + all: { count: 1, data: [newAADAlerts[1]] }, + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts new file mode 100644 index 00000000000000..df667a3e207750 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/get_summarized_alerts.ts @@ -0,0 +1,78 @@ +/* + * 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_UUID } from '@kbn/rule-data-utils'; +import { createTaskRunError, TaskErrorSource } from '@kbn/task-manager-plugin/server'; +import { GetSummarizedAlertsParams, IAlertsClient } from '../../alerts_client/types'; +import { + AlertInstanceContext, + AlertInstanceState, + CombinedSummarizedAlerts, + RuleAlertData, +} from '../../types'; + +interface GetSummarizedAlertsOpts< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> { + alertsClient: IAlertsClient; + queryOptions: GetSummarizedAlertsParams; +} + +export const getSummarizedAlerts = async < + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +>({ + alertsClient, + queryOptions, +}: GetSummarizedAlertsOpts< + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData +>): Promise => { + let alerts; + try { + alerts = await alertsClient.getSummarizedAlerts!(queryOptions); + } catch (e) { + throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); + } + + /** + * We need to remove all new alerts with maintenance windows retrieved from + * getSummarizedAlerts because they might not have maintenance window IDs + * associated with them from maintenance windows with scoped query updated + * yet (the update call uses refresh: false). So we need to rely on the in + * memory alerts to do this. + */ + const newAlertsInMemory = Object.values(alertsClient.getProcessedAlerts('new') || {}) || []; + + const newAlertsWithMaintenanceWindowIds = newAlertsInMemory.reduce((result, alert) => { + if (alert.getMaintenanceWindowIds().length > 0) { + result.push(alert.getUuid()); + } + return result; + }, []); + + const newAlerts = alerts.new.data.filter((alert) => { + return !newAlertsWithMaintenanceWindowIds.includes(alert[ALERT_UUID]); + }); + + const total = newAlerts.length + alerts.ongoing.count + alerts.recovered.count; + return { + ...alerts, + new: { count: newAlerts.length, data: newAlerts }, + all: { count: total, data: [...newAlerts, ...alerts.ongoing.data, ...alerts.recovered.data] }, + }; +}; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/index.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/index.ts new file mode 100644 index 00000000000000..83673d71c78b80 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/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 { ActionScheduler } from './action_scheduler'; +export type { RunResult } from './action_scheduler'; +export type { RuleUrl } from './types'; diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts similarity index 91% rename from x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts index 2edd66bc6f43c4..cc8a0a1b0cde5c 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.test.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.test.ts @@ -6,15 +6,16 @@ */ import { Logger } from '@kbn/logging'; -import { RuleAction } from '../types'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { RuleAction } from '../../types'; import { generateActionHash, getSummaryActionsFromTaskState, isActionOnInterval, isSummaryAction, - isSummaryActionOnInterval, isSummaryActionThrottled, getSummaryActionTimeBounds, + logNumberOfFilteredAlerts, } from './rule_action_helper'; const now = '2021-05-13T12:33:37.000Z'; @@ -291,30 +292,6 @@ describe('rule_action_helper', () => { }); }); - describe('isSummaryActionOnInterval', () => { - test('returns true for a summary action on interval', () => { - expect(isSummaryActionOnInterval(mockSummaryAction)).toBe(true); - }); - - test('returns false for a non-summary ', () => { - expect( - isSummaryActionOnInterval({ - ...mockAction, - frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, - }) - ).toBe(false); - }); - - test('returns false for a summary per rule run ', () => { - expect( - isSummaryActionOnInterval({ - ...mockAction, - frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, - }) - ).toBe(false); - }); - }); - describe('getSummaryActionTimeBounds', () => { test('returns undefined start and end action is not summary action', () => { expect(getSummaryActionTimeBounds(mockAction, { interval: '1m' }, null)).toEqual({ @@ -370,4 +347,30 @@ describe('rule_action_helper', () => { expect(start).toEqual(new Date('2021-05-13T12:32:37.000Z').valueOf()); }); }); + + describe('logNumberOfFilteredAlerts', () => { + test('should log when the number of alerts is different than the number of summarized alerts', () => { + const logger = loggingSystemMock.create().get(); + logNumberOfFilteredAlerts({ + logger, + numberOfAlerts: 10, + numberOfSummarizedAlerts: 5, + action: mockSummaryAction, + }); + expect(logger.debug).toHaveBeenCalledWith( + '(5) alerts have been filtered out for: slack:111-111' + ); + }); + + test('should not log when the number of alerts is the same as the number of summarized alerts', () => { + const logger = loggingSystemMock.create().get(); + logNumberOfFilteredAlerts({ + logger, + numberOfAlerts: 10, + numberOfSummarizedAlerts: 10, + action: mockSummaryAction, + }); + expect(logger.debug).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts similarity index 85% rename from x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts rename to x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts index 8845988e06bd4d..67223b07286898 100644 --- a/x-pack/plugins/alerting/server/task_runner/rule_action_helper.ts +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/rule_action_helper.ts @@ -12,7 +12,7 @@ import { RuleAction, RuleNotifyWhenTypeValues, ThrottledActions, -} from '../../common'; +} from '../../../common'; export const isSummaryAction = (action?: RuleAction) => { return action?.frequency?.summary ?? false; @@ -28,10 +28,6 @@ export const isActionOnInterval = (action?: RuleAction) => { ); }; -export const isSummaryActionOnInterval = (action: RuleAction) => { - return isActionOnInterval(action) && action.frequency?.summary; -}; - export const isSummaryActionThrottled = ({ action, throttledSummaryActions, @@ -129,3 +125,25 @@ export const getSummaryActionTimeBounds = ( return { start: startDate.valueOf(), end: now.valueOf() }; }; + +interface LogNumberOfFilteredAlertsOpts { + logger: Logger; + numberOfAlerts: number; + numberOfSummarizedAlerts: number; + action: RuleAction; +} +export const logNumberOfFilteredAlerts = ({ + logger, + numberOfAlerts = 0, + numberOfSummarizedAlerts = 0, + action, +}: LogNumberOfFilteredAlertsOpts) => { + const count = numberOfAlerts - numberOfSummarizedAlerts; + if (count > 0) { + logger.debug( + `(${count}) alert${count > 1 ? 's' : ''} ${ + count > 1 ? 'have' : 'has' + } been filtered out for: ${action.actionTypeId}:${action.uuid}` + ); + } +}; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/index.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/index.ts new file mode 100644 index 00000000000000..85754cbae0f6b7 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/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 { SystemActionScheduler } from './system_action_scheduler'; +export { SummaryActionScheduler } from './summary_action_scheduler'; +export { PerAlertActionScheduler } from './per_alert_action_scheduler'; diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts new file mode 100644 index 00000000000000..53e75245d94d0c --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.test.ts @@ -0,0 +1,849 @@ +/* + * 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 sinon from 'sinon'; +import { actionsClientMock, actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { alertsClientMock } from '../../../alerts_client/alerts_client.mock'; +import { alertingEventLoggerMock } from '../../../lib/alerting_event_logger/alerting_event_logger.mock'; +import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; +import { mockAAD } from '../../fixtures'; +import { PerAlertActionScheduler } from './per_alert_action_scheduler'; +import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; +import { SanitizedRuleAction } from '@kbn/alerting-types'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; + +const alertingEventLogger = alertingEventLoggerMock.create(); +const actionsClient = actionsClientMock.create(); +const alertsClient = alertsClientMock.create(); +const mockActionsPlugin = actionsMock.createStart(); +const logger = loggingSystemMock.create().get(); + +let ruleRunMetricsStore: RuleRunMetricsStore; +const rule = getRule({ + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }, + { + id: '3', + group: 'default', + actionTypeId: 'test', + frequency: { summary: true, notifyWhen: 'onActiveAlert' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '333-333', + }, + ], +}); +const ruleType = getRuleType(); +const defaultSchedulerContext = getDefaultSchedulerContext( + logger, + mockActionsPlugin, + alertingEventLogger, + actionsClient, + alertsClient +); + +// @ts-ignore +const getSchedulerContext = (params = {}) => { + return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; +}; + +let clock: sinon.SinonFakeTimers; + +describe('Per-Alert Action Scheduler', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); + mockActionsPlugin.isActionExecutable.mockReturnValue(true); + mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); + ruleRunMetricsStore = new RuleRunMetricsStore(); + }); + + afterAll(() => { + clock.restore(); + }); + + test('should initialize with only per-alert actions', () => { + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(2); + // @ts-expect-error private variable + expect(scheduler.actions).toEqual([rule.actions[0], rule.actions[1]]); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test('should not initialize action and log if rule type does not support summarized alerts and action has alertsFilter', () => { + const actions = [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + alertsFilter: { + query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] }, + }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }, + ]; + const scheduler = new PerAlertActionScheduler( + getSchedulerContext({ + rule: { + ...rule, + actions, + }, + ruleType: { ...ruleType, alerts: undefined }, + }) + ); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(1); + // @ts-expect-error private variable + expect(scheduler.actions).toEqual([actions[0]]); + expect(logger.error).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith( + `Skipping action \"2\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + ); + }); + + describe('generateExecutables', () => { + const newAlert1 = generateAlert({ id: 1 }); + const newAlert2 = generateAlert({ id: 2 }); + const alerts = { ...newAlert1, ...newAlert2 }; + + test('should generate executable for each alert and each action', async () => { + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(executables).toHaveLength(4); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: rule.actions[1], alert: alerts['1'] }, + { action: rule.actions[1], alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable when alert has maintenance window', async () => { + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + const newAlertWithMaintenanceWindow = generateAlert({ + id: 1, + maintenanceWindowIds: ['mw-1'], + }); + const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; + const executables = await scheduler.generateExecutables({ + alerts: alertsWithMaintenanceWindow, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `no scheduling of summary actions \"1\" for rule \"1\": has active maintenance windows mw-1.` + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + `no scheduling of summary actions \"2\" for rule \"1\": has active maintenance windows mw-1.` + ); + + expect(executables).toHaveLength(2); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['2'] }, + { action: rule.actions[1], alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable when alert has invalid action group', async () => { + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + const newAlertInvalidActionGroup = generateAlert({ + id: 1, + // @ts-expect-error + group: 'invalid', + }); + const alertsWithInvalidActionGroup = { ...newAlertInvalidActionGroup, ...newAlert2 }; + const executables = await scheduler.generateExecutables({ + alerts: alertsWithInvalidActionGroup, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenNthCalledWith( + 1, + `Invalid action group \"invalid\" for rule \"test\".` + ); + expect(logger.error).toHaveBeenNthCalledWith( + 2, + `Invalid action group \"invalid\" for rule \"test\".` + ); + + expect(executables).toHaveLength(2); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['2'] }, + { action: rule.actions[1], alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable when alert has pending recovered count greater than 0 and notifyWhen is onActiveAlert', async () => { + const scheduler = new PerAlertActionScheduler(getSchedulerContext()); + const newAlertWithPendingRecoveredCount = generateAlert({ + id: 1, + pendingRecoveredCount: 3, + }); + const alertsWithPendingRecoveredCount = { + ...newAlertWithPendingRecoveredCount, + ...newAlert2, + }; + const executables = await scheduler.generateExecutables({ + alerts: alertsWithPendingRecoveredCount, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(executables).toHaveLength(2); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['2'] }, + { action: rule.actions[1], alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable when alert has pending recovered count greater than 0 and notifyWhen is onThrottleInterval', async () => { + const onThrottleIntervalAction: SanitizedRuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, + }); + const newAlertWithPendingRecoveredCount = generateAlert({ + id: 1, + pendingRecoveredCount: 3, + }); + const alertsWithPendingRecoveredCount = { + ...newAlertWithPendingRecoveredCount, + ...newAlert2, + }; + const executables = await scheduler.generateExecutables({ + alerts: alertsWithPendingRecoveredCount, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(executables).toHaveLength(2); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['2'] }, + { action: onThrottleIntervalAction, alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable when alert is muted', async () => { + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, mutedInstanceIds: ['2'] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `skipping scheduling of actions for '2' in rule rule-label: rule is muted` + ); + expect(executables).toHaveLength(2); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'muted' } }); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[1], alert: alerts['1'] }, + ]); + }); + + test('should skip generating executable when alert action group has not changed and notifyWhen is onActionGroupChange', async () => { + const onActionGroupChangeAction: SanitizedRuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActionGroupChange', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + + const activeAlert1 = generateAlert({ + id: 1, + group: 'default', + lastScheduledActionsGroup: 'other-group', + }); + const activeAlert2 = generateAlert({ + id: 2, + group: 'default', + lastScheduledActionsGroup: 'default', + }); + const alertsWithOngoingAlert = { ...activeAlert1, ...activeAlert2 }; + + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], onActionGroupChangeAction] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts: alertsWithOngoingAlert, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `skipping scheduling of actions for '2' in rule rule-label: alert is active but action group has not changed` + ); + expect(executables).toHaveLength(3); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'actionGroupHasNotChanged' } }); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, + { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, + { action: onActionGroupChangeAction, alert: alertsWithOngoingAlert['1'] }, + ]); + }); + + test('should skip generating executable when throttle interval has not passed and notifyWhen is onThrottleInterval', async () => { + const onThrottleIntervalAction: SanitizedRuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + + const activeAlert2 = generateAlert({ + id: 2, + lastScheduledActionsGroup: 'default', + throttledActions: { '222-222': { date: '1969-12-31T23:10:00.000Z' } }, + }); + const alertsWithOngoingAlert = { ...newAlert1, ...activeAlert2 }; + + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts: alertsWithOngoingAlert, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `skipping scheduling of actions for '2' in rule rule-label: rule is throttled` + ); + expect(executables).toHaveLength(3); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({ '2': { reason: 'throttled' } }); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, + { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, + { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['1'] }, + ]); + }); + + test('should not skip generating executable when throttle interval has passed and notifyWhen is onThrottleInterval', async () => { + const onThrottleIntervalAction: SanitizedRuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + + const activeAlert2 = generateAlert({ + id: 2, + lastScheduledActionsGroup: 'default', + throttledActions: { '222-222': { date: '1969-12-31T22:10:00.000Z' } }, + }); + const alertsWithOngoingAlert = { ...newAlert1, ...activeAlert2 }; + + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], onThrottleIntervalAction] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts: alertsWithOngoingAlert, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + expect(executables).toHaveLength(4); + + // @ts-expect-error private variable + expect(scheduler.skippedAlerts).toEqual({}); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alertsWithOngoingAlert['1'] }, + { action: rule.actions[0], alert: alertsWithOngoingAlert['2'] }, + { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['1'] }, + { action: onThrottleIntervalAction, alert: alertsWithOngoingAlert['2'] }, + ]); + }); + + test('should query for summarized alerts if useAlertDataForTemplate is true', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithUseAlertDataForTemplate: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + useAlertDataForTemplate: true, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + + expect(executables).toHaveLength(4); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithUseAlertDataForTemplate, alert: alerts['1'] }, + { action: actionWithUseAlertDataForTemplate, alert: alerts['2'] }, + ]); + }); + + test('should query for summarized alerts if useAlertDataForTemplate is true and action has throttle interval', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithUseAlertDataForTemplate: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '1h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + useAlertDataForTemplate: true, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithUseAlertDataForTemplate] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + ruleId: '1', + spaceId: 'test1', + start: new Date('1969-12-31T23:00:00.000Z'), + end: new Date('1970-01-01T00:00:00.000Z'), + }); + + expect(executables).toHaveLength(4); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithUseAlertDataForTemplate, alert: alerts['1'] }, + { action: actionWithUseAlertDataForTemplate, alert: alerts['2'] }, + ]); + }); + + test('should query for summarized alerts if action has alertsFilter', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithAlertsFilter: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }); + + expect(executables).toHaveLength(4); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithAlertsFilter, alert: alerts['1'] }, + { action: actionWithAlertsFilter, alert: alerts['2'] }, + ]); + }); + + test('should query for summarized alerts if action has alertsFilter and action has throttle interval', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithAlertsFilter: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onThrottleInterval', throttle: '6h' }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + start: new Date('1969-12-31T18:00:00.000Z'), + end: new Date('1970-01-01T00:00:00.000Z'), + }); + + expect(executables).toHaveLength(4); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithAlertsFilter, alert: alerts['1'] }, + { action: actionWithAlertsFilter, alert: alerts['2'] }, + ]); + }); + + test('should skip generating executable if alert does not match any alerts in summarized alerts', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, [ALERT_UUID]: 'uuid-not-a-match' }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithAlertsFilter: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }); + + expect(executables).toHaveLength(3); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithAlertsFilter, alert: alerts['1'] }, + ]); + }); + + test('should set alerts as data', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { + count: 1, + data: [ + { ...mockAAD, _id: alerts[1].getUuid(), [ALERT_UUID]: alerts[1].getUuid() }, + { ...mockAAD, _id: alerts[2].getUuid(), [ALERT_UUID]: alerts[2].getUuid() }, + ], + }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const actionWithAlertsFilter: SanitizedRuleAction = { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }; + const scheduler = new PerAlertActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [rule.actions[0], actionWithAlertsFilter] }, + }); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', filters: [] } }, + }); + + expect(executables).toHaveLength(4); + + expect(alerts['1'].getAlertAsData()).not.toBeUndefined(); + expect(alerts['2'].getAlertAsData()).not.toBeUndefined(); + + expect(executables).toEqual([ + { action: rule.actions[0], alert: alerts['1'] }, + { action: rule.actions[0], alert: alerts['2'] }, + { action: actionWithAlertsFilter, alert: alerts['1'] }, + { action: actionWithAlertsFilter, alert: alerts['2'] }, + ]); + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts new file mode 100644 index 00000000000000..602d3c31688c10 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/per_alert_action_scheduler.ts @@ -0,0 +1,264 @@ +/* + * 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 { AlertInstanceState, AlertInstanceContext } from '@kbn/alerting-state-types'; +import { RuleAction, RuleNotifyWhen, RuleTypeParams } from '@kbn/alerting-types'; +import { compact } from 'lodash'; +import { RuleTypeState, RuleAlertData, parseDuration } from '../../../../common'; +import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; +import { AlertHit } from '../../../types'; +import { Alert } from '../../../alert'; +import { getSummarizedAlerts } from '../get_summarized_alerts'; +import { + generateActionHash, + isActionOnInterval, + isSummaryAction, + logNumberOfFilteredAlerts, +} from '../rule_action_helper'; +import { + ActionSchedulerOptions, + Executable, + GenerateExecutablesOpts, + IActionScheduler, +} from '../types'; + +enum Reasons { + MUTED = 'muted', + THROTTLED = 'throttled', + ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', +} + +export class PerAlertActionScheduler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> implements IActionScheduler +{ + private actions: RuleAction[] = []; + private mutedAlertIdsSet: Set = new Set(); + private ruleTypeActionGroups?: Map; + private skippedAlerts: { [key: string]: { reason: string } } = {}; + + constructor( + private readonly context: ActionSchedulerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + > + ) { + this.ruleTypeActionGroups = new Map( + context.ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) + ); + this.mutedAlertIdsSet = new Set(context.rule.mutedInstanceIds); + + const canGetSummarizedAlerts = + !!context.ruleType.alerts && !!context.alertsClient.getSummarizedAlerts; + + // filter for per-alert actions; if the action has an alertsFilter, check that + // rule type supports summarized alerts and filter out if not + this.actions = compact( + (context.rule.actions ?? []) + .filter((action) => !isSummaryAction(action)) + .map((action) => { + if (!canGetSummarizedAlerts && action.alertsFilter) { + this.context.logger.error( + `Skipping action "${action.id}" for rule "${this.context.rule.id}" because the rule type "${this.context.ruleType.name}" does not support alert-as-data.` + ); + return null; + } + + return action; + }) + ); + } + + public get priority(): number { + return 2; + } + + public async generateExecutables({ + alerts, + }: GenerateExecutablesOpts): Promise< + Array> + > { + const executables = []; + + const alertsArray = Object.entries(alerts); + for (const action of this.actions) { + let summarizedAlerts = null; + + if (action.useAlertDataForTemplate || action.alertsFilter) { + const optionsBase = { + spaceId: this.context.taskInstance.params.spaceId, + ruleId: this.context.taskInstance.params.alertId, + excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, + alertsFilter: action.alertsFilter, + }; + + let options: GetSummarizedAlertsParams; + if (isActionOnInterval(action)) { + const throttleMills = parseDuration(action.frequency!.throttle!); + const start = new Date(Date.now() - throttleMills); + options = { ...optionsBase, start, end: new Date() }; + } else { + options = { ...optionsBase, executionUuid: this.context.executionId }; + } + summarizedAlerts = await getSummarizedAlerts({ + queryOptions: options, + alertsClient: this.context.alertsClient, + }); + + logNumberOfFilteredAlerts({ + logger: this.context.logger, + numberOfAlerts: Object.entries(alerts).length, + numberOfSummarizedAlerts: summarizedAlerts.all.count, + action, + }); + } + + for (const [alertId, alert] of alertsArray) { + const alertMaintenanceWindowIds = alert.getMaintenanceWindowIds(); + if (alertMaintenanceWindowIds.length !== 0) { + this.context.logger.debug( + `no scheduling of summary actions "${action.id}" for rule "${ + this.context.taskInstance.params.alertId + }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` + ); + continue; + } + + if (alert.isFilteredOut(summarizedAlerts)) { + continue; + } + + const actionGroup = + alert.getScheduledActionOptions()?.actionGroup || + this.context.ruleType.recoveryActionGroup.id; + + if (!this.ruleTypeActionGroups!.has(actionGroup)) { + this.context.logger.error( + `Invalid action group "${actionGroup}" for rule "${this.context.ruleType.id}".` + ); + continue; + } + + // only actions with notifyWhen set to "on status change" should return + // notifications for flapping pending recovered alerts + if ( + alert.getPendingRecoveredCount() > 0 && + action?.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE + ) { + continue; + } + + if (summarizedAlerts) { + const alertAsData = summarizedAlerts.all.data.find( + (alertHit: AlertHit) => alertHit._id === alert.getUuid() + ); + if (alertAsData) { + alert.setAlertAsData(alertAsData); + } + } + + if (action.group === actionGroup && !this.isAlertMuted(alertId)) { + if ( + this.isRecoveredAlert(action.group) || + this.isExecutableActiveAlert({ alert, action }) + ) { + executables.push({ action, alert }); + } + } + } + } + + return executables; + } + + private isAlertMuted(alertId: string) { + const muted = this.mutedAlertIdsSet.has(alertId); + if (muted) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) + ) { + this.context.logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${this.context.ruleLabel}: rule is muted` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; + return true; + } + return false; + } + + private isExecutableActiveAlert({ + alert, + action, + }: { + alert: Alert; + action: RuleAction; + }) { + const alertId = alert.getId(); + const { + context: { rule, logger, ruleLabel }, + } = this; + const notifyWhen = action.frequency?.notifyWhen || rule.notifyWhen; + + if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && + this.skippedAlerts[alertId].reason !== Reasons.ACTION_GROUP_NOT_CHANGED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.ACTION_GROUP_NOT_CHANGED }; + return false; + } + + if (notifyWhen === 'onThrottleInterval') { + const throttled = action.frequency?.throttle + ? alert.isThrottled({ + throttle: action.frequency.throttle ?? null, + actionHash: generateActionHash(action), // generateActionHash must be removed once all the hash identifiers removed from the task state + uuid: action.uuid, + }) + : alert.isThrottled({ throttle: rule.throttle ?? null }); + + if (throttled) { + if ( + !this.skippedAlerts[alertId] || + (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.THROTTLED) + ) { + logger.debug( + `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is throttled` + ); + } + this.skippedAlerts[alertId] = { reason: Reasons.THROTTLED }; + return false; + } + } + + return alert.hasScheduledActions(); + } + + private isRecoveredAlert(actionGroup: string) { + return actionGroup === this.context.ruleType.recoveryActionGroup.id; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts new file mode 100644 index 00000000000000..600dd0e1951d5b --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.test.ts @@ -0,0 +1,468 @@ +/* + * 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 sinon from 'sinon'; +import { actionsClientMock, actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { alertsClientMock } from '../../../alerts_client/alerts_client.mock'; +import { alertingEventLoggerMock } from '../../../lib/alerting_event_logger/alerting_event_logger.mock'; +import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; +import { mockAAD } from '../../fixtures'; +import { SummaryActionScheduler } from './summary_action_scheduler'; +import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; +import { RuleAction } from '@kbn/alerting-types'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { + getErrorSource, + TaskErrorSource, +} from '@kbn/task-manager-plugin/server/task_running/errors'; + +const alertingEventLogger = alertingEventLoggerMock.create(); +const actionsClient = actionsClientMock.create(); +const alertsClient = alertsClientMock.create(); +const mockActionsPlugin = actionsMock.createStart(); +const logger = loggingSystemMock.create().get(); + +let ruleRunMetricsStore: RuleRunMetricsStore; +const rule = getRule({ + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + frequency: { summary: false, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }, + { + id: '3', + group: 'default', + actionTypeId: 'test', + frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '333-333', + }, + ], +}); +const ruleType = getRuleType(); +const defaultSchedulerContext = getDefaultSchedulerContext( + logger, + mockActionsPlugin, + alertingEventLogger, + actionsClient, + alertsClient +); + +// @ts-ignore +const getSchedulerContext = (params = {}) => { + return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; +}; + +let clock: sinon.SinonFakeTimers; + +describe('Summary Action Scheduler', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); + mockActionsPlugin.isActionExecutable.mockReturnValue(true); + mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); + ruleRunMetricsStore = new RuleRunMetricsStore(); + }); + + afterAll(() => { + clock.restore(); + }); + + test('should initialize with only summary actions', () => { + const scheduler = new SummaryActionScheduler(getSchedulerContext()); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(2); + // @ts-expect-error private variable + expect(scheduler.actions).toEqual([rule.actions[1], rule.actions[2]]); + expect(logger.error).not.toHaveBeenCalled(); + }); + + test('should log if rule type does not support summarized alerts and not initialize any actions', () => { + const scheduler = new SummaryActionScheduler( + getSchedulerContext({ ruleType: { ...ruleType, alerts: undefined } }) + ); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(0); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenNthCalledWith( + 1, + `Skipping action \"2\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + ); + expect(logger.error).toHaveBeenNthCalledWith( + 2, + `Skipping action \"3\" for rule \"1\" because the rule type \"Test\" does not support alert-as-data.` + ); + }); + + describe('generateExecutables', () => { + const newAlert1 = generateAlert({ id: 1 }); + const newAlert2 = generateAlert({ id: 2 }); + const alerts = { ...newAlert1, ...newAlert2 }; + + const summaryActionWithAlertFilter: RuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { + summary: true, + notifyWhen: 'onActiveAlert', + throttle: null, + }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, + uuid: '222-222', + }; + + const summaryActionWithThrottle: RuleAction = { + id: '2', + group: 'default', + actionTypeId: 'test', + frequency: { + summary: true, + notifyWhen: 'onThrottleInterval', + throttle: '1d', + }, + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '222-222', + }; + + test('should generate executable for summary action when summary action is per rule run', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SummaryActionScheduler(getSchedulerContext()); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(executables).toHaveLength(2); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(executables).toEqual([ + { action: rule.actions[1], summarizedAlerts: finalSummary }, + { action: rule.actions[2], summarizedAlerts: finalSummary }, + ]); + }); + + test('should generate executable for summary action when summary action has alertsFilter', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [summaryActionWithAlertFilter] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(executables).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(executables).toEqual([ + { action: summaryActionWithAlertFilter, summarizedAlerts: finalSummary }, + ]); + }); + + test('should generate executable for summary action when summary action is throttled with no throttle history', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [summaryActionWithThrottle] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + ruleId: '1', + spaceId: 'test1', + start: new Date('1969-12-31T00:00:00.000Z'), + end: new Date(), + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(executables).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(executables).toEqual([ + { action: summaryActionWithThrottle, summarizedAlerts: finalSummary }, + ]); + }); + + test('should skip generating executable for summary action when summary action is throttled', async () => { + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [summaryActionWithThrottle] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: { + '222-222': { date: '1969-12-31T13:00:00.000Z' }, + }, + }); + + expect(alertsClient.getSummarizedAlerts).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + `skipping scheduling the action 'test:2', summary action is still being throttled` + ); + + expect(executables).toHaveLength(0); + }); + + test('should remove new alerts from summary if suppressed by maintenance window', async () => { + const newAlertWithMaintenanceWindow = generateAlert({ + id: 1, + maintenanceWindowIds: ['mw-1'], + }); + const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; + alertsClient.getProcessedAlerts.mockReturnValue(alertsWithMaintenanceWindow); + const newAADAlerts = [ + { ...mockAAD, [ALERT_UUID]: newAlertWithMaintenanceWindow[1].getUuid() }, + mockAAD, + ]; + const summarizedAlerts = { + new: { count: 2, data: newAADAlerts }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const scheduler = new SummaryActionScheduler(getSchedulerContext()); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(2); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(1, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + expect(alertsClient.getSummarizedAlerts).toHaveBeenNthCalledWith(2, { + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + expect(logger.debug).toHaveBeenCalledTimes(2); + expect(logger.debug).toHaveBeenNthCalledWith( + 1, + `(1) alert has been filtered out for: test:222-222` + ); + expect(logger.debug).toHaveBeenNthCalledWith( + 2, + `(1) alert has been filtered out for: test:333-333` + ); + + expect(executables).toHaveLength(2); + + const finalSummary = { + all: { count: 1, data: [newAADAlerts[1]] }, + new: { count: 1, data: [newAADAlerts[1]] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + expect(executables).toEqual([ + { action: rule.actions[1], summarizedAlerts: finalSummary }, + { action: rule.actions[2], summarizedAlerts: finalSummary }, + ]); + }); + + test('should generate executable for summary action and log when alerts have been filtered out by action condition', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 1, data: [mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [summaryActionWithAlertFilter] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + alertsFilter: { query: { kql: 'kibana.alert.rule.name:foo', dsl: '{}', filters: [] } }, + }); + expect(logger.debug).toHaveBeenCalledTimes(1); + expect(logger.debug).toHaveBeenCalledWith( + `(1) alert has been filtered out for: test:222-222` + ); + + expect(executables).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 1, data: [mockAAD] } }; + expect(executables).toEqual([ + { action: summaryActionWithAlertFilter, summarizedAlerts: finalSummary }, + ]); + }); + + test('should skip generating executable for summary action when no alerts found', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 0, data: [] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SummaryActionScheduler({ + ...getSchedulerContext(), + rule: { ...rule, actions: [summaryActionWithThrottle] }, + }); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + ruleId: '1', + spaceId: 'test1', + start: new Date('1969-12-31T00:00:00.000Z'), + end: new Date(), + }); + expect(logger.debug).not.toHaveBeenCalled(); + + expect(executables).toHaveLength(0); + }); + + test('should throw framework error if getSummarizedAlerts throws error', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + alertsClient.getSummarizedAlerts.mockImplementation(() => { + throw new Error('no alerts for you'); + }); + + const scheduler = new SummaryActionScheduler(getSchedulerContext()); + + try { + await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + } catch (err) { + expect(err.message).toEqual(`no alerts for you`); + expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); + } + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts new file mode 100644 index 00000000000000..9b67c37e6216ec --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/summary_action_scheduler.ts @@ -0,0 +1,127 @@ +/* + * 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 { AlertInstanceState, AlertInstanceContext } from '@kbn/alerting-state-types'; +import { RuleAction, RuleTypeParams } from '@kbn/alerting-types'; +import { compact } from 'lodash'; +import { RuleTypeState, RuleAlertData, parseDuration } from '../../../../common'; +import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; +import { getSummarizedAlerts } from '../get_summarized_alerts'; +import { + isActionOnInterval, + isSummaryAction, + isSummaryActionThrottled, + logNumberOfFilteredAlerts, +} from '../rule_action_helper'; +import { + ActionSchedulerOptions, + Executable, + GenerateExecutablesOpts, + IActionScheduler, +} from '../types'; + +export class SummaryActionScheduler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> implements IActionScheduler +{ + private actions: RuleAction[] = []; + + constructor( + private readonly context: ActionSchedulerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + > + ) { + const canGetSummarizedAlerts = + !!context.ruleType.alerts && !!context.alertsClient.getSummarizedAlerts; + + // filter for summary actions where the rule type supports summarized alerts + this.actions = compact( + (context.rule.actions ?? []) + .filter((action) => isSummaryAction(action)) + .map((action) => { + if (!canGetSummarizedAlerts) { + this.context.logger.error( + `Skipping action "${action.id}" for rule "${this.context.rule.id}" because the rule type "${this.context.ruleType.name}" does not support alert-as-data.` + ); + return null; + } + + return action; + }) + ); + } + + public get priority(): number { + return 0; + } + + public async generateExecutables({ + alerts, + throttledSummaryActions, + }: GenerateExecutablesOpts): Promise< + Array> + > { + const executables = []; + for (const action of this.actions) { + if ( + // if summary action is throttled, we won't send any notifications + !isSummaryActionThrottled({ action, throttledSummaryActions, logger: this.context.logger }) + ) { + const actionHasThrottleInterval = isActionOnInterval(action); + const optionsBase = { + spaceId: this.context.taskInstance.params.spaceId, + ruleId: this.context.taskInstance.params.alertId, + excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, + alertsFilter: action.alertsFilter, + }; + + let options: GetSummarizedAlertsParams; + if (actionHasThrottleInterval) { + const throttleMills = parseDuration(action.frequency!.throttle!); + const start = new Date(Date.now() - throttleMills); + options = { ...optionsBase, start, end: new Date() }; + } else { + options = { ...optionsBase, executionUuid: this.context.executionId }; + } + + const summarizedAlerts = await getSummarizedAlerts({ + queryOptions: options, + alertsClient: this.context.alertsClient, + }); + + if (!actionHasThrottleInterval) { + logNumberOfFilteredAlerts({ + logger: this.context.logger, + numberOfAlerts: Object.entries(alerts).length, + numberOfSummarizedAlerts: summarizedAlerts.all.count, + action, + }); + } + + if (summarizedAlerts.all.count !== 0) { + executables.push({ action, summarizedAlerts }); + } + } + } + + return executables; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts new file mode 100644 index 00000000000000..fd4db6ce346788 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.test.ts @@ -0,0 +1,218 @@ +/* + * 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 sinon from 'sinon'; +import { actionsClientMock, actionsMock } from '@kbn/actions-plugin/server/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-server-mocks'; +import { alertsClientMock } from '../../../alerts_client/alerts_client.mock'; +import { alertingEventLoggerMock } from '../../../lib/alerting_event_logger/alerting_event_logger.mock'; +import { RuleRunMetricsStore } from '../../../lib/rule_run_metrics_store'; +import { mockAAD } from '../../fixtures'; +import { getRule, getRuleType, getDefaultSchedulerContext, generateAlert } from '../test_fixtures'; +import { SystemActionScheduler } from './system_action_scheduler'; +import { ALERT_UUID } from '@kbn/rule-data-utils'; +import { + getErrorSource, + TaskErrorSource, +} from '@kbn/task-manager-plugin/server/task_running/errors'; + +const alertingEventLogger = alertingEventLoggerMock.create(); +const actionsClient = actionsClientMock.create(); +const alertsClient = alertsClientMock.create(); +const mockActionsPlugin = actionsMock.createStart(); +const logger = loggingSystemMock.create().get(); + +let ruleRunMetricsStore: RuleRunMetricsStore; +const rule = getRule({ + systemActions: [ + { + id: '1', + actionTypeId: '.test-system-action', + params: { myParams: 'test' }, + uui: 'test', + }, + ], +}); +const ruleType = getRuleType(); +const defaultSchedulerContext = getDefaultSchedulerContext( + logger, + mockActionsPlugin, + alertingEventLogger, + actionsClient, + alertsClient +); + +// @ts-ignore +const getSchedulerContext = (params = {}) => { + return { ...defaultSchedulerContext, rule, ...params, ruleRunMetricsStore }; +}; + +let clock: sinon.SinonFakeTimers; + +describe('System Action Scheduler', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + mockActionsPlugin.isActionTypeEnabled.mockReturnValue(true); + mockActionsPlugin.isActionExecutable.mockReturnValue(true); + mockActionsPlugin.getActionsClientWithRequest.mockResolvedValue(actionsClient); + ruleRunMetricsStore = new RuleRunMetricsStore(); + }); + + afterAll(() => { + clock.restore(); + }); + + test('should initialize with only system actions', () => { + const scheduler = new SystemActionScheduler(getSchedulerContext()); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(1); + // @ts-expect-error private variable + expect(scheduler.actions).toEqual(rule.systemActions); + }); + + test('should not initialize any system actions if rule type does not support summarized alerts', () => { + const scheduler = new SystemActionScheduler( + getSchedulerContext({ ruleType: { ...ruleType, alerts: undefined } }) + ); + + // @ts-expect-error private variable + expect(scheduler.actions).toHaveLength(0); + }); + + describe('generateExecutables', () => { + const newAlert1 = generateAlert({ id: 1 }); + const newAlert2 = generateAlert({ id: 2 }); + const alerts = { ...newAlert1, ...newAlert2 }; + + test('should generate executable for each system action', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 2, data: [mockAAD, mockAAD] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SystemActionScheduler(getSchedulerContext()); + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + + expect(executables).toHaveLength(1); + + const finalSummary = { ...summarizedAlerts, all: { count: 2, data: [mockAAD, mockAAD] } }; + expect(executables).toEqual([ + { action: rule.systemActions?.[0], summarizedAlerts: finalSummary }, + ]); + }); + + test('should remove new alerts from summary if suppressed by maintenance window', async () => { + const newAlertWithMaintenanceWindow = generateAlert({ + id: 1, + maintenanceWindowIds: ['mw-1'], + }); + const alertsWithMaintenanceWindow = { ...newAlertWithMaintenanceWindow, ...newAlert2 }; + alertsClient.getProcessedAlerts.mockReturnValue(alertsWithMaintenanceWindow); + const newAADAlerts = [ + { ...mockAAD, [ALERT_UUID]: newAlertWithMaintenanceWindow[1].getUuid() }, + mockAAD, + ]; + const summarizedAlerts = { + new: { count: 2, data: newAADAlerts }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + const scheduler = new SystemActionScheduler(getSchedulerContext()); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + + expect(executables).toHaveLength(1); + + const finalSummary = { + all: { count: 1, data: [newAADAlerts[1]] }, + new: { count: 1, data: [newAADAlerts[1]] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + expect(executables).toEqual([ + { action: rule.systemActions?.[0], summarizedAlerts: finalSummary }, + ]); + }); + + test('should skip generating executable for summary action when no alerts found', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + const summarizedAlerts = { + new: { count: 0, data: [] }, + ongoing: { count: 0, data: [] }, + recovered: { count: 0, data: [] }, + }; + alertsClient.getSummarizedAlerts.mockResolvedValue(summarizedAlerts); + + const scheduler = new SystemActionScheduler(getSchedulerContext()); + + const executables = await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledTimes(1); + expect(alertsClient.getSummarizedAlerts).toHaveBeenCalledWith({ + excludedAlertInstanceIds: [], + executionUuid: defaultSchedulerContext.executionId, + ruleId: '1', + spaceId: 'test1', + }); + + expect(executables).toHaveLength(0); + }); + + test('should throw framework error if getSummarizedAlerts throws error', async () => { + alertsClient.getProcessedAlerts.mockReturnValue(alerts); + alertsClient.getSummarizedAlerts.mockImplementation(() => { + throw new Error('no alerts for you'); + }); + + const scheduler = new SystemActionScheduler(getSchedulerContext()); + + try { + await scheduler.generateExecutables({ + alerts, + throttledSummaryActions: {}, + }); + } catch (err) { + expect(err.message).toEqual(`no alerts for you`); + expect(getErrorSource(err)).toBe(TaskErrorSource.FRAMEWORK); + } + }); + }); +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts new file mode 100644 index 00000000000000..b923baf8fbf388 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/schedulers/system_action_scheduler.ts @@ -0,0 +1,80 @@ +/* + * 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 { AlertInstanceState, AlertInstanceContext } from '@kbn/alerting-state-types'; +import { RuleSystemAction, RuleTypeParams } from '@kbn/alerting-types'; +import { RuleTypeState, RuleAlertData } from '../../../../common'; +import { GetSummarizedAlertsParams } from '../../../alerts_client/types'; +import { getSummarizedAlerts } from '../get_summarized_alerts'; +import { + ActionSchedulerOptions, + Executable, + GenerateExecutablesOpts, + IActionScheduler, +} from '../types'; + +export class SystemActionScheduler< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> implements IActionScheduler +{ + private actions: RuleSystemAction[] = []; + + constructor( + private readonly context: ActionSchedulerOptions< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + > + ) { + const canGetSummarizedAlerts = + !!context.ruleType.alerts && !!context.alertsClient.getSummarizedAlerts; + + // only process system actions when rule type supports summarized alerts + this.actions = canGetSummarizedAlerts ? context.rule.systemActions ?? [] : []; + } + + public get priority(): number { + return 1; + } + + public async generateExecutables( + _: GenerateExecutablesOpts + ): Promise>> { + const executables = []; + for (const action of this.actions) { + const options: GetSummarizedAlertsParams = { + spaceId: this.context.taskInstance.params.spaceId, + ruleId: this.context.taskInstance.params.alertId, + excludedAlertInstanceIds: this.context.rule.mutedInstanceIds, + executionUuid: this.context.executionId, + }; + + const summarizedAlerts = await getSummarizedAlerts({ + queryOptions: options, + alertsClient: this.context.alertsClient, + }); + + if (summarizedAlerts && summarizedAlerts.all.count !== 0) { + executables.push({ action, summarizedAlerts }); + } + } + + return executables; + } +} diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/test_fixtures.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/test_fixtures.ts new file mode 100644 index 00000000000000..5d56e03d0a462c --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/test_fixtures.ts @@ -0,0 +1,208 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { + AlertInstanceState, + AlertInstanceContext, + ThrottledActions, +} from '@kbn/alerting-state-types'; +import { RuleTypeParams, SanitizedRule } from '@kbn/alerting-types'; +import { schema } from '@kbn/config-schema'; +import { KibanaRequest } from '@kbn/core-http-server'; +import { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import { ActionsClient, PluginStartContract } from '@kbn/actions-plugin/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { RuleAlertData, RuleTypeState } from '../../../common'; +import { ConnectorAdapterRegistry } from '../../connector_adapters/connector_adapter_registry'; +import { NormalizedRuleType } from '../../rule_type_registry'; +import { TaskRunnerContext } from '../types'; +import { AlertingEventLogger } from '../../lib/alerting_event_logger/alerting_event_logger'; +import { Alert } from '../../alert'; + +const apiKey = Buffer.from('123:abc').toString('base64'); + +type ActiveActionGroup = 'default' | 'other-group'; +export const generateAlert = ({ + id, + group = 'default', + context, + state, + scheduleActions = true, + throttledActions = {}, + lastScheduledActionsGroup = 'default', + maintenanceWindowIds, + pendingRecoveredCount, + activeCount, +}: { + id: number; + group?: ActiveActionGroup | 'recovered'; + context?: AlertInstanceContext; + state?: AlertInstanceState; + scheduleActions?: boolean; + throttledActions?: ThrottledActions; + lastScheduledActionsGroup?: string; + maintenanceWindowIds?: string[]; + pendingRecoveredCount?: number; + activeCount?: number; +}) => { + const alert = new Alert( + String(id), + { + state: state || { test: true }, + meta: { + maintenanceWindowIds, + lastScheduledActions: { + date: new Date().toISOString(), + group: lastScheduledActionsGroup, + actions: throttledActions, + }, + pendingRecoveredCount, + activeCount, + }, + } + ); + if (scheduleActions) { + alert.scheduleActions(group as ActiveActionGroup); + } + if (context) { + alert.setContext(context); + } + + return { [id]: alert }; +}; + +export const generateRecoveredAlert = ({ + id, + state, +}: { + id: number; + state?: AlertInstanceState; +}) => { + const alert = new Alert(String(id), { + state: state || { test: true }, + meta: { + lastScheduledActions: { + date: new Date().toISOString(), + group: 'recovered', + actions: {}, + }, + }, + }); + return { [id]: alert }; +}; + +export const getRule = (overrides = {}) => + ({ + id: '1', + name: 'name-of-alert', + tags: ['tag-A', 'tag-B'], + mutedInstanceIds: [], + params: { + foo: true, + contextVal: 'My other {{context.value}} goes here', + stateVal: 'My other {{state.value}} goes here', + }, + schedule: { interval: '1m' }, + notifyWhen: 'onActiveAlert', + actions: [ + { + id: '1', + group: 'default', + actionTypeId: 'test', + params: { + foo: true, + contextVal: 'My {{context.value}} goes here', + stateVal: 'My {{state.value}} goes here', + alertVal: + 'My {{rule.id}} {{rule.name}} {{rule.spaceId}} {{rule.tags}} {{alert.id}} goes here', + }, + uuid: '111-111', + }, + ], + consumer: 'test-consumer', + ...overrides, + } as unknown as SanitizedRule); + +export const getRuleType = (): NormalizedRuleType< + RuleTypeParams, + RuleTypeParams, + RuleTypeState, + AlertInstanceState, + AlertInstanceContext, + 'default' | 'other-group', + 'recovered', + {} +> => ({ + id: 'test', + name: 'Test', + actionGroups: [ + { id: 'default', name: 'Default' }, + { id: 'recovered', name: 'Recovered' }, + { id: 'other-group', name: 'Other Group' }, + ], + defaultActionGroupId: 'default', + minimumLicenseRequired: 'basic', + isExportable: true, + recoveryActionGroup: { + id: 'recovered', + name: 'Recovered', + }, + executor: jest.fn(), + category: 'test', + producer: 'alerts', + validate: { + params: schema.any(), + }, + alerts: { + context: 'context', + mappings: { fieldMap: { field: { type: 'fieldType', required: false } } }, + }, + autoRecoverAlerts: false, + validLegacyConsumers: [], +}); + +export const getDefaultSchedulerContext = < + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +>( + loggerMock: Logger, + actionsPluginMock: jest.Mocked, + alertingEventLoggerMock: jest.Mocked, + actionsClientMock: jest.Mocked>, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + alertsClientMock: jest.Mocked +) => ({ + rule: getRule(), + ruleType: getRuleType(), + logger: loggerMock, + taskRunnerContext: { + actionsConfigMap: { + default: { + max: 1000, + }, + }, + actionsPlugin: actionsPluginMock, + connectorAdapterRegistry: new ConnectorAdapterRegistry(), + } as unknown as TaskRunnerContext, + apiKey, + ruleConsumer: 'rule-consumer', + executionId: '5f6aa57d-3e22-484e-bae8-cbed868f4d28', + alertUuid: 'uuid-1', + ruleLabel: 'rule-label', + request: {} as KibanaRequest, + alertingEventLogger: alertingEventLoggerMock, + previousStartedAt: null, + taskInstance: { + params: { spaceId: 'test1', alertId: '1' }, + } as unknown as ConcreteTaskInstance, + actionsClient: actionsClientMock, + alertsClient: alertsClientMock, +}); diff --git a/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts new file mode 100644 index 00000000000000..efcb51fcb26980 --- /dev/null +++ b/x-pack/plugins/alerting/server/task_runner/action_scheduler/types.ts @@ -0,0 +1,111 @@ +/* + * 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 { Logger } from '@kbn/core/server'; +import { PublicMethodsOf } from '@kbn/utility-types'; +import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; +import { IAlertsClient } from '../../alerts_client/types'; +import { Alert } from '../../alert'; +import { + AlertInstanceContext, + AlertInstanceState, + RuleTypeParams, + SanitizedRule, + RuleTypeState, + RuleAction, + RuleAlertData, + RuleSystemAction, + ThrottledActions, +} from '../../../common'; +import { NormalizedRuleType } from '../../rule_type_registry'; +import { CombinedSummarizedAlerts, RawRule } from '../../types'; +import { RuleRunMetricsStore } from '../../lib/rule_run_metrics_store'; +import { AlertingEventLogger } from '../../lib/alerting_event_logger/alerting_event_logger'; +import { RuleTaskInstance, TaskRunnerContext } from '../types'; + +export interface ActionSchedulerOptions< + Params extends RuleTypeParams, + ExtractedParams extends RuleTypeParams, + RuleState extends RuleTypeState, + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string, + AlertData extends RuleAlertData +> { + ruleType: NormalizedRuleType< + Params, + ExtractedParams, + RuleState, + State, + Context, + ActionGroupIds, + RecoveryActionGroupId, + AlertData + >; + logger: Logger; + alertingEventLogger: PublicMethodsOf; + rule: SanitizedRule; + taskRunnerContext: TaskRunnerContext; + taskInstance: RuleTaskInstance; + ruleRunMetricsStore: RuleRunMetricsStore; + apiKey: RawRule['apiKey']; + ruleConsumer: string; + executionId: string; + ruleLabel: string; + previousStartedAt: Date | null; + actionsClient: PublicMethodsOf; + alertsClient: IAlertsClient; +} + +export type Executable< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> = { + action: RuleAction | RuleSystemAction; +} & ( + | { + alert: Alert; + summarizedAlerts?: never; + } + | { + alert?: never; + summarizedAlerts: CombinedSummarizedAlerts; + } +); + +export interface GenerateExecutablesOpts< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + alerts: Record>; + throttledSummaryActions: ThrottledActions; +} + +export interface IActionScheduler< + State extends AlertInstanceState, + Context extends AlertInstanceContext, + ActionGroupIds extends string, + RecoveryActionGroupId extends string +> { + get priority(): number; + generateExecutables( + opts: GenerateExecutablesOpts + ): Promise>>; +} + +export interface RuleUrl { + absoluteUrl?: string; + kibanaBaseUrl?: string; + basePathname?: string; + spaceIdSegment?: string; + relativePath?: string; +} diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts deleted file mode 100644 index f5a61bb6ccabce..00000000000000 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ /dev/null @@ -1,975 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Logger } from '@kbn/core/server'; -import { ALERT_UUID, getRuleDetailsRoute, triggersActionsRoute } from '@kbn/rule-data-utils'; -import { asSavedObjectExecutionSource } from '@kbn/actions-plugin/server'; -import { - createTaskRunError, - isEphemeralTaskRejectedDueToCapacityError, - TaskErrorSource, -} from '@kbn/task-manager-plugin/server'; -import { - ExecuteOptions as EnqueueExecutionOptions, - ExecutionResponseItem, - ExecutionResponseType, -} from '@kbn/actions-plugin/server/create_execute_function'; -import { ActionsCompletion } from '@kbn/alerting-state-types'; -import { ActionsClient } from '@kbn/actions-plugin/server/actions_client'; -import { chunk } from 'lodash'; -import { GetSummarizedAlertsParams, IAlertsClient } from '../alerts_client/types'; -import { AlertingEventLogger } from '../lib/alerting_event_logger/alerting_event_logger'; -import { AlertHit, parseDuration, CombinedSummarizedAlerts, ThrottledActions } from '../types'; -import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store'; -import { injectActionParams } from './inject_action_params'; -import { Executable, ExecutionHandlerOptions, RuleTaskInstance, TaskRunnerContext } from './types'; -import { - transformActionParams, - TransformActionParamsOptions, - transformSummaryActionParams, -} from './transform_action_params'; -import { Alert } from '../alert'; -import { NormalizedRuleType } from '../rule_type_registry'; -import { - AlertInstanceContext, - AlertInstanceState, - RuleAction, - RuleTypeParams, - RuleTypeState, - SanitizedRule, - RuleAlertData, - RuleNotifyWhen, - RuleSystemAction, -} from '../../common'; -import { - generateActionHash, - getSummaryActionsFromTaskState, - getSummaryActionTimeBounds, - isActionOnInterval, - isSummaryAction, - isSummaryActionOnInterval, - isSummaryActionThrottled, -} from './rule_action_helper'; -import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; -import { ConnectorAdapter } from '../connector_adapters/types'; -import { withAlertingSpan } from './lib'; - -enum Reasons { - MUTED = 'muted', - THROTTLED = 'throttled', - ACTION_GROUP_NOT_CHANGED = 'actionGroupHasNotChanged', -} - -interface LogAction { - id: string; - typeId: string; - alertId?: string; - alertGroup?: string; - alertSummary?: { - new: number; - ongoing: number; - recovered: number; - }; -} - -interface RunSummarizedActionArgs { - action: RuleAction; - summarizedAlerts: CombinedSummarizedAlerts; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} - -interface RunSystemActionArgs { - action: RuleSystemAction; - connectorAdapter: ConnectorAdapter; - summarizedAlerts: CombinedSummarizedAlerts; - rule: SanitizedRule; - ruleProducer: string; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} - -interface RunActionArgs< - State extends AlertInstanceState, - Context extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string -> { - action: RuleAction; - alert: Alert; - ruleId: string; - spaceId: string; - bulkActions: EnqueueExecutionOptions[]; -} - -export interface RunResult { - throttledSummaryActions: ThrottledActions; -} - -export interface RuleUrl { - absoluteUrl?: string; - kibanaBaseUrl?: string; - basePathname?: string; - spaceIdSegment?: string; - relativePath?: string; -} - -export class ExecutionHandler< - Params extends RuleTypeParams, - ExtractedParams extends RuleTypeParams, - RuleState extends RuleTypeState, - State extends AlertInstanceState, - Context extends AlertInstanceContext, - ActionGroupIds extends string, - RecoveryActionGroupId extends string, - AlertData extends RuleAlertData -> { - private logger: Logger; - private alertingEventLogger: PublicMethodsOf; - private rule: SanitizedRule; - private ruleType: NormalizedRuleType< - Params, - ExtractedParams, - RuleState, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId, - AlertData - >; - private taskRunnerContext: TaskRunnerContext; - private taskInstance: RuleTaskInstance; - private ruleRunMetricsStore: RuleRunMetricsStore; - private apiKey: string | null; - private ruleConsumer: string; - private executionId: string; - private ruleLabel: string; - private ephemeralActionsToSchedule: number; - private CHUNK_SIZE = 1000; - private skippedAlerts: { [key: string]: { reason: string } } = {}; - private actionsClient: PublicMethodsOf; - private ruleTypeActionGroups?: Map; - private mutedAlertIdsSet: Set = new Set(); - private previousStartedAt: Date | null; - private alertsClient: IAlertsClient< - AlertData, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId - >; - - constructor({ - rule, - ruleType, - logger, - alertingEventLogger, - taskRunnerContext, - taskInstance, - ruleRunMetricsStore, - apiKey, - ruleConsumer, - executionId, - ruleLabel, - previousStartedAt, - actionsClient, - alertsClient, - }: ExecutionHandlerOptions< - Params, - ExtractedParams, - RuleState, - State, - Context, - ActionGroupIds, - RecoveryActionGroupId, - AlertData - >) { - this.logger = logger; - this.alertingEventLogger = alertingEventLogger; - this.rule = rule; - this.ruleType = ruleType; - this.taskRunnerContext = taskRunnerContext; - this.taskInstance = taskInstance; - this.ruleRunMetricsStore = ruleRunMetricsStore; - this.apiKey = apiKey; - this.ruleConsumer = ruleConsumer; - this.executionId = executionId; - this.ruleLabel = ruleLabel; - this.actionsClient = actionsClient; - this.ephemeralActionsToSchedule = taskRunnerContext.maxEphemeralActionsPerRule; - this.ruleTypeActionGroups = new Map( - ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name]) - ); - this.previousStartedAt = previousStartedAt; - this.mutedAlertIdsSet = new Set(rule.mutedInstanceIds); - this.alertsClient = alertsClient; - } - - public async run( - alerts: Record> - ): Promise { - const throttledSummaryActions: ThrottledActions = getSummaryActionsFromTaskState({ - actions: this.rule.actions, - summaryActions: this.taskInstance.state?.summaryActions, - }); - const executables = await this.generateExecutables(alerts, throttledSummaryActions); - - if (executables.length === 0) { - return { throttledSummaryActions }; - } - - const { - CHUNK_SIZE, - logger, - alertingEventLogger, - ruleRunMetricsStore, - taskRunnerContext: { actionsConfigMap }, - taskInstance: { - params: { spaceId, alertId: ruleId }, - }, - } = this; - - const logActions: Record = {}; - const bulkActions: EnqueueExecutionOptions[] = []; - let bulkActionsResponse: ExecutionResponseItem[] = []; - - this.ruleRunMetricsStore.incrementNumberOfGeneratedActions(executables.length); - - for (const { action, alert, summarizedAlerts } of executables) { - const { actionTypeId } = action; - - ruleRunMetricsStore.incrementNumberOfGeneratedActionsByConnectorType(actionTypeId); - if (ruleRunMetricsStore.hasReachedTheExecutableActionsLimit(actionsConfigMap)) { - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions has been reached.` - ); - break; - } - - if ( - ruleRunMetricsStore.hasReachedTheExecutableActionsLimitByConnectorType({ - actionTypeId, - actionsConfigMap, - }) - ) { - if (!ruleRunMetricsStore.hasConnectorTypeReachedTheLimit(actionTypeId)) { - logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${action.id}" because the maximum number of allowed actions for connector type ${actionTypeId} has been reached.` - ); - } - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - continue; - } - - if (!this.isExecutableAction(action)) { - this.logger.warn( - `Rule "${this.taskInstance.params.alertId}" skipped scheduling action "${action.id}" because it is disabled` - ); - continue; - } - - ruleRunMetricsStore.incrementNumberOfTriggeredActions(); - ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId); - - if (!this.isSystemAction(action) && summarizedAlerts) { - const defaultAction = action as RuleAction; - if (isActionOnInterval(action)) { - throttledSummaryActions[defaultAction.uuid!] = { date: new Date().toISOString() }; - } - - logActions[defaultAction.id] = await this.runSummarizedAction({ - action, - summarizedAlerts, - spaceId, - bulkActions, - }); - } else if (summarizedAlerts && this.isSystemAction(action)) { - const hasConnectorAdapter = this.taskRunnerContext.connectorAdapterRegistry.has( - action.actionTypeId - ); - /** - * System actions without an adapter - * cannot be executed - * - */ - if (!hasConnectorAdapter) { - this.logger.warn( - `Rule "${this.taskInstance.params.alertId}" skipped scheduling system action "${action.id}" because no connector adapter is configured` - ); - - continue; - } - - const connectorAdapter = this.taskRunnerContext.connectorAdapterRegistry.get( - action.actionTypeId - ); - logActions[action.id] = await this.runSystemAction({ - action, - connectorAdapter, - summarizedAlerts, - rule: this.rule, - ruleProducer: this.ruleType.producer, - spaceId, - bulkActions, - }); - } else if (!this.isSystemAction(action) && alert) { - const defaultAction = action as RuleAction; - logActions[defaultAction.id] = await this.runAction({ - action, - spaceId, - alert, - ruleId, - bulkActions, - }); - - const actionGroup = defaultAction.group; - if (!this.isRecoveredAlert(actionGroup)) { - if (isActionOnInterval(action)) { - alert.updateLastScheduledActions( - defaultAction.group as ActionGroupIds, - generateActionHash(action), - defaultAction.uuid - ); - } else { - alert.updateLastScheduledActions(defaultAction.group as ActionGroupIds); - } - alert.unscheduleActions(); - } - } - } - - if (!!bulkActions.length) { - for (const c of chunk(bulkActions, CHUNK_SIZE)) { - let enqueueResponse; - try { - enqueueResponse = await withAlertingSpan('alerting:bulk-enqueue-actions', () => - this.actionsClient!.bulkEnqueueExecution(c) - ); - } catch (e) { - if (e.statusCode === 404) { - throw createTaskRunError(e, TaskErrorSource.USER); - } - throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); - } - if (enqueueResponse.errors) { - bulkActionsResponse = bulkActionsResponse.concat( - enqueueResponse.items.filter( - (i) => i.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR - ) - ); - } - } - } - - if (!!bulkActionsResponse.length) { - for (const r of bulkActionsResponse) { - if (r.response === ExecutionResponseType.QUEUED_ACTIONS_LIMIT_ERROR) { - ruleRunMetricsStore.setHasReachedQueuedActionsLimit(true); - ruleRunMetricsStore.decrementNumberOfTriggeredActions(); - ruleRunMetricsStore.decrementNumberOfTriggeredActionsByConnectorType(r.actionTypeId); - ruleRunMetricsStore.setTriggeredActionsStatusByConnectorType({ - actionTypeId: r.actionTypeId, - status: ActionsCompletion.PARTIAL, - }); - - logger.debug( - `Rule "${this.rule.id}" skipped scheduling action "${r.id}" because the maximum number of queued actions has been reached.` - ); - - delete logActions[r.id]; - } - } - } - - const logActionsValues = Object.values(logActions); - if (!!logActionsValues.length) { - for (const action of logActionsValues) { - alertingEventLogger.logAction(action); - } - } - - return { throttledSummaryActions }; - } - - private async runSummarizedAction({ - action, - summarizedAlerts, - spaceId, - bulkActions, - }: RunSummarizedActionArgs): Promise { - const { start, end } = getSummaryActionTimeBounds( - action, - this.rule.schedule, - this.previousStartedAt - ); - const ruleUrl = this.buildRuleUrl(spaceId, start, end); - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId: action.actionTypeId, - ruleUrl, - ruleName: this.rule.name, - actionParams: transformSummaryActionParams({ - alerts: summarizedAlerts, - rule: this.rule, - ruleTypeId: this.ruleType.id, - actionId: action.id, - actionParams: action.params, - spaceId, - actionsPlugin: this.taskRunnerContext.actionsPlugin, - actionTypeId: action.actionTypeId, - kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, - ruleUrl: ruleUrl?.absoluteUrl, - }), - }), - }; - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertSummary: { - new: summarizedAlerts.new.count, - ongoing: summarizedAlerts.ongoing.count, - recovered: summarizedAlerts.recovered.count, - }, - }; - } - - private async runSystemAction({ - action, - spaceId, - connectorAdapter, - summarizedAlerts, - rule, - ruleProducer, - bulkActions, - }: RunSystemActionArgs): Promise { - const ruleUrl = this.buildRuleUrl(spaceId); - - const connectorAdapterActionParams = connectorAdapter.buildActionParams({ - alerts: summarizedAlerts, - rule: { - id: rule.id, - tags: rule.tags, - name: rule.name, - consumer: rule.consumer, - producer: ruleProducer, - }, - ruleUrl: ruleUrl?.absoluteUrl, - spaceId, - params: action.params, - }); - - const actionToRun = Object.assign(action, { params: connectorAdapterActionParams }); - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertSummary: { - new: summarizedAlerts.new.count, - ongoing: summarizedAlerts.ongoing.count, - recovered: summarizedAlerts.recovered.count, - }, - }; - } - - private async runAction({ - action, - spaceId, - alert, - ruleId, - bulkActions, - }: RunActionArgs): Promise { - const ruleUrl = this.buildRuleUrl(spaceId); - const executableAlert = alert!; - const actionGroup = action.group as ActionGroupIds; - const transformActionParamsOptions: TransformActionParamsOptions = { - actionsPlugin: this.taskRunnerContext.actionsPlugin, - alertId: ruleId, - alertType: this.ruleType.id, - actionTypeId: action.actionTypeId, - alertName: this.rule.name, - spaceId, - tags: this.rule.tags, - alertInstanceId: executableAlert.getId(), - alertUuid: executableAlert.getUuid(), - alertActionGroup: actionGroup, - alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, - context: executableAlert.getContext(), - actionId: action.id, - state: executableAlert.getState(), - kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, - alertParams: this.rule.params, - actionParams: action.params, - flapping: executableAlert.getFlapping(), - ruleUrl: ruleUrl?.absoluteUrl, - }; - - if (executableAlert.isAlertAsData()) { - transformActionParamsOptions.aadAlert = executableAlert.getAlertAsData(); - } - const actionToRun = { - ...action, - params: injectActionParams({ - actionTypeId: action.actionTypeId, - ruleUrl, - ruleName: this.rule.name, - actionParams: transformActionParams(transformActionParamsOptions), - }), - }; - - await this.actionRunOrAddToBulk({ - enqueueOptions: this.getEnqueueOptions(actionToRun), - bulkActions, - }); - - return { - id: action.id, - typeId: action.actionTypeId, - alertId: alert.getId(), - alertGroup: action.group, - }; - } - - private logNumberOfFilteredAlerts({ - numberOfAlerts = 0, - numberOfSummarizedAlerts = 0, - action, - }: { - numberOfAlerts: number; - numberOfSummarizedAlerts: number; - action: RuleAction | RuleSystemAction; - }) { - const count = numberOfAlerts - numberOfSummarizedAlerts; - if (count > 0) { - this.logger.debug( - `(${count}) alert${count > 1 ? 's' : ''} ${ - count > 1 ? 'have' : 'has' - } been filtered out for: ${action.actionTypeId}:${action.uuid}` - ); - } - } - - private isAlertMuted(alertId: string) { - const muted = this.mutedAlertIdsSet.has(alertId); - if (muted) { - if ( - !this.skippedAlerts[alertId] || - (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.MUTED) - ) { - this.logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${this.ruleLabel}: rule is muted` - ); - } - this.skippedAlerts[alertId] = { reason: Reasons.MUTED }; - return true; - } - return false; - } - - private isExecutableAction(action: RuleAction | RuleSystemAction) { - return this.taskRunnerContext.actionsPlugin.isActionExecutable(action.id, action.actionTypeId, { - notifyUsage: true, - }); - } - - private isSystemAction(action?: RuleAction | RuleSystemAction): action is RuleSystemAction { - return this.taskRunnerContext.actionsPlugin.isSystemActionConnector(action?.id ?? ''); - } - - private isRecoveredAlert(actionGroup: string) { - return actionGroup === this.ruleType.recoveryActionGroup.id; - } - - private isExecutableActiveAlert({ - alert, - action, - }: { - alert: Alert; - action: RuleAction; - }) { - const alertId = alert.getId(); - const { rule, ruleLabel, logger } = this; - const notifyWhen = action.frequency?.notifyWhen || rule.notifyWhen; - - if (notifyWhen === 'onActionGroupChange' && !alert.scheduledActionGroupHasChanged()) { - if ( - !this.skippedAlerts[alertId] || - (this.skippedAlerts[alertId] && - this.skippedAlerts[alertId].reason !== Reasons.ACTION_GROUP_NOT_CHANGED) - ) { - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: alert is active but action group has not changed` - ); - } - this.skippedAlerts[alertId] = { reason: Reasons.ACTION_GROUP_NOT_CHANGED }; - return false; - } - - if (notifyWhen === 'onThrottleInterval') { - const throttled = action.frequency?.throttle - ? alert.isThrottled({ - throttle: action.frequency.throttle ?? null, - actionHash: generateActionHash(action), // generateActionHash must be removed once all the hash identifiers removed from the task state - uuid: action.uuid, - }) - : alert.isThrottled({ throttle: rule.throttle ?? null }); - - if (throttled) { - if ( - !this.skippedAlerts[alertId] || - (this.skippedAlerts[alertId] && this.skippedAlerts[alertId].reason !== Reasons.THROTTLED) - ) { - logger.debug( - `skipping scheduling of actions for '${alertId}' in rule ${ruleLabel}: rule is throttled` - ); - } - this.skippedAlerts[alertId] = { reason: Reasons.THROTTLED }; - return false; - } - } - - return alert.hasScheduledActions(); - } - - private getActionGroup(alert: Alert) { - return alert.getScheduledActionOptions()?.actionGroup || this.ruleType.recoveryActionGroup.id; - } - - private buildRuleUrl(spaceId: string, start?: number, end?: number): RuleUrl | undefined { - if (!this.taskRunnerContext.kibanaBaseUrl) { - return; - } - - const relativePath = this.ruleType.getViewInAppRelativeUrl - ? this.ruleType.getViewInAppRelativeUrl({ rule: this.rule, start, end }) - : `${triggersActionsRoute}${getRuleDetailsRoute(this.rule.id)}`; - - try { - const basePathname = new URL(this.taskRunnerContext.kibanaBaseUrl).pathname; - const basePathnamePrefix = basePathname !== '/' ? `${basePathname}` : ''; - const spaceIdSegment = spaceId !== 'default' ? `/s/${spaceId}` : ''; - - const ruleUrl = new URL( - [basePathnamePrefix, spaceIdSegment, relativePath].join(''), - this.taskRunnerContext.kibanaBaseUrl - ); - - return { - absoluteUrl: ruleUrl.toString(), - kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, - basePathname: basePathnamePrefix, - spaceIdSegment, - relativePath, - }; - } catch (error) { - this.logger.debug( - `Rule "${this.rule.id}" encountered an error while constructing the rule.url variable: ${error.message}` - ); - return; - } - } - - private getEnqueueOptions(action: RuleAction | RuleSystemAction): EnqueueExecutionOptions { - const { - apiKey, - ruleConsumer, - executionId, - taskInstance: { - params: { spaceId, alertId: ruleId }, - }, - } = this; - - const namespace = spaceId === 'default' ? {} : { namespace: spaceId }; - return { - id: action.id, - params: action.params, - spaceId, - apiKey: apiKey ?? null, - consumer: ruleConsumer, - source: asSavedObjectExecutionSource({ - id: ruleId, - type: RULE_SAVED_OBJECT_TYPE, - }), - executionId, - relatedSavedObjects: [ - { - id: ruleId, - type: RULE_SAVED_OBJECT_TYPE, - namespace: namespace.namespace, - typeId: this.ruleType.id, - }, - ], - actionTypeId: action.actionTypeId, - }; - } - - private async generateExecutables( - alerts: Record>, - throttledSummaryActions: ThrottledActions - ): Promise>> { - const executables = []; - for (const action of this.rule.actions) { - const alertsArray = Object.entries(alerts); - let summarizedAlerts = null; - - if (this.shouldGetSummarizedAlerts({ action, throttledSummaryActions })) { - summarizedAlerts = await this.getSummarizedAlerts({ - action, - spaceId: this.taskInstance.params.spaceId, - ruleId: this.taskInstance.params.alertId, - }); - - if (!isSummaryActionOnInterval(action)) { - this.logNumberOfFilteredAlerts({ - numberOfAlerts: alertsArray.length, - numberOfSummarizedAlerts: summarizedAlerts.all.count, - action, - }); - } - } - - if (isSummaryAction(action)) { - if (summarizedAlerts && summarizedAlerts.all.count !== 0) { - executables.push({ action, summarizedAlerts }); - } - continue; - } - - for (const [alertId, alert] of alertsArray) { - const alertMaintenanceWindowIds = alert.getMaintenanceWindowIds(); - if (alertMaintenanceWindowIds.length !== 0) { - this.logger.debug( - `no scheduling of summary actions "${action.id}" for rule "${ - this.taskInstance.params.alertId - }": has active maintenance windows ${alertMaintenanceWindowIds.join(', ')}.` - ); - continue; - } - - if (alert.isFilteredOut(summarizedAlerts)) { - continue; - } - - const actionGroup = this.getActionGroup(alert); - - if (!this.ruleTypeActionGroups!.has(actionGroup)) { - this.logger.error( - `Invalid action group "${actionGroup}" for rule "${this.ruleType.id}".` - ); - continue; - } - - // only actions with notifyWhen set to "on status change" should return - // notifications for flapping pending recovered alerts - if ( - alert.getPendingRecoveredCount() > 0 && - action?.frequency?.notifyWhen !== RuleNotifyWhen.CHANGE - ) { - continue; - } - - if (summarizedAlerts) { - const alertAsData = summarizedAlerts.all.data.find( - (alertHit: AlertHit) => alertHit._id === alert.getUuid() - ); - if (alertAsData) { - alert.setAlertAsData(alertAsData); - } - } - - if (action.group === actionGroup && !this.isAlertMuted(alertId)) { - if ( - this.isRecoveredAlert(action.group) || - this.isExecutableActiveAlert({ alert, action }) - ) { - executables.push({ action, alert }); - } - } - } - } - - if (!this.canGetSummarizedAlerts()) { - return executables; - } - - for (const systemAction of this.rule?.systemActions ?? []) { - const summarizedAlerts = await this.getSummarizedAlerts({ - action: systemAction, - spaceId: this.taskInstance.params.spaceId, - ruleId: this.taskInstance.params.alertId, - }); - - if (summarizedAlerts && summarizedAlerts.all.count !== 0) { - executables.push({ action: systemAction, summarizedAlerts }); - } - } - - return executables; - } - - private canGetSummarizedAlerts() { - return !!this.ruleType.alerts && !!this.alertsClient.getSummarizedAlerts; - } - - private shouldGetSummarizedAlerts({ - action, - throttledSummaryActions, - }: { - action: RuleAction; - throttledSummaryActions: ThrottledActions; - }) { - if (!this.canGetSummarizedAlerts()) { - if (action.frequency?.summary) { - this.logger.error( - `Skipping action "${action.id}" for rule "${this.rule.id}" because the rule type "${this.ruleType.name}" does not support alert-as-data.` - ); - } - return false; - } - - if (action.useAlertDataForTemplate) { - return true; - } - // we fetch summarizedAlerts to filter alerts in memory as well - if (!isSummaryAction(action) && !action.alertsFilter) { - return false; - } - if ( - isSummaryAction(action) && - isSummaryActionThrottled({ - action, - throttledSummaryActions, - logger: this.logger, - }) - ) { - return false; - } - - return true; - } - - private async getSummarizedAlerts({ - action, - ruleId, - spaceId, - }: { - action: RuleAction | RuleSystemAction; - ruleId: string; - spaceId: string; - }): Promise { - const optionsBase = { - ruleId, - spaceId, - excludedAlertInstanceIds: this.rule.mutedInstanceIds, - alertsFilter: this.isSystemAction(action) ? undefined : (action as RuleAction).alertsFilter, - }; - - let options: GetSummarizedAlertsParams; - - if (!this.isSystemAction(action) && isActionOnInterval(action)) { - const throttleMills = parseDuration((action as RuleAction).frequency!.throttle!); - const start = new Date(Date.now() - throttleMills); - - options = { - ...optionsBase, - start, - end: new Date(), - }; - } else { - options = { - ...optionsBase, - executionUuid: this.executionId, - }; - } - - let alerts; - try { - alerts = await withAlertingSpan(`alerting:get-summarized-alerts-${action.uuid}`, () => - this.alertsClient.getSummarizedAlerts!(options) - ); - } catch (e) { - throw createTaskRunError(e, TaskErrorSource.FRAMEWORK); - } - - /** - * We need to remove all new alerts with maintenance windows retrieved from - * getSummarizedAlerts because they might not have maintenance window IDs - * associated with them from maintenance windows with scoped query updated - * yet (the update call uses refresh: false). So we need to rely on the in - * memory alerts to do this. - */ - const newAlertsInMemory = - Object.values(this.alertsClient.getProcessedAlerts('new') || {}) || []; - - const newAlertsWithMaintenanceWindowIds = newAlertsInMemory.reduce( - (result, alert) => { - if (alert.getMaintenanceWindowIds().length > 0) { - result.push(alert.getUuid()); - } - return result; - }, - [] - ); - - const newAlerts = alerts.new.data.filter((alert) => { - return !newAlertsWithMaintenanceWindowIds.includes(alert[ALERT_UUID]); - }); - - const total = newAlerts.length + alerts.ongoing.count + alerts.recovered.count; - return { - ...alerts, - new: { - count: newAlerts.length, - data: newAlerts, - }, - all: { - count: total, - data: [...newAlerts, ...alerts.ongoing.data, ...alerts.recovered.data], - }, - }; - } - - private async actionRunOrAddToBulk({ - enqueueOptions, - bulkActions, - }: { - enqueueOptions: EnqueueExecutionOptions; - bulkActions: EnqueueExecutionOptions[]; - }) { - if (this.taskRunnerContext.supportsEphemeralTasks && this.ephemeralActionsToSchedule > 0) { - this.ephemeralActionsToSchedule--; - try { - await this.actionsClient!.ephemeralEnqueuedExecution(enqueueOptions); - } catch (err) { - if (isEphemeralTaskRejectedDueToCapacityError(err)) { - bulkActions.push(enqueueOptions); - } - } - } else { - bulkActions.push(enqueueOptions); - } - } -} diff --git a/x-pack/plugins/alerting/server/task_runner/inject_action_params.ts b/x-pack/plugins/alerting/server/task_runner/inject_action_params.ts index 65cb7f9e65bad6..421796c08bbff2 100644 --- a/x-pack/plugins/alerting/server/task_runner/inject_action_params.ts +++ b/x-pack/plugins/alerting/server/task_runner/inject_action_params.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { RuleActionParams } from '../types'; -import { RuleUrl } from './execution_handler'; +import { RuleUrl } from './action_scheduler'; export interface InjectActionParamsOpts { actionTypeId: string; 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 9b6d2172d0d5f8..5eb15bff0107bf 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -18,7 +18,7 @@ import { } from '@kbn/task-manager-plugin/server'; import { nanosToMillis } from '@kbn/event-log-plugin/server'; import { getErrorSource, isUserError } from '@kbn/task-manager-plugin/server/task_running'; -import { ExecutionHandler, RunResult } from './execution_handler'; +import { ActionScheduler, type RunResult } from './action_scheduler'; import { RuleRunnerErrorStackTraceLog, RuleTaskInstance, @@ -381,7 +381,7 @@ export class TaskRunner< throw error; } - const executionHandler = new ExecutionHandler({ + const actionScheduler = new ActionScheduler({ rule, ruleType: this.ruleType, logger: this.logger, @@ -398,7 +398,7 @@ export class TaskRunner< alertsClient, }); - let executionHandlerRunResult: RunResult = { throttledSummaryActions: {} }; + let actionSchedulerResult: RunResult = { throttledSummaryActions: {} }; await withAlertingSpan('alerting:schedule-actions', () => this.timer.runWithTimer(TaskRunnerTimerSpan.TriggerActions, async () => { @@ -410,7 +410,7 @@ export class TaskRunner< ); this.countUsageOfActionExecutionAfterRuleCancellation(); } else { - executionHandlerRunResult = await executionHandler.run({ + actionSchedulerResult = await actionScheduler.run({ ...alertsClient.getProcessedAlerts('activeCurrent'), ...alertsClient.getProcessedAlerts('recoveredCurrent'), }); @@ -435,7 +435,7 @@ export class TaskRunner< alertTypeState: updatedRuleTypeState || undefined, alertInstances: alertsToReturn, alertRecoveredInstances: recoveredAlertsToReturn, - summaryActions: executionHandlerRunResult.throttledSummaryActions, + summaryActions: actionSchedulerResult.throttledSummaryActions, }; } diff --git a/x-pack/plugins/alerting/server/task_runner/types.ts b/x-pack/plugins/alerting/server/task_runner/types.ts index e6701d26277e9d..9d40c186bcead5 100644 --- a/x-pack/plugins/alerting/server/task_runner/types.ts +++ b/x-pack/plugins/alerting/server/task_runner/types.ts @@ -83,9 +83,8 @@ export interface RuleTaskInstance extends ConcreteTaskInstance { state: RuleTaskState; } -// / ExecutionHandler - -export interface ExecutionHandlerOptions< +// ActionScheduler +export interface ActionSchedulerOptions< Params extends RuleTypeParams, ExtractedParams extends RuleTypeParams, RuleState extends RuleTypeState, diff --git a/x-pack/plugins/alerting/tsconfig.json b/x-pack/plugins/alerting/tsconfig.json index 63d1ea5768c4e7..0f07c2e8f8b8ec 100644 --- a/x-pack/plugins/alerting/tsconfig.json +++ b/x-pack/plugins/alerting/tsconfig.json @@ -70,7 +70,8 @@ "@kbn/react-kibana-context-render", "@kbn/search-types", "@kbn/alerting-state-types", - "@kbn/core-security-server" + "@kbn/core-security-server", + "@kbn/core-http-server" ], "exclude": [ "target/**/*"