Skip to content

Commit

Permalink
[Alerting] Introduces a ActionSubGroup which allows for more granular…
Browse files Browse the repository at this point in the history
… action group scheduling (#84751)


This PR introduces a new concept of an _Action Subgroup_ (naming is open for discussion) which can be used by an Alert Type when scheduling actions.
An Action Subgroup can be dynamically specified, unlike Action Groups which have to be specified on the AlertType definition.
When scheduling actions, and AlertType can specify an _Action Subgroup_ along side the scheduled _Action Group_, which denotes that the alert instance falls into some kind of narrower grouping in the action group.
  • Loading branch information
gmmorris authored Dec 10, 2020
1 parent 0b929f3 commit 015f3c9
Show file tree
Hide file tree
Showing 24 changed files with 588 additions and 58 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,13 @@ import {
AlwaysFiringParams,
} from '../../common/constants';

const ACTION_GROUPS = [
{ id: 'small', name: 'Small t-shirt' },
{ id: 'medium', name: 'Medium t-shirt' },
{ id: 'large', name: 'Large t-shirt' },
];
const DEFAULT_ACTION_GROUP = 'small';
type ActionGroups = 'small' | 'medium' | 'large';
const DEFAULT_ACTION_GROUP: ActionGroups = 'small';

function getTShirtSizeByIdAndThreshold(id: string, thresholds: AlwaysFiringParams['thresholds']) {
function getTShirtSizeByIdAndThreshold(
id: string,
thresholds: AlwaysFiringParams['thresholds']
): ActionGroups {
const idAsNumber = parseInt(id, 10);
if (!isNaN(idAsNumber)) {
if (thresholds?.large && thresholds.large < idAsNumber) {
Expand All @@ -36,10 +35,19 @@ function getTShirtSizeByIdAndThreshold(id: string, thresholds: AlwaysFiringParam
return DEFAULT_ACTION_GROUP;
}

export const alertType: AlertType<AlwaysFiringParams> = {
export const alertType: AlertType<
AlwaysFiringParams,
{ count?: number },
{ triggerdOnCycle: number },
never
> = {
id: 'example.always-firing',
name: 'Always firing',
actionGroups: ACTION_GROUPS,
actionGroups: [
{ id: 'small', name: 'Small t-shirt' },
{ id: 'medium', name: 'Medium t-shirt' },
{ id: 'large', name: 'Large t-shirt' },
],
defaultActionGroupId: DEFAULT_ACTION_GROUP,
async executor({
services,
Expand Down
21 changes: 18 additions & 3 deletions x-pack/plugins/alerts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -623,16 +623,31 @@ This factory returns an instance of `AlertInstance`. The alert instance class ha
|Method|Description|
|---|---|
|getState()|Get the current state of the alert instance.|
|scheduleActions(actionGroup, context)|Called 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. This should only be called once per alert instance.|
|replaceState(state)|Used to replace the current state of the alert instance. 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 alert type executions whenever you re-create an alert instance with the same id. The instance state will be erased when `scheduleActions` isn't called during an execution.|
|scheduleActions(actionGroup, context)|Called 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 instance.|
|scheduleActionsWithSubGroup(actionGroup, subgroup, context)|Called 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 instance.|
|replaceState(state)|Used to replace the current state of the alert instance. 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 alert type executions whenever you re-create an alert instance with the same id. The instance state will be erased when `scheduleActions` or `scheduleActionsWithSubGroup` aren't called during an execution.|

### 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.
It's important to note though, that when an actions are scheduled for an instance, we check whether the instance was already active in this action group after the previous execution. If it was, then we might throttle the actions (adhering to the user's configuration), as we don't consider this a change in the instance.

What happens though, if the instance _has_ changed, but they just happen to be in the same action group after this change? This is where subgroups come in. By specifying a subgroup (using the `scheduleActionsWithSubGroup` method), the instance becomes active within the action group, but it will also keep track of the subgroup.
If the subgroup changes, then the framework will treat the instance as if it had been placed in a new action group. It is important to note though, we only use the subgroup to denote a change if both the current execution and the previous one specified a subgroup.

You might wonder, why bother using a subgroup if you can just add a new action group?
Action Groups are static, and have to be define when the Alert Type is defined.
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 AlertType implementer to reuse the same action group for multiple different active subgroups.

## Templating actions

There needs to be a way to map alert context into action parameters. For this, we started off by adding template support. Any string within the `params` of an alert saved object's `actions` will be processed as a template and can inject context or state values.

When an alert instance executes, the first argument is the `group` of actions to execute and the second is the context the alert exposes to templates. We iterate through each action params attributes recursively and render templates if they are a string. Templates have access to the following "variables":

- `context` - provided by second argument of `.scheduleActions(...)` on an alert instance
- `context` - provided by context argument of `.scheduleActions(...)` and `.scheduleActionsWithSubGroup(...)` on an alert instance
- `state` - the alert instance's `state` provided by the most recent `replaceState` call on an alert instance
- `alertId` - the id of the alert
- `alertInstanceId` - the alert instance id
Expand Down
13 changes: 9 additions & 4 deletions x-pack/plugins/alerts/common/alert_instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,15 @@ import * as t from 'io-ts';
import { DateFromString } from './date_from_string';

const metaSchema = t.partial({
lastScheduledActions: t.type({
group: t.string,
date: DateFromString,
}),
lastScheduledActions: t.intersection([
t.partial({
subgroup: t.string,
}),
t.type({
group: t.string,
date: DateFromString,
}),
]),
});
export type AlertInstanceMeta = t.TypeOf<typeof metaSchema>;

Expand Down
1 change: 1 addition & 0 deletions x-pack/plugins/alerts/common/alert_instance_summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,6 @@ export interface AlertInstanceStatus {
status: AlertInstanceStatusValues;
muted: boolean;
actionGroupId?: string;
actionSubgroup?: string;
activeStartDate?: string;
}
128 changes: 128 additions & 0 deletions x-pack/plugins/alerts/server/alert_instance/alert_instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,134 @@ describe('scheduleActions()', () => {
});
});

describe('scheduleActionsWithSubGroup()', () => {
test('makes hasScheduledActions() return true', () => {
const alertInstance = new AlertInstance({
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
},
},
});
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.hasScheduledActions()).toEqual(true);
});

test('makes isThrottled() return true when throttled and subgroup is the same', () => {
const alertInstance = new AlertInstance({
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
subgroup: 'subgroup',
},
},
});
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.isThrottled('1m')).toEqual(true);
});

test('makes isThrottled() return true when throttled and last schedule had no subgroup', () => {
const alertInstance = new AlertInstance({
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
},
},
});
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.isThrottled('1m')).toEqual(true);
});

test('makes isThrottled() return false when throttled and subgroup is the different', () => {
const alertInstance = new AlertInstance({
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
subgroup: 'prev-subgroup',
},
},
});
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.isThrottled('1m')).toEqual(false);
});

test('make isThrottled() return false when throttled expired', () => {
const alertInstance = new AlertInstance({
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
},
},
});
clock.tick(120000);
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.isThrottled('1m')).toEqual(false);
});

test('makes getScheduledActionOptions() return given options', () => {
const alertInstance = new AlertInstance({ state: { foo: true }, meta: {} });
alertInstance
.replaceState({ otherField: true })
.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(alertInstance.getScheduledActionOptions()).toEqual({
actionGroup: 'default',
subgroup: 'subgroup',
context: { field: true },
state: { otherField: true },
});
});

test('cannot schdule for execution twice', () => {
const alertInstance = new AlertInstance();
alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(() =>
alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false })
).toThrowErrorMatchingInlineSnapshot(
`"Alert instance execution has already been scheduled, cannot schedule twice"`
);
});

test('cannot schdule for execution twice with different subgroups', () => {
const alertInstance = new AlertInstance();
alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: true });
expect(() =>
alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false })
).toThrowErrorMatchingInlineSnapshot(
`"Alert instance execution has already been scheduled, cannot schedule twice"`
);
});

test('cannot schdule for execution twice whether there are subgroups', () => {
const alertInstance = new AlertInstance();
alertInstance.scheduleActions('default', { field: true });
expect(() =>
alertInstance.scheduleActionsWithSubGroup('default', 'subgroup', { field: false })
).toThrowErrorMatchingInlineSnapshot(
`"Alert instance execution has already been scheduled, cannot schedule twice"`
);
});
});

describe('replaceState()', () => {
test('replaces previous state', () => {
const alertInstance = new AlertInstance({ state: { foo: true } });
Expand Down
84 changes: 69 additions & 15 deletions x-pack/plugins/alerts/server/alert_instance/alert_instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,29 @@ import {

import { parseDuration } from '../lib';

export type AlertInstances = Record<string, AlertInstance>;
interface ScheduledExecutionOptions<
State extends AlertInstanceState,
Context extends AlertInstanceContext
> {
actionGroup: string;
subgroup?: string;
context: Context;
state: State;
}

export type PublicAlertInstance<
State extends AlertInstanceState = AlertInstanceState,
Context extends AlertInstanceContext = AlertInstanceContext
> = Pick<
AlertInstance<State, Context>,
'getState' | 'replaceState' | 'scheduleActions' | 'scheduleActionsWithSubGroup'
>;

export class AlertInstance<
State extends AlertInstanceState = AlertInstanceState,
Context extends AlertInstanceContext = AlertInstanceContext
> {
private scheduledExecutionOptions?: {
actionGroup: string;
context: Context;
state: State;
};
private scheduledExecutionOptions?: ScheduledExecutionOptions<State, Context>;
private meta: AlertInstanceMeta;
private state: State;

Expand All @@ -40,17 +53,39 @@ export class AlertInstance<
return false;
}
const throttleMills = throttle ? parseDuration(throttle) : 0;
const actionGroup = this.scheduledExecutionOptions.actionGroup;
if (
this.meta.lastScheduledActions &&
this.meta.lastScheduledActions.group === actionGroup &&
this.scheduledActionGroupIsUnchanged(
this.meta.lastScheduledActions,
this.scheduledExecutionOptions
) &&
this.scheduledActionSubgroupIsUnchanged(
this.meta.lastScheduledActions,
this.scheduledExecutionOptions
) &&
this.meta.lastScheduledActions.date.getTime() + throttleMills > Date.now()
) {
return true;
}
return false;
}

private scheduledActionGroupIsUnchanged(
lastScheduledActions: NonNullable<AlertInstanceMeta['lastScheduledActions']>,
scheduledExecutionOptions: ScheduledExecutionOptions<State, Context>
) {
return lastScheduledActions.group === scheduledExecutionOptions.actionGroup;
}

private scheduledActionSubgroupIsUnchanged(
lastScheduledActions: NonNullable<AlertInstanceMeta['lastScheduledActions']>,
scheduledExecutionOptions: ScheduledExecutionOptions<State, Context>
) {
return lastScheduledActions.subgroup && scheduledExecutionOptions.subgroup
? lastScheduledActions.subgroup === scheduledExecutionOptions.subgroup
: true;
}

getLastScheduledActions() {
return this.meta.lastScheduledActions;
}
Expand All @@ -68,25 +103,44 @@ export class AlertInstance<
return this.state;
}

scheduleActions(actionGroup: string, context?: Context) {
if (this.hasScheduledActions()) {
throw new Error('Alert instance execution has already been scheduled, cannot schedule twice');
}
scheduleActions(actionGroup: string, context: Context = {} as Context) {
this.ensureHasNoScheduledActions();
this.scheduledExecutionOptions = {
actionGroup,
context: (context || {}) as Context,
context,
state: this.state,
};
return this;
}

scheduleActionsWithSubGroup(
actionGroup: string,
subgroup: string,
context: Context = {} as Context
) {
this.ensureHasNoScheduledActions();
this.scheduledExecutionOptions = {
actionGroup,
subgroup,
context,
state: this.state,
};
return this;
}

private ensureHasNoScheduledActions() {
if (this.hasScheduledActions()) {
throw new Error('Alert instance execution has already been scheduled, cannot schedule twice');
}
}

replaceState(state: State) {
this.state = state;
return this;
}

updateLastScheduledActions(group: string) {
this.meta.lastScheduledActions = { group, date: new Date() };
updateLastScheduledActions(group: string, subgroup?: string) {
this.meta.lastScheduledActions = { group, subgroup, date: new Date() };
}

/**
Expand Down
2 changes: 1 addition & 1 deletion x-pack/plugins/alerts/server/alert_instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@
* you may not use this file except in compliance with the Elastic License.
*/

export { AlertInstance } from './alert_instance';
export { AlertInstance, PublicAlertInstance } from './alert_instance';
export { createAlertInstanceFactory } from './create_alert_instance_factory';
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,21 @@ describe('getAlertInstanceSummary()', () => {
"instances": Object {
"instance-currently-active": Object {
"actionGroupId": "action group A",
"actionSubgroup": undefined,
"activeStartDate": "2019-02-12T21:01:22.479Z",
"muted": false,
"status": "Active",
},
"instance-muted-no-activity": Object {
"actionGroupId": undefined,
"actionSubgroup": undefined,
"activeStartDate": undefined,
"muted": true,
"status": "OK",
},
"instance-previously-active": Object {
"actionGroupId": undefined,
"actionSubgroup": undefined,
"activeStartDate": undefined,
"muted": false,
"status": "OK",
Expand Down
Loading

0 comments on commit 015f3c9

Please sign in to comment.