diff --git a/x-pack/plugins/alerting/README.md b/x-pack/plugins/alerting/README.md index bc917fbf43bc4..8fdfe77776b4e 100644 --- a/x-pack/plugins/alerting/README.md +++ b/x-pack/plugins/alerting/README.md @@ -19,6 +19,7 @@ Table of Contents - [Methods](#methods) - [Executor](#executor) - [Action variables](#action-variables) + - [Recovered Alerts](#recovered-alerts) - [Licensing](#licensing) - [Documentation](#documentation) - [Tests](#tests) @@ -100,6 +101,7 @@ The following table describes the properties of the `options` object. |isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean| |defaultScheduleInterval|The default interval that will show up in the UI when creating a rule of this rule type.|boolean| |minimumScheduleInterval|The minimum interval that will be allowed for all rules of this rule type.|boolean| +|doesSetRecoveryContext|Whether the rule type will set context variables for recovered alerts. Defaults to `false`. If this is set to true, context variables are made available for the recovery action group and executors will be provided with the ability to set recovery context.|boolean| ### Executor @@ -170,6 +172,35 @@ This function should take the rule type params as input and extract out any save This function should take the rule type params (with saved object references) and the saved object references array as input and inject the saved object ID in place of any saved object references in the rule type params. Note that any error thrown within this function will be propagated. + +## Recovered Alerts +The Alerting framework automatically determines which alerts are recovered by comparing the active alerts from the previous rule execution to the active alerts in the current rule execution. Alerts that were active previously but not active currently are considered `recovered`. If any actions were specified on the Recovery action group for the rule, they will be scheduled at the end of the execution cycle. + +Because this determination occurs after rule type executors have completed execution, the framework provides a mechanism for rule type executors to set contextual information for recovered alerts that can be templated and used inside recovery actions. In order to use this mechanism, the rule type must set the `doesSetRecoveryContext` flag to `true` during rule type registration. + +Then, the following code would be added within a rule type executor. As you can see, when the rule type is finished creating and scheduling actions for active alerts, it should call `done()` on the alertFactory. This will give the executor access to the list recovered alerts for this execution cycle, for which it can iterate and set context. + +``` +// Create and schedule actions for active alerts +for (const i = 0; i < 5; ++i) { + alertFactory + .create('server_1') + .scheduleActions('default', { + server: 'server_1', + }); +} + +// Call done() to gain access to recovery utils +// If `doesSetRecoveryContext` is set to `false`, getRecoveredAlerts() returns an empty list +const { getRecoveredAlerts } = alertsFactory.done(); + +for (const alert of getRecoveredAlerts()) { + const alertId = alert.getId(); + alert.setContext({ + server: + }) +} +``` ## Licensing Currently most rule types are free features. But some rule types are subscription features, such as the tracking containment rule. @@ -743,6 +774,7 @@ This factory returns an instance of `Alert`. The `Alert` class has the following |scheduleActions(actionGroup, context)|Call this to schedule the execution of actions. The actionGroup is a string `id` that relates to the group of alert `actions` to execute and the context will be used for templating purposes. `scheduleActions` or `scheduleActionsWithSubGroup` should only be called once per alert.| |scheduleActionsWithSubGroup(actionGroup, subgroup, context)|Call this to schedule the execution of actions within a subgroup. The actionGroup is a string `id` that relates to the group of alert `actions` to execute, the `subgroup` is a dynamic string that denotes a subgroup within the actionGroup and the context will be used for templating purposes. `scheduleActions` or `scheduleActionsWithSubGroup` should only be called once per alert.| |replaceState(state)|Used to replace the current state of the alert. This doesn't work like React, the entire state must be provided. Use this feature as you see fit. The state that is set will persist between rule executions whenever you re-create an alert with the same id. The alert state will be erased when `scheduleActions` or `scheduleActionsWithSubGroup` aren't called during an execution.| +|setContext(context)|Call this to set the context for this alert that is used for templating purposes. ### When should I use `scheduleActions` and `scheduleActionsWithSubGroup`? The `scheduleActions` or `scheduleActionsWithSubGroup` methods are both used to achieve the same thing: schedule actions to be run under a specific action group. @@ -758,13 +790,16 @@ Action Subgroups are dynamic, and can be defined on the fly. This approach enables users to specify actions under specific action groups, but they can't specify actions that are specific to subgroups. As subgroups fall under action groups, we will schedule the actions specified for the action group, but the subgroup allows the RuleType implementer to reuse the same action group for multiple different active subgroups. +### When should I use `setContext`? +`setContext` is intended to be used for setting context for recovered alerts. While rule type executors make the determination as to which alerts are active for an execution, the Alerting Framework automatically determines which alerts are recovered for an execution. `setContext` empowers rule type executors to provide additional contextual information for these recovered alerts that will be templated into actions. + ## Templating Actions There needs to be a way to map rule context into action parameters. For this, we started off by adding template support. Any string within the `params` of a rule saved object's `actions` will be processed as a template and can inject context or state values. When an alert executes, the first argument is the `group` of actions to execute and the second is the context the rule exposes to templates. We iterate through each action parameter attributes recursively and render templates if they are a string. Templates have access to the following "variables": -- `context` - provided by context argument of `.scheduleActions(...)` and `.scheduleActionsWithSubGroup(...)` on an alert. +- `context` - provided by context argument of `.scheduleActions(...)`, `.scheduleActionsWithSubGroup(...)` and `setContext(...)` on an alert. - `state` - the alert's `state` provided by the most recent `replaceState` call on an alert. - `alertId` - the id of the rule - `alertInstanceId` - the alert id diff --git a/x-pack/plugins/alerting/common/rule_type.ts b/x-pack/plugins/alerting/common/rule_type.ts index 6f5f00e8f4073..eb24e29f552b9 100644 --- a/x-pack/plugins/alerting/common/rule_type.ts +++ b/x-pack/plugins/alerting/common/rule_type.ts @@ -37,6 +37,7 @@ export interface RuleType< ruleTaskTimeout?: string; defaultScheduleInterval?: string; minimumScheduleInterval?: string; + doesSetRecoveryContext?: boolean; enabledInLicense: boolean; authorizedConsumers: Record; } diff --git a/x-pack/plugins/alerting/server/alert/alert.test.ts b/x-pack/plugins/alerting/server/alert/alert.test.ts index 83b82de904703..eae1b18164b0f 100644 --- a/x-pack/plugins/alerting/server/alert/alert.test.ts +++ b/x-pack/plugins/alerting/server/alert/alert.test.ts @@ -17,14 +17,21 @@ beforeAll(() => { beforeEach(() => clock.reset()); afterAll(() => clock.restore()); +describe('getId()', () => { + test('correctly sets id in constructor', () => { + const alert = new Alert('1'); + expect(alert.getId()).toEqual('1'); + }); +}); + describe('hasScheduledActions()', () => { test('defaults to false', () => { - const alert = new Alert(); + const alert = new Alert('1'); expect(alert.hasScheduledActions()).toEqual(false); }); test('returns true when scheduleActions is called', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default'); expect(alert.hasScheduledActions()).toEqual(true); }); @@ -32,7 +39,7 @@ describe('hasScheduledActions()', () => { describe('isThrottled', () => { test(`should throttle when group didn't change and throttle period is still active`, () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -46,7 +53,7 @@ describe('isThrottled', () => { }); test(`shouldn't throttle when group didn't change and throttle period expired`, () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -60,7 +67,7 @@ describe('isThrottled', () => { }); test(`shouldn't throttle when group changes`, () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -76,12 +83,12 @@ describe('isThrottled', () => { describe('scheduledActionGroupOrSubgroupHasChanged()', () => { test('should be false if no last scheduled and nothing scheduled', () => { - const alert = new Alert(); + const alert = new Alert('1'); expect(alert.scheduledActionGroupOrSubgroupHasChanged()).toEqual(false); }); test('should be false if group does not change', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -94,7 +101,7 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be false if group and subgroup does not change', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -108,7 +115,7 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be false if group does not change and subgroup goes from undefined to defined', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -121,7 +128,7 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be false if group does not change and subgroup goes from defined to undefined', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -135,13 +142,13 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be true if no last scheduled and has scheduled action', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default'); expect(alert.scheduledActionGroupOrSubgroupHasChanged()).toEqual(true); }); test('should be true if group does change', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -154,7 +161,7 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be true if group does change and subgroup does change', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -168,7 +175,7 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { }); test('should be true if group does not change and subgroup does change', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: { lastScheduledActions: { date: new Date(), @@ -184,14 +191,14 @@ describe('scheduledActionGroupOrSubgroupHasChanged()', () => { describe('getScheduledActionOptions()', () => { test('defaults to undefined', () => { - const alert = new Alert(); + const alert = new Alert('1'); expect(alert.getScheduledActionOptions()).toBeUndefined(); }); }); describe('unscheduleActions()', () => { test('makes hasScheduledActions() return false', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default'); expect(alert.hasScheduledActions()).toEqual(true); alert.unscheduleActions(); @@ -199,7 +206,7 @@ describe('unscheduleActions()', () => { }); test('makes getScheduledActionOptions() return undefined', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default'); expect(alert.getScheduledActionOptions()).toEqual({ actionGroup: 'default', @@ -214,7 +221,7 @@ describe('unscheduleActions()', () => { describe('getState()', () => { test('returns state passed to constructor', () => { const state = { foo: true }; - const alert = new Alert({ + const alert = new Alert('1', { state, }); expect(alert.getState()).toEqual(state); @@ -223,7 +230,7 @@ describe('getState()', () => { describe('scheduleActions()', () => { test('makes hasScheduledActions() return true', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -237,7 +244,7 @@ describe('scheduleActions()', () => { }); test('makes isThrottled() return true when throttled', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -251,7 +258,7 @@ describe('scheduleActions()', () => { }); test('make isThrottled() return false when throttled expired', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -266,7 +273,7 @@ describe('scheduleActions()', () => { }); test('makes getScheduledActionOptions() return given options', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: {}, }); @@ -279,7 +286,7 @@ describe('scheduleActions()', () => { }); test('cannot schdule for execution twice', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default', { field: true }); expect(() => alert.scheduleActions('default', { field: false }) @@ -291,7 +298,7 @@ describe('scheduleActions()', () => { describe('scheduleActionsWithSubGroup()', () => { test('makes hasScheduledActions() return true', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -307,7 +314,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('makes isThrottled() return true when throttled and subgroup is the same', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -324,7 +331,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('makes isThrottled() return true when throttled and last schedule had no subgroup', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -340,7 +347,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('makes isThrottled() return false when throttled and subgroup is the different', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -357,7 +364,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('make isThrottled() return false when throttled expired', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: { lastScheduledActions: { @@ -374,7 +381,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('makes getScheduledActionOptions() return given options', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, meta: {}, }); @@ -390,7 +397,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('cannot schdule for execution twice', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); expect(() => alert.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) @@ -400,7 +407,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('cannot schdule for execution twice with different subgroups', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActionsWithSubGroup('default', 'subgroup', { field: true }); expect(() => alert.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) @@ -410,7 +417,7 @@ describe('scheduleActionsWithSubGroup()', () => { }); test('cannot schdule for execution twice whether there are subgroups', () => { - const alert = new Alert(); + const alert = new Alert('1'); alert.scheduleActions('default', { field: true }); expect(() => alert.scheduleActionsWithSubGroup('default', 'subgroup', { field: false }) @@ -422,7 +429,7 @@ describe('scheduleActionsWithSubGroup()', () => { describe('replaceState()', () => { test('replaces previous state', () => { - const alert = new Alert({ + const alert = new Alert('1', { state: { foo: true }, }); alert.replaceState({ bar: true }); @@ -434,7 +441,7 @@ describe('replaceState()', () => { describe('updateLastScheduledActions()', () => { test('replaces previous lastScheduledActions', () => { - const alert = new Alert({ + const alert = new Alert('1', { meta: {}, }); alert.updateLastScheduledActions('default'); @@ -450,9 +457,82 @@ describe('updateLastScheduledActions()', () => { }); }); +describe('getContext()', () => { + test('returns empty object when context has not been set', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + expect(alert.getContext()).toStrictEqual({}); + }); + + test('returns context when context has not been set', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alert.setContext({ field: true }); + expect(alert.getContext()).toStrictEqual({ field: true }); + }); +}); + +describe('hasContext()', () => { + test('returns true when context has been set via scheduleActions()', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alert.scheduleActions('default', { field: true }); + expect(alert.hasContext()).toEqual(true); + }); + + test('returns true when context has been set via setContext()', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + alert.setContext({ field: true }); + expect(alert.hasContext()).toEqual(true); + }); + + test('returns false when context has not been set', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { + lastScheduledActions: { + date: new Date(), + group: 'default', + }, + }, + }); + expect(alert.hasContext()).toEqual(false); + }); +}); + describe('toJSON', () => { test('only serializes state and meta', () => { const alertInstance = new Alert( + '1', { state: { foo: true }, meta: { @@ -481,6 +561,7 @@ describe('toRaw', () => { }, }; const alertInstance = new Alert( + '1', raw ); expect(alertInstance.toRaw()).toEqual(raw); diff --git a/x-pack/plugins/alerting/server/alert/alert.ts b/x-pack/plugins/alerting/server/alert/alert.ts index d34aa68ac1a11..bf29cacf556c1 100644 --- a/x-pack/plugins/alerting/server/alert/alert.ts +++ b/x-pack/plugins/alerting/server/alert/alert.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { isEmpty } from 'lodash'; import { AlertInstanceMeta, AlertInstanceState, @@ -33,7 +34,13 @@ export type PublicAlert< ActionGroupIds extends string = DefaultActionGroupId > = Pick< Alert, - 'getState' | 'replaceState' | 'scheduleActions' | 'scheduleActionsWithSubGroup' + | 'getState' + | 'replaceState' + | 'scheduleActions' + | 'scheduleActionsWithSubGroup' + | 'setContext' + | 'getContext' + | 'hasContext' >; export class Alert< @@ -44,12 +51,20 @@ export class Alert< private scheduledExecutionOptions?: ScheduledExecutionOptions; private meta: AlertInstanceMeta; private state: State; + private context: Context; + private readonly id: string; - constructor({ state, meta = {} }: RawAlertInstance = {}) { + constructor(id: string, { state, meta = {} }: RawAlertInstance = {}) { + this.id = id; this.state = (state || {}) as State; + this.context = {} as Context; this.meta = meta; } + getId() { + return this.id; + } + hasScheduledActions() { return this.scheduledExecutionOptions !== undefined; } @@ -134,8 +149,17 @@ export class Alert< return this.state; } + getContext() { + return this.context; + } + + hasContext() { + return !isEmpty(this.context); + } + scheduleActions(actionGroup: ActionGroupIds, context: Context = {} as Context) { this.ensureHasNoScheduledActions(); + this.setContext(context); this.scheduledExecutionOptions = { actionGroup, context, @@ -150,6 +174,7 @@ export class Alert< context: Context = {} as Context ) { this.ensureHasNoScheduledActions(); + this.setContext(context); this.scheduledExecutionOptions = { actionGroup, subgroup, @@ -159,6 +184,11 @@ export class Alert< return this; } + setContext(context: Context) { + this.context = context; + return this; + } + private ensureHasNoScheduledActions() { if (this.hasScheduledActions()) { throw new Error('Alert instance execution has already been scheduled, cannot schedule twice'); diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts index ecb1a10bbac42..254da05c0dd53 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.test.ts @@ -6,64 +6,206 @@ */ import sinon from 'sinon'; +import { loggingSystemMock } from 'src/core/server/mocks'; import { Alert } from './alert'; import { createAlertFactory } from './create_alert_factory'; +import { getRecoveredAlerts } from '../lib'; -let clock: sinon.SinonFakeTimers; +jest.mock('../lib', () => ({ + getRecoveredAlerts: jest.fn(), +})); -beforeAll(() => { - clock = sinon.useFakeTimers(); -}); -beforeEach(() => clock.reset()); -afterAll(() => clock.restore()); - -test('creates new alerts for ones not passed in', () => { - const alertFactory = createAlertFactory({ alerts: {} }); - const result = alertFactory.create('1'); - expect(result).toMatchInlineSnapshot(` - Object { - "meta": Object {}, - "state": Object {}, - } - `); -}); +let clock: sinon.SinonFakeTimers; +const logger = loggingSystemMock.create().get(); -test('reuses existing alerts', () => { - const alert = new Alert({ - state: { foo: true }, - meta: { lastScheduledActions: { group: 'default', date: new Date() } }, +describe('createAlertFactory()', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(); }); - const alertFactory = createAlertFactory({ - alerts: { - '1': alert, - }, + beforeEach(() => clock.reset()); + afterAll(() => clock.restore()); + + test('creates new alerts for ones not passed in', () => { + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + }); + const result = alertFactory.create('1'); + expect(result).toMatchInlineSnapshot(` + Object { + "meta": Object {}, + "state": Object {}, + } + `); + expect(result.getId()).toEqual('1'); }); - const result = alertFactory.create('1'); - expect(result).toMatchInlineSnapshot(` - Object { - "meta": Object { - "lastScheduledActions": Object { - "date": "1970-01-01T00:00:00.000Z", - "group": "default", + + test('reuses existing alerts', () => { + const alert = new Alert('1', { + state: { foo: true }, + meta: { lastScheduledActions: { group: 'default', date: new Date() } }, + }); + const alertFactory = createAlertFactory({ + alerts: { + '1': alert, + }, + logger, + }); + const result = alertFactory.create('1'); + expect(result).toMatchInlineSnapshot(` + Object { + "meta": Object { + "lastScheduledActions": Object { + "date": "1970-01-01T00:00:00.000Z", + "group": "default", + }, + }, + "state": Object { + "foo": true, }, + } + `); + }); + + test('mutates given alerts', () => { + const alerts = {}; + const alertFactory = createAlertFactory({ + alerts, + logger, + }); + alertFactory.create('1'); + expect(alerts).toMatchInlineSnapshot(` + Object { + "1": Object { + "meta": Object {}, + "state": Object {}, + }, + } + `); + }); + + test('throws error when creating alerts after done() is called', () => { + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + }); + const result = alertFactory.create('1'); + expect(result).toEqual({ + meta: {}, + state: {}, + context: {}, + scheduledExecutionOptions: undefined, + id: '1', + }); + + alertFactory.done(); + + expect(() => { + alertFactory.create('2'); + }).toThrowErrorMatchingInlineSnapshot( + `"Can't create new alerts after calling done() in AlertsFactory."` + ); + }); + + test('returns recovered alerts when setsRecoveryContext is true', () => { + (getRecoveredAlerts as jest.Mock).mockReturnValueOnce({ + z: { + id: 'z', + state: { foo: true }, + meta: { lastScheduledActions: { group: 'default', date: new Date() } }, }, - "state": Object { - "foo": true, + y: { + id: 'y', + state: { foo: true }, + meta: { lastScheduledActions: { group: 'default', date: new Date() } }, }, - } - `); -}); + }); + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + canSetRecoveryContext: true, + }); + const result = alertFactory.create('1'); + expect(result).toEqual({ + meta: {}, + state: {}, + context: {}, + scheduledExecutionOptions: undefined, + id: '1', + }); -test('mutates given alerts', () => { - const alerts = {}; - const alertFactory = createAlertFactory({ alerts }); - alertFactory.create('1'); - expect(alerts).toMatchInlineSnapshot(` - Object { - "1": Object { - "meta": Object {}, - "state": Object {}, - }, - } - `); + const { getRecoveredAlerts: getRecoveredAlertsFn } = alertFactory.done(); + expect(getRecoveredAlertsFn).toBeDefined(); + const recoveredAlerts = getRecoveredAlertsFn!(); + expect(Array.isArray(recoveredAlerts)).toBe(true); + expect(recoveredAlerts.length).toEqual(2); + }); + + test('returns empty array if no recovered alerts', () => { + (getRecoveredAlerts as jest.Mock).mockReturnValueOnce({}); + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + canSetRecoveryContext: true, + }); + const result = alertFactory.create('1'); + expect(result).toEqual({ + meta: {}, + state: {}, + context: {}, + scheduledExecutionOptions: undefined, + id: '1', + }); + + const { getRecoveredAlerts: getRecoveredAlertsFn } = alertFactory.done(); + const recoveredAlerts = getRecoveredAlertsFn!(); + expect(Array.isArray(recoveredAlerts)).toBe(true); + expect(recoveredAlerts.length).toEqual(0); + }); + + test('returns empty array if getRecoveredAlerts returns null', () => { + (getRecoveredAlerts as jest.Mock).mockReturnValueOnce(null); + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + canSetRecoveryContext: true, + }); + const result = alertFactory.create('1'); + expect(result).toEqual({ + meta: {}, + state: {}, + context: {}, + scheduledExecutionOptions: undefined, + id: '1', + }); + + const { getRecoveredAlerts: getRecoveredAlertsFn } = alertFactory.done(); + const recoveredAlerts = getRecoveredAlertsFn!(); + expect(Array.isArray(recoveredAlerts)).toBe(true); + expect(recoveredAlerts.length).toEqual(0); + }); + + test('returns empty array if recovered alerts exist but setsRecoveryContext is false', () => { + const alertFactory = createAlertFactory({ + alerts: {}, + logger, + canSetRecoveryContext: false, + }); + const result = alertFactory.create('1'); + expect(result).toEqual({ + meta: {}, + state: {}, + context: {}, + scheduledExecutionOptions: undefined, + id: '1', + }); + + const { getRecoveredAlerts: getRecoveredAlertsFn } = alertFactory.done(); + const recoveredAlerts = getRecoveredAlertsFn!(); + expect(Array.isArray(recoveredAlerts)).toBe(true); + expect(recoveredAlerts.length).toEqual(0); + expect(logger.debug).toHaveBeenCalledWith( + `Set doesSetRecoveryContext to true on rule type to get access to recovered alerts.` + ); + }); }); diff --git a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts index 07f4dbc7b20ea..ad83b0a416c72 100644 --- a/x-pack/plugins/alerting/server/alert/create_alert_factory.ts +++ b/x-pack/plugins/alerting/server/alert/create_alert_factory.ts @@ -5,8 +5,18 @@ * 2.0. */ +import { Logger } from 'src/core/server'; import { AlertInstanceContext, AlertInstanceState } from '../types'; import { Alert } from './alert'; +import { getRecoveredAlerts } from '../lib'; + +export interface AlertFactoryDoneUtils< + InstanceState extends AlertInstanceState, + InstanceContext extends AlertInstanceContext, + ActionGroupIds extends string +> { + getRecoveredAlerts: () => Array>; +} export interface CreateAlertFactoryOpts< InstanceState extends AlertInstanceState, @@ -14,20 +24,50 @@ export interface CreateAlertFactoryOpts< ActionGroupIds extends string > { alerts: Record>; + logger: Logger; + canSetRecoveryContext?: boolean; } export function createAlertFactory< InstanceState extends AlertInstanceState, InstanceContext extends AlertInstanceContext, ActionGroupIds extends string ->({ alerts }: CreateAlertFactoryOpts) { +>({ + alerts, + logger, + canSetRecoveryContext = false, +}: CreateAlertFactoryOpts) { + // Keep track of which alerts we started with so we can determine which have recovered + const initialAlertIds = new Set(Object.keys(alerts)); + let isDone = false; return { create: (id: string): Alert => { + if (isDone) { + throw new Error(`Can't create new alerts after calling done() in AlertsFactory.`); + } if (!alerts[id]) { - alerts[id] = new Alert(); + alerts[id] = new Alert(id); } return alerts[id]; }, + done: (): AlertFactoryDoneUtils => { + isDone = true; + return { + getRecoveredAlerts: () => { + if (!canSetRecoveryContext) { + logger.debug( + `Set doesSetRecoveryContext to true on rule type to get access to recovered alerts.` + ); + return []; + } + + const recoveredAlerts = getRecoveredAlerts(alerts, initialAlertIds); + return Object.keys(recoveredAlerts ?? []).map( + (alertId: string) => recoveredAlerts[alertId] + ); + }, + }; + }, }; } diff --git a/x-pack/plugins/alerting/server/alert/index.ts b/x-pack/plugins/alerting/server/alert/index.ts index 5e1a9ee626b57..2b5dc4791037e 100644 --- a/x-pack/plugins/alerting/server/alert/index.ts +++ b/x-pack/plugins/alerting/server/alert/index.ts @@ -8,3 +8,4 @@ export type { PublicAlert } from './alert'; export { Alert } from './alert'; export { createAlertFactory } from './create_alert_factory'; +export type { AlertFactoryDoneUtils } from './create_alert_factory'; diff --git a/x-pack/plugins/alerting/server/lib/get_recovered_alerts.test.ts b/x-pack/plugins/alerting/server/lib/get_recovered_alerts.test.ts new file mode 100644 index 0000000000000..b984b04fc65d4 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_recovered_alerts.test.ts @@ -0,0 +1,40 @@ +/* + * 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 { getRecoveredAlerts } from './get_recovered_alerts'; +import { Alert } from '../alert'; +import { AlertInstanceState, AlertInstanceContext, DefaultActionGroupId } from '../types'; + +describe('getRecoveredAlerts', () => { + test('considers alert recovered if it has no scheduled actions', () => { + const alert1 = new Alert('1'); + alert1.scheduleActions('default', { foo: '1' }); + + const alert2 = new Alert('2'); + alert2.setContext({ foo: '2' }); + const alerts = { + '1': alert1, + '2': alert2, + }; + + expect(getRecoveredAlerts(alerts, new Set(['1', '2']))).toEqual({ + '2': alert2, + }); + }); + + test('does not consider alert recovered if it has no actions but was not in original alerts list', () => { + const alert1 = new Alert('1'); + alert1.scheduleActions('default', { foo: '1' }); + const alert2 = new Alert('2'); + const alerts = { + '1': alert1, + '2': alert2, + }; + + expect(getRecoveredAlerts(alerts, new Set(['1']))).toEqual({}); + }); +}); diff --git a/x-pack/plugins/alerting/server/lib/get_recovered_alerts.ts b/x-pack/plugins/alerting/server/lib/get_recovered_alerts.ts new file mode 100644 index 0000000000000..f389f56a813d0 --- /dev/null +++ b/x-pack/plugins/alerting/server/lib/get_recovered_alerts.ts @@ -0,0 +1,25 @@ +/* + * 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 { Dictionary, pickBy } from 'lodash'; +import { Alert } from '../alert'; +import { AlertInstanceState, AlertInstanceContext } from '../types'; + +export function getRecoveredAlerts< + InstanceState extends AlertInstanceState, + InstanceContext extends AlertInstanceContext, + RecoveryActionGroupId extends string +>( + alerts: Record>, + originalAlertIds: Set +): Dictionary> { + return pickBy( + alerts, + (alert: Alert, id) => + !alert.hasScheduledActions() && originalAlertIds.has(id) + ); +} diff --git a/x-pack/plugins/alerting/server/lib/index.ts b/x-pack/plugins/alerting/server/lib/index.ts index 29526f17268f2..a5fa1b29c3044 100644 --- a/x-pack/plugins/alerting/server/lib/index.ts +++ b/x-pack/plugins/alerting/server/lib/index.ts @@ -24,3 +24,4 @@ export { ruleExecutionStatusToRaw, ruleExecutionStatusFromRaw, } from './rule_execution_status'; +export { getRecoveredAlerts } from './get_recovered_alerts'; diff --git a/x-pack/plugins/alerting/server/mocks.ts b/x-pack/plugins/alerting/server/mocks.ts index afbc3ef9cec43..f7872ba797856 100644 --- a/x-pack/plugins/alerting/server/mocks.ts +++ b/x-pack/plugins/alerting/server/mocks.ts @@ -7,7 +7,7 @@ import { rulesClientMock } from './rules_client.mock'; import { PluginSetupContract, PluginStartContract } from './plugin'; -import { Alert } from './alert'; +import { Alert, AlertFactoryDoneUtils } from './alert'; import { elasticsearchServiceMock, savedObjectsClientMock, @@ -64,6 +64,17 @@ const createAlertFactoryMock = { return mock as unknown as AlertInstanceMock; }, + done: < + InstanceState extends AlertInstanceState = AlertInstanceState, + InstanceContext extends AlertInstanceContext = AlertInstanceContext, + ActionGroupIds extends string = string + >() => { + const mock: jest.Mocked> = + { + getRecoveredAlerts: jest.fn().mockReturnValue([]), + }; + return mock; + }, }; const createAbortableSearchClientMock = () => { @@ -86,9 +97,11 @@ const createAlertServicesMock = < InstanceContext extends AlertInstanceContext = AlertInstanceContext >() => { const alertFactoryMockCreate = createAlertFactoryMock.create(); + const alertFactoryMockDone = createAlertFactoryMock.done(); return { alertFactory: { create: jest.fn().mockReturnValue(alertFactoryMockCreate), + done: jest.fn().mockReturnValue(alertFactoryMockDone), }, savedObjectsClient: savedObjectsClientMock.create(), scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), diff --git a/x-pack/plugins/alerting/server/plugin.ts b/x-pack/plugins/alerting/server/plugin.ts index 70aad0d6921e1..ac3253346138a 100644 --- a/x-pack/plugins/alerting/server/plugin.ts +++ b/x-pack/plugins/alerting/server/plugin.ts @@ -293,6 +293,7 @@ export class AlertingPlugin { ruleType.ruleTaskTimeout = ruleType.ruleTaskTimeout ?? config.defaultRuleTaskTimeout; ruleType.cancelAlertsOnRuleTimeout = ruleType.cancelAlertsOnRuleTimeout ?? config.cancelAlertsOnRuleTimeout; + ruleType.doesSetRecoveryContext = ruleType.doesSetRecoveryContext ?? false; ruleTypeRegistry.register(ruleType); }); }, diff --git a/x-pack/plugins/alerting/server/routes/rule_types.test.ts b/x-pack/plugins/alerting/server/routes/rule_types.test.ts index 7deb2704fb7ec..752f729fb8e38 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.test.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.test.ts @@ -60,6 +60,7 @@ describe('ruleTypesRoute', () => { enabledInLicense: true, minimumScheduleInterval: '1m', defaultScheduleInterval: '10m', + doesSetRecoveryContext: false, } as RegistryAlertTypeWithAuth, ]; const expectedResult: Array> = [ @@ -74,6 +75,7 @@ describe('ruleTypesRoute', () => { ], default_action_group_id: 'default', default_schedule_interval: '10m', + does_set_recovery_context: false, minimum_license_required: 'basic', minimum_schedule_interval: '1m', is_exportable: true, @@ -109,6 +111,7 @@ describe('ruleTypesRoute', () => { "authorized_consumers": Object {}, "default_action_group_id": "default", "default_schedule_interval": "10m", + "does_set_recovery_context": false, "enabled_in_license": true, "id": "1", "is_exportable": true, diff --git a/x-pack/plugins/alerting/server/routes/rule_types.ts b/x-pack/plugins/alerting/server/routes/rule_types.ts index d1f24538d76d8..7b2a0c63be198 100644 --- a/x-pack/plugins/alerting/server/routes/rule_types.ts +++ b/x-pack/plugins/alerting/server/routes/rule_types.ts @@ -25,6 +25,7 @@ const rewriteBodyRes: RewriteResponseCase = (result authorizedConsumers, minimumScheduleInterval, defaultScheduleInterval, + doesSetRecoveryContext, ...rest }) => ({ ...rest, @@ -39,6 +40,7 @@ const rewriteBodyRes: RewriteResponseCase = (result authorized_consumers: authorizedConsumers, minimum_schedule_interval: minimumScheduleInterval, default_schedule_interval: defaultScheduleInterval, + does_set_recovery_context: doesSetRecoveryContext, }) ); }; diff --git a/x-pack/plugins/alerting/server/rule_type_registry.test.ts b/x-pack/plugins/alerting/server/rule_type_registry.test.ts index e23c7f25a4f76..8ba2847486bca 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.test.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.test.ts @@ -493,6 +493,7 @@ describe('list()', () => { }, ], defaultActionGroupId: 'testActionGroup', + doesSetRecoveryContext: false, isExportable: true, ruleTaskTimeout: '20m', minimumLicenseRequired: 'basic', @@ -520,6 +521,7 @@ describe('list()', () => { }, "defaultActionGroupId": "testActionGroup", "defaultScheduleInterval": undefined, + "doesSetRecoveryContext": false, "enabledInLicense": false, "id": "test", "isExportable": true, diff --git a/x-pack/plugins/alerting/server/rule_type_registry.ts b/x-pack/plugins/alerting/server/rule_type_registry.ts index 9b4f94f3510be..6673fb630ef59 100644 --- a/x-pack/plugins/alerting/server/rule_type_registry.ts +++ b/x-pack/plugins/alerting/server/rule_type_registry.ts @@ -51,6 +51,7 @@ export interface RegistryRuleType | 'ruleTaskTimeout' | 'minimumScheduleInterval' | 'defaultScheduleInterval' + | 'doesSetRecoveryContext' > { id: string; enabledInLicense: boolean; @@ -331,6 +332,7 @@ export class RuleTypeRegistry { ruleTaskTimeout, minimumScheduleInterval, defaultScheduleInterval, + doesSetRecoveryContext, }, ]: [string, UntypedNormalizedRuleType]) => ({ id, @@ -345,6 +347,7 @@ export class RuleTypeRegistry { ruleTaskTimeout, minimumScheduleInterval, defaultScheduleInterval, + doesSetRecoveryContext, enabledInLicense: !!this.licenseState.getLicenseCheckForRuleType( id, name, diff --git a/x-pack/plugins/alerting/server/rules_client/rules_client.ts b/x-pack/plugins/alerting/server/rules_client/rules_client.ts index 63e35583bc9a1..72ef2dba89ce7 100644 --- a/x-pack/plugins/alerting/server/rules_client/rules_client.ts +++ b/x-pack/plugins/alerting/server/rules_client/rules_client.ts @@ -1337,7 +1337,7 @@ export class RulesClient { const recoveredAlertInstances = mapValues, Alert>( state.alertInstances ?? {}, - (rawAlertInstance) => new Alert(rawAlertInstance) + (rawAlertInstance, alertId) => new Alert(alertId, rawAlertInstance) ); const recoveredAlertInstanceIds = Object.keys(recoveredAlertInstances); 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 51d50c398c6f5..8bc4ad280873e 100644 --- a/x-pack/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerting/server/task_runner/task_runner.ts @@ -66,6 +66,7 @@ import { Event, } from '../lib/create_alert_event_log_record_object'; import { createAbortableEsClientFactory } from '../lib/create_abortable_es_client_factory'; +import { getRecoveredAlerts } from '../lib'; const FALLBACK_RETRY_INTERVAL = '5m'; const CONNECTIVITY_RETRY_INTERVAL = '5m'; @@ -334,7 +335,11 @@ export class TaskRunner< const alerts = mapValues< Record, CreatedAlert - >(alertRawInstances, (rawAlert) => new CreatedAlert(rawAlert)); + >( + alertRawInstances, + (rawAlert, alertId) => new CreatedAlert(alertId, rawAlert) + ); + const originalAlerts = cloneDeep(alerts); const originalAlertIds = new Set(Object.keys(originalAlerts)); @@ -364,6 +369,8 @@ export class TaskRunner< WithoutReservedActionGroups >({ alerts, + logger: this.logger, + canSetRecoveryContext: ruleType.doesSetRecoveryContext ?? false, }), shouldWriteAlerts: () => this.shouldLogAndScheduleActionsForAlerts(), shouldStopExecution: () => this.cancelled, @@ -424,17 +431,15 @@ export class TaskRunner< alerts, (alert: CreatedAlert) => alert.hasScheduledActions() ); - const recoveredAlerts = pickBy( - alerts, - (alert: CreatedAlert, id) => - !alert.hasScheduledActions() && originalAlertIds.has(id) - ); + + const recoveredAlerts = getRecoveredAlerts(alerts, originalAlertIds); logActiveAndRecoveredAlerts({ logger: this.logger, activeAlerts: alertsWithScheduledActions, recoveredAlerts, ruleLabel, + canSetRecoveryContext: ruleType.doesSetRecoveryContext ?? false, }); trackAlertDurations({ @@ -1155,7 +1160,7 @@ async function scheduleActionsForRecoveredAlerts< alert.unscheduleActions(); const triggeredActionsForRecoveredAlert = await executionHandler({ actionGroup: recoveryActionGroup.id, - context: {}, + context: alert.getContext(), state: {}, alertId: id, }); @@ -1176,6 +1181,7 @@ interface LogActiveAndRecoveredAlertsParams< activeAlerts: Dictionary>; recoveredAlerts: Dictionary>; ruleLabel: string; + canSetRecoveryContext: boolean; } function logActiveAndRecoveredAlerts< @@ -1191,7 +1197,7 @@ function logActiveAndRecoveredAlerts< RecoveryActionGroupId > ) { - const { logger, activeAlerts, recoveredAlerts, ruleLabel } = params; + const { logger, activeAlerts, recoveredAlerts, ruleLabel, canSetRecoveryContext } = params; const activeAlertIds = Object.keys(activeAlerts); const recoveredAlertIds = Object.keys(recoveredAlerts); @@ -1218,6 +1224,16 @@ function logActiveAndRecoveredAlerts< recoveredAlertIds )}` ); + + if (canSetRecoveryContext) { + for (const id of recoveredAlertIds) { + if (!recoveredAlerts[id].hasContext()) { + logger.debug( + `rule ${ruleLabel} has no recovery context specified for recovered alert ${id}` + ); + } + } + } } } diff --git a/x-pack/plugins/alerting/server/types.ts b/x-pack/plugins/alerting/server/types.ts index 9d6302774f889..50acb67a3de47 100644 --- a/x-pack/plugins/alerting/server/types.ts +++ b/x-pack/plugins/alerting/server/types.ts @@ -7,7 +7,7 @@ import type { IRouter, RequestHandlerContext, SavedObjectReference } from 'src/core/server'; import type { PublicMethodsOf } from '@kbn/utility-types'; -import { PublicAlert } from './alert'; +import { AlertFactoryDoneUtils, PublicAlert } from './alert'; import { RuleTypeRegistry as OrigruleTypeRegistry } from './rule_type_registry'; import { PluginSetupContract, PluginStartContract } from './plugin'; import { RulesClient } from './rules_client'; @@ -76,6 +76,7 @@ export interface AlertServices< > extends Services { alertFactory: { create: (id: string) => PublicAlert; + done: () => AlertFactoryDoneUtils; }; shouldWriteAlerts: () => boolean; shouldStopExecution: () => boolean; @@ -167,6 +168,7 @@ export interface RuleType< minimumScheduleInterval?: string; ruleTaskTimeout?: string; cancelAlertsOnRuleTimeout?: boolean; + doesSetRecoveryContext?: boolean; } export type UntypedRuleType = RuleType< AlertTypeParams, diff --git a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts index f881b4476fe22..a34b3cdb1334d 100644 --- a/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts +++ b/x-pack/plugins/apm/server/routes/alerts/test_utils/index.ts @@ -42,7 +42,7 @@ export const createRuleTypeMocks = () => { savedObjectsClient: { get: () => ({ attributes: { consumer: APM_SERVER_FEATURE_ID } }), }, - alertFactory: { create: jest.fn(() => ({ scheduleActions })) }, + alertFactory: { create: jest.fn(() => ({ scheduleActions })), done: {} }, alertWithLifecycle: jest.fn(), logger: loggerMock, shouldWriteAlerts: () => true, diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts index baa60664dea57..3593030913ba7 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -72,6 +72,7 @@ function createRule(shouldWriteAlerts: boolean = true) { scheduleActions, } as any; }, + done: () => ({ getRecoveredAlerts: () => [] }), }; return { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts index 775817dcb8a0c..11396864d802d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/preview_rules_route.ts @@ -145,8 +145,15 @@ export const previewRulesRoute = async ( id: string ) => Pick< Alert, - 'getState' | 'replaceState' | 'scheduleActions' | 'scheduleActionsWithSubGroup' + | 'getState' + | 'replaceState' + | 'scheduleActions' + | 'scheduleActionsWithSubGroup' + | 'setContext' + | 'getContext' + | 'hasContext' >; + done: () => { getRecoveredAlerts: () => [] }; } ) => { let statePreview = runState as TState; @@ -228,7 +235,7 @@ export const previewRulesRoute = async ( queryAlertType.name, previewRuleParams, () => true, - { create: alertInstanceFactoryStub } + { create: alertInstanceFactoryStub, done: () => ({ getRecoveredAlerts: () => [] }) } ); break; case 'threshold': @@ -241,7 +248,7 @@ export const previewRulesRoute = async ( thresholdAlertType.name, previewRuleParams, () => true, - { create: alertInstanceFactoryStub } + { create: alertInstanceFactoryStub, done: () => ({ getRecoveredAlerts: () => [] }) } ); break; case 'threat_match': @@ -254,7 +261,7 @@ export const previewRulesRoute = async ( threatMatchAlertType.name, previewRuleParams, () => true, - { create: alertInstanceFactoryStub } + { create: alertInstanceFactoryStub, done: () => ({ getRecoveredAlerts: () => [] }) } ); break; case 'eql': @@ -265,7 +272,7 @@ export const previewRulesRoute = async ( eqlAlertType.name, previewRuleParams, () => true, - { create: alertInstanceFactoryStub } + { create: alertInstanceFactoryStub, done: () => ({ getRecoveredAlerts: () => [] }) } ); break; case 'machine_learning': @@ -276,7 +283,7 @@ export const previewRulesRoute = async ( mlAlertType.name, previewRuleParams, () => true, - { create: alertInstanceFactoryStub } + { create: alertInstanceFactoryStub, done: () => ({ getRecoveredAlerts: () => [] }) } ); break; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts index 3d96e3bb77907..3d390cac6b91f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts @@ -76,7 +76,10 @@ export const createRuleTypeMocks = ( search: elasticsearchServiceMock.createScopedClusterClient(), savedObjectsClient: mockSavedObjectsClient, scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(), - alertFactory: { create: jest.fn(() => ({ scheduleActions })) }, + alertFactory: { + create: jest.fn(() => ({ scheduleActions })), + done: jest.fn().mockResolvedValue({}), + }, findAlerts: jest.fn(), // TODO: does this stay? alertWithPersistence: jest.fn(), logger: loggerMock, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/alert_instance_factory_stub.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/alert_instance_factory_stub.ts index 7cc709bbe8994..88d6114387aa3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/alert_instance_factory_stub.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/preview/alert_instance_factory_stub.ts @@ -27,13 +27,13 @@ export const alertInstanceFactoryStub = < return {} as unknown as TInstanceState; }, replaceState(state: TInstanceState) { - return new Alert({ + return new Alert('', { state: {} as TInstanceState, meta: { lastScheduledActions: { group: 'default', date: new Date() } }, }); }, scheduleActions(actionGroup: TActionGroupIds, alertcontext: TInstanceContext) { - return new Alert({ + return new Alert('', { state: {} as TInstanceState, meta: { lastScheduledActions: { group: 'default', date: new Date() } }, }); @@ -43,9 +43,21 @@ export const alertInstanceFactoryStub = < subgroup: string, alertcontext: TInstanceContext ) { - return new Alert({ + return new Alert('', { state: {} as TInstanceState, meta: { lastScheduledActions: { group: 'default', date: new Date() } }, }); }, + setContext(alertContext: TInstanceContext) { + return new Alert('', { + state: {} as TInstanceState, + meta: { lastScheduledActions: { group: 'default', date: new Date() } }, + }); + }, + getContext() { + return {} as unknown as TInstanceContext; + }, + hasContext() { + return false; + }, }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts index 185474b827eda..bf9489e12777c 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/geo_containment/tests/geo_containment.test.ts @@ -40,6 +40,7 @@ const alertFactory = (contextKeys: unknown[], testAlertActionArr: unknown[]) => ); return alertInstance; }, + done: () => ({ getRecoveredAlerts: () => [] }), }); describe('geo_containment', () => { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts index 9a5e0de9d53bf..4d8c1dc3d9b9f 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts @@ -96,6 +96,36 @@ describe('ActionContext', () => { - Value: 4 - Conditions Met: count between 4 and 5 over 5m +- Timestamp: 2020-01-01T00:00:00.000Z` + ); + }); + + it('generates expected properties if value is string', async () => { + const params = ParamsSchema.validate({ + index: '[index]', + timeField: '[timeField]', + aggType: 'count', + groupBy: 'top', + termField: 'x', + termSize: 100, + timeWindowSize: 5, + timeWindowUnit: 'm', + thresholdComparator: 'between', + threshold: [4, 5], + }); + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + value: 'unknown', + conditions: 'count between 4 and 5', + }; + const context = addMessages({ name: '[alert-name]' }, base, params); + expect(context.title).toMatchInlineSnapshot(`"alert [alert-name] group [group] met threshold"`); + expect(context.message).toEqual( + `alert '[alert-name]' is active for group '[group]': + +- Value: unknown +- Conditions Met: count between 4 and 5 over 5m - Timestamp: 2020-01-01T00:00:00.000Z` ); }); diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts index 69ca1c2700ebe..02450da5bbdf7 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts @@ -11,7 +11,7 @@ import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerting // alert type context provided to actions -type AlertInfo = Pick; +type RuleInfo = Pick; export interface ActionContext extends BaseActionContext { // a short pre-constructed message which may be used in an action field @@ -27,43 +27,72 @@ export interface BaseActionContext extends AlertInstanceContext { // the date the alert was run as an ISO date date: string; // the value that met the threshold - value: number; + value: number | string; // threshold conditions conditions: string; } -export function addMessages( - alertInfo: AlertInfo, - baseContext: BaseActionContext, - params: Params -): ActionContext { - const title = i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle', { +const DEFAULT_TITLE = (name: string, group: string) => + i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeContextSubjectTitle', { defaultMessage: 'alert {name} group {group} met threshold', + values: { name, group }, + }); + +const RECOVERY_TITLE = (name: string, group: string) => + i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeRecoveryContextSubjectTitle', { + defaultMessage: 'alert {name} group {group} recovered', + values: { name, group }, + }); + +const DEFAULT_MESSAGE = (name: string, context: BaseActionContext, window: string) => + i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', { + defaultMessage: `alert '{name}' is active for group '{group}': + +- Value: {value} +- Conditions Met: {conditions} over {window} +- Timestamp: {date}`, values: { - name: alertInfo.name, - group: baseContext.group, + name, + group: context.group, + value: context.value, + conditions: context.conditions, + window, + date: context.date, }, }); - const window = `${params.timeWindowSize}${params.timeWindowUnit}`; - const message = i18n.translate( - 'xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', - { - defaultMessage: `alert '{name}' is active for group '{group}': +const RECOVERY_MESSAGE = (name: string, context: BaseActionContext, window: string) => + i18n.translate('xpack.stackAlerts.indexThreshold.alertTypeRecoveryContextMessageDescription', { + defaultMessage: `alert '{name}' is recovered for group '{group}': - Value: {value} - Conditions Met: {conditions} over {window} - Timestamp: {date}`, - values: { - name: alertInfo.name, - group: baseContext.group, - value: baseContext.value, - conditions: baseContext.conditions, - window, - date: baseContext.date, - }, - } - ); + values: { + name, + group: context.group, + value: context.value, + conditions: context.conditions, + window, + date: context.date, + }, + }); + +export function addMessages( + ruleInfo: RuleInfo, + baseContext: BaseActionContext, + params: Params, + isRecoveryMessage?: boolean +): ActionContext { + const title = isRecoveryMessage + ? RECOVERY_TITLE(ruleInfo.name, baseContext.group) + : DEFAULT_TITLE(ruleInfo.name, baseContext.group); + + const window = `${params.timeWindowSize}${params.timeWindowUnit}`; + + const message = isRecoveryMessage + ? RECOVERY_MESSAGE(ruleInfo.name, baseContext, window) + : DEFAULT_MESSAGE(ruleInfo.name, baseContext, window); return { ...baseContext, title, message }; } diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 0eb2810626ac3..7725721ed8efa 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -128,12 +128,13 @@ export function getAlertType( isExportable: true, executor, producer: STACK_ALERTS_FEATURE_ID, + doesSetRecoveryContext: true, }; async function executor( options: AlertExecutorOptions ) { - const { alertId, name, services, params } = options; + const { alertId: ruleId, name, services, params } = options; const { alertFactory, search } = services; const compareFn = ComparatorFns.get(params.thresholdComparator); @@ -173,19 +174,22 @@ export function getAlertType( abortableEsClient, query: queryParams, }); - logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); + logger.debug(`rule ${ID}:${ruleId} "${name}" query result: ${JSON.stringify(result)}`); + + const unmetGroupValues: Record = {}; + const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; const groupResults = result.results || []; // console.log(`index_threshold: response: ${JSON.stringify(groupResults, null, 4)}`); for (const groupResult of groupResults) { - const instanceId = groupResult.group; + const alertId = groupResult.group; const metric = groupResult.metrics && groupResult.metrics.length > 0 ? groupResult.metrics[0] : null; const value = metric && metric.length === 2 ? metric[1] : null; if (value === null || value === undefined) { logger.debug( - `alert ${ID}:${alertId} "${name}": no metrics found for group ${instanceId}} from groupResult ${JSON.stringify( + `rule ${ID}:${ruleId} "${name}": no metrics found for group ${alertId}} from groupResult ${JSON.stringify( groupResult )}` ); @@ -194,23 +198,41 @@ export function getAlertType( const met = compareFn(value, params.threshold); - if (!met) continue; + if (!met) { + unmetGroupValues[alertId] = value; + continue; + } - const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; const humanFn = `${agg} is ${getHumanReadableComparator( params.thresholdComparator )} ${params.threshold.join(' and ')}`; const baseContext: BaseActionContext = { date, - group: instanceId, + group: alertId, value, conditions: humanFn, }; const actionContext = addMessages(options, baseContext, params); - const alertInstance = alertFactory.create(instanceId); - alertInstance.scheduleActions(ActionGroupId, actionContext); + const alert = alertFactory.create(alertId); + alert.scheduleActions(ActionGroupId, actionContext); logger.debug(`scheduled actionGroup: ${JSON.stringify(actionContext)}`); } + + const { getRecoveredAlerts } = services.alertFactory.done(); + for (const recoveredAlert of getRecoveredAlerts()) { + const alertId = recoveredAlert.getId(); + logger.debug(`setting context for recovered alert ${alertId}`); + const baseContext: BaseActionContext = { + date, + value: unmetGroupValues[alertId] ?? 'unknown', + group: alertId, + conditions: `${agg} is NOT ${getHumanReadableComparator( + params.thresholdComparator + )} ${params.threshold.join(' and ')}`, + }; + const recoveryContext = addMessages(options, baseContext, params, true); + recoveredAlert.setContext(recoveryContext); + } } } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts index 30e594f35d1f8..c2ab2936e0cc7 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.test.ts @@ -191,17 +191,30 @@ describe('transformActionVariables', () => { ]); }); - test('should return only the required action variables when omitOptionalMessageVariables is provided', () => { + test(`should return only the required action variables when omitMessageVariables is "all"`, () => { const alertType = getAlertType({ context: mockContextVariables(), state: mockStateVariables(), params: mockParamsVariables(), }); - expect(transformActionVariables(alertType.actionVariables, true)).toEqual([ + expect(transformActionVariables(alertType.actionVariables, 'all')).toEqual([ ...expectedTransformResult, ...expectedParamsTransformResult(), ]); }); + + test(`should return required and context action variables when omitMessageVariables is "keepContext"`, () => { + const alertType = getAlertType({ + context: mockContextVariables(), + state: mockStateVariables(), + params: mockParamsVariables(), + }); + expect(transformActionVariables(alertType.actionVariables, 'keepContext')).toEqual([ + ...expectedTransformResult, + ...expectedContextTransformResult(), + ...expectedParamsTransformResult(), + ]); + }); }); function getAlertType(actionVariables: ActionVariables): RuleType { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts index 2cf1df85a3447..6aff0781a56c2 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/action_variables.ts @@ -7,16 +7,20 @@ import { i18n } from '@kbn/i18n'; import { pick } from 'lodash'; -import { ActionVariables, REQUIRED_ACTION_VARIABLES } from '../../types'; +import { ActionVariables, REQUIRED_ACTION_VARIABLES, CONTEXT_ACTION_VARIABLES } from '../../types'; import { ActionVariable } from '../../../../alerting/common'; +export type OmitMessageVariablesType = 'all' | 'keepContext'; + // return a "flattened" list of action variables for an alertType export function transformActionVariables( actionVariables: ActionVariables, - omitOptionalMessageVariables?: boolean + omitMessageVariables?: OmitMessageVariablesType ): ActionVariable[] { - const filteredActionVariables: ActionVariables = omitOptionalMessageVariables - ? pick(actionVariables, ...REQUIRED_ACTION_VARIABLES) + const filteredActionVariables: ActionVariables = omitMessageVariables + ? omitMessageVariables === 'all' + ? pick(actionVariables, REQUIRED_ACTION_VARIABLES) + : pick(actionVariables, [...REQUIRED_ACTION_VARIABLES, ...CONTEXT_ACTION_VARIABLES]) : actionVariables; const alwaysProvidedVars = getAlwaysProvidedActionVariables(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts index 99478f250f6a2..63207dd35dfac 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api/rule_types.ts @@ -22,6 +22,7 @@ const rewriteBodyReq: RewriteRequestCase = ({ action_variables: actionVariables, authorized_consumers: authorizedConsumers, rule_task_timeout: ruleTaskTimeout, + does_set_recovery_context: doesSetRecoveryContext, ...rest }: AsApiContract) => ({ enabledInLicense, @@ -32,6 +33,7 @@ const rewriteBodyReq: RewriteRequestCase = ({ actionVariables, authorizedConsumers, ruleTaskTimeout, + doesSetRecoveryContext, ...rest, }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 836e63fa7a68a..f25827fb4ba99 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -40,9 +40,10 @@ import { useKibana } from '../../../common/lib/kibana'; import { DefaultActionParamsGetter } from '../../lib/get_defaults_for_action_params'; import { ConnectorAddModal } from '.'; import { suspendedComponentWithProps } from '../../lib/suspended_component_with_props'; +import { OmitMessageVariablesType } from '../../lib/action_variables'; export interface ActionGroupWithMessageVariables extends ActionGroup { - omitOptionalMessageVariables?: boolean; + omitMessageVariables?: OmitMessageVariablesType; defaultActionMessage?: string; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx index 4a4230c233dfa..4a27c4c1e6fef 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -346,7 +346,7 @@ function getAvailableActionVariables( ) { const transformedActionVariables: ActionVariable[] = transformActionVariables( actionVariables, - actionGroup?.omitOptionalMessageVariables + actionGroup?.omitMessageVariables ); // partition deprecated items so they show up last diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 06542cbb3a1a4..fa226c4a74cdd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -556,7 +556,9 @@ export const AlertForm = ({ actionGroup.id === selectedAlertType.recoveryActionGroup.id ? { ...actionGroup, - omitOptionalMessageVariables: true, + omitMessageVariables: selectedAlertType.doesSetRecoveryContext + ? 'keepContext' + : 'all', defaultActionMessage: recoveredActionGroupMessage, } : { ...actionGroup, defaultActionMessage: alertTypeModel?.defaultActionMessage } diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index 72fd48d355774..718a637518cbe 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -205,7 +205,8 @@ type AsActionVariables = { [Req in Keys]: ActionVariable[]; }; export const REQUIRED_ACTION_VARIABLES = ['params'] as const; -export const OPTIONAL_ACTION_VARIABLES = ['state', 'context'] as const; +export const CONTEXT_ACTION_VARIABLES = ['context'] as const; +export const OPTIONAL_ACTION_VARIABLES = [...CONTEXT_ACTION_VARIABLES, 'state'] as const; export type ActionVariables = AsActionVariables & Partial>; @@ -224,6 +225,7 @@ export interface RuleType< | 'ruleTaskTimeout' | 'defaultScheduleInterval' | 'minimumScheduleInterval' + | 'doesSetRecoveryContext' > { actionVariables: ActionVariables; authorizedConsumers: Record; diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts index b070219410fd9..0c527ac1449f8 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/rule_types.ts @@ -21,6 +21,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { { id: 'recovered', name: 'Recovered' }, ], default_action_group_id: 'default', + does_set_recovery_context: false, id: 'test.noop', name: 'Test: Noop', action_variables: { @@ -49,6 +50,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { name: 'Restricted Recovery', }, default_action_group_id: 'default', + does_set_recovery_context: false, id: 'test.restricted-noop', name: 'Test: Restricted Noop', action_variables: { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index 8c796c1a39d67..ba2a2cb8fdf47 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -17,27 +17,27 @@ import { } from '../../../../../common/lib'; import { createEsDocuments } from './create_test_data'; -const ALERT_TYPE_ID = '.index-threshold'; -const ACTION_TYPE_ID = '.index'; +const RULE_TYPE_ID = '.index-threshold'; +const CONNECTOR_TYPE_ID = '.index'; const ES_TEST_INDEX_SOURCE = 'builtin-alert:index-threshold'; const ES_TEST_INDEX_REFERENCE = '-na-'; const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`; -const ALERT_INTERVALS_TO_WRITE = 5; -const ALERT_INTERVAL_SECONDS = 3; -const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const RULE_INTERVALS_TO_WRITE = 5; +const RULE_INTERVAL_SECONDS = 3; +const RULE_INTERVAL_MILLIS = RULE_INTERVAL_SECONDS * 1000; // eslint-disable-next-line import/no-default-export -export default function alertTests({ getService }: FtrProviderContext) { +export default function ruleTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const retry = getService('retry'); const es = getService('es'); const esTestIndexTool = new ESTestIndexTool(es, retry); const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); - describe('alert', async () => { + describe('rule', async () => { let endDate: string; - let actionId: string; + let connectorId: string; const objectRemover = new ObjectRemover(supertest); beforeEach(async () => { @@ -47,10 +47,10 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexToolOutput.destroy(); await esTestIndexToolOutput.setup(); - actionId = await createAction(supertest, objectRemover); + connectorId = await createConnector(supertest, objectRemover); // write documents in the future, figure out the end date - const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS; + const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS; endDate = new Date(endDateMillis).toISOString(); // write documents from now to the future end date in 3 groups @@ -67,7 +67,7 @@ export default function alertTests({ getService }: FtrProviderContext) { // never fire; the tests ensure the ones that should fire, do fire, and // those that shouldn't fire, do not fire. it('runs correctly: count all < >', async () => { - await createAlert({ + await createRule({ name: 'never fire', aggType: 'count', groupBy: 'all', @@ -75,7 +75,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [0], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'count', groupBy: 'all', @@ -104,7 +104,7 @@ export default function alertTests({ getService }: FtrProviderContext) { // create some more documents in the first group createEsDocumentsInGroups(1); - await createAlert({ + await createRule({ name: 'never fire', aggType: 'count', groupBy: 'top', @@ -114,7 +114,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [-1], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'count', groupBy: 'top', @@ -148,7 +148,7 @@ export default function alertTests({ getService }: FtrProviderContext) { // create some more documents in the first group createEsDocumentsInGroups(1); - await createAlert({ + await createRule({ name: 'never fire', aggType: 'sum', aggField: 'testedValue', @@ -157,7 +157,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [-2, -1], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'sum', aggField: 'testedValue', @@ -183,7 +183,7 @@ export default function alertTests({ getService }: FtrProviderContext) { createEsDocumentsInGroups(1); // this never fires because of bad fields error - await createAlert({ + await createRule({ name: 'never fire', timeField: 'source', // bad field for time aggType: 'avg', @@ -193,7 +193,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [0], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'avg', aggField: 'testedValue', @@ -218,7 +218,7 @@ export default function alertTests({ getService }: FtrProviderContext) { // create some more documents in the first group createEsDocumentsInGroups(1); - await createAlert({ + await createRule({ name: 'never fire', aggType: 'max', aggField: 'testedValue', @@ -229,7 +229,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [0], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'max', aggField: 'testedValue', @@ -264,7 +264,7 @@ export default function alertTests({ getService }: FtrProviderContext) { // create some more documents in the first group createEsDocumentsInGroups(1); - await createAlert({ + await createRule({ name: 'never fire', aggType: 'min', aggField: 'testedValue', @@ -275,7 +275,7 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: [0], }); - await createAlert({ + await createRule({ name: 'always fire', aggType: 'min', aggField: 'testedValue', @@ -306,13 +306,59 @@ export default function alertTests({ getService }: FtrProviderContext) { expect(inGroup0).to.be.greaterThan(0); }); + it('runs correctly and populates recovery context', async () => { + // This rule should be active initially when the number of documents is below the threshold + // and then recover when we add more documents. + await createRule({ + name: 'fire then recovers', + aggType: 'count', + groupBy: 'all', + thresholdComparator: '<', + threshold: [10], + timeWindowSize: 60, + }); + + await createEsDocumentsInGroups(1); + + const docs = await waitForDocs(2); + const activeDoc = docs[0]; + const { group: activeGroup } = activeDoc._source; + const { + name: activeName, + title: activeTitle, + message: activeMessage, + } = activeDoc._source.params; + + expect(activeName).to.be('fire then recovers'); + expect(activeGroup).to.be('all documents'); + expect(activeTitle).to.be('alert fire then recovers group all documents met threshold'); + expect(activeMessage).to.match( + /alert 'fire then recovers' is active for group \'all documents\':\n\n- Value: \d+\n- Conditions Met: count is less than 10 over 60s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/ + ); + + const recoveredDoc = docs[1]; + const { group: recoveredGroup } = recoveredDoc._source; + const { + name: recoveredName, + title: recoveredTitle, + message: recoveredMessage, + } = recoveredDoc._source.params; + + expect(recoveredName).to.be('fire then recovers'); + expect(recoveredGroup).to.be('all documents'); + expect(recoveredTitle).to.be('alert fire then recovers group all documents recovered'); + expect(recoveredMessage).to.match( + /alert 'fire then recovers' is recovered for group \'all documents\':\n\n- Value: \d+\n- Conditions Met: count is NOT less than 10 over 60s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/ + ); + }); + async function createEsDocumentsInGroups(groups: number) { await createEsDocuments( es, esTestIndexTool, endDate, - ALERT_INTERVALS_TO_WRITE, - ALERT_INTERVAL_MILLIS, + RULE_INTERVALS_TO_WRITE, + RULE_INTERVAL_MILLIS, groups ); } @@ -325,11 +371,12 @@ export default function alertTests({ getService }: FtrProviderContext) { ); } - interface CreateAlertParams { + interface CreateRuleParams { name: string; aggType: string; aggField?: string; timeField?: string; + timeWindowSize?: number; groupBy: 'all' | 'top'; termField?: string; termSize?: number; @@ -337,9 +384,9 @@ export default function alertTests({ getService }: FtrProviderContext) { threshold: number[]; } - async function createAlert(params: CreateAlertParams): Promise { + async function createRule(params: CreateRuleParams): Promise { const action = { - id: actionId, + id: connectorId, group: 'threshold met', params: { documents: [ @@ -347,7 +394,7 @@ export default function alertTests({ getService }: FtrProviderContext) { source: ES_TEST_INDEX_SOURCE, reference: ES_TEST_INDEX_REFERENCE, params: { - name: '{{{alertName}}}', + name: '{{{rule.name}}}', value: '{{{context.value}}}', title: '{{{context.title}}}', message: '{{{context.message}}}', @@ -362,16 +409,37 @@ export default function alertTests({ getService }: FtrProviderContext) { }, }; - const { status, body: createdAlert } = await supertest + const recoveryAction = { + id: connectorId, + group: 'recovered', + params: { + documents: [ + { + source: ES_TEST_INDEX_SOURCE, + reference: ES_TEST_INDEX_REFERENCE, + params: { + name: '{{{rule.name}}}', + value: '{{{context.value}}}', + title: '{{{context.title}}}', + message: '{{{context.message}}}', + }, + date: '{{{context.date}}}', + group: '{{{context.group}}}', + }, + ], + }, + }; + + const { status, body: createdRule } = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`) .set('kbn-xsrf', 'foo') .send({ name: params.name, consumer: 'alerts', enabled: true, - rule_type_id: ALERT_TYPE_ID, - schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` }, - actions: [action], + rule_type_id: RULE_TYPE_ID, + schedule: { interval: `${RULE_INTERVAL_SECONDS}s` }, + actions: [action, recoveryAction], notify_when: 'onActiveAlert', params: { index: ES_TEST_INDEX_NAME, @@ -381,7 +449,7 @@ export default function alertTests({ getService }: FtrProviderContext) { groupBy: params.groupBy, termField: params.termField, termSize: params.termSize, - timeWindowSize: ALERT_INTERVAL_SECONDS * 5, + timeWindowSize: params.timeWindowSize ?? RULE_INTERVAL_SECONDS * 5, timeWindowUnit: 's', thresholdComparator: params.thresholdComparator, threshold: params.threshold, @@ -389,25 +457,25 @@ export default function alertTests({ getService }: FtrProviderContext) { }); // will print the error body, if an error occurred - // if (statusCode !== 200) console.log(createdAlert); + // if (statusCode !== 200) console.log(createdRule); expect(status).to.be(200); - const alertId = createdAlert.id; - objectRemover.add(Spaces.space1.id, alertId, 'rule', 'alerting'); + const ruleId = createdRule.id; + objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting'); - return alertId; + return ruleId; } }); } -async function createAction(supertest: any, objectRemover: ObjectRemover): Promise { - const { statusCode, body: createdAction } = await supertest +async function createConnector(supertest: any, objectRemover: ObjectRemover): Promise { + const { statusCode, body: createdConnector } = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/connector`) .set('kbn-xsrf', 'foo') .send({ - name: 'index action for index threshold FT', - connector_type_id: ACTION_TYPE_ID, + name: 'index connector for index threshold FT', + connector_type_id: CONNECTOR_TYPE_ID, config: { index: ES_TEST_OUTPUT_INDEX_NAME, }, @@ -415,12 +483,12 @@ async function createAction(supertest: any, objectRemover: ObjectRemover): Promi }); // will print the error body, if an error occurred - // if (statusCode !== 200) console.log(createdAction); + // if (statusCode !== 200) console.log(createdConnector); expect(statusCode).to.be(200); - const actionId = createdAction.id; - objectRemover.add(Spaces.space1.id, actionId, 'connector', 'actions'); + const connectorId = createdConnector.id; + objectRemover.add(Spaces.space1.id, connectorId, 'connector', 'actions'); - return actionId; + return connectorId; } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts index 77638ed90fbe4..d9b3035ac05dd 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/rule_types.ts @@ -29,6 +29,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { { id: 'recovered', name: 'Recovered' }, ], default_action_group_id: 'default', + does_set_recovery_context: false, id: 'test.noop', name: 'Test: Noop', action_variables: { @@ -115,6 +116,7 @@ export default function listAlertTypes({ getService }: FtrProviderContext) { { id: 'recovered', name: 'Recovered' }, ], defaultActionGroupId: 'default', + doesSetRecoveryContext: false, id: 'test.noop', name: 'Test: Noop', actionVariables: {