diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 66342266f1dbc..51099815ec938 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -4,6 +4,7 @@ "xpack.actions": "plugins/actions", "xpack.advancedUiActions": "plugins/advanced_ui_actions", "xpack.alerting": "plugins/alerting", + "xpack.alertingBuiltins": "plugins/alerting_builtins", "xpack.apm": ["legacy/plugins/apm", "plugins/apm"], "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", diff --git a/x-pack/plugins/alerting_builtins/README.md b/x-pack/plugins/alerting_builtins/README.md new file mode 100644 index 0000000000000..233984a1ff23f --- /dev/null +++ b/x-pack/plugins/alerting_builtins/README.md @@ -0,0 +1,23 @@ +# alerting_builtins plugin + +This plugin provides alertTypes shipped with Kibana for use with the +[the alerting plugin](../alerting/README.md). When enabled, it will register +the built-in alertTypes with the alerting plugin, register associated HTTP +routes, etc. + +The plugin `setup` and `start` contracts for this plugin are the following +type, which provides some runtime capabilities. Each built-in alertType will +have it's own top-level property in the `IService` interface, if it needs to +expose functionality. + +```ts +export interface IService { + indexThreshold: { + timeSeriesQuery(params: TimeSeriesQueryParameters): Promise; + } +} +``` + +Each built-in alertType is described in it's own README: + +- index threshold: [`server/alert_types/index_threshold`](server/alert_types/index_threshold/README.md) diff --git a/x-pack/plugins/alerting_builtins/kibana.json b/x-pack/plugins/alerting_builtins/kibana.json new file mode 100644 index 0000000000000..cd6bb7519c093 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "alertingBuiltins", + "server": true, + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": ["alerting"], + "ui": false +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts new file mode 100644 index 0000000000000..475efc87b443a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { Service, IRouter, AlertingSetup } from '../types'; +import { register as registerIndexThreshold } from './index_threshold'; + +interface RegisterBuiltInAlertTypesParams { + service: Service; + router: IRouter; + alerting: AlertingSetup; + baseRoute: string; +} + +export function registerBuiltInAlertTypes(params: RegisterBuiltInAlertTypesParams) { + registerIndexThreshold(params); +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md new file mode 100644 index 0000000000000..b1a9e6daaaee3 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/README.md @@ -0,0 +1,271 @@ +# built-in alertType index threshold + +directory in plugin: `server/alert_types/index_threshold` + +The index threshold alert type is designed to run an ES query over indices, +aggregating field values from documents, comparing them to threshold values, +and scheduling actions to run when the thresholds are met. + +And example would be checking a monitoring index for percent cpu usage field +values that are greater than some threshold, which could then be used to invoke +an action (email, slack, etc) to notify interested parties when the threshold +is exceeded. + +## alertType `.index-threshold` + +The alertType parameters are specified in +[`lib/core_query_types.ts`][it-core-query] +and +[`alert_type_params.ts`][it-alert-params]. + +The alertType has a single actionGroup, `'threshold met'`. The `context` object +provided to actions is specified in +[`action_context.ts`][it-alert-context]. + +[it-alert-params]: alert_type_params.ts +[it-alert-context]: action_context.ts +[it-core-query]: lib/core_query_types.ts + +### example + +This example uses [kbn-action][]'s `kbn-alert` command to create the alert, +and [es-hb-sim][] to generate ES documents for the alert to run queries +against. + +Start `es-hb-sim`: + +``` +es-hb-sim 1 es-hb-sim host-A https://elastic:changeme@localhost:9200 +``` + +This will start indexing documents of the following form, to the `es-hb-sim` +index: + +``` +{"@timestamp":"2020-02-20T22:10:30.011Z","summary":{"up":1,"down":0},"monitor":{"status":"up","name":"host-A"}} +``` + +Press `u` to have it start writing "down" documents instead of "up" documents. + +Create a server log action that we can use with the alert: + +``` +export ACTION_ID=`kbn-action create .server-log 'server-log' '{}' '{}' | jq -r '.id'` +``` + +Finally, create the alert: + +``` +kbn-alert create .index-threshold 'es-hb-sim threshold' 1s \ + '{ + index: es-hb-sim + timeField: @timestamp + aggType: average + aggField: summary.up + groupField: monitor.name.keyword + window: 5s + comparator: lessThan + threshold: [ 0.6 ] + }' \ + "[ + { + group: threshold met + id: '$ACTION_ID' + params: { + level: warn + message: '{{context.message}}' + } + } + ]" +``` + +This alert will run a query over the `es-hb-sim` index, using the `@timestamp` +field as the date field, using an `average` aggregation over the `summary.up` +field. The results are then aggregated by `monitor.name.keyword`. If we ran +another instance of `es-hb-sim`, using `host-B` instead of `host-A`, then the +alert will end up potentially scheduling actions for both, independently. +Within the alerting plugin, this grouping is also referred to as "instanceIds" +(`host-A` and `host-B` being distinct instanceIds, which can have actions +scheduled against them independently). + +The `window` is set to `5s` which is 5 seconds. That means, every time the +alert runs it's queries (every second, in the example above), it will run it's +ES query over the last 5 seconds. Thus, the queries, over time, will overlap. +Sometimes that's what you want. Other times, maybe you just want to do +sampling, running an alert every hour, with a 5 minute window. Up to the you! + +Using the `comparator` `lessThan` and `threshold` `[0.6]`, the alert will +calculate the average of all the `summary.up` fields for each unique +`monitor.name.keyword`, and then if the value is less than 0.6, it will +schedule the specified action (server log) to run. The `message` param +passed to the action includes a mustache template for the context variable +`message`, which is created by the alert type. That message generates +a generic but useful text message, already constructed. Alternatively, +a customer could set the `message` param in the action to a much more +complex message, using other context variables made available by the +alert type. + +Here's the message you should see in the Kibana console, if everything is +working: + +``` +server log [17:32:10.060] [warning][actions][actions][plugins] \ + Server log: alert es-hb-sim threshold instance host-A value 0 \ + exceeded threshold average(summary.up) lessThan 0.6 over 5s \ + on 2020-02-20T22:32:07.000Z +``` + +[kbn-action]: https://github.com/pmuellr/kbn-action +[es-hb-sim]: https://github.com/pmuellr/es-hb-sim +[now-iso]: https://github.com/pmuellr/now-iso + + +## http endpoints + +An HTTP endpoint is provided to return the values the alertType would calculate, +over a series of time. This is intended to be used in the alerting UI to +provide a "preview" of the alert during creation/editing based on recent data, +and could be used to show a "simulation" of the the alert over an arbitrary +range of time. + +The endpoint is `POST /api/alerting_builtins/index_threshold/_time_series_query`. +The request and response bodies are specifed in +[`lib/core_query_types.ts`][it-core-query] +and +[`lib/time_series_types.ts`][it-timeSeries-types]. +The request body is very similar to the alertType's parameters. + +### example + +Continuing with the example above, here's a query to get the values calculated +for the last 10 seconds. +This example uses [now-iso][] to generate iso date strings. + +```console +curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ + -H "kbn-xsrf: foo" -H "content-type: application/json" -d "{ + \"index\": \"es-hb-sim\", + \"timeField\": \"@timestamp\", + \"aggType\": \"average\", + \"aggField\": \"summary.up\", + \"groupField\": \"monitor.name.keyword\", + \"interval\": \"1s\", + \"dateStart\": \"`now-iso -10s`\", + \"dateEnd\": \"`now-iso`\", + \"window\": \"5s\" +}" +``` + +``` +{ + "results": [ + { + "group": "host-A", + "metrics": [ + [ "2020-02-26T15:10:40.000Z", 0 ], + [ "2020-02-26T15:10:41.000Z", 0 ], + [ "2020-02-26T15:10:42.000Z", 0 ], + [ "2020-02-26T15:10:43.000Z", 0 ], + [ "2020-02-26T15:10:44.000Z", 0 ], + [ "2020-02-26T15:10:45.000Z", 0 ], + [ "2020-02-26T15:10:46.000Z", 0 ], + [ "2020-02-26T15:10:47.000Z", 0 ], + [ "2020-02-26T15:10:48.000Z", 0 ], + [ "2020-02-26T15:10:49.000Z", 0 ], + [ "2020-02-26T15:10:50.000Z", 0 ] + ] + } + ] +} +``` + +To get the current value of the calculated metric, you can leave off the date: + +``` +curl -k "https://elastic:changeme@localhost:5601/api/alerting_builtins/index_threshold/_time_series_query" \ + -H "kbn-xsrf: foo" -H "content-type: application/json" -d '{ + "index": "es-hb-sim", + "timeField": "@timestamp", + "aggType": "average", + "aggField": "summary.up", + "groupField": "monitor.name.keyword", + "interval": "1s", + "window": "5s" +}' +``` + +``` +{ + "results": [ + { + "group": "host-A", + "metrics": [ + [ "2020-02-26T15:23:36.635Z", 0 ] + ] + } + ] +} +``` + +[it-timeSeries-types]: lib/time_series_types.ts + +## service functions + +A single service function is available that provides the functionality +of the http endpoint `POST /api/alerting_builtins/index_threshold/_time_series_query`, +but as an API for Kibana plugins. The function is available as +`alertingService.indexThreshold.timeSeriesQuery()` + +The parameters and return value for the function are the same as for the HTTP +request, though some additional parameters are required (logger, callCluster, +etc). + +## notes on the timeSeriesQuery API / http endpoint + +This API provides additional parameters beyond what the alertType itself uses: + +- `dateStart` +- `dateEnd` +- `interval` + +The `dateStart` and `dateEnd` parameters are ISO date strings. + +The `interval` parameter is intended to model the `interval` the alert is +currently using, and uses the same `1s`, `2m`, `3h`, etc format. Over the +supplied date range, a time-series data point will be calculated every +`interval` duration. + +So the number of time-series points in the output of the API should be: + +``` +( dateStart - dateEnd ) / interval +``` + +Example: + +``` +dateStart: '2020-01-01T00:00:00' +dateEnd: '2020-01-02T00:00:00' +interval: '1h' +``` + +The date range is 1 day === 24 hours. The interval is 1 hour. So there should +be ~24 time series points in the output. + +For preview purposes: + +- The `groupLimit` parameter should be used to help cut +down on the amount of work ES does, and keep the generated graphs a little +simpler. Probably something like `10`. + +- For queries with long date ranges, you probably don't want to use the +`interval` the alert is set to, as the `interval` used in the query, as this +could result in a lot of time-series points being generated, which is both +costly in ES, and may result in noisy graphs. + +- The `window` parameter should be the same as what the alert is using, +especially for the `count` and `sum` aggregation types. Those aggregations +don't scale the same way the others do, when the window changes. Even for +the other aggregations, changing the window could result in dramatically +different values being generated - `averages` will be more "average-y", `min` +and `max` will be a little stickier. \ No newline at end of file diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts new file mode 100644 index 0000000000000..fbadf14f1d560 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.test.ts @@ -0,0 +1,89 @@ +/* + * 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 { BaseActionContext, addMessages } from './action_context'; +import { ParamsSchema } from './alert_type_params'; + +describe('ActionContext', () => { + it('generates expected properties if aggField is null', async () => { + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + name: '[name]', + spaceId: '[spaceId]', + namespace: '[spaceId]', + value: 42, + }; + const params = ParamsSchema.validate({ + index: '[index]', + timeField: '[timeField]', + aggType: 'count', + window: '5m', + comparator: 'greaterThan', + threshold: [4], + }); + const context = addMessages(base, params); + expect(context.subject).toMatchInlineSnapshot( + `"alert [name] group [group] exceeded threshold"` + ); + expect(context.message).toMatchInlineSnapshot( + `"alert [name] group [group] value 42 exceeded threshold count greaterThan 4 over 5m on 2020-01-01T00:00:00.000Z"` + ); + }); + + it('generates expected properties if aggField is not null', async () => { + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + name: '[name]', + spaceId: '[spaceId]', + namespace: '[spaceId]', + value: 42, + }; + const params = ParamsSchema.validate({ + index: '[index]', + timeField: '[timeField]', + aggType: 'average', + aggField: '[aggField]', + window: '5m', + comparator: 'greaterThan', + threshold: [4.2], + }); + const context = addMessages(base, params); + expect(context.subject).toMatchInlineSnapshot( + `"alert [name] group [group] exceeded threshold"` + ); + expect(context.message).toMatchInlineSnapshot( + `"alert [name] group [group] value 42 exceeded threshold average([aggField]) greaterThan 4.2 over 5m on 2020-01-01T00:00:00.000Z"` + ); + }); + + it('generates expected properties if comparator is between', async () => { + const base: BaseActionContext = { + date: '2020-01-01T00:00:00.000Z', + group: '[group]', + name: '[name]', + spaceId: '[spaceId]', + namespace: '[spaceId]', + value: 4, + }; + const params = ParamsSchema.validate({ + index: '[index]', + timeField: '[timeField]', + aggType: 'count', + window: '5m', + comparator: 'between', + threshold: [4, 5], + }); + const context = addMessages(base, params); + expect(context.subject).toMatchInlineSnapshot( + `"alert [name] group [group] exceeded threshold"` + ); + expect(context.message).toMatchInlineSnapshot( + `"alert [name] group [group] value 4 exceeded threshold count between 4,5 over 5m on 2020-01-01T00:00:00.000Z"` + ); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts new file mode 100644 index 0000000000000..98a8e5ae14b7f --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/action_context.ts @@ -0,0 +1,69 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { Params } from './alert_type_params'; + +// alert type context provided to actions + +export interface ActionContext extends BaseActionContext { + // a short generic message which may be used in an action message + subject: string; + // a longer generic message which may be used in an action message + message: string; +} + +export interface BaseActionContext { + // the alert name + name: string; + // the spaceId of the alert + spaceId: string; + // the namespace of the alert (spaceId === (namespace || 'default') + namespace?: string; + // the alert tags + tags?: string[]; + // the aggType used in the alert + // the value of the aggField, if used, otherwise 'all documents' + group: string; + // the date the alert was run as an ISO date + date: string; + // the value that met the threshold + value: number; +} + +export function addMessages(c: BaseActionContext, p: Params): ActionContext { + const subject = i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.alertTypeContextSubjectTitle', + { + defaultMessage: 'alert {name} group {group} exceeded threshold', + values: { + name: c.name, + group: c.group, + }, + } + ); + + const agg = p.aggField ? `${p.aggType}(${p.aggField})` : `${p.aggType}`; + const humanFn = `${agg} ${p.comparator} ${p.threshold.join(',')}`; + + const message = i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.alertTypeContextMessageDescription', + { + defaultMessage: + 'alert {name} group {group} value {value} exceeded threshold {function} over {window} on {date}', + values: { + name: c.name, + group: c.group, + value: c.value, + function: humanFn, + window: p.window, + date: c.date, + }, + } + ); + + return { ...c, subject, message }; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts new file mode 100644 index 0000000000000..f6e26cdaa283a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { loggingServiceMock } from '../../../../../../src/core/server/mocks'; +import { getAlertType } from './alert_type'; + +describe('alertType', () => { + const service = { + indexThreshold: { + timeSeriesQuery: jest.fn(), + }, + logger: loggingServiceMock.create().get(), + }; + + const alertType = getAlertType(service); + + it('alert type creation structure is the expected value', async () => { + expect(alertType.id).toBe('.index-threshold'); + expect(alertType.name).toBe('Index Threshold'); + expect(alertType.actionGroups).toEqual([{ id: 'threshold met', name: 'Threshold Met' }]); + }); + + it('validator succeeds with valid params', async () => { + const params = { + index: 'index-name', + timeField: 'time-field', + aggType: 'count', + window: '5m', + comparator: 'greaterThan', + threshold: [0], + }; + + expect(alertType.validate?.params?.validate(params)).toBeTruthy(); + }); + + it('validator fails with invalid params', async () => { + const paramsSchema = alertType.validate?.params; + if (!paramsSchema) throw new Error('params validator not set'); + + const params = { + index: 'index-name', + timeField: 'time-field', + aggType: 'foo', + window: '5m', + comparator: 'greaterThan', + threshold: [0], + }; + + expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot( + `"[aggType]: invalid aggType: \\"foo\\""` + ); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts new file mode 100644 index 0000000000000..2b0c07ed4355a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type.ts @@ -0,0 +1,129 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { AlertType, AlertExecutorOptions } from '../../types'; +import { Params, ParamsSchema } from './alert_type_params'; +import { BaseActionContext, addMessages } from './action_context'; + +export const ID = '.index-threshold'; + +import { Service } from '../../types'; + +const ActionGroupId = 'threshold met'; +const ComparatorFns = getComparatorFns(); +export const ComparatorFnNames = new Set(ComparatorFns.keys()); + +export function getAlertType(service: Service): AlertType { + const { logger } = service; + + const alertTypeName = i18n.translate('xpack.alertingBuiltins.indexThreshold.alertTypeTitle', { + defaultMessage: 'Index Threshold', + }); + + const actionGroupName = i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.actionGroupThresholdMetTitle', + { + defaultMessage: 'Threshold Met', + } + ); + + return { + id: ID, + name: alertTypeName, + actionGroups: [{ id: ActionGroupId, name: actionGroupName }], + defaultActionGroupId: ActionGroupId, + validate: { + params: ParamsSchema, + }, + executor, + }; + + async function executor(options: AlertExecutorOptions) { + const { alertId, name, services } = options; + const params: Params = options.params as Params; + + const compareFn = ComparatorFns.get(params.comparator); + if (compareFn == null) { + throw new Error(getInvalidComparatorMessage(params.comparator)); + } + + const callCluster = services.callCluster; + const date = new Date().toISOString(); + // the undefined values below are for config-schema optional types + const queryParams = { + index: params.index, + timeField: params.timeField, + aggType: params.aggType, + aggField: params.aggField, + groupField: params.groupField, + groupLimit: params.groupLimit, + dateStart: date, + dateEnd: date, + window: params.window, + interval: undefined, + }; + const result = await service.indexThreshold.timeSeriesQuery({ + logger, + callCluster, + query: queryParams, + }); + logger.debug(`alert ${ID}:${alertId} "${name}" query result: ${JSON.stringify(result)}`); + + const groupResults = result.results || []; + for (const groupResult of groupResults) { + const instanceId = groupResult.group; + const value = groupResult.metrics[0][1]; + const met = compareFn(value, params.threshold); + + if (!met) continue; + + const baseContext: BaseActionContext = { + name, + spaceId: options.spaceId, + namespace: options.namespace, + tags: options.tags, + date, + group: instanceId, + value, + }; + const actionContext = addMessages(baseContext, params); + const alertInstance = options.services.alertInstanceFactory(instanceId); + alertInstance.scheduleActions(ActionGroupId, actionContext); + logger.debug(`scheduled actionGroup: ${JSON.stringify(actionContext)}`); + } + } +} + +export function getInvalidComparatorMessage(comparator: string) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidComparatorErrorMessage', { + defaultMessage: 'invalid comparator specified: {comparator}', + values: { + comparator, + }, + }); +} + +type ComparatorFn = (value: number, threshold: number[]) => boolean; + +function getComparatorFns(): Map { + const fns: Record = { + lessThan: (value: number, threshold: number[]) => value < threshold[0], + lessThanOrEqual: (value: number, threshold: number[]) => value <= threshold[0], + greaterThanOrEqual: (value: number, threshold: number[]) => value >= threshold[0], + greaterThan: (value: number, threshold: number[]) => value > threshold[0], + between: (value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1], + notBetween: (value: number, threshold: number[]) => + value < threshold[0] || value > threshold[1], + }; + + const result = new Map(); + for (const key of Object.keys(fns)) { + result.set(key, fns[key]); + } + + return result; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts new file mode 100644 index 0000000000000..b9f66cfa7a253 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.test.ts @@ -0,0 +1,67 @@ +/* + * 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 { ParamsSchema } from './alert_type_params'; +import { runTests } from './lib/core_query_types.test'; + +const DefaultParams = { + index: 'index-name', + timeField: 'time-field', + aggType: 'count', + window: '5m', + comparator: 'greaterThan', + threshold: [0], +}; + +describe('alertType Params validate()', () => { + runTests(ParamsSchema, DefaultParams); + + let params: any; + beforeEach(() => { + params = { ...DefaultParams }; + }); + + it('passes for minimal valid input', async () => { + expect(validate()).toBeTruthy(); + }); + + it('passes for maximal valid input', async () => { + params.aggType = 'average'; + params.aggField = 'agg-field'; + params.groupField = 'group-field'; + params.groupLimit = 100; + expect(validate()).toBeTruthy(); + }); + + it('fails for invalid comparator', async () => { + params.comparator = '[invalid-comparator]'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[comparator]: invalid comparator specified: [invalid-comparator]"` + ); + }); + + it('fails for invalid threshold length', async () => { + params.comparator = 'lessThan'; + params.threshold = [0, 1]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have one element for the \\"lessThan\\" comparator"` + ); + + params.comparator = 'between'; + params.threshold = [0]; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[threshold]: must have two elements for the \\"between\\" comparator"` + ); + }); + + function onValidate(): () => void { + return () => validate(); + } + + function validate(): any { + return ParamsSchema.validate(params); + } +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts new file mode 100644 index 0000000000000..d5b83f9f6ad5a --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/alert_type_params.ts @@ -0,0 +1,65 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type'; +import { CoreQueryParamsSchemaProperties, validateCoreQueryBody } from './lib/core_query_types'; + +// alert type parameters + +export type Params = TypeOf; + +export const ParamsSchema = schema.object( + { + ...CoreQueryParamsSchemaProperties, + // the comparison function to use to determine if the threshold as been met + comparator: schema.string({ validate: validateComparator }), + // the values to use as the threshold; `between` and `notBetween` require + // two values, the others require one. + threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }), + }, + { + validate: validateParams, + } +); + +const betweenComparators = new Set(['between', 'notBetween']); + +// using direct type not allowed, circular reference, so body is typed to any +function validateParams(anyParams: any): string | undefined { + // validate core query parts, return if it fails validation (returning string) + const coreQueryValidated = validateCoreQueryBody(anyParams); + if (coreQueryValidated) return coreQueryValidated; + + const { comparator, threshold }: Params = anyParams; + + if (betweenComparators.has(comparator)) { + if (threshold.length === 1) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold2ErrorMessage', { + defaultMessage: '[threshold]: must have two elements for the "{comparator}" comparator', + values: { + comparator, + }, + }); + } + } else { + if (threshold.length === 2) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidThreshold1ErrorMessage', { + defaultMessage: '[threshold]: must have one element for the "{comparator}" comparator', + values: { + comparator, + }, + }); + } + } +} + +export function validateComparator(comparator: string): string | undefined { + if (ComparatorFnNames.has(comparator)) return; + + return getInvalidComparatorMessage(comparator); +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts new file mode 100644 index 0000000000000..05c6101e0a515 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/index.ts @@ -0,0 +1,37 @@ +/* + * 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 { Service, AlertingSetup, IRouter } from '../../types'; +import { timeSeriesQuery } from './lib/time_series_query'; +import { getAlertType } from './alert_type'; +import { createTimeSeriesQueryRoute } from './routes'; + +// future enhancement: make these configurable? +export const MAX_INTERVALS = 1000; +export const MAX_GROUPS = 1000; +export const DEFAULT_GROUPS = 100; + +export function getService() { + return { + timeSeriesQuery, + }; +} + +interface RegisterParams { + service: Service; + router: IRouter; + alerting: AlertingSetup; + baseRoute: string; +} + +export function register(params: RegisterParams) { + const { service, router, alerting, baseRoute } = params; + + alerting.registerType(getAlertType(service)); + + const alertTypeBaseRoute = `${baseRoute}/index_threshold`; + createTimeSeriesQueryRoute(service, router, alertTypeBaseRoute); +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts new file mode 100644 index 0000000000000..b4f061adb8f54 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.test.ts @@ -0,0 +1,165 @@ +/* + * 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. + */ + +// tests of common properties on time_series_query and alert_type_params + +import { ObjectType } from '@kbn/config-schema'; + +import { MAX_GROUPS } from '../index'; + +const DefaultParams: Record = { + index: 'index-name', + timeField: 'time-field', + aggType: 'count', + window: '5m', +}; + +export function runTests(schema: ObjectType, defaultTypeParams: Record): void { + let params: any; + + describe('coreQueryTypes', () => { + beforeEach(() => { + params = { ...DefaultParams, ...defaultTypeParams }; + }); + + it('succeeds with minimal properties', async () => { + expect(validate()).toBeTruthy(); + }); + + it('succeeds with maximal properties', async () => { + params.aggType = 'average'; + params.aggField = 'agg-field'; + params.groupField = 'group-field'; + params.groupLimit = 200; + expect(validate()).toBeTruthy(); + }); + + it('fails for invalid index', async () => { + delete params.index; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [string] but got [undefined]"` + ); + + params.index = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: expected value of type [string] but got [number]"` + ); + + params.index = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[index]: value is [] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid timeField', async () => { + delete params.timeField; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [undefined]"` + ); + + params.timeField = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: expected value of type [string] but got [number]"` + ); + + params.timeField = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[timeField]: value is [] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid aggType', async () => { + params.aggType = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggType]: expected value of type [string] but got [number]"` + ); + + params.aggType = '-not-a-valid-aggType-'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggType]: invalid aggType: \\"-not-a-valid-aggType-\\""` + ); + }); + + it('fails for invalid aggField', async () => { + params.aggField = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggField]: expected value of type [string] but got [number]"` + ); + + params.aggField = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggField]: value is [] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid groupField', async () => { + params.groupField = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[groupField]: expected value of type [string] but got [number]"` + ); + + params.groupField = ''; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[groupField]: value is [] but it must have a minimum length of [1]."` + ); + }); + + it('fails for invalid groupLimit', async () => { + params.groupLimit = 'foo'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[groupLimit]: expected value of type [number] but got [string]"` + ); + + params.groupLimit = 0; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[groupLimit]: must be greater than 0"` + ); + + params.groupLimit = MAX_GROUPS + 1; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[groupLimit]: must be less than or equal to 1000"` + ); + }); + + it('fails for invalid window', async () => { + params.window = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[window]: expected value of type [string] but got [number]"` + ); + + params.window = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[window]: invalid duration: \\"x\\""` + ); + }); + + it('fails for invalid aggType/aggField', async () => { + params.aggType = 'count'; + params.aggField = 'agg-field-1'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggField]: must not have a value when [aggType] is \\"count\\""` + ); + + params.aggType = 'average'; + delete params.aggField; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[aggField]: must have a value when [aggType] is \\"average\\""` + ); + }); + }); + + function onValidate(): () => void { + return () => validate(); + } + + function validate(): any { + return schema.validate(params); + } +} + +describe('coreQueryTypes wrapper', () => { + test('this test suite is meant to be called via the export', () => {}); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts new file mode 100644 index 0000000000000..265a70eba4d6b --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/core_query_types.ts @@ -0,0 +1,108 @@ +/* + * 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. + */ + +// common properties on time_series_query and alert_type_params + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; + +import { MAX_GROUPS } from '../index'; +import { parseDuration } from '../../../../../alerting/server'; + +export const CoreQueryParamsSchemaProperties = { + // name of the index to search + index: schema.string({ minLength: 1 }), + // field in index used for date/time + timeField: schema.string({ minLength: 1 }), + // aggregation type + aggType: schema.string({ validate: validateAggType }), + // aggregation field + aggField: schema.maybe(schema.string({ minLength: 1 })), + // group field + groupField: schema.maybe(schema.string({ minLength: 1 })), + // limit on number of groups returned + groupLimit: schema.maybe(schema.number()), + // size of time window for date range aggregations + window: schema.string({ validate: validateDuration }), +}; + +const CoreQueryParamsSchema = schema.object(CoreQueryParamsSchemaProperties); +export type CoreQueryParams = TypeOf; + +// Meant to be used in a "subclass"'s schema body validator, so the +// anyParams object is assumed to have been validated with the schema +// above. +// Using direct type not allowed, circular reference, so body is typed to any. +export function validateCoreQueryBody(anyParams: any): string | undefined { + const { aggType, aggField, groupLimit }: CoreQueryParams = anyParams; + + if (aggType === 'count' && aggField) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeNotEmptyErrorMessage', { + defaultMessage: '[aggField]: must not have a value when [aggType] is "{aggType}"', + values: { + aggType, + }, + }); + } + + if (aggType !== 'count' && !aggField) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.aggTypeRequiredErrorMessage', { + defaultMessage: '[aggField]: must have a value when [aggType] is "{aggType}"', + values: { + aggType, + }, + }); + } + + // schema.number doesn't seem to check the max value ... + if (groupLimit != null) { + if (groupLimit <= 0) { + return i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.invalidGroupMinimumErrorMessage', + { + defaultMessage: '[groupLimit]: must be greater than 0', + } + ); + } + if (groupLimit > MAX_GROUPS) { + return i18n.translate( + 'xpack.alertingBuiltins.indexThreshold.invalidGroupMaximumErrorMessage', + { + defaultMessage: '[groupLimit]: must be less than or equal to {maxGroups}', + values: { + maxGroups: MAX_GROUPS, + }, + } + ); + } + } +} + +const AggTypes = new Set(['count', 'average', 'min', 'max', 'sum']); + +function validateAggType(aggType: string): string | undefined { + if (AggTypes.has(aggType)) return; + + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidAggTypeErrorMessage', { + defaultMessage: 'invalid aggType: "{aggType}"', + values: { + aggType, + }, + }); +} + +export function validateDuration(duration: string): string | undefined { + try { + parseDuration(duration); + } catch (err) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDurationErrorMessage', { + defaultMessage: 'invalid duration: "{duration}"', + values: { + duration, + }, + }); + } +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts new file mode 100644 index 0000000000000..eff5ef2567784 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.test.ts @@ -0,0 +1,225 @@ +/* + * 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 { times } from 'lodash'; + +import { getDateRangeInfo, DateRangeInfo } from './date_range_info'; + +// dates to test with, separated by 1m, starting with BaseDate, descending +const BaseDate = Date.parse('2000-01-01T00:00:00Z'); +const Dates: string[] = []; + +// array of date strings, starting at 2000-01-01T00:00:00Z, decreasing by 1 minute +times(10, index => Dates.push(new Date(BaseDate - index * 1000 * 60).toISOString())); + +const DEFAULT_WINDOW_MINUTES = 5; + +const BaseRangeQuery = { + window: `${DEFAULT_WINDOW_MINUTES}m`, +}; + +describe('getRangeInfo', () => { + it('should return 1 date range when no dateStart or interval specified', async () => { + const info = getDateRangeInfo({ ...BaseRangeQuery, dateEnd: Dates[0] }); + const rInfo = asReadableDateRangeInfo(info); + expect(rInfo).toMatchInlineSnapshot(` + Object { + "dateStart": "1999-12-31T23:55:00.000Z", + "dateStop_": "2000-01-01T00:00:00.000Z", + "ranges": Array [ + Object { + "f": "1999-12-31T23:55:00.000Z", + "t": "2000-01-01T00:00:00.000Z", + }, + ], + } + `); + }); + + it('should return 1 date range when no dateStart specified', async () => { + const info = getDateRangeInfo({ ...BaseRangeQuery, dateEnd: Dates[0], interval: '1000d' }); + const rInfo = asReadableDateRangeInfo(info); + expect(rInfo).toMatchInlineSnapshot(` + Object { + "dateStart": "1999-12-31T23:55:00.000Z", + "dateStop_": "2000-01-01T00:00:00.000Z", + "ranges": Array [ + Object { + "f": "1999-12-31T23:55:00.000Z", + "t": "2000-01-01T00:00:00.000Z", + }, + ], + } + `); + }); + + it('should return 1 date range when no interval specified', async () => { + const info = getDateRangeInfo({ ...BaseRangeQuery, dateStart: Dates[1], dateEnd: Dates[0] }); + const rInfo = asReadableDateRangeInfo(info); + expect(rInfo).toMatchInlineSnapshot(` + Object { + "dateStart": "1999-12-31T23:55:00.000Z", + "dateStop_": "2000-01-01T00:00:00.000Z", + "ranges": Array [ + Object { + "f": "1999-12-31T23:55:00.000Z", + "t": "2000-01-01T00:00:00.000Z", + }, + ], + } + `); + }); + + it('should return 2 date ranges as expected', async () => { + const info = getDateRangeInfo({ + ...BaseRangeQuery, + dateStart: Dates[1], + dateEnd: Dates[0], + interval: '1m', + }); + const rInfo = asReadableDateRangeInfo(info); + expect(rInfo).toMatchInlineSnapshot(` + Object { + "dateStart": "1999-12-31T23:54:00.000Z", + "dateStop_": "2000-01-01T00:00:00.000Z", + "ranges": Array [ + Object { + "f": "1999-12-31T23:54:00.000Z", + "t": "1999-12-31T23:59:00.000Z", + }, + Object { + "f": "1999-12-31T23:55:00.000Z", + "t": "2000-01-01T00:00:00.000Z", + }, + ], + } + `); + }); + + it('should return 3 date ranges as expected', async () => { + const info = getDateRangeInfo({ + ...BaseRangeQuery, + dateStart: Dates[2], + dateEnd: Dates[0], + interval: '1m', + }); + const rInfo = asReadableDateRangeInfo(info); + expect(rInfo).toMatchInlineSnapshot(` + Object { + "dateStart": "1999-12-31T23:53:00.000Z", + "dateStop_": "2000-01-01T00:00:00.000Z", + "ranges": Array [ + Object { + "f": "1999-12-31T23:53:00.000Z", + "t": "1999-12-31T23:58:00.000Z", + }, + Object { + "f": "1999-12-31T23:54:00.000Z", + "t": "1999-12-31T23:59:00.000Z", + }, + Object { + "f": "1999-12-31T23:55:00.000Z", + "t": "2000-01-01T00:00:00.000Z", + }, + ], + } + `); + }); + + it('should handle no dateStart, dateEnd or interval specified', async () => { + const nowM0 = Date.now(); + const nowM5 = nowM0 - 1000 * 60 * 5; + + const info = getDateRangeInfo(BaseRangeQuery); + expect(sloppyMilliDiff(nowM5, Date.parse(info.dateStart))).toBeCloseTo(0); + expect(sloppyMilliDiff(nowM0, Date.parse(info.dateEnd))).toBeCloseTo(0); + expect(info.dateRanges.length).toEqual(1); + expect(info.dateRanges[0].from).toEqual(info.dateStart); + expect(info.dateRanges[0].to).toEqual(info.dateEnd); + }); + + it('should throw an error if passed dateStart > dateEnd', async () => { + const params = { + ...BaseRangeQuery, + dateStart: '2020-01-01T00:00:00.000Z', + dateEnd: '2000-01-01T00:00:00.000Z', + }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"[dateStart]: is greater than [dateEnd]"` + ); + }); + + it('should throw an error if passed an unparseable dateStart', async () => { + const params = { + ...BaseRangeQuery, + dateStart: 'woopsie', + }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"invalid date format for dateStart: \\"woopsie\\""` + ); + }); + + it('should throw an error if passed an unparseable dateEnd', async () => { + const params = { + ...BaseRangeQuery, + dateStart: '2020-01-01T00:00:00.000Z', + dateEnd: 'woopsie', + }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"invalid date format for dateEnd: \\"woopsie\\""` + ); + }); + + it('should throw an error if passed an unparseable window', async () => { + const params = { window: 'woopsie' }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"invalid duration format for window: \\"woopsie\\""` + ); + }); + + it('should throw an error if passed an unparseable interval', async () => { + const params = { + ...BaseRangeQuery, + interval: 'woopsie', + }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"invalid duration format for interval: \\"woopsie\\""` + ); + }); + + it('should throw an error if too many intervals calculated', async () => { + const params = { + ...BaseRangeQuery, + dateStart: '2000-01-01T00:00:00.000Z', + dateEnd: '2020-01-01T00:00:00.000Z', + interval: '1s', + }; + expect(() => getDateRangeInfo(params)).toThrowErrorMatchingInlineSnapshot( + `"calculated number of intervals 631152001 is greater than maximum 1000"` + ); + }); +}); + +// Calculate 1/1000 of the millisecond diff between two millisecond values, +// to be used with jest `toBeCloseTo()` +function sloppyMilliDiff(ms1: number | string, ms2: number | string) { + const m1 = typeof ms1 === 'number' ? ms1 : Date.parse(ms1); + const m2 = typeof ms2 === 'number' ? ms2 : Date.parse(ms2); + return Math.abs(m1 - m2) / 1000; +} + +function asReadableDateRangeInfo(info: DateRangeInfo) { + return { + dateStart: info.dateStart, + dateStop_: info.dateEnd, + ranges: info.dateRanges.map(dateRange => { + return { + f: new Date(dateRange.from).toISOString(), + t: new Date(dateRange.to).toISOString(), + }; + }), + }; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts new file mode 100644 index 0000000000000..0a4accc983d79 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/date_range_info.ts @@ -0,0 +1,128 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import { times } from 'lodash'; +import { parseDuration } from '../../../../../alerting/server'; +import { MAX_INTERVALS } from '../index'; + +// dates as numbers are epoch millis +// dates as strings are ISO + +export interface DateRange { + from: string; + to: string; +} + +export interface DateRangeInfo { + dateStart: string; + dateEnd: string; + dateRanges: DateRange[]; +} + +export interface GetDateRangeInfoParams { + dateStart?: string; + dateEnd?: string; + interval?: string; + window: string; +} + +// Given a start and end date, an interval, and a window, calculate the +// array of date ranges, each date range is offset by it's peer by one interval, +// and each date range is window milliseconds long. +export function getDateRangeInfo(params: GetDateRangeInfoParams): DateRangeInfo { + const { dateStart: dateStartS, dateEnd: dateEndS, interval: intervalS, window: windowS } = params; + + // get dates in epoch millis, interval and window in millis + const dateEnd = getDateOrUndefined(dateEndS, 'dateEnd') || Date.now(); + const dateStart = getDateOrUndefined(dateStartS, 'dateStart') || dateEnd; + + if (dateStart > dateEnd) throw new Error(getDateStartAfterDateEndErrorMessage()); + + const interval = getDurationOrUndefined(intervalS, 'interval') || 0; + const window = getDuration(windowS, 'window'); + + // Start from the end, as it's more likely the user wants precision there. + // We'll reverse the resultant ranges at the end, to get ascending order. + let dateCurrent = dateEnd; + const dateRanges: DateRange[] = []; + + // Calculate number of intervals; if no interval specified, only calculate one. + const intervals = !interval ? 1 : 1 + Math.round((dateEnd - dateStart) / interval); + if (intervals > MAX_INTERVALS) { + throw new Error(getTooManyIntervalsErrorMessage(intervals, MAX_INTERVALS)); + } + + times(intervals, () => { + dateRanges.push({ + from: new Date(dateCurrent - window).toISOString(), + to: new Date(dateCurrent).toISOString(), + }); + dateCurrent -= interval; + }); + + // reverse in-place + dateRanges.reverse(); + + return { + dateStart: dateRanges[0].from, + dateEnd: dateRanges[dateRanges.length - 1].to, + dateRanges, + }; +} + +function getDateOrUndefined(dateS: string | undefined, field: string): number | undefined { + if (!dateS) return undefined; + return getDate(dateS, field); +} + +function getDate(dateS: string, field: string): number { + const date = Date.parse(dateS); + if (isNaN(date)) throw new Error(getParseErrorMessage('date', field, dateS)); + + return date.valueOf(); +} + +function getDurationOrUndefined(durationS: string | undefined, field: string): number | undefined { + if (!durationS) return undefined; + return getDuration(durationS, field); +} + +function getDuration(durationS: string, field: string): number { + try { + return parseDuration(durationS); + } catch (err) { + throw new Error(getParseErrorMessage('duration', field, durationS)); + } +} + +function getParseErrorMessage(formatName: string, fieldName: string, fieldValue: string) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.formattedFieldErrorMessage', { + defaultMessage: 'invalid {formatName} format for {fieldName}: "{fieldValue}"', + values: { + formatName, + fieldName, + fieldValue, + }, + }); +} + +export function getTooManyIntervalsErrorMessage(intervals: number, maxIntervals: number) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.maxIntervalsErrorMessage', { + defaultMessage: + 'calculated number of intervals {intervals} is greater than maximum {maxIntervals}', + values: { + intervals, + maxIntervals, + }, + }); +} + +export function getDateStartAfterDateEndErrorMessage(): string { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.dateStartGTdateEndErrorMessage', { + defaultMessage: '[dateStart]: is greater than [dateEnd]', + }); +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts new file mode 100644 index 0000000000000..1955cdfa4cea6 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.test.ts @@ -0,0 +1,64 @@ +/* + * 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. + */ + +// test error conditions of calling timeSeriesQuery - postive results tested in FT + +import { loggingServiceMock } from '../../../../../../../src/core/server/mocks'; +import { coreMock } from '../../../../../../../src/core/server/mocks'; +import { AlertingBuiltinsPlugin } from '../../../plugin'; +import { TimeSeriesQueryParameters, TimeSeriesResult } from './time_series_query'; + +type TimeSeriesQuery = (params: TimeSeriesQueryParameters) => Promise; + +const DefaultQueryParams = { + index: 'index-name', + timeField: 'time-field', + aggType: 'count', + aggField: undefined, + window: '5m', + dateStart: undefined, + dateEnd: undefined, + interval: undefined, + groupField: undefined, + groupLimit: undefined, +}; + +describe('timeSeriesQuery', () => { + let params: TimeSeriesQueryParameters; + const mockCallCluster = jest.fn(); + + let timeSeriesQuery: TimeSeriesQuery; + + beforeEach(async () => { + // rather than use the function from an import, retrieve it from the plugin + const context = coreMock.createPluginInitializerContext(); + const plugin = new AlertingBuiltinsPlugin(context); + const coreStart = coreMock.createStart(); + const service = await plugin.start(coreStart); + timeSeriesQuery = service.indexThreshold.timeSeriesQuery; + + mockCallCluster.mockReset(); + params = { + logger: loggingServiceMock.create().get(), + callCluster: mockCallCluster, + query: { ...DefaultQueryParams }, + }; + }); + + it('fails as expected when the callCluster call fails', async () => { + mockCallCluster.mockRejectedValue(new Error('woopsie')); + expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + `"error running search"` + ); + }); + + it('fails as expected when the query params are invalid', async () => { + params.query = { ...params.query, dateStart: 'x' }; + expect(timeSeriesQuery(params)).rejects.toThrowErrorMatchingInlineSnapshot( + `"invalid date format for dateStart: \\"x\\""` + ); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts new file mode 100644 index 0000000000000..8ea2a7dd1dcc5 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_query.ts @@ -0,0 +1,152 @@ +/* + * 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 { DEFAULT_GROUPS } from '../index'; +import { getDateRangeInfo } from './date_range_info'; +import { Logger, CallCluster } from '../../../types'; + +import { TimeSeriesQuery, TimeSeriesResult, TimeSeriesResultRow } from './time_series_types'; +export { TimeSeriesQuery, TimeSeriesResult } from './time_series_types'; + +export interface TimeSeriesQueryParameters { + logger: Logger; + callCluster: CallCluster; + query: TimeSeriesQuery; +} + +export async function timeSeriesQuery( + params: TimeSeriesQueryParameters +): Promise { + const { logger, callCluster, query: queryParams } = params; + const { index, window, interval, timeField, dateStart, dateEnd } = queryParams; + + const dateRangeInfo = getDateRangeInfo({ dateStart, dateEnd, window, interval }); + + // core query + const esQuery: any = { + index, + body: { + size: 0, + query: { + bool: { + filter: { + range: { + [timeField]: { + gte: dateRangeInfo.dateStart, + lt: dateRangeInfo.dateEnd, + format: 'strict_date_time', + }, + }, + }, + }, + }, + // aggs: {...}, filled in below + }, + ignoreUnavailable: true, + allowNoIndices: true, + ignore: [404], + }; + + // add the aggregations + const { aggType, aggField, groupField, groupLimit } = queryParams; + + const isCountAgg = aggType === 'count'; + const isGroupAgg = !!groupField; + + let aggParent = esQuery.body; + + // first, add a group aggregation, if requested + if (isGroupAgg) { + aggParent.aggs = { + groupAgg: { + terms: { + field: groupField, + size: groupLimit || DEFAULT_GROUPS, + }, + }, + }; + aggParent = aggParent.aggs.groupAgg; + } + + // next, add the time window aggregation + aggParent.aggs = { + dateAgg: { + date_range: { + field: timeField, + ranges: dateRangeInfo.dateRanges, + }, + }, + }; + aggParent = aggParent.aggs.dateAgg; + + // finally, the metric aggregation, if requested + const actualAggType = aggType === 'average' ? 'avg' : aggType; + if (!isCountAgg) { + aggParent.aggs = { + metricAgg: { + [actualAggType]: { + field: aggField, + }, + }, + }; + } + + let esResult: any; + const logPrefix = 'indexThreshold timeSeriesQuery: callCluster'; + logger.debug(`${logPrefix} call: ${JSON.stringify(esQuery)}`); + + try { + esResult = await callCluster('search', esQuery); + } catch (err) { + logger.warn(`${logPrefix} error: ${JSON.stringify(err.message)}`); + throw new Error('error running search'); + } + + logger.debug(`${logPrefix} result: ${JSON.stringify(esResult)}`); + return getResultFromEs(isCountAgg, isGroupAgg, esResult); +} + +function getResultFromEs( + isCountAgg: boolean, + isGroupAgg: boolean, + esResult: Record +): TimeSeriesResult { + const aggregations = esResult?.aggregations || {}; + + // add a fake 'all documents' group aggregation, if a group aggregation wasn't used + if (!isGroupAgg) { + const dateAgg = aggregations.dateAgg || {}; + + aggregations.groupAgg = { + buckets: [{ key: 'all documents', dateAgg }], + }; + + delete aggregations.dateAgg; + } + + const groupBuckets = aggregations.groupAgg?.buckets || []; + const result: TimeSeriesResult = { + results: [], + }; + + for (const groupBucket of groupBuckets) { + const groupName: string = `${groupBucket?.key}`; + const dateBuckets = groupBucket?.dateAgg?.buckets || []; + const groupResult: TimeSeriesResultRow = { + group: groupName, + metrics: [], + }; + result.results.push(groupResult); + + for (const dateBucket of dateBuckets) { + const date: string = dateBucket.to_as_string; + const value: number = isCountAgg ? dateBucket.doc_count : dateBucket.metricAgg.value; + groupResult.metrics.push([date, value]); + } + } + + return result; +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts new file mode 100644 index 0000000000000..d69d48efcdf6b --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.test.ts @@ -0,0 +1,105 @@ +/* + * 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 { TimeSeriesQuerySchema } from './time_series_types'; +import { runTests } from './core_query_types.test'; + +const DefaultParams = { + index: 'index-name', + timeField: 'time-field', + aggType: 'count', + window: '5m', +}; + +describe('TimeSeriesParams validate()', () => { + runTests(TimeSeriesQuerySchema, DefaultParams); + + let params: any; + beforeEach(() => { + params = { ...DefaultParams }; + }); + + it('passes for minimal valid input', async () => { + expect(validate()).toBeTruthy(); + }); + + it('passes for maximal valid input', async () => { + params.aggType = 'average'; + params.aggField = 'agg-field'; + params.groupField = 'group-field'; + params.groupLimit = 100; + params.dateStart = new Date().toISOString(); + params.dateEnd = new Date().toISOString(); + params.interval = '1s'; + expect(validate()).toBeTruthy(); + }); + + it('fails for invalid dateStart', async () => { + params.dateStart = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[dateStart]: expected value of type [string] but got [number]"` + ); + + params.dateStart = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[dateStart]: invalid date x"`); + }); + + it('fails for invalid dateEnd', async () => { + params.dateEnd = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[dateEnd]: expected value of type [string] but got [number]"` + ); + + params.dateEnd = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[dateEnd]: invalid date x"`); + }); + + it('fails for invalid interval', async () => { + params.interval = 42; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[interval]: expected value of type [string] but got [number]"` + ); + + params.interval = 'x'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[interval]: invalid duration: \\"x\\""` + ); + }); + + it('fails for dateStart > dateEnd', async () => { + params.dateStart = '2021-01-01T00:00:00.000Z'; + params.dateEnd = '2020-01-01T00:00:00.000Z'; + params.interval = '1s'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[dateStart]: is greater than [dateEnd]"` + ); + }); + + it('fails for dateStart != dateEnd and no interval', async () => { + params.dateStart = '2020-01-01T00:00:00.000Z'; + params.dateEnd = '2021-01-01T00:00:00.000Z'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"[interval]: must be specified if [dateStart] does not equal [dateEnd]"` + ); + }); + + it('fails for too many intervals', async () => { + params.dateStart = '2020-01-01T00:00:00.000Z'; + params.dateEnd = '2021-01-01T00:00:00.000Z'; + params.interval = '1s'; + expect(onValidate()).toThrowErrorMatchingInlineSnapshot( + `"calculated number of intervals 31622400 is greater than maximum 1000"` + ); + }); + + function onValidate(): () => void { + return () => validate(); + } + + function validate(): any { + return TimeSeriesQuerySchema.validate(params); + } +}); diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts new file mode 100644 index 0000000000000..a727e67c621d4 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/lib/time_series_types.ts @@ -0,0 +1,106 @@ +/* + * 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. + */ + +// The parameters and response for the `timeSeriesQuery()` service function, +// and associated HTTP endpoint. + +import { i18n } from '@kbn/i18n'; +import { schema, TypeOf } from '@kbn/config-schema'; + +import { parseDuration } from '../../../../../alerting/server'; +import { MAX_INTERVALS } from '../index'; +import { + CoreQueryParamsSchemaProperties, + validateCoreQueryBody, + validateDuration, +} from './core_query_types'; +import { + getTooManyIntervalsErrorMessage, + getDateStartAfterDateEndErrorMessage, +} from './date_range_info'; + +// The result is an object with a key for every field value aggregated +// via the `aggField` property. If `aggField` is not specified, the +// object will have a single key of `all documents`. The value associated +// with each key is an array of 2-tuples of `[ ISO-date, calculated-value ]` + +export interface TimeSeriesResult { + results: TimeSeriesResultRow[]; +} +export interface TimeSeriesResultRow { + group: string; + metrics: MetricResult[]; +} +export type MetricResult = [string, number]; // [iso date, value] + +// The parameters here are very similar to the alert parameters. +// Missing are `comparator` and `threshold`, which aren't needed to generate +// data values, only needed when evaluating the data. +// Additional parameters are used to indicate the date range of the search, +// and the interval. +export type TimeSeriesQuery = TypeOf; + +export const TimeSeriesQuerySchema = schema.object( + { + ...CoreQueryParamsSchemaProperties, + // start of the date range to search, as an iso string; defaults to dateEnd + dateStart: schema.maybe(schema.string({ validate: validateDate })), + // end of the date range to search, as an iso string; defaults to now + dateEnd: schema.maybe(schema.string({ validate: validateDate })), + // intended to be set to the `interval` property of the alert itself, + // this value indicates the amount of time between time series dates + // that will be calculated. + interval: schema.maybe(schema.string({ validate: validateDuration })), + }, + { + validate: validateBody, + } +); + +// using direct type not allowed, circular reference, so body is typed to any +function validateBody(anyParams: any): string | undefined { + // validate core query parts, return if it fails validation (returning string) + const coreQueryValidated = validateCoreQueryBody(anyParams); + if (coreQueryValidated) return coreQueryValidated; + + const { dateStart, dateEnd, interval }: TimeSeriesQuery = anyParams; + + // dates already validated in validateDate(), if provided + const epochStart = dateStart ? Date.parse(dateStart) : undefined; + const epochEnd = dateEnd ? Date.parse(dateEnd) : undefined; + + if (epochStart && epochEnd) { + if (epochStart > epochEnd) { + return getDateStartAfterDateEndErrorMessage(); + } + + if (epochStart !== epochEnd && !interval) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.intervalRequiredErrorMessage', { + defaultMessage: '[interval]: must be specified if [dateStart] does not equal [dateEnd]', + }); + } + + if (interval) { + const intervalMillis = parseDuration(interval); + const intervals = Math.round((epochEnd - epochStart) / intervalMillis); + if (intervals > MAX_INTERVALS) { + return getTooManyIntervalsErrorMessage(intervals, MAX_INTERVALS); + } + } + } +} + +function validateDate(dateString: string): string | undefined { + const parsed = Date.parse(dateString); + if (isNaN(parsed)) { + return i18n.translate('xpack.alertingBuiltins.indexThreshold.invalidDateErrorMessage', { + defaultMessage: 'invalid date {date}', + values: { + date: dateString, + }, + }); + } +} diff --git a/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts new file mode 100644 index 0000000000000..1aabca8af0715 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/alert_types/index_threshold/routes.ts @@ -0,0 +1,53 @@ +/* + * 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 { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; + +import { Service } from '../../types'; +import { TimeSeriesQuery, TimeSeriesQuerySchema, TimeSeriesResult } from './lib/time_series_types'; +export { TimeSeriesQuery, TimeSeriesResult } from './lib/time_series_types'; + +export function createTimeSeriesQueryRoute(service: Service, router: IRouter, baseRoute: string) { + const path = `${baseRoute}/_time_series_query`; + service.logger.debug(`registering indexThreshold timeSeriesQuery route POST ${path}`); + router.post( + { + path, + validate: { + body: TimeSeriesQuerySchema, + }, + }, + handler + ); + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise { + service.logger.debug(`route query_data request: ${JSON.stringify(req.body, null, 4)}`); + + let result: TimeSeriesResult; + try { + result = await service.indexThreshold.timeSeriesQuery({ + logger: service.logger, + callCluster: ctx.core.elasticsearch.dataClient.callAsCurrentUser, + query: req.body, + }); + } catch (err) { + service.logger.debug(`route query_data error: ${err.message}`); + return res.internalError({ body: 'error running time series query' }); + } + + service.logger.debug(`route query_data response: ${JSON.stringify(result, null, 4)}`); + return res.ok({ body: result }); + } +} diff --git a/x-pack/plugins/alerting_builtins/server/config.ts b/x-pack/plugins/alerting_builtins/server/config.ts new file mode 100644 index 0000000000000..8a13aedd5fdd8 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/config.ts @@ -0,0 +1,13 @@ +/* + * 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 { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type Config = TypeOf; diff --git a/x-pack/plugins/alerting_builtins/server/index.ts b/x-pack/plugins/alerting_builtins/server/index.ts new file mode 100644 index 0000000000000..00613213d5aed --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/index.ts @@ -0,0 +1,17 @@ +/* + * 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 { PluginInitializerContext } from 'src/core/server'; +import { AlertingBuiltinsPlugin } from './plugin'; +import { configSchema } from './config'; + +export const plugin = (ctx: PluginInitializerContext) => new AlertingBuiltinsPlugin(ctx); + +export const config = { + schema: configSchema, +}; + +export { IService } from './types'; diff --git a/x-pack/plugins/alerting_builtins/server/plugin.test.ts b/x-pack/plugins/alerting_builtins/server/plugin.test.ts new file mode 100644 index 0000000000000..6bcf0379d5abe --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/plugin.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { AlertingBuiltinsPlugin } from './plugin'; +import { coreMock } from '../../../../src/core/server/mocks'; +import { alertsMock } from '../../../plugins/alerting/server/mocks'; + +describe('AlertingBuiltins Plugin', () => { + describe('setup()', () => { + let context: ReturnType; + let plugin: AlertingBuiltinsPlugin; + let coreSetup: ReturnType; + + beforeEach(() => { + context = coreMock.createPluginInitializerContext(); + plugin = new AlertingBuiltinsPlugin(context); + coreSetup = coreMock.createSetup(); + }); + + it('should register built-in alert types', async () => { + const alertingSetup = alertsMock.createSetup(); + await plugin.setup(coreSetup, { alerting: alertingSetup }); + + expect(alertingSetup.registerType).toHaveBeenCalledTimes(1); + + const args = alertingSetup.registerType.mock.calls[0][0]; + const testedArgs = { id: args.id, name: args.name, actionGroups: args.actionGroups }; + expect(testedArgs).toMatchInlineSnapshot(` + Object { + "actionGroups": Array [ + Object { + "id": "threshold met", + "name": "Threshold Met", + }, + ], + "id": ".index-threshold", + "name": "Index Threshold", + } + `); + }); + + it('should return a service in the expected shape', async () => { + const alertingSetup = alertsMock.createSetup(); + const service = await plugin.setup(coreSetup, { alerting: alertingSetup }); + + expect(typeof service.indexThreshold.timeSeriesQuery).toBe('function'); + }); + }); + + describe('start()', () => { + let context: ReturnType; + let plugin: AlertingBuiltinsPlugin; + let coreStart: ReturnType; + + beforeEach(() => { + context = coreMock.createPluginInitializerContext(); + plugin = new AlertingBuiltinsPlugin(context); + coreStart = coreMock.createStart(); + }); + + it('should return a service in the expected shape', async () => { + const service = await plugin.start(coreStart); + + expect(typeof service.indexThreshold.timeSeriesQuery).toBe('function'); + }); + }); +}); diff --git a/x-pack/plugins/alerting_builtins/server/plugin.ts b/x-pack/plugins/alerting_builtins/server/plugin.ts new file mode 100644 index 0000000000000..9a9483f9c9dfa --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/plugin.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Plugin, Logger, CoreSetup, CoreStart, PluginInitializerContext } from 'src/core/server'; + +import { Service, IService, AlertingBuiltinsDeps } from './types'; +import { getService as getServiceIndexThreshold } from './alert_types/index_threshold'; +import { registerBuiltInAlertTypes } from './alert_types'; + +export class AlertingBuiltinsPlugin implements Plugin { + private readonly logger: Logger; + private readonly service: Service; + + constructor(ctx: PluginInitializerContext) { + this.logger = ctx.logger.get(); + this.service = { + indexThreshold: getServiceIndexThreshold(), + logger: this.logger, + }; + } + + public async setup(core: CoreSetup, { alerting }: AlertingBuiltinsDeps): Promise { + registerBuiltInAlertTypes({ + service: this.service, + router: core.http.createRouter(), + alerting, + baseRoute: '/api/alerting_builtins', + }); + return this.service; + } + + public async start(core: CoreStart): Promise { + return this.service; + } + + public async stop(): Promise {} +} diff --git a/x-pack/plugins/alerting_builtins/server/types.ts b/x-pack/plugins/alerting_builtins/server/types.ts new file mode 100644 index 0000000000000..ff07b85fd3038 --- /dev/null +++ b/x-pack/plugins/alerting_builtins/server/types.ts @@ -0,0 +1,34 @@ +/* + * 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 { Logger, ScopedClusterClient } from '../../../../src/core/server'; +import { PluginSetupContract as AlertingSetup } from '../../alerting/server'; +import { getService as getServiceIndexThreshold } from './alert_types/index_threshold'; + +export { Logger, IRouter } from '../../../../src/core/server'; + +export { + PluginSetupContract as AlertingSetup, + AlertType, + AlertExecutorOptions, +} from '../../alerting/server'; + +// this plugin's dependendencies +export interface AlertingBuiltinsDeps { + alerting: AlertingSetup; +} + +// external service exposed through plugin setup/start +export interface IService { + indexThreshold: ReturnType; +} + +// version of service for internal use +export interface Service extends IService { + logger: Logger; +} + +export type CallCluster = ScopedClusterClient['callAsCurrentUser']; diff --git a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts index d409ea4e2615a..ccd7748d9e899 100644 --- a/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts +++ b/x-pack/test/alerting_api_integration/common/lib/es_test_index_tool.ts @@ -39,6 +39,16 @@ export class ESTestIndexTool { enabled: false, type: 'object', }, + date: { + type: 'date', + format: 'strict_date_time', + }, + testedValue: { + type: 'long', + }, + group: { + type: 'keyword', + }, }, }, }, diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts new file mode 100644 index 0000000000000..c0147cbedcdfe --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: FtrProviderContext) { + describe('builtin alertTypes', () => { + loadTestFile(require.resolve('./index_threshold')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts new file mode 100644 index 0000000000000..41c07c428a089 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/create_test_data.ts @@ -0,0 +1,72 @@ +/* + * 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 { times } from 'lodash'; +import { v4 as uuid } from 'uuid'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib'; + +// date to start writing data +export const START_DATE = '2020-01-01T00:00:00Z'; + +const DOCUMENT_SOURCE = 'queryDataEndpointTests'; + +// Create a set of es documents to run the queries against. +// Will create 2 documents for each interval. +// The difference between the dates of the docs will be intervalMillis. +// The date of the last documents will be startDate - intervalMillis / 2. +// So there will be 2 documents written in the middle of each interval range. +// The data value written to each doc is a power of 2, with 2^0 as the value +// of the last documents, the values increasing for older documents. The +// second document for each time value will be power of 2 + 1 +export async function createEsDocuments( + es: any, + esTestIndexTool: ESTestIndexTool, + startDate: string, + intervals: number, + intervalMillis: number +) { + const totalDocuments = intervals * 2; + const startDateMillis = Date.parse(startDate) - intervalMillis / 2; + + times(intervals, interval => { + const date = startDateMillis - interval * intervalMillis; + + // base value for each window is 2^window + const testedValue = 2 ** interval; + + // don't need await on these, wait at the end of the function + createEsDocument(es, '-na-', date, testedValue, 'groupA'); + createEsDocument(es, '-na-', date, testedValue + 1, 'groupB'); + }); + + await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, '-na-', totalDocuments); +} + +async function createEsDocument( + es: any, + reference: string, + epochMillis: number, + testedValue: number, + group: string +) { + const document = { + source: DOCUMENT_SOURCE, + reference, + date: new Date(epochMillis).toISOString(), + testedValue, + group, + }; + + const response = await es.index({ + id: uuid(), + index: ES_TEST_INDEX_NAME, + body: document, + }); + + if (response.result !== 'created') { + throw new Error(`document not created: ${JSON.stringify(response)}`); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts new file mode 100644 index 0000000000000..6fdc68889b66f --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function alertingTests({ loadTestFile }: FtrProviderContext) { + describe('index_threshold', () => { + loadTestFile(require.resolve('./query_data_endpoint')); + }); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts new file mode 100644 index 0000000000000..9c1a58760be79 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/query_data_endpoint.ts @@ -0,0 +1,283 @@ +/* + * 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 expect from '@kbn/expect'; + +import { Spaces } from '../../../../scenarios'; +import { FtrProviderContext } from '../../../../../common/ftr_provider_context'; +import { ESTestIndexTool, ES_TEST_INDEX_NAME, getUrlPrefix } from '../../../../../common/lib'; +import { TimeSeriesQuery } from '../../../../../../../plugins/alerting_builtins/server/alert_types/index_threshold/routes'; + +import { createEsDocuments } from './create_test_data'; + +const INDEX_THRESHOLD_TIME_SERIES_QUERY_URL = + 'api/alerting_builtins/index_threshold/_time_series_query'; + +const START_DATE_MM_DD_HH_MM_SS_MS = '01-01T00:00:00.000Z'; +const START_DATE = `2020-${START_DATE_MM_DD_HH_MM_SS_MS}`; +const INTERVALS = 3; + +// time length of a window +const INTERVAL_MINUTES = 1; +const INTERVAL_DURATION = `${INTERVAL_MINUTES}m`; +const INTERVAL_MILLIS = INTERVAL_MINUTES * 60 * 1000; + +const WINDOW_MINUTES = 5; +const WINDOW_DURATION = `${WINDOW_MINUTES}m`; + +// interesting dates pertaining to docs and intervals +const START_DATE_PLUS_YEAR = `2021-${START_DATE_MM_DD_HH_MM_SS_MS}`; +const START_DATE_MINUS_YEAR = `2019-${START_DATE_MM_DD_HH_MM_SS_MS}`; +const START_DATE_MINUS_0INTERVALS = START_DATE; +const START_DATE_MINUS_1INTERVALS = getStartDate(-1 * INTERVAL_MILLIS); +const START_DATE_MINUS_2INTERVALS = getStartDate(-2 * INTERVAL_MILLIS); + +/* creates the following documents to run queries over; the documents + are offset from the top of the minute by 30 seconds, the queries always + run from the top of the hour. + + { "date":"2019-12-31T23:59:30.000Z", "testedValue":1, "group":"groupA" } + { "date":"2019-12-31T23:59:30.000Z", "testedValue":2, "group":"groupB" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":2, "group":"groupA" } + { "date":"2019-12-31T23:58:30.000Z", "testedValue":3, "group":"groupB" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":4, "group":"groupA" } + { "date":"2019-12-31T23:57:30.000Z", "testedValue":5, "group":"groupB" } +*/ + +// eslint-disable-next-line import/no-default-export +export default function queryDataEndpointTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + const es = getService('legacyEs'); + const esTestIndexTool = new ESTestIndexTool(es, retry); + + describe('query_data endpoint', () => { + before(async () => { + await esTestIndexTool.destroy(); + await esTestIndexTool.setup(); + // To browse the documents created, comment out esTestIndexTool.destroy() in below, then: + // curl http://localhost:9220/.kibaka-alerting-test-data/_search?size=100 | json + await createEsDocuments(es, esTestIndexTool, START_DATE, INTERVALS, INTERVAL_MILLIS); + }); + + after(async () => { + await esTestIndexTool.destroy(); + }); + + it('should handle queries before any data available', async () => { + const query = getQueryBody({ + dateStart: undefined, + dateEnd: START_DATE_PLUS_YEAR, + }); + + const expected = { + results: [{ group: 'all documents', metrics: [[START_DATE_PLUS_YEAR, 0]] }], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should handle queries after any data available', async () => { + const query = getQueryBody({ + dateStart: undefined, + dateEnd: START_DATE_MINUS_YEAR, + }); + + const expected = { + results: [{ group: 'all documents', metrics: [[START_DATE_MINUS_YEAR, 0]] }], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return the current count for 1 interval, not grouped', async () => { + const query = getQueryBody({ + dateStart: START_DATE, + dateEnd: START_DATE, + }); + + const expected = { + results: [{ group: 'all documents', metrics: [[START_DATE, 6]] }], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return correct count for all intervals, not grouped', async () => { + const query = getQueryBody({ + dateStart: START_DATE_MINUS_2INTERVALS, + dateEnd: START_DATE_MINUS_0INTERVALS, + }); + + const expected = { + results: [ + { + group: 'all documents', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 2], + [START_DATE_MINUS_1INTERVALS, 4], + [START_DATE_MINUS_0INTERVALS, 6], + ], + }, + ], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return correct min for all intervals, not grouped', async () => { + const query = getQueryBody({ + aggType: 'min', + aggField: 'testedValue', + dateStart: START_DATE_MINUS_2INTERVALS, + dateEnd: START_DATE_MINUS_0INTERVALS, + }); + + const expected = { + results: [ + { + group: 'all documents', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 4], + [START_DATE_MINUS_1INTERVALS, 2], + [START_DATE_MINUS_0INTERVALS, 1], + ], + }, + ], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return correct count for all intervals, grouped', async () => { + const query = getQueryBody({ + groupField: 'group', + dateStart: START_DATE_MINUS_2INTERVALS, + dateEnd: START_DATE_MINUS_0INTERVALS, + }); + + const expected = { + results: [ + { + group: 'groupA', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 1], + [START_DATE_MINUS_1INTERVALS, 2], + [START_DATE_MINUS_0INTERVALS, 3], + ], + }, + { + group: 'groupB', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 1], + [START_DATE_MINUS_1INTERVALS, 2], + [START_DATE_MINUS_0INTERVALS, 3], + ], + }, + ], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return correct average for all intervals, grouped', async () => { + const query = getQueryBody({ + aggType: 'average', + aggField: 'testedValue', + groupField: 'group', + dateStart: START_DATE_MINUS_2INTERVALS, + dateEnd: START_DATE_MINUS_0INTERVALS, + }); + + const expected = { + results: [ + { + group: 'groupA', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 4 / 1], + [START_DATE_MINUS_1INTERVALS, (4 + 2) / 2], + [START_DATE_MINUS_0INTERVALS, (4 + 2 + 1) / 3], + ], + }, + { + group: 'groupB', + metrics: [ + [START_DATE_MINUS_2INTERVALS, 5 / 1], + [START_DATE_MINUS_1INTERVALS, (5 + 3) / 2], + [START_DATE_MINUS_0INTERVALS, (5 + 3 + 2) / 3], + ], + }, + ], + }; + + expect(await runQueryExpect(query, 200)).eql(expected); + }); + + it('should return an error when passed invalid input', async () => { + const query = { ...getQueryBody(), aggType: 'invalid-agg-type' }; + const expected = { + error: 'Bad Request', + message: '[request body.aggType]: invalid aggType: "invalid-agg-type"', + statusCode: 400, + }; + expect(await runQueryExpect(query, 400)).eql(expected); + }); + + it('should return an error when too many intervals calculated', async () => { + const query = { + ...getQueryBody(), + dateStart: '2000-01-01T00:00:00.000Z', + dateEnd: '2020-01-01T00:00:00.000Z', + interval: '1s', + }; + const expected = { + error: 'Bad Request', + message: + '[request body]: calculated number of intervals 631152000 is greater than maximum 1000', + statusCode: 400, + }; + expect(await runQueryExpect(query, 400)).eql(expected); + }); + }); + + async function runQueryExpect(requestBody: TimeSeriesQuery, status: number): Promise { + const url = `${getUrlPrefix(Spaces.space1.id)}/${INDEX_THRESHOLD_TIME_SERIES_QUERY_URL}`; + const res = await supertest + .post(url) + .set('kbn-xsrf', 'foo') + .send(requestBody); + + if (res.status !== status) { + // good place to put a console log for debugging unexpected results + // console.log(res.body) + throw new Error(`expected status ${status}, but got ${res.status}`); + } + + return res.body; + } +} + +function getQueryBody(body: Partial = {}): TimeSeriesQuery { + const defaults: TimeSeriesQuery = { + index: ES_TEST_INDEX_NAME, + timeField: 'date', + aggType: 'count', + aggField: undefined, + groupField: undefined, + groupLimit: undefined, + dateStart: START_DATE_MINUS_0INTERVALS, + dateEnd: undefined, + window: WINDOW_DURATION, + interval: INTERVAL_DURATION, + }; + return Object.assign({}, defaults, body); +} + +function getStartDate(deltaMillis: number) { + const startDateMillis = Date.parse(START_DATE); + const returnedDateMillis = startDateMillis + deltaMillis; + return new Date(returnedDateMillis).toISOString(); +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index 0b7f51ac9a79b..a0c4da361bd38 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -25,5 +25,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./update_api_key')); loadTestFile(require.resolve('./alerts_space1')); loadTestFile(require.resolve('./alerts_default_space')); + loadTestFile(require.resolve('./builtin_alert_types')); }); }