From 8eea4914126fc079cfb77dce29e4ec1899c64807 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Wed, 16 Jun 2021 20:15:59 +0200 Subject: [PATCH] [RAC] Update alert documents in lifecycle rule type helper (#101598) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/services/get_service_alerts.ts | 6 +- .../server/lib/rules/get_top_alerts.ts | 12 +- x-pack/plugins/rule_registry/README.md | 3 - .../create_rule_data_client_mock.ts | 42 ++ .../server/rule_data_client/index.ts | 37 +- .../utils/create_lifecycle_rule_type.test.ts | 381 ++++++++++++++++++ .../create_lifecycle_rule_type_factory.ts | 29 +- .../tests/alerts/rule_registry.ts | 23 +- 8 files changed, 501 insertions(+), 32 deletions(-) create mode 100644 x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts create mode 100644 x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts diff --git a/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts b/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts index f58452ce4d9160..2141570f521c01 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_alerts.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { ALERT_UUID } from '@kbn/rule-data-utils/target/technical_field_names'; +import { EVENT_KIND } from '@kbn/rule-data-utils/target/technical_field_names'; import { RuleDataClient } from '../../../../rule_registry/server'; import { SERVICE_NAME, @@ -36,6 +36,7 @@ export async function getServiceAlerts({ ...rangeQuery(start, end), ...environmentQuery(environment), { term: { [SERVICE_NAME]: serviceName } }, + { term: { [EVENT_KIND]: 'signal' } }, ], should: [ { @@ -64,9 +65,6 @@ export async function getServiceAlerts({ }, size: 100, fields: ['*'], - collapse: { - field: ALERT_UUID, - }, sort: { '@timestamp': 'desc', }, diff --git a/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts index 9560de6ec00ff0..db8191136686a1 100644 --- a/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts +++ b/x-pack/plugins/observability/server/lib/rules/get_top_alerts.ts @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { ALERT_UUID, TIMESTAMP } from '@kbn/rule-data-utils/target/technical_field_names'; +import { EVENT_KIND, TIMESTAMP } from '@kbn/rule-data-utils/target/technical_field_names'; import { RuleDataClient } from '../../../../rule_registry/server'; import type { AlertStatus } from '../../../common/typings'; import { kqlQuery, rangeQuery, alertStatusQuery } from '../../utils/queries'; @@ -28,13 +28,15 @@ export async function getTopAlerts({ body: { query: { bool: { - filter: [...rangeQuery(start, end), ...kqlQuery(kuery), ...alertStatusQuery(status)], + filter: [ + ...rangeQuery(start, end), + ...kqlQuery(kuery), + ...alertStatusQuery(status), + { term: { [EVENT_KIND]: 'signal' } }, + ], }, }, fields: ['*'], - collapse: { - field: ALERT_UUID, - }, size, sort: { [TIMESTAMP]: 'desc', diff --git a/x-pack/plugins/rule_registry/README.md b/x-pack/plugins/rule_registry/README.md index e12c2b29ed3738..3fe6305a0d9f6e 100644 --- a/x-pack/plugins/rule_registry/README.md +++ b/x-pack/plugins/rule_registry/README.md @@ -111,9 +111,6 @@ const response = await ruleDataClient.getReader().search({ }, size: 100, fields: ['*'], - collapse: { - field: ALERT_UUID, - }, sort: { '@timestamp': 'desc', }, diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts new file mode 100644 index 00000000000000..18f3c21fafc155 --- /dev/null +++ b/x-pack/plugins/rule_registry/server/rule_data_client/create_rule_data_client_mock.ts @@ -0,0 +1,42 @@ +/* + * 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. + */ +import { Assign } from '@kbn/utility-types'; +import type { RuleDataClient } from '.'; +import { RuleDataReader, RuleDataWriter } from './types'; + +type MockInstances> = { + [K in keyof T]: T[K] extends (...args: infer TArgs) => infer TReturn + ? jest.MockInstance + : never; +}; + +export function createRuleDataClientMock() { + const bulk = jest.fn(); + const search = jest.fn(); + const getDynamicIndexPattern = jest.fn(); + + return ({ + createOrUpdateWriteTarget: jest.fn(({ namespace }) => Promise.resolve()), + getReader: jest.fn(() => ({ + getDynamicIndexPattern, + search, + })), + getWriter: jest.fn(() => ({ + bulk, + })), + } as unknown) as Assign< + RuleDataClient & Omit, 'options' | 'getClusterClient'>, + { + getWriter: ( + ...args: Parameters + ) => MockInstances; + getReader: ( + ...args: Parameters + ) => MockInstances; + } + >; +} diff --git a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts index cd7467c903e52b..cb336580ca3540 100644 --- a/x-pack/plugins/rule_registry/server/rule_data_client/index.ts +++ b/x-pack/plugins/rule_registry/server/rule_data_client/index.ts @@ -4,6 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ + +import { isEmpty } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { IndexPatternsFetcher } from '../../../../../src/plugins/data/server'; @@ -44,15 +46,26 @@ export class RuleDataClient implements IRuleDataClient { const clusterClient = await this.getClusterClient(); const indexPatternsFetcher = new IndexPatternsFetcher(clusterClient); - const fields = await indexPatternsFetcher.getFieldsForWildcard({ - pattern: index, - }); - - return { - fields, - timeFieldName: '@timestamp', - title: index, - }; + try { + const fields = await indexPatternsFetcher.getFieldsForWildcard({ + pattern: index, + }); + + return { + fields, + timeFieldName: '@timestamp', + title: index, + }; + } catch (err) { + if (err.output?.payload?.code === 'no_matching_indices') { + return { + fields: [], + timeFieldName: '@timestamp', + title: index, + }; + } + throw err; + } }, }; } @@ -127,6 +140,12 @@ export class RuleDataClient implements IRuleDataClient { const mappings: estypes.MappingTypeMapping = simulateResponse.template.mappings; + if (isEmpty(mappings)) { + throw new Error( + 'No mappings would be generated for this index, possibly due to failed/misconfigured bootstrapping' + ); + } + await clusterClient.indices.putMapping({ index: `${alias}*`, body: mappings }); } } diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts new file mode 100644 index 00000000000000..85e69eb51fd02f --- /dev/null +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type.test.ts @@ -0,0 +1,381 @@ +/* + * 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. + */ + +import { schema } from '@kbn/config-schema'; +import { loggerMock } from '@kbn/logging/target/mocks'; +import { castArray, omit, mapValues } from 'lodash'; +import { RuleDataClient } from '../rule_data_client'; +import { createRuleDataClientMock } from '../rule_data_client/create_rule_data_client_mock'; +import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory'; + +type RuleTestHelpers = ReturnType; + +function createRule() { + const ruleDataClientMock = createRuleDataClientMock(); + + const factory = createLifecycleRuleTypeFactory({ + ruleDataClient: (ruleDataClientMock as unknown) as RuleDataClient, + logger: loggerMock.create(), + }); + + let nextAlerts: Array<{ id: string; fields: Record }> = []; + + const type = factory({ + actionGroups: [ + { + id: 'warning', + name: 'warning', + }, + ], + defaultActionGroupId: 'warning', + executor: async ({ services }) => { + nextAlerts.forEach((alert) => { + services.alertWithLifecycle(alert); + }); + nextAlerts = []; + }, + id: 'test_type', + minimumLicenseRequired: 'basic', + name: 'Test type', + producer: 'test', + actionVariables: { + context: [], + params: [], + state: [], + }, + validate: { + params: schema.object({}, { unknowns: 'allow' }), + }, + }); + + let state: Record = {}; + let previousStartedAt: Date | null; + const createdAt = new Date('2021-06-16T09:00:00.000Z'); + + const scheduleActions = jest.fn(); + + const alertInstanceFactory = () => { + return { + scheduleActions, + } as any; + }; + + return { + alertWithLifecycle: async (alerts: Array<{ id: string; fields: Record }>) => { + nextAlerts = alerts; + + const startedAt = new Date((previousStartedAt ?? createdAt).getTime() + 60000); + + scheduleActions.mockClear(); + + state = await type.executor({ + alertId: 'alertId', + createdBy: 'createdBy', + name: 'name', + params: {}, + previousStartedAt, + startedAt, + rule: { + actions: [], + consumer: 'consumer', + createdAt, + createdBy: 'createdBy', + enabled: true, + name: 'name', + notifyWhen: 'onActionGroupChange', + producer: 'producer', + ruleTypeId: 'ruleTypeId', + ruleTypeName: 'ruleTypeName', + schedule: { + interval: '1m', + }, + tags: ['tags'], + throttle: null, + updatedAt: createdAt, + updatedBy: 'updatedBy', + }, + services: { + alertInstanceFactory, + savedObjectsClient: {} as any, + scopedClusterClient: {} as any, + }, + spaceId: 'spaceId', + state, + tags: ['tags'], + updatedBy: 'updatedBy', + namespace: 'namespace', + }); + + previousStartedAt = startedAt; + }, + scheduleActions, + ruleDataClientMock, + }; +} + +describe('createLifecycleRuleTypeFactory', () => { + describe('with a new rule', () => { + let helpers: RuleTestHelpers; + + beforeEach(() => { + helpers = createRule(); + }); + + describe('when alerts are new', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(1); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[0][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); + const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); + + expect(evaluationDocuments.length).toBe(2); + expect(alertDocuments.length).toBe(2); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.status'] === 'open') + ).toBeTruthy(); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.duration.us'] === 0) + ).toBeTruthy(); + + expect(alertDocuments.every((doc) => doc['event.action'] === 'open')).toBeTruthy(); + + expect(documents.map((doc) => omit(doc, 'kibana.rac.alert.uuid'))).toMatchInlineSnapshot(` + Array [ + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "event", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-java", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "event", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-node", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "signal", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-java", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-java", + "tags": Array [ + "tags", + ], + }, + Object { + "@timestamp": "2021-06-16T09:01:00.000Z", + "event.action": "open", + "event.kind": "signal", + "kibana.rac.alert.duration.us": 0, + "kibana.rac.alert.id": "opbeans-node", + "kibana.rac.alert.producer": "test", + "kibana.rac.alert.start": "2021-06-16T09:01:00.000Z", + "kibana.rac.alert.status": "open", + "rule.category": "Test type", + "rule.id": "test_type", + "rule.name": "name", + "rule.uuid": "alertId", + "service.name": "opbeans-node", + "tags": Array [ + "tags", + ], + }, + ] + `); + }); + }); + + describe('when alerts are active', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(2); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[1][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const evaluationDocuments = documents.filter((doc) => doc['event.kind'] === 'event'); + const alertDocuments = documents.filter((doc) => doc['event.kind'] === 'signal'); + + expect(evaluationDocuments.length).toBe(2); + expect(alertDocuments.length).toBe(2); + + expect( + alertDocuments.every((doc) => doc['kibana.rac.alert.status'] === 'open') + ).toBeTruthy(); + expect(alertDocuments.every((doc) => doc['event.action'] === 'active')).toBeTruthy(); + + expect(alertDocuments.every((doc) => doc['kibana.rac.alert.duration.us'] > 0)).toBeTruthy(); + }); + }); + + describe('when alerts recover', () => { + beforeEach(async () => { + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + { + id: 'opbeans-node', + fields: { + 'service.name': 'opbeans-node', + }, + }, + ]); + + const lastOpbeansNodeDoc = helpers.ruleDataClientMock + .getWriter() + .bulk.mock.calls[0][0].body?.concat() + .reverse() + .find( + (doc: any) => !('index' in doc) && doc['service.name'] === 'opbeans-node' + ) as Record; + + const stored = mapValues(lastOpbeansNodeDoc, (val) => { + return castArray(val); + }); + + helpers.ruleDataClientMock.getReader().search.mockResolvedValueOnce({ + hits: { + hits: [{ fields: stored } as any], + total: { + value: 1, + relation: 'eq', + }, + }, + took: 0, + timed_out: false, + _shards: { + failed: 0, + successful: 1, + total: 1, + }, + }); + + await helpers.alertWithLifecycle([ + { + id: 'opbeans-java', + fields: { + 'service.name': 'opbeans-java', + }, + }, + ]); + }); + + it('writes the correct alerts', () => { + expect(helpers.ruleDataClientMock.getWriter().bulk).toHaveBeenCalledTimes(2); + + const body = helpers.ruleDataClientMock.getWriter().bulk.mock.calls[1][0].body!; + + const documents = body.filter((op: any) => !('index' in op)) as any[]; + + const opbeansJavaAlertDoc = documents.find( + (doc) => castArray(doc['service.name'])[0] === 'opbeans-java' + ); + const opbeansNodeAlertDoc = documents.find( + (doc) => castArray(doc['service.name'])[0] === 'opbeans-node' + ); + + expect(opbeansJavaAlertDoc['event.action']).toBe('active'); + expect(opbeansJavaAlertDoc['kibana.rac.alert.status']).toBe('open'); + + expect(opbeansNodeAlertDoc['event.action']).toBe('close'); + expect(opbeansNodeAlertDoc['kibana.rac.alert.status']).toBe('closed'); + }); + }); + }); +}); diff --git a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts index b523dd6770b9f3..c2e0ae7c151ca0 100644 --- a/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts +++ b/x-pack/plugins/rule_registry/server/utils/create_lifecycle_rule_type_factory.ts @@ -32,7 +32,7 @@ import { AlertTypeWithExecutor } from '../types'; import { ParsedTechnicalFields, parseTechnicalFields } from '../../common/parse_technical_fields'; import { getRuleExecutorData } from './get_rule_executor_data'; -type LifecycleAlertService> = (alert: { +export type LifecycleAlertService> = (alert: { id: string; fields: Record; }) => AlertInstance; @@ -179,7 +179,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ ...alertData, ...ruleExecutorData, [TIMESTAMP]: timestamp, - [EVENT_KIND]: 'state', + [EVENT_KIND]: 'event', [ALERT_ID]: alertId, }; @@ -221,8 +221,29 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ }); if (eventsToIndex.length) { + const alertEvents: Map = new Map(); + + for (const event of eventsToIndex) { + const uuid = event[ALERT_UUID]!; + let storedEvent = alertEvents.get(uuid); + if (!storedEvent) { + storedEvent = event; + } + alertEvents.set(uuid, { + ...storedEvent, + [EVENT_KIND]: 'signal', + }); + } + await ruleDataClient.getWriter().bulk({ - body: eventsToIndex.flatMap((event) => [{ index: {} }, event]), + body: eventsToIndex + .flatMap((event) => [{ index: {} }, event]) + .concat( + Array.from(alertEvents.values()).flatMap((event) => [ + { index: { _id: event[ALERT_UUID]! } }, + event, + ]) + ), }); } @@ -238,7 +259,7 @@ export const createLifecycleRuleTypeFactory: CreateLifecycleRuleTypeFactory = ({ ); return { - wrapped: nextWrappedState, + wrapped: nextWrappedState ?? {}, trackedAlerts: nextTrackedAlerts, }; }, diff --git a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts index 1f8d1144349dd5..3c2e98cfdae47c 100644 --- a/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts +++ b/x-pack/test/apm_api_integration/tests/alerts/rule_registry.ts @@ -8,6 +8,7 @@ import expect from '@kbn/expect'; import { merge, omit } from 'lodash'; import { format } from 'url'; +import { EVENT_KIND } from '@kbn/rule-data-utils/target/technical_field_names'; import { FtrProviderContext } from '../../common/ftr_provider_context'; import { registry } from '../../common/registry'; @@ -259,7 +260,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { index: ALERTS_INDEX_TARGET, body: { query: { - match_all: {}, + term: { + [EVENT_KIND]: 'signal', + }, }, size: 1, sort: { @@ -286,7 +289,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { index: ALERTS_INDEX_TARGET, body: { query: { - match_all: {}, + term: { + [EVENT_KIND]: 'signal', + }, }, size: 1, sort: { @@ -313,7 +318,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { index: ALERTS_INDEX_TARGET, body: { query: { - match_all: {}, + term: { + [EVENT_KIND]: 'signal', + }, }, size: 1, sort: { @@ -346,7 +353,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { "open", ], "event.kind": Array [ - "state", + "signal", ], "kibana.rac.alert.duration.us": Array [ 0, @@ -416,7 +423,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { "open", ], "event.kind": Array [ - "state", + "signal", ], "kibana.rac.alert.duration.us": Array [ 0, @@ -486,7 +493,9 @@ export default function ApiTest({ getService }: FtrProviderContext) { index: ALERTS_INDEX_TARGET, body: { query: { - match_all: {}, + term: { + [EVENT_KIND]: 'signal', + }, }, size: 1, sort: { @@ -521,7 +530,7 @@ export default function ApiTest({ getService }: FtrProviderContext) { "close", ], "event.kind": Array [ - "state", + "signal", ], "kibana.rac.alert.evaluation.threshold": Array [ 30,