diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/generated/stack_schema.ts b/packages/kbn-alerts-as-data-utils/src/schemas/generated/stack_schema.ts new file mode 100644 index 000000000000..362d64de05d9 --- /dev/null +++ b/packages/kbn-alerts-as-data-utils/src/schemas/generated/stack_schema.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +// ---------------------------------- WARNING ---------------------------------- +// this file was generated, and should not be edited by hand +// ---------------------------------- WARNING ---------------------------------- +import * as rt from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; +import { AlertSchema } from './alert_schema'; +const ISO_DATE_PATTERN = /^d{4}-d{2}-d{2}Td{2}:d{2}:d{2}.d{3}Z$/; +export const IsoDateString = new rt.Type( + 'IsoDateString', + rt.string.is, + (input, context): Either => { + if (typeof input === 'string' && ISO_DATE_PATTERN.test(input)) { + return rt.success(input); + } else { + return rt.failure(input, context); + } + }, + rt.identity +); +export type IsoDateStringC = typeof IsoDateString; +export const schemaDate = IsoDateString; +export const schemaDateArray = rt.array(IsoDateString); +export const schemaDateRange = rt.partial({ + gte: schemaDate, + lte: schemaDate, +}); +export const schemaDateRangeArray = rt.array(schemaDateRange); +export const schemaUnknown = rt.unknown; +export const schemaUnknownArray = rt.array(rt.unknown); +export const schemaString = rt.string; +export const schemaStringArray = rt.array(schemaString); +export const schemaNumber = rt.number; +export const schemaNumberArray = rt.array(schemaNumber); +export const schemaStringOrNumber = rt.union([schemaString, schemaNumber]); +export const schemaStringOrNumberArray = rt.array(schemaStringOrNumber); +export const schemaBoolean = rt.boolean; +export const schemaBooleanArray = rt.array(schemaBoolean); +const schemaGeoPointCoords = rt.type({ + type: schemaString, + coordinates: schemaNumberArray, +}); +const schemaGeoPointString = schemaString; +const schemaGeoPointLatLon = rt.type({ + lat: schemaNumber, + lon: schemaNumber, +}); +const schemaGeoPointLocation = rt.type({ + location: schemaNumberArray, +}); +const schemaGeoPointLocationString = rt.type({ + location: schemaString, +}); +export const schemaGeoPoint = rt.union([ + schemaGeoPointCoords, + schemaGeoPointString, + schemaGeoPointLatLon, + schemaGeoPointLocation, + schemaGeoPointLocationString, +]); +export const schemaGeoPointArray = rt.array(schemaGeoPoint); +// prettier-ignore +const StackAlertRequired = rt.type({ +}); +const StackAlertOptional = rt.partial({ + kibana: rt.partial({ + alert: rt.partial({ + evaluation: rt.partial({ + conditions: schemaString, + value: schemaString, + }), + title: schemaString, + }), + }), +}); + +// prettier-ignore +export const StackAlertSchema = rt.intersection([StackAlertRequired, StackAlertOptional, AlertSchema]); +// prettier-ignore +export type StackAlert = rt.TypeOf; diff --git a/packages/kbn-alerts-as-data-utils/src/schemas/index.ts b/packages/kbn-alerts-as-data-utils/src/schemas/index.ts index a8aa3194aa8e..77d9476d2034 100644 --- a/packages/kbn-alerts-as-data-utils/src/schemas/index.ts +++ b/packages/kbn-alerts-as-data-utils/src/schemas/index.ts @@ -23,6 +23,7 @@ export type { ObservabilityMetricsAlert } from './generated/observability_metric export type { ObservabilitySloAlert } from './generated/observability_slo_schema'; export type { ObservabilityUptimeAlert } from './generated/observability_uptime_schema'; export type { SecurityAlert } from './generated/security_schema'; +export type { StackAlert } from './generated/stack_schema'; export type AADAlert = | Alert diff --git a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts index 9f455d53700e..7f5e0b0c6f45 100644 --- a/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts +++ b/x-pack/plugins/alerting/server/alerts_client/alerts_client.mock.ts @@ -30,9 +30,11 @@ const createPublicAlertsClientMock = () => { return jest.fn().mockImplementation(() => { return { create: jest.fn(), - getAlertLimitValue: jest.fn(), + report: jest.fn(), + getAlertLimitValue: jest.fn().mockReturnValue(1000), setAlertLimitReached: jest.fn(), - getRecoveredAlerts: jest.fn(), + getRecoveredAlerts: jest.fn().mockReturnValue([]), + setAlertData: jest.fn(), }; }); }; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts index e0276e527515..a566c3be9ea7 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/index.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/index.ts @@ -14,4 +14,5 @@ export { getHitsWithCount, getLifecycleAlertsQueries, getContinualAlertsQuery, + expandFlattenedAlert, } from './get_summarized_alerts_query'; diff --git a/x-pack/plugins/alerting/server/alerts_client/lib/strip_framework_fields.ts b/x-pack/plugins/alerting/server/alerts_client/lib/strip_framework_fields.ts index 9d91d7ec1bea..bc55f72147d6 100644 --- a/x-pack/plugins/alerting/server/alerts_client/lib/strip_framework_fields.ts +++ b/x-pack/plugins/alerting/server/alerts_client/lib/strip_framework_fields.ts @@ -6,11 +6,16 @@ */ import { omit } from 'lodash'; -import { ALERT_REASON, ALERT_WORKFLOW_STATUS, TAGS } from '@kbn/rule-data-utils'; +import { ALERT_REASON, ALERT_WORKFLOW_STATUS, TAGS, ALERT_URL } from '@kbn/rule-data-utils'; import { alertFieldMap } from '@kbn/alerts-as-data-utils'; import { RuleAlertData } from '../../types'; -const allowedFrameworkFields = new Set([ALERT_REASON, ALERT_WORKFLOW_STATUS, TAGS]); +const allowedFrameworkFields = new Set([ + ALERT_REASON, + ALERT_WORKFLOW_STATUS, + TAGS, + ALERT_URL, +]); /** * Remove framework fields from the alert payload reported by diff --git a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts index 5e33dd8ddb01..f4d8a3151ff2 100644 --- a/x-pack/plugins/alerting/server/task_runner/execution_handler.ts +++ b/x-pack/plugins/alerting/server/task_runner/execution_handler.ts @@ -292,7 +292,7 @@ export class ExecutionHandler< alertActionGroupName: this.ruleTypeActionGroups!.get(actionGroup)!, context: alert.getContext(), actionId: action.id, - state: alert.getScheduledActionOptions()?.state || {}, + state: alert.getState(), kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl, alertParams: this.rule.params, actionParams: action.params, diff --git a/x-pack/plugins/stack_alerts/server/rule_types/constants.ts b/x-pack/plugins/stack_alerts/server/rule_types/constants.ts new file mode 100644 index 000000000000..68ab6698f2d3 --- /dev/null +++ b/x-pack/plugins/stack_alerts/server/rule_types/constants.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const STACK_AAD_INDEX_NAME = 'stack'; diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts index b42623d91cab..c33457fab43b 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.test.ts @@ -45,11 +45,19 @@ jest.mock('./lib/fetch_search_source_query', () => ({ mockFetchSearchSourceQuery(...args), })); -const scheduleActions = jest.fn(); -const replaceState = jest.fn(() => ({ scheduleActions })); -const mockCreateAlert = jest.fn(() => ({ replaceState })); const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]); const mockSetLimitReached = jest.fn(); +const mockReport = jest.fn(); +const mockSetAlertData = jest.fn(); +const mockGetAlertLimitValue = jest.fn().mockReturnValue(1000); + +const mockAlertClient = { + report: mockReport, + getAlertLimitValue: mockGetAlertLimitValue, + setAlertLimitReached: mockSetLimitReached, + getRecoveredAlerts: mockGetRecoveredAlerts, + setAlertData: mockSetAlertData, +}; const mockNow = jest.getRealSystemTime(); @@ -87,16 +95,7 @@ describe('es_query executor', () => { get: () => ({ attributes: { consumer: 'alerts' } }), }, searchSourceClient: searchSourceClientMock, - alertFactory: { - create: mockCreateAlert, - alertLimit: { - getValue: jest.fn().mockReturnValue(1000), - setLimitReached: mockSetLimitReached, - }, - done: () => ({ - getRecoveredAlerts: mockGetRecoveredAlerts, - }), - }, + alertsClient: mockAlertClient, alertWithLifecycle: jest.fn(), logger, shouldWriteAlerts: () => true, @@ -210,7 +209,7 @@ describe('es_query executor', () => { params: { ...defaultProps, threshold: [500], thresholdComparator: '>=' as Comparator }, }); - expect(mockCreateAlert).not.toHaveBeenCalled(); + expect(mockReport).not.toHaveBeenCalled(); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(false); }); @@ -237,22 +236,47 @@ describe('es_query executor', () => { params: { ...defaultProps, threshold: [200], thresholdComparator: '>=' as Comparator }, }); - expect(mockCreateAlert).toHaveBeenCalledTimes(1); - expect(mockCreateAlert).toHaveBeenNthCalledWith(1, 'query matched'); - expect(scheduleActions).toHaveBeenCalledTimes(1); - expect(scheduleActions).toHaveBeenNthCalledWith(1, 'query matched', { - conditions: 'Number of matching documents is greater than or equal to 200', - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is active: + expect(mockReport).toHaveBeenCalledTimes(1); + expect(mockReport).toHaveBeenNthCalledWith(1, { + actionGroup: 'query matched', + context: { + conditions: 'Number of matching documents is greater than or equal to 200', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is active: - Value: 491 - Conditions Met: Number of matching documents is greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' matched query", - value: 491, + title: "rule 'test-rule-name' matched query", + value: 491, + }, + id: 'query matched', + state: { + dateEnd: new Date(mockNow).toISOString(), + dateStart: new Date(mockNow).toISOString(), + latestTimestamp: undefined, + }, + payload: { + kibana: { + alert: { + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + reason: `rule 'test-rule-name' is active: + +- Value: 491 +- Conditions Met: Number of matching documents is greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query", + evaluation: { + conditions: 'Number of matching documents is greater than or equal to 200', + value: 491, + }, + }, + }, + }, }); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(false); @@ -297,55 +321,135 @@ describe('es_query executor', () => { }, }); - expect(mockCreateAlert).toHaveBeenCalledTimes(3); - expect(mockCreateAlert).toHaveBeenNthCalledWith(1, 'host-1'); - expect(mockCreateAlert).toHaveBeenNthCalledWith(2, 'host-2'); - expect(mockCreateAlert).toHaveBeenNthCalledWith(3, 'host-3'); - expect(scheduleActions).toHaveBeenCalledTimes(3); - expect(scheduleActions).toHaveBeenNthCalledWith(1, 'query matched', { - conditions: - 'Number of matching documents for group "host-1" is greater than or equal to 200', - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is active: + expect(mockReport).toHaveBeenCalledTimes(3); + expect(mockReport).toHaveBeenNthCalledWith(1, { + actionGroup: 'query matched', + context: { + conditions: + 'Number of matching documents for group "host-1" is greater than or equal to 200', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is active: - Value: 291 - Conditions Met: Number of matching documents for group "host-1" is greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' matched query for group host-1", - value: 291, + title: "rule 'test-rule-name' matched query for group host-1", + value: 291, + }, + id: 'host-1', + state: { + dateEnd: new Date(mockNow).toISOString(), + dateStart: new Date(mockNow).toISOString(), + latestTimestamp: undefined, + }, + payload: { + kibana: { + alert: { + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + reason: `rule 'test-rule-name' is active: + +- Value: 291 +- Conditions Met: Number of matching documents for group "host-1" is greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query for group host-1", + evaluation: { + conditions: + 'Number of matching documents for group "host-1" is greater than or equal to 200', + value: 291, + }, + }, + }, + }, }); - expect(scheduleActions).toHaveBeenNthCalledWith(2, 'query matched', { - conditions: - 'Number of matching documents for group "host-2" is greater than or equal to 200', - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is active: + expect(mockReport).toHaveBeenNthCalledWith(2, { + actionGroup: 'query matched', + context: { + conditions: + 'Number of matching documents for group "host-2" is greater than or equal to 200', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is active: - Value: 477 - Conditions Met: Number of matching documents for group "host-2" is greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' matched query for group host-2", - value: 477, + title: "rule 'test-rule-name' matched query for group host-2", + value: 477, + }, + id: 'host-2', + state: { + dateEnd: new Date(mockNow).toISOString(), + dateStart: new Date(mockNow).toISOString(), + latestTimestamp: undefined, + }, + payload: { + kibana: { + alert: { + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + reason: `rule 'test-rule-name' is active: + +- Value: 477 +- Conditions Met: Number of matching documents for group "host-2" is greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query for group host-2", + evaluation: { + conditions: + 'Number of matching documents for group "host-2" is greater than or equal to 200', + value: 477, + }, + }, + }, + }, }); - expect(scheduleActions).toHaveBeenNthCalledWith(3, 'query matched', { - conditions: - 'Number of matching documents for group "host-3" is greater than or equal to 200', - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is active: + expect(mockReport).toHaveBeenNthCalledWith(3, { + actionGroup: 'query matched', + context: { + conditions: + 'Number of matching documents for group "host-3" is greater than or equal to 200', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is active: - Value: 999 - Conditions Met: Number of matching documents for group "host-3" is greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' matched query for group host-3", - value: 999, + title: "rule 'test-rule-name' matched query for group host-3", + value: 999, + }, + id: 'host-3', + state: { + dateEnd: new Date(mockNow).toISOString(), + dateStart: new Date(mockNow).toISOString(), + latestTimestamp: undefined, + }, + payload: { + kibana: { + alert: { + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + reason: `rule 'test-rule-name' is active: + +- Value: 999 +- Conditions Met: Number of matching documents for group \"host-3\" is greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' matched query for group host-3", + evaluation: { + conditions: + 'Number of matching documents for group "host-3" is greater than or equal to 200', + value: 999, + }, + }, + }, + }, }); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(false); @@ -389,21 +493,20 @@ describe('es_query executor', () => { }, }); - expect(mockCreateAlert).toHaveBeenCalledTimes(3); - expect(mockCreateAlert).toHaveBeenNthCalledWith(1, 'host-1'); - expect(mockCreateAlert).toHaveBeenNthCalledWith(2, 'host-2'); - expect(mockCreateAlert).toHaveBeenNthCalledWith(3, 'host-3'); - expect(scheduleActions).toHaveBeenCalledTimes(3); + expect(mockReport).toHaveBeenCalledTimes(3); + expect(mockReport).toHaveBeenNthCalledWith(1, expect.objectContaining({ id: 'host-1' })); + expect(mockReport).toHaveBeenNthCalledWith(2, expect.objectContaining({ id: 'host-2' })); + expect(mockReport).toHaveBeenNthCalledWith(3, expect.objectContaining({ id: 'host-3' })); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(true); }); it('should correctly handle recovered alerts for ungrouped alert', async () => { - const mockSetContext = jest.fn(); mockGetRecoveredAlerts.mockReturnValueOnce([ { - getId: () => 'query matched', - setContext: mockSetContext, + alert: { + getId: () => 'query matched', + }, }, ]); mockFetchEsQuery.mockResolvedValueOnce({ @@ -427,36 +530,58 @@ describe('es_query executor', () => { params: { ...defaultProps, threshold: [500], thresholdComparator: '>=' as Comparator }, }); - expect(mockCreateAlert).not.toHaveBeenCalled(); - expect(mockSetContext).toHaveBeenCalledTimes(1); - expect(mockSetContext).toHaveBeenNthCalledWith(1, { - conditions: 'Number of matching documents is NOT greater than or equal to 500', - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is recovered: + expect(mockReport).not.toHaveBeenCalled(); + expect(mockSetAlertData).toHaveBeenCalledTimes(1); + expect(mockSetAlertData).toHaveBeenNthCalledWith(1, { + id: 'query matched', + context: { + conditions: 'Number of matching documents is NOT greater than or equal to 500', + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is recovered: - Value: 0 - Conditions Met: Number of matching documents is NOT greater than or equal to 500 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' recovered", - value: 0, + title: "rule 'test-rule-name' recovered", + value: 0, + }, + payload: { + kibana: { + alert: { + evaluation: { + conditions: 'Number of matching documents is NOT greater than or equal to 500', + value: 0, + }, + reason: `rule 'test-rule-name' is recovered: + +- Value: 0 +- Conditions Met: Number of matching documents is NOT greater than or equal to 500 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' recovered", + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }, + }, + }, }); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(false); }); it('should correctly handle recovered alerts for grouped alerts', async () => { - const mockSetContext = jest.fn(); mockGetRecoveredAlerts.mockReturnValueOnce([ { - getId: () => 'host-1', - setContext: mockSetContext, + alert: { + getId: () => 'host-1', + }, }, { - getId: () => 'host-2', - setContext: mockSetContext, + alert: { + getId: () => 'host-2', + }, }, ]); mockFetchEsQuery.mockResolvedValueOnce({ @@ -478,35 +603,79 @@ describe('es_query executor', () => { }, }); - expect(mockCreateAlert).not.toHaveBeenCalled(); - expect(mockSetContext).toHaveBeenCalledTimes(2); - expect(mockSetContext).toHaveBeenNthCalledWith(1, { - conditions: `Number of matching documents for group "host-1" is NOT greater than or equal to 200`, - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is recovered: + expect(mockReport).not.toHaveBeenCalled(); + expect(mockSetAlertData).toHaveBeenCalledTimes(2); + expect(mockSetAlertData).toHaveBeenNthCalledWith(1, { + id: 'host-1', + context: { + conditions: `Number of matching documents for group "host-1" is NOT greater than or equal to 200`, + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is recovered: - Value: 0 - Conditions Met: Number of matching documents for group "host-1" is NOT greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' recovered", - value: 0, + title: "rule 'test-rule-name' recovered", + value: 0, + }, + payload: { + kibana: { + alert: { + evaluation: { + conditions: + 'Number of matching documents for group "host-1" is NOT greater than or equal to 200', + value: 0, + }, + reason: `rule 'test-rule-name' is recovered: + +- Value: 0 +- Conditions Met: Number of matching documents for group \"host-1\" is NOT greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' recovered", + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }, + }, + }, }); - expect(mockSetContext).toHaveBeenNthCalledWith(2, { - conditions: `Number of matching documents for group "host-2" is NOT greater than or equal to 200`, - date: new Date(mockNow).toISOString(), - hits: [], - link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', - message: `rule 'test-rule-name' is recovered: + expect(mockSetAlertData).toHaveBeenNthCalledWith(2, { + id: 'host-2', + context: { + conditions: `Number of matching documents for group "host-2" is NOT greater than or equal to 200`, + date: new Date(mockNow).toISOString(), + hits: [], + link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + message: `rule 'test-rule-name' is recovered: - Value: 0 - Conditions Met: Number of matching documents for group "host-2" is NOT greater than or equal to 200 over 5m - Timestamp: ${new Date(mockNow).toISOString()} - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - title: "rule 'test-rule-name' recovered", - value: 0, + title: "rule 'test-rule-name' recovered", + value: 0, + }, + payload: { + kibana: { + alert: { + evaluation: { + conditions: + 'Number of matching documents for group "host-2" is NOT greater than or equal to 200', + value: 0, + }, + reason: `rule 'test-rule-name' is recovered: + +- Value: 0 +- Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m +- Timestamp: ${new Date(mockNow).toISOString()} +- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, + title: "rule 'test-rule-name' recovered", + url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', + }, + }, + }, }); expect(mockSetLimitReached).toHaveBeenCalledTimes(1); expect(mockSetLimitReached).toHaveBeenCalledWith(false); diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts index 1b0f0437b74d..ae8ae99ba26a 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/executor.ts @@ -9,6 +9,10 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from '@kbn/core/server'; import { parseDuration } from '@kbn/alerting-plugin/server'; import { isGroupAggregation, UngroupedGroupId } from '@kbn/triggers-actions-ui-plugin/common'; +import { ALERT_EVALUATION_VALUE, ALERT_REASON, ALERT_URL } from '@kbn/rule-data-utils'; + +import { expandFlattenedAlert } from '@kbn/alerting-plugin/server/alerts_client/lib'; +import { ALERT_TITLE, ALERT_EVALUATION_CONDITIONS } from './fields'; import { ComparatorFns } from '../../../common'; import { addMessages, @@ -32,11 +36,11 @@ export async function executor(core: CoreSetup, options: ExecutorOptions { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockNow); + }); + beforeEach(() => { + jest.clearAllMocks(); + }); + afterAll(() => { + jest.useRealTimers(); + }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('rule type creation structure is the expected value', async () => { expect(ruleType.id).toBe('.es-query'); expect(ruleType.name).toBe('Elasticsearch query'); @@ -168,7 +179,7 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - expect(ruleServices.alertFactory.create).not.toHaveBeenCalled(); + expect(ruleServices.alertsClient.report).not.toHaveBeenCalled(); expect(result).toMatchInlineSnapshot(` Object { @@ -215,13 +226,17 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - expect(ruleServices.alertFactory.create).toHaveBeenCalledWith(ConditionMetAlertInstanceId); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: undefined, - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: undefined, + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result).toMatchObject({ state: { @@ -269,13 +284,18 @@ describe('ruleType', () => { }, }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - // ensure the invalid "latestTimestamp" in the state is stored as an ISO string going forward - latestTimestamp: new Date(previousTimestamp).toISOString(), - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + // ensure the invalid "latestTimestamp" in the state is stored as an ISO string going forward + latestTimestamp: new Date(previousTimestamp).toISOString(), + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result).toMatchObject({ state: { @@ -318,12 +338,17 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: undefined, - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: undefined, + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result).toMatchObject({ state: { @@ -363,12 +388,17 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: undefined, - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: undefined, + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result?.state).toMatchObject({ latestTimestamp: new Date(oldestDocumentTimestamp).toISOString(), @@ -394,13 +424,17 @@ describe('ruleType', () => { state: result?.state as EsQueryRuleState, }); - const existingInstance: AlertInstanceMock = - ruleServices.alertFactory.create.mock.results[1].value; - expect(existingInstance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: new Date(oldestDocumentTimestamp).toISOString(), - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: new Date(oldestDocumentTimestamp).toISOString(), + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(secondResult).toMatchObject({ state: { @@ -446,12 +480,17 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: undefined, - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: undefined, + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result).toMatchObject({ state: { @@ -498,12 +537,17 @@ describe('ruleType', () => { const result = await invokeExecutor({ params, ruleServices }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.replaceState).toHaveBeenCalledWith({ - latestTimestamp: undefined, - dateStart: expect.any(String), - dateEnd: expect.any(String), - }); + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + id: ConditionMetAlertInstanceId, + actionGroup: ActionGroupId, + state: { + latestTimestamp: undefined, + dateStart: expect.any(String), + dateEnd: expect.any(String), + }, + }) + ); expect(result).toMatchObject({ state: { @@ -599,7 +643,7 @@ describe('ruleType', () => { await invokeExecutor({ params, ruleServices }); - expect(ruleServices.alertFactory.create).not.toHaveBeenCalled(); + expect(ruleServices.alertsClient.report).not.toHaveBeenCalled(); }); it('rule executor throws an error when index does not have time field', async () => { @@ -637,10 +681,33 @@ describe('ruleType', () => { hits: { total: 3, hits: [{}, {}, {}] }, }); - await invokeExecutor({ params, ruleServices }); + await invokeExecutor({ + params, + ruleServices, + state: { latestTimestamp: new Date(mockNow).toISOString(), dateStart: '', dateEnd: '' }, + }); - const instance: AlertInstanceMock = ruleServices.alertFactory.create.mock.results[0].value; - expect(instance.scheduleActions).toHaveBeenCalled(); + expect(ruleServices.alertsClient.report).toHaveBeenCalledTimes(1); + + expect(ruleServices.alertsClient.report).toHaveBeenCalledWith( + expect.objectContaining({ + actionGroup: 'query matched', + id: 'query matched', + payload: expect.objectContaining({ + kibana: { + alert: { + url: expect.any(String), + reason: expect.any(String), + title: "rule 'rule-name' matched query", + evaluation: { + conditions: 'Number of matching documents is greater than or equal to 3', + value: 3, + }, + }, + }, + }), + }) + ); }); }); }); @@ -711,7 +778,7 @@ async function invokeExecutor({ spaceId: uuidv4(), rule: { id: uuidv4(), - name: uuidv4(), + name: 'rule-name', tags: [], consumer: '', producer: '', diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts index ef1008f360c8..01d6ee1497b3 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/rule_type.ts @@ -8,6 +8,11 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from '@kbn/core/server'; import { extractReferences, injectReferences } from '@kbn/data-plugin/common'; +import { IRuleTypeAlerts } from '@kbn/alerting-plugin/server'; +import { ALERT_EVALUATION_VALUE } from '@kbn/rule-data-utils'; +import { StackAlert } from '@kbn/alerts-as-data-utils'; +import { STACK_AAD_INDEX_NAME } from '..'; +import { ALERT_TITLE, ALERT_EVALUATION_CONDITIONS } from './fields'; import { RuleType } from '../../types'; import { ActionContext } from './action_context'; import { @@ -30,7 +35,9 @@ export function getRuleType( EsQueryRuleState, {}, ActionContext, - typeof ActionGroupId + typeof ActionGroupId, + never, + StackAlert > { const ruleTypeName = i18n.translate('xpack.stackAlerts.esQuery.alertTypeTitle', { defaultMessage: 'Elasticsearch query', @@ -135,6 +142,18 @@ export function getRuleType( } ); + const alerts: IRuleTypeAlerts = { + context: STACK_AAD_INDEX_NAME, + mappings: { + fieldMap: { + [ALERT_TITLE]: { type: 'keyword', array: false, required: false }, + [ALERT_EVALUATION_CONDITIONS]: { type: 'keyword', array: false, required: false }, + [ALERT_EVALUATION_VALUE]: { type: 'keyword', array: false, required: false }, + }, + }, + shouldWrite: true, + }; + return { id: ES_QUERY_ID, name: ruleTypeName, @@ -188,5 +207,6 @@ export function getRuleType( }, producer: STACK_ALERTS_FEATURE_ID, doesSetRecoveryContext: true, + alerts, }; } diff --git a/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts b/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts index c8844b19a678..b20f52f03ebe 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/es_query/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { StackAlert } from '@kbn/alerts-as-data-utils'; import { RuleExecutorOptions, RuleTypeParams } from '../../types'; import { ActionContext } from './action_context'; import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params'; @@ -24,5 +25,6 @@ export type ExecutorOptions

= RuleExecutorOptions< EsQueryRuleState, {}, ActionContext, - typeof ActionGroupId + typeof ActionGroupId, + StackAlert >; diff --git a/x-pack/plugins/stack_alerts/server/rule_types/index.ts b/x-pack/plugins/stack_alerts/server/rule_types/index.ts index 5bc7f4cc1c7d..0bd47f6c2572 100644 --- a/x-pack/plugins/stack_alerts/server/rule_types/index.ts +++ b/x-pack/plugins/stack_alerts/server/rule_types/index.ts @@ -10,6 +10,8 @@ import { register as registerIndexThreshold } from './index_threshold'; import { register as registerGeoContainment } from './geo_containment'; import { register as registerEsQuery } from './es_query'; +export * from './constants'; + export function registerBuiltInRuleTypes(params: RegisterRuleTypesParams) { registerIndexThreshold(params); registerGeoContainment(params); diff --git a/x-pack/plugins/stack_alerts/tsconfig.json b/x-pack/plugins/stack_alerts/tsconfig.json index 81e9d5b57bcf..207e883aa890 100644 --- a/x-pack/plugins/stack_alerts/tsconfig.json +++ b/x-pack/plugins/stack_alerts/tsconfig.json @@ -42,6 +42,8 @@ "@kbn/logging-mocks", "@kbn/share-plugin", "@kbn/discover-plugin", + "@kbn/rule-data-utils", + "@kbn/alerts-as-data-utils", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/alerting_api_integration/packages/helpers/es_test_index_tool.ts b/x-pack/test/alerting_api_integration/packages/helpers/es_test_index_tool.ts index 98d69309365e..9b106700ee61 100644 --- a/x-pack/test/alerting_api_integration/packages/helpers/es_test_index_tool.ts +++ b/x-pack/test/alerting_api_integration/packages/helpers/es_test_index_tool.ts @@ -110,6 +110,31 @@ export class ESTestIndexTool { return await this.es.search(params, { meta: true }); } + async getAll(size: number = 10) { + const params = { + index: this.index, + size, + body: { + query: { + match_all: {}, + }, + }, + }; + return await this.es.search(params, { meta: true }); + } + + async removeAll() { + const params = { + index: this.index, + body: { + query: { + match_all: {}, + }, + }, + }; + return await this.es.deleteByQuery(params); + } + async waitForDocs(source: string, reference: string, numDocs: number = 1) { return await this.retry.try(async () => { const searchResult = await this.search(source, reference); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts index 9ec5171d74d5..fc7a65978aaa 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/common.ts @@ -6,6 +6,7 @@ */ import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '@kbn/alerting-api-integration-helpers'; +import { STACK_AAD_INDEX_NAME } from '@kbn/stack-alerts-plugin/server/rule_types'; import { FtrProviderContext } from '../../../../../../common/ftr_provider_context'; import { Spaces } from '../../../../../scenarios'; import { getUrlPrefix, ObjectRemover } from '../../../../../../common/lib'; @@ -69,6 +70,11 @@ export function getRuleServices(getService: FtrProviderContext['getService']) { const esTestIndexTool = new ESTestIndexTool(es, retry); const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME); const esTestIndexToolDataStream = new ESTestIndexTool(es, retry, ES_TEST_DATA_STREAM_NAME); + const esTestIndexToolAAD = new ESTestIndexTool( + es, + retry, + `.internal.alerts-${STACK_AAD_INDEX_NAME}.alerts-default-000001` + ); async function createEsDocumentsInGroups( groups: number, @@ -112,6 +118,14 @@ export function getRuleServices(getService: FtrProviderContext['getService']) { ); } + async function getAllAADDocs(size: number): Promise { + return await esTestIndexToolAAD.getAll(size); + } + + async function removeAllAADDocs(): Promise { + return await esTestIndexToolAAD.removeAll(); + } + return { retry, es, @@ -121,5 +135,7 @@ export function getRuleServices(getService: FtrProviderContext['getService']) { createEsDocumentsInGroups, createGroupedEsDocumentsInGroups, waitForDocs, + getAllAADDocs, + removeAllAADDocs, }; } diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts index 622e4878a675..c0b9113fa614 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group3/builtin_alert_types/es_query/rule.ts @@ -38,6 +38,8 @@ export default function ruleTests({ getService }: FtrProviderContext) { esTestIndexToolDataStream, createEsDocumentsInGroups, createGroupedEsDocumentsInGroups, + removeAllAADDocs, + getAllAADDocs, } = getRuleServices(getService); describe('rule', async () => { @@ -66,6 +68,7 @@ export default function ruleTests({ getService }: FtrProviderContext) { await esTestIndexTool.destroy(); await esTestIndexToolOutput.destroy(); await deleteDataStream(es, ES_TEST_DATA_STREAM_NAME); + await removeAllAADDocs(); }); [ @@ -135,6 +138,9 @@ export default function ruleTests({ getService }: FtrProviderContext) { await initData(); const docs = await waitForDocs(2); + const messagePattern = + /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + for (let i = 0; i < docs.length; i++) { const doc = docs[i]; const { previousTimestamp, hits } = doc._source; @@ -142,8 +148,6 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(name).to.be('always fire'); expect(title).to.be(`rule 'always fire' matched query`); - const messagePattern = - /rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; expect(message).to.match(messagePattern); expect(hits).not.to.be.empty(); @@ -155,6 +159,17 @@ export default function ruleTests({ getService }: FtrProviderContext) { expect(previousTimestamp).not.to.be.empty(); } } + + const aadDocs = await getAllAADDocs(1); + + const alertDoc = aadDocs.body.hits.hits[0]._source.kibana.alert; + expect(alertDoc.reason).to.match(messagePattern); + expect(alertDoc.title).to.be("rule 'always fire' matched query"); + expect(alertDoc.evaluation.conditions).to.be( + 'Number of matching documents is greater than -1' + ); + expect(alertDoc.evaluation.value).greaterThan(0); + expect(alertDoc.url).to.contain('/s/space1/app/'); }) ); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/generate_alert_schemas.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/generate_alert_schemas.ts index 09a780b70d13..478a9b17a21f 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/generate_alert_schemas.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/group4/generate_alert_schemas.ts @@ -84,7 +84,13 @@ export default function checkAlertSchemasTest({ getService }: FtrProviderContext } }); - const { stdout } = await execa('git', ['ls-files', '--modified']); + const { stdout } = await execa('git', [ + 'ls-files', + '--modified', + '--others', + '--exclude-standard', + ]); + expect(stdout).not.to.contain('packages/kbn-alerts-as-data-utils/src/schemas/generated'); }); }); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json index 437ff50201b8..7992cf9ba250 100644 --- a/x-pack/test/tsconfig.json +++ b/x-pack/test/tsconfig.json @@ -138,6 +138,7 @@ "@kbn/ml-category-validator", "@kbn/observability-ai-assistant-plugin", "@kbn/stack-connectors-plugin", + "@kbn/stack-alerts-plugin", "@kbn/aiops-utils" ] }