Skip to content

Commit 249a1a4

Browse files
authored
[Alerting] Enables AlertTypes to define the custom recovery action groups (#84408)
In this PR we introduce a new `recoveryActionGroup` field on AlertTypes which allows an implementor to specify a custom action group which the framework will use when an alert instance goes from _active_ to _inactive_. By default all alert types will use the existing `RecoveryActionGroup`, but when `recoveryActionGroup` is specified, this group is used instead. This is applied across the UI, event log and underlying object model, rather than just being a label change. To support this we also introduced the `alertActionGroupName` message variable which is the human readable version of existing `alertActionGroup` variable.
1 parent b9b2704 commit 249a1a4

File tree

49 files changed

+576
-151
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+576
-151
lines changed

x-pack/examples/alerting_example/server/alert_types/astros.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ export const alertType: AlertType = {
4343
name: 'People In Space Right Now',
4444
actionGroups: [{ id: 'default', name: 'default' }],
4545
defaultActionGroupId: 'default',
46+
recoveryActionGroup: {
47+
id: 'hasLandedBackOnEarth',
48+
name: 'Has landed back on Earth',
49+
},
4650
async executor({ services, params }) {
4751
const { outerSpaceCapacity, craft: craftToTriggerBy, op } = params;
4852

x-pack/plugins/alerts/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ The following table describes the properties of the `options` object.
9191
|name|A user-friendly name for the alert type. These will be displayed in dropdowns when choosing alert types.|string|
9292
|actionGroups|An explicit list of groups the alert type may schedule actions for, each specifying the ActionGroup's unique ID and human readable name. Alert `actions` validation will use this configuartion to ensure groups are valid. We highly encourage using `kbn-i18n` to translate the names of actionGroup when registering the AlertType. |Array<{id:string, name:string}>|
9393
|defaultActionGroupId|Default ID value for the group of the alert type.|string|
94+
|recoveryActionGroup|An action group to use when an alert instance goes from an active state, to an inactive one. This action group should not be specified under the `actionGroups` property. If no recoveryActionGroup is specified, the default `recovered` action group will be used. |{id:string, name:string}|
9495
|actionVariables|An explicit list of action variables the alert type makes available via context and state in action parameter templates, and a short human readable description. Alert UI will use this to display prompts for the users for these variables, in action parameter editors. We highly encourage using `kbn-i18n` to translate the descriptions. |{ context: Array<{name:string, description:string}, state: Array<{name:string, description:string}>|
9596
|validate.params|When developing an alert type, you can choose to accept a series of parameters. You may also have the parameters validated before they are passed to the `executor` function or created as an alert saved object. In order to do this, provide a `@kbn/config-schema` schema that we will use to validate the `params` attribute.|@kbn/config-schema|
9697
|executor|This is where the code of the alert type lives. This is a function to be called when executing an alert on an interval basis. For full details, see executor section below.|Function|

x-pack/plugins/alerts/common/alert_type.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface AlertType {
88
id: string;
99
name: string;
1010
actionGroups: ActionGroup[];
11+
recoveryActionGroup: ActionGroup;
1112
actionVariables: string[];
1213
defaultActionGroupId: ActionGroup['id'];
1314
producer: string;

x-pack/plugins/alerts/common/builtin_action_groups.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
import { i18n } from '@kbn/i18n';
77
import { ActionGroup } from './alert_type';
88

9-
export const RecoveredActionGroup: ActionGroup = {
9+
export const RecoveredActionGroup: Readonly<ActionGroup> = {
1010
id: 'recovered',
1111
name: i18n.translate('xpack.alerts.builtinActionGroups.recovered', {
1212
defaultMessage: 'Recovered',
1313
}),
1414
};
1515

16-
export function getBuiltinActionGroups(): ActionGroup[] {
17-
return [RecoveredActionGroup];
16+
export function getBuiltinActionGroups(customRecoveryGroup?: ActionGroup): ActionGroup[] {
17+
return [customRecoveryGroup ?? Object.freeze(RecoveredActionGroup)];
1818
}

x-pack/plugins/alerts/public/alert_api.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* you may not use this file except in compliance with the Elastic License.
55
*/
66

7-
import { AlertType } from '../common';
7+
import { AlertType, RecoveredActionGroup } from '../common';
88
import { httpServiceMock } from '../../../../src/core/public/mocks';
99
import { loadAlert, loadAlertType, loadAlertTypes } from './alert_api';
1010
import uuid from 'uuid';
@@ -22,6 +22,7 @@ describe('loadAlertTypes', () => {
2222
actionVariables: ['var1'],
2323
actionGroups: [{ id: 'default', name: 'Default' }],
2424
defaultActionGroupId: 'default',
25+
recoveryActionGroup: RecoveredActionGroup,
2526
producer: 'alerts',
2627
},
2728
];
@@ -45,6 +46,7 @@ describe('loadAlertType', () => {
4546
actionVariables: ['var1'],
4647
actionGroups: [{ id: 'default', name: 'Default' }],
4748
defaultActionGroupId: 'default',
49+
recoveryActionGroup: RecoveredActionGroup,
4850
producer: 'alerts',
4951
};
5052
http.get.mockResolvedValueOnce([alertType]);
@@ -65,6 +67,7 @@ describe('loadAlertType', () => {
6567
actionVariables: [],
6668
actionGroups: [{ id: 'default', name: 'Default' }],
6769
defaultActionGroupId: 'default',
70+
recoveryActionGroup: RecoveredActionGroup,
6871
producer: 'alerts',
6972
};
7073
http.get.mockResolvedValueOnce([alertType]);
@@ -80,6 +83,7 @@ describe('loadAlertType', () => {
8083
actionVariables: [],
8184
actionGroups: [{ id: 'default', name: 'Default' }],
8285
defaultActionGroupId: 'default',
86+
recoveryActionGroup: RecoveredActionGroup,
8387
producer: 'alerts',
8488
},
8589
]);

x-pack/plugins/alerts/public/alert_navigation_registry/alert_navigation_registry.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { AlertNavigationRegistry } from './alert_navigation_registry';
8-
import { AlertType, SanitizedAlert } from '../../common';
8+
import { AlertType, RecoveredActionGroup, SanitizedAlert } from '../../common';
99
import uuid from 'uuid';
1010

1111
beforeEach(() => jest.resetAllMocks());
@@ -14,6 +14,7 @@ const mockAlertType = (id: string): AlertType => ({
1414
id,
1515
name: id,
1616
actionGroups: [],
17+
recoveryActionGroup: RecoveredActionGroup,
1718
actionVariables: [],
1819
defaultActionGroupId: 'default',
1920
producer: 'alerts',

x-pack/plugins/alerts/server/alert_type_registry.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,71 @@ describe('register()', () => {
122122
);
123123
});
124124

125+
test('allows an AlertType to specify a custom recovery group', () => {
126+
const alertType = {
127+
id: 'test',
128+
name: 'Test',
129+
actionGroups: [
130+
{
131+
id: 'default',
132+
name: 'Default',
133+
},
134+
],
135+
defaultActionGroupId: 'default',
136+
recoveryActionGroup: {
137+
id: 'backToAwesome',
138+
name: 'Back To Awesome',
139+
},
140+
executor: jest.fn(),
141+
producer: 'alerts',
142+
};
143+
const registry = new AlertTypeRegistry(alertTypeRegistryParams);
144+
registry.register(alertType);
145+
expect(registry.get('test').actionGroups).toMatchInlineSnapshot(`
146+
Array [
147+
Object {
148+
"id": "default",
149+
"name": "Default",
150+
},
151+
Object {
152+
"id": "backToAwesome",
153+
"name": "Back To Awesome",
154+
},
155+
]
156+
`);
157+
});
158+
159+
test('throws if the custom recovery group is contained in the AlertType action groups', () => {
160+
const alertType = {
161+
id: 'test',
162+
name: 'Test',
163+
actionGroups: [
164+
{
165+
id: 'default',
166+
name: 'Default',
167+
},
168+
{
169+
id: 'backToAwesome',
170+
name: 'Back To Awesome',
171+
},
172+
],
173+
recoveryActionGroup: {
174+
id: 'backToAwesome',
175+
name: 'Back To Awesome',
176+
},
177+
defaultActionGroupId: 'default',
178+
executor: jest.fn(),
179+
producer: 'alerts',
180+
};
181+
const registry = new AlertTypeRegistry(alertTypeRegistryParams);
182+
183+
expect(() => registry.register(alertType)).toThrowError(
184+
new Error(
185+
`Alert type [id="${alertType.id}"] cannot be registered. Action group [backToAwesome] cannot be used as both a recovery and an active action group.`
186+
)
187+
);
188+
});
189+
125190
test('registers the executor with the task manager', () => {
126191
const alertType = {
127192
id: 'test',
@@ -243,6 +308,10 @@ describe('get()', () => {
243308
"id": "test",
244309
"name": "Test",
245310
"producer": "alerts",
311+
"recoveryActionGroup": Object {
312+
"id": "recovered",
313+
"name": "Recovered",
314+
},
246315
}
247316
`);
248317
});
@@ -300,6 +369,10 @@ describe('list()', () => {
300369
"id": "test",
301370
"name": "Test",
302371
"producer": "alerts",
372+
"recoveryActionGroup": Object {
373+
"id": "recovered",
374+
"name": "Recovered",
375+
},
303376
},
304377
}
305378
`);

x-pack/plugins/alerts/server/alert_type_registry.ts

Lines changed: 80 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n';
99
import { schema } from '@kbn/config-schema';
1010
import typeDetect from 'type-detect';
1111
import { intersection } from 'lodash';
12-
import _ from 'lodash';
1312
import { RunContext, TaskManagerSetupContract } from '../../task_manager/server';
1413
import { TaskRunnerFactory } from './task_runner';
1514
import {
@@ -18,9 +17,8 @@ import {
1817
AlertTypeState,
1918
AlertInstanceState,
2019
AlertInstanceContext,
21-
ActionGroup,
2220
} from './types';
23-
import { getBuiltinActionGroups } from '../common';
21+
import { RecoveredActionGroup, getBuiltinActionGroups } from '../common';
2422

2523
interface ConstructorOptions {
2624
taskManager: TaskManagerSetupContract;
@@ -29,8 +27,13 @@ interface ConstructorOptions {
2927

3028
export interface RegistryAlertType
3129
extends Pick<
32-
AlertType,
33-
'name' | 'actionGroups' | 'defaultActionGroupId' | 'actionVariables' | 'producer'
30+
NormalizedAlertType,
31+
| 'name'
32+
| 'actionGroups'
33+
| 'recoveryActionGroup'
34+
| 'defaultActionGroupId'
35+
| 'actionVariables'
36+
| 'producer'
3437
> {
3538
id: string;
3639
}
@@ -55,9 +58,17 @@ const alertIdSchema = schema.string({
5558
},
5659
});
5760

61+
export type NormalizedAlertType<
62+
Params extends AlertTypeParams = AlertTypeParams,
63+
State extends AlertTypeState = AlertTypeState,
64+
InstanceState extends AlertInstanceState = AlertInstanceState,
65+
InstanceContext extends AlertInstanceContext = AlertInstanceContext
66+
> = Omit<AlertType<Params, State, InstanceState, InstanceContext>, 'recoveryActionGroup'> &
67+
Pick<Required<AlertType<Params, State, InstanceState, InstanceContext>>, 'recoveryActionGroup'>;
68+
5869
export class AlertTypeRegistry {
5970
private readonly taskManager: TaskManagerSetupContract;
60-
private readonly alertTypes: Map<string, AlertType> = new Map();
71+
private readonly alertTypes: Map<string, NormalizedAlertType> = new Map();
6172
private readonly taskRunnerFactory: TaskRunnerFactory;
6273

6374
constructor({ taskManager, taskRunnerFactory }: ConstructorOptions) {
@@ -86,14 +97,15 @@ export class AlertTypeRegistry {
8697
);
8798
}
8899
alertType.actionVariables = normalizedActionVariables(alertType.actionVariables);
89-
validateActionGroups(alertType.id, alertType.actionGroups);
90-
alertType.actionGroups = [...alertType.actionGroups, ..._.cloneDeep(getBuiltinActionGroups())];
91-
this.alertTypes.set(alertIdSchema.validate(alertType.id), { ...alertType } as AlertType);
100+
101+
const normalizedAlertType = augmentActionGroupsWithReserved(alertType as AlertType);
102+
103+
this.alertTypes.set(alertIdSchema.validate(alertType.id), normalizedAlertType);
92104
this.taskManager.registerTaskDefinitions({
93105
[`alerting:${alertType.id}`]: {
94106
title: alertType.name,
95107
createTaskRunner: (context: RunContext) =>
96-
this.taskRunnerFactory.create({ ...alertType } as AlertType, context),
108+
this.taskRunnerFactory.create(normalizedAlertType, context),
97109
},
98110
});
99111
}
@@ -103,7 +115,7 @@ export class AlertTypeRegistry {
103115
State extends AlertTypeState = AlertTypeState,
104116
InstanceState extends AlertInstanceState = AlertInstanceState,
105117
InstanceContext extends AlertInstanceContext = AlertInstanceContext
106-
>(id: string): AlertType<Params, State, InstanceState, InstanceContext> {
118+
>(id: string): NormalizedAlertType<Params, State, InstanceState, InstanceContext> {
107119
if (!this.has(id)) {
108120
throw Boom.badRequest(
109121
i18n.translate('xpack.alerts.alertTypeRegistry.get.missingAlertTypeError', {
@@ -114,19 +126,32 @@ export class AlertTypeRegistry {
114126
})
115127
);
116128
}
117-
return this.alertTypes.get(id)! as AlertType<Params, State, InstanceState, InstanceContext>;
129+
return this.alertTypes.get(id)! as NormalizedAlertType<
130+
Params,
131+
State,
132+
InstanceState,
133+
InstanceContext
134+
>;
118135
}
119136

120137
public list(): Set<RegistryAlertType> {
121138
return new Set(
122139
Array.from(this.alertTypes).map(
123-
([id, { name, actionGroups, defaultActionGroupId, actionVariables, producer }]: [
124-
string,
125-
AlertType
126-
]) => ({
140+
([
141+
id,
142+
{
143+
name,
144+
actionGroups,
145+
recoveryActionGroup,
146+
defaultActionGroupId,
147+
actionVariables,
148+
producer,
149+
},
150+
]: [string, NormalizedAlertType]) => ({
127151
id,
128152
name,
129153
actionGroups,
154+
recoveryActionGroup,
130155
defaultActionGroupId,
131156
actionVariables,
132157
producer,
@@ -144,21 +169,52 @@ function normalizedActionVariables(actionVariables: AlertType['actionVariables']
144169
};
145170
}
146171

147-
function validateActionGroups(alertTypeId: string, actionGroups: ActionGroup[]) {
148-
const reservedActionGroups = intersection(
149-
actionGroups.map((item) => item.id),
150-
getBuiltinActionGroups().map((item) => item.id)
172+
function augmentActionGroupsWithReserved<
173+
Params extends AlertTypeParams,
174+
State extends AlertTypeState,
175+
InstanceState extends AlertInstanceState,
176+
InstanceContext extends AlertInstanceContext
177+
>(
178+
alertType: AlertType<Params, State, InstanceState, InstanceContext>
179+
): NormalizedAlertType<Params, State, InstanceState, InstanceContext> {
180+
const reservedActionGroups = getBuiltinActionGroups(alertType.recoveryActionGroup);
181+
const { id, actionGroups, recoveryActionGroup } = alertType;
182+
183+
const activeActionGroups = new Set(actionGroups.map((item) => item.id));
184+
const intersectingReservedActionGroups = intersection(
185+
[...activeActionGroups.values()],
186+
reservedActionGroups.map((item) => item.id)
151187
);
152-
if (reservedActionGroups.length > 0) {
188+
if (recoveryActionGroup && activeActionGroups.has(recoveryActionGroup.id)) {
189+
throw new Error(
190+
i18n.translate(
191+
'xpack.alerts.alertTypeRegistry.register.customRecoveryActionGroupUsageError',
192+
{
193+
defaultMessage:
194+
'Alert type [id="{id}"] cannot be registered. Action group [{actionGroup}] cannot be used as both a recovery and an active action group.',
195+
values: {
196+
actionGroup: recoveryActionGroup.id,
197+
id,
198+
},
199+
}
200+
)
201+
);
202+
} else if (intersectingReservedActionGroups.length > 0) {
153203
throw new Error(
154204
i18n.translate('xpack.alerts.alertTypeRegistry.register.reservedActionGroupUsageError', {
155205
defaultMessage:
156-
'Alert type [id="{alertTypeId}"] cannot be registered. Action groups [{actionGroups}] are reserved by the framework.',
206+
'Alert type [id="{id}"] cannot be registered. Action groups [{actionGroups}] are reserved by the framework.',
157207
values: {
158-
actionGroups: reservedActionGroups.join(', '),
159-
alertTypeId,
208+
actionGroups: intersectingReservedActionGroups.join(', '),
209+
id,
160210
},
161211
})
162212
);
163213
}
214+
215+
return {
216+
...alertType,
217+
actionGroups: [...actionGroups, ...reservedActionGroups],
218+
recoveryActionGroup: recoveryActionGroup ?? RecoveredActionGroup,
219+
};
164220
}

0 commit comments

Comments
 (0)