Skip to content

Commit

Permalink
adds ability to fetch Alert and Alert Instance state (#56625)
Browse files Browse the repository at this point in the history
Enables access to the Alert State, which allows us to see which current Alert Instances are active.

This includes:

1. Addition of a `get` api on Task Manager
2. Typing and validation on Serialisation & Deserialisation of the State of an Alert's underlying Task
3. Addition of the `getAlertState` api on AlertsClient
  • Loading branch information
gmmorris committed Feb 9, 2020
1 parent e637b1c commit e9002c6
Show file tree
Hide file tree
Showing 33 changed files with 958 additions and 70 deletions.
9 changes: 9 additions & 0 deletions x-pack/legacy/plugins/alerting/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Table of Contents
- [`DELETE /api/alert/{id}`: Delete alert](#delete-apialertid-delete-alert)
- [`GET /api/alert/_find`: Find alerts](#get-apialertfind-find-alerts)
- [`GET /api/alert/{id}`: Get alert](#get-apialertid-get-alert)
- [`GET /api/alert/{id}/state`: Get alert state](#get-apialertidstate-get-alert-state)
- [`GET /api/alert/types`: List alert types](#get-apialerttypes-list-alert-types)
- [`PUT /api/alert/{id}`: Update alert](#put-apialertid-update-alert)
- [`POST /api/alert/{id}/_enable`: Enable an alert](#post-apialertidenable-enable-an-alert)
Expand Down Expand Up @@ -273,6 +274,14 @@ Params:
|---|---|---|
|id|The id of the alert you're trying to get.|string|

### `GET /api/alert/{id}/state`: Get alert state

Params:

|Property|Description|Type|
|---|---|---|
|id|The id of the alert whose state you're trying to get.|string|

### `GET /api/alert/types`: List alert types

No parameters.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('updateLastScheduledActions()', () => {
state: {},
meta: {
lastScheduledActions: {
date: new Date(),
date: new Date().toISOString(),
group: 'default',
},
},
Expand All @@ -216,3 +216,19 @@ describe('toJSON', () => {
);
});
});

describe('toRaw', () => {
test('returns unserialised underlying state and meta', () => {
const raw = {
state: { foo: true },
meta: {
lastScheduledActions: {
date: new Date(),
group: 'default',
},
},
};
const alertInstance = new AlertInstance(raw);
expect(alertInstance.toRaw()).toEqual(raw);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,41 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as t from 'io-ts';

import { State, Context } from '../types';
import { DateFromString } from '../lib/types';
import { parseDuration } from '../lib';

interface Meta {
lastScheduledActions?: {
group: string;
date: Date;
};
}

interface ScheduledExecutionOptions {
actionGroup: string;
context: Context;
state: State;
}

interface ConstructorOptions {
state?: State;
meta?: Meta;
}
const metaSchema = t.partial({
lastScheduledActions: t.type({
group: t.string,
date: DateFromString,
}),
});
type AlertInstanceMeta = t.TypeOf<typeof metaSchema>;

const stateSchema = t.record(t.string, t.unknown);
type AlertInstanceState = t.TypeOf<typeof stateSchema>;

export const rawAlertInstance = t.partial({
state: stateSchema,
meta: metaSchema,
});
export type RawAlertInstance = t.TypeOf<typeof rawAlertInstance>;

export class AlertInstance {
private scheduledExecutionOptions?: ScheduledExecutionOptions;
private meta: Meta;
private state: State;
private meta: AlertInstanceMeta;
private state: AlertInstanceState;

constructor({ state = {}, meta = {} }: ConstructorOptions = {}) {
constructor({ state = {}, meta = {} }: RawAlertInstance = {}) {
this.state = state;
this.meta = meta;
}
Expand All @@ -48,7 +55,7 @@ export class AlertInstance {
if (
this.meta.lastScheduledActions &&
this.meta.lastScheduledActions.group === actionGroup &&
new Date(this.meta.lastScheduledActions.date).getTime() + throttleMills > Date.now()
this.meta.lastScheduledActions.date.getTime() + throttleMills > Date.now()
) {
return true;
}
Expand Down Expand Up @@ -89,6 +96,10 @@ export class AlertInstance {
* Used to serialize alert instance state
*/
toJSON() {
return rawAlertInstance.encode(this.toRaw());
}

toRaw(): RawAlertInstance {
return {
state: this.state,
meta: this.meta,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test('reuses existing instances', () => {
Object {
"meta": Object {
"lastScheduledActions": Object {
"date": 1970-01-01T00:00:00.000Z,
"date": "1970-01-01T00:00:00.000Z",
"group": "default",
},
},
Expand Down
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, RawAlertInstance, rawAlertInstance } from './alert_instance';
export { createAlertInstanceFactory } from './create_alert_instance_factory';
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const createAlertsClientMock = () => {
const mocked: jest.Mocked<Schema> = {
create: jest.fn(),
get: jest.fn(),
getAlertState: jest.fn(),
find: jest.fn(),
delete: jest.fn(),
update: jest.fn(),
Expand Down
114 changes: 114 additions & 0 deletions x-pack/legacy/plugins/alerting/server/alerts_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,120 @@ describe('get()', () => {
});
});

describe('getAlertState()', () => {
test('calls saved objects client with given params', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
savedObjectsClient.get.mockResolvedValueOnce({
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
});

taskManager.get.mockResolvedValueOnce({
id: '1',
taskType: 'alerting:123',
scheduledAt: new Date(),
attempts: 1,
status: TaskStatus.Idle,
runAt: new Date(),
startedAt: null,
retryAt: null,
state: {},
params: {},
ownerId: null,
});

await alertsClient.getAlertState({ id: '1' });
expect(savedObjectsClient.get).toHaveBeenCalledTimes(1);
expect(savedObjectsClient.get.mock.calls[0]).toMatchInlineSnapshot(`
Array [
"alert",
"1",
]
`);
});

test('gets the underlying task from TaskManager', async () => {
const alertsClient = new AlertsClient(alertsClientParams);

const scheduledTaskId = 'task-123';

savedObjectsClient.get.mockResolvedValueOnce({
id: '1',
type: 'alert',
attributes: {
alertTypeId: '123',
schedule: { interval: '10s' },
params: {
bar: true,
},
actions: [
{
group: 'default',
actionRef: 'action_0',
params: {
foo: true,
},
},
],
enabled: true,
scheduledTaskId,
mutedInstanceIds: [],
muteAll: true,
},
references: [
{
name: 'action_0',
type: 'action',
id: '1',
},
],
});

taskManager.get.mockResolvedValueOnce({
id: scheduledTaskId,
taskType: 'alerting:123',
scheduledAt: new Date(),
attempts: 1,
status: TaskStatus.Idle,
runAt: new Date(),
startedAt: null,
retryAt: null,
state: {},
params: {
alertId: '1',
},
ownerId: null,
});

await alertsClient.getAlertState({ id: '1' });
expect(taskManager.get).toHaveBeenCalledTimes(1);
expect(taskManager.get).toHaveBeenCalledWith(scheduledTaskId);
});
});

describe('find()', () => {
test('calls saved objects client with given params', async () => {
const alertsClient = new AlertsClient(alertsClientParams);
Expand Down
12 changes: 12 additions & 0 deletions x-pack/legacy/plugins/alerting/server/alerts_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
} from '../../../../plugins/security/server';
import { EncryptedSavedObjectsPluginStart } from '../../../../plugins/encrypted_saved_objects/server';
import { TaskManagerStartContract } from '../../../../plugins/task_manager/server';
import { AlertTaskState, taskInstanceToAlertTaskInstance } from './task_runner/alert_task_instance';

type NormalizedAlertAction = Omit<AlertAction, 'actionTypeId'>;
export type CreateAPIKeyResult =
Expand Down Expand Up @@ -204,6 +205,17 @@ export class AlertsClient {
return this.getAlertFromRaw(result.id, result.attributes, result.updated_at, result.references);
}

public async getAlertState({ id }: { id: string }): Promise<AlertTaskState | void> {
const alert = await this.get({ id });
if (alert.scheduledTaskId) {
const { state } = taskInstanceToAlertTaskInstance(
await this.taskManager.get(alert.scheduledTaskId),
alert
);
return state;
}
}

public async find({ options = {} }: FindOptions = {}): Promise<FindResult> {
const {
page,
Expand Down
28 changes: 28 additions & 0 deletions x-pack/legacy/plugins/alerting/server/lib/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/

import { DateFromString } from './types';
import { right, isLeft } from 'fp-ts/lib/Either';

describe('DateFromString', () => {
test('validated and parses a string into a Date', () => {
const date = new Date(1973, 10, 30);
expect(DateFromString.decode(date.toISOString())).toEqual(right(date));
});

test('validated and returns a failure for an actual Date', () => {
const date = new Date(1973, 10, 30);
expect(isLeft(DateFromString.decode(date))).toEqual(true);
});

test('validated and returns a failure for an invalid Date string', () => {
expect(isLeft(DateFromString.decode('1234-23-45'))).toEqual(true);
});

test('validated and returns a failure for a null value', () => {
expect(isLeft(DateFromString.decode(null))).toEqual(true);
});
});
25 changes: 25 additions & 0 deletions x-pack/legacy/plugins/alerting/server/lib/types.ts
Original file line number Diff line number Diff line change
@@ -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;
* you may not use this file except in compliance with the Elastic License.
*/

import * as t from 'io-ts';
import { either } from 'fp-ts/lib/Either';
// represents a Date from an ISO string
export const DateFromString = new t.Type<Date, string, unknown>(
'DateFromString',
// detect the type
(value): value is Date => value instanceof Date,
(valueToDecode, context) =>
either.chain(
// validate this is a string
t.string.validate(valueToDecode, context),
// decode
value => {
const decoded = new Date(value);
return isNaN(decoded.getTime()) ? t.failure(valueToDecode, context) : t.success(decoded);
}
),
valueToEncode => valueToEncode.toISOString()
);
2 changes: 2 additions & 0 deletions x-pack/legacy/plugins/alerting/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
deleteAlertRoute,
findAlertRoute,
getAlertRoute,
getAlertStateRoute,
listAlertTypesRoute,
updateAlertRoute,
enableAlertRoute,
Expand Down Expand Up @@ -92,6 +93,7 @@ export class Plugin {
core.http.route(extendRouteWithLicenseCheck(deleteAlertRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(findAlertRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(getAlertRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(getAlertStateRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(listAlertTypesRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(updateAlertRoute, this.licenseState));
core.http.route(extendRouteWithLicenseCheck(enableAlertRoute, this.licenseState));
Expand Down
Loading

0 comments on commit e9002c6

Please sign in to comment.