From 1407654c43f164830d3ab7c0ed690658059384ee Mon Sep 17 00:00:00 2001 From: Bena Kansara <69037875+benakansara@users.noreply.github.com> Date: Tue, 17 Sep 2024 11:50:09 +0200 Subject: [PATCH] [RCA] [Recent events] Create API endpoint to get events (#192947) Closes https://github.com/elastic/observability-dev/issues/3924 Closes https://github.com/elastic/observability-dev/issues/3927 This PR introduces an events API (`/api/observability/events`) that will fetch - - All the "point in time" annotations from` observability-annotations` index. This includes both manual and auto (e.g. service deployment) annotations - The annotations will be filtered with supported source fields (host.name, service.name, slo.id, slo.instanceId) when specified as `filter` - Alerts that newly triggered on same source in given time range. The source needs to be specified as `filter`, when no filter is specified all alerts triggered in given time range will be returned ### Testing - Create annotations (APM service deployment annotations and annotations using Observability UI) - Generate some alerts - API call should return annotations and alerts, example API requests - `http://localhost:5601/kibana/api/observability/events?rangeFrom=2024-09-01T19:53:20.243Z&rangeTo=2024-09-19T19:53:20.243Z&filter={"annotation.type":"deployment"}` - `http://localhost:5601/kibana/api/observability/events?rangeFrom=2024-09-01T19:53:20.243Z&rangeTo=2024-09-19T19:53:20.243Z&filter={"slo.id":"*"}` - `http://localhost:5601/kibana/api/observability/events?rangeFrom=2024-09-01T19:53:20.243Z&rangeTo=2024-09-19T19:53:20.243Z&filter={"host.name":"host-0"}` - `http://localhost:5601/kibana/api/observability/events?rangeFrom=2024-09-01T19:53:20.243Z&rangeTo=2024-09-19T19:53:20.243Z` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> (cherry picked from commit 808212e97e413216655aaa9e755c671656decb46) --- .../src/rest_specs/event.ts | 18 +++ .../src/rest_specs/get_events.ts | 31 +++++ .../src/rest_specs/index.ts | 4 + .../src/schema/event.ts | 51 ++++++++ .../src/schema/index.ts | 1 + .../investigate_app/kibana.jsonc | 3 + ...investigate_app_server_route_repository.ts | 32 +++++ .../investigate_app/server/routes/types.ts | 2 + .../server/services/get_alerts_client.ts | 49 +++++++ .../server/services/get_events.ts | 123 ++++++++++++++++++ .../investigate_app/server/types.ts | 15 ++- .../investigate_app/tsconfig.json | 3 + .../observability/common/annotations.ts | 2 + .../annotations/create_annotations_client.ts | 45 ++++--- 14 files changed, 359 insertions(+), 20 deletions(-) create mode 100644 packages/kbn-investigation-shared/src/rest_specs/event.ts create mode 100644 packages/kbn-investigation-shared/src/rest_specs/get_events.ts create mode 100644 packages/kbn-investigation-shared/src/schema/event.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/get_alerts_client.ts create mode 100644 x-pack/plugins/observability_solution/investigate_app/server/services/get_events.ts diff --git a/packages/kbn-investigation-shared/src/rest_specs/event.ts b/packages/kbn-investigation-shared/src/rest_specs/event.ts new file mode 100644 index 000000000000..df2f3941ad33 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/event.ts @@ -0,0 +1,18 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod'; +import { eventSchema } from '../schema'; + +const eventResponseSchema = eventSchema; + +type EventResponse = z.output; + +export { eventResponseSchema }; +export type { EventResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/get_events.ts b/packages/kbn-investigation-shared/src/rest_specs/get_events.ts new file mode 100644 index 000000000000..064a75fab156 --- /dev/null +++ b/packages/kbn-investigation-shared/src/rest_specs/get_events.ts @@ -0,0 +1,31 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod'; +import { eventResponseSchema } from './event'; + +const getEventsParamsSchema = z + .object({ + query: z + .object({ + rangeFrom: z.string(), + rangeTo: z.string(), + filter: z.string(), + }) + .partial(), + }) + .partial(); + +const getEventsResponseSchema = z.array(eventResponseSchema); + +type GetEventsParams = z.infer; +type GetEventsResponse = z.output; + +export { getEventsParamsSchema, getEventsResponseSchema }; +export type { GetEventsParams, GetEventsResponse }; diff --git a/packages/kbn-investigation-shared/src/rest_specs/index.ts b/packages/kbn-investigation-shared/src/rest_specs/index.ts index c00ec5035765..42bec32041af 100644 --- a/packages/kbn-investigation-shared/src/rest_specs/index.ts +++ b/packages/kbn-investigation-shared/src/rest_specs/index.ts @@ -25,6 +25,8 @@ export type * from './investigation_note'; export type * from './update'; export type * from './update_item'; export type * from './update_note'; +export type * from './event'; +export type * from './get_events'; export * from './create'; export * from './create_item'; @@ -44,3 +46,5 @@ export * from './investigation_note'; export * from './update'; export * from './update_item'; export * from './update_note'; +export * from './event'; +export * from './get_events'; diff --git a/packages/kbn-investigation-shared/src/schema/event.ts b/packages/kbn-investigation-shared/src/schema/event.ts new file mode 100644 index 000000000000..c954a0de13fb --- /dev/null +++ b/packages/kbn-investigation-shared/src/schema/event.ts @@ -0,0 +1,51 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { z } from '@kbn/zod'; + +const eventTypeSchema = z.union([ + z.literal('annotation'), + z.literal('alert'), + z.literal('error_rate'), + z.literal('latency'), + z.literal('anomaly'), +]); + +const annotationEventSchema = z.object({ + eventType: z.literal('annotation'), + annotationType: z.string().optional(), +}); + +const alertStatusSchema = z.union([ + z.literal('active'), + z.literal('flapping'), + z.literal('recovered'), + z.literal('untracked'), +]); + +const alertEventSchema = z.object({ + eventType: z.literal('alert'), + alertStatus: alertStatusSchema, +}); + +const sourceSchema = z.record(z.string(), z.any()); + +const eventSchema = z.intersection( + z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + timestamp: z.number(), + eventType: eventTypeSchema, + source: sourceSchema.optional(), + }), + z.discriminatedUnion('eventType', [annotationEventSchema, alertEventSchema]) +); + +export { eventSchema }; diff --git a/packages/kbn-investigation-shared/src/schema/index.ts b/packages/kbn-investigation-shared/src/schema/index.ts index 7491ecce76cc..f65fe9baf1f6 100644 --- a/packages/kbn-investigation-shared/src/schema/index.ts +++ b/packages/kbn-investigation-shared/src/schema/index.ts @@ -11,5 +11,6 @@ export * from './investigation'; export * from './investigation_item'; export * from './investigation_note'; export * from './origin'; +export * from './event'; export type * from './investigation'; diff --git a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc index ecfe77b5a058..2cc904dafac0 100644 --- a/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc +++ b/x-pack/plugins/observability_solution/investigate_app/kibana.jsonc @@ -19,6 +19,9 @@ "datasetQuality", "unifiedSearch", "security", + "observability", + "licensing", + "ruleRegistry" ], "requiredBundles": [ "esql", diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts index b0ecd8927591..195fbdb23436 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/get_global_investigate_app_server_route_repository.ts @@ -21,7 +21,10 @@ import { updateInvestigationItemParamsSchema, updateInvestigationNoteParamsSchema, updateInvestigationParamsSchema, + getEventsParamsSchema, + GetEventsResponse, } from '@kbn/investigation-shared'; +import { ScopedAnnotationsClient } from '@kbn/observability-plugin/server'; import { createInvestigation } from '../services/create_investigation'; import { createInvestigationItem } from '../services/create_investigation_item'; import { createInvestigationNote } from '../services/create_investigation_note'; @@ -35,6 +38,8 @@ import { getInvestigationItems } from '../services/get_investigation_items'; import { getInvestigationNotes } from '../services/get_investigation_notes'; import { investigationRepositoryFactory } from '../services/investigation_repository'; import { updateInvestigation } from '../services/update_investigation'; +import { getAlertEvents, getAnnotationEvents } from '../services/get_events'; +import { AlertsClient, getAlertsClient } from '../services/get_alerts_client'; import { updateInvestigationItem } from '../services/update_investigation_item'; import { updateInvestigationNote } from '../services/update_investigation_note'; import { createInvestigateAppServerRoute } from './create_investigate_app_server_route'; @@ -313,6 +318,32 @@ const deleteInvestigationItemRoute = createInvestigateAppServerRoute({ }, }); +const getEventsRoute = createInvestigateAppServerRoute({ + endpoint: 'GET /api/observability/events 2023-10-31', + options: { + tags: [], + }, + params: getEventsParamsSchema, + handler: async ({ params, context, request, plugins }) => { + const annotationsClient: ScopedAnnotationsClient | undefined = + await plugins.observability.setup.getScopedAnnotationsClient(context, request); + const alertsClient: AlertsClient = await getAlertsClient({ plugins, request }); + const events: GetEventsResponse = []; + + if (annotationsClient) { + const annotationEvents = await getAnnotationEvents(params?.query ?? {}, annotationsClient); + events.push(...annotationEvents); + } + + if (alertsClient) { + const alertEvents = await getAlertEvents(params?.query ?? {}, alertsClient); + events.push(...alertEvents); + } + + return events; + }, +}); + export function getGlobalInvestigateAppServerRouteRepository() { return { ...createInvestigationRoute, @@ -328,6 +359,7 @@ export function getGlobalInvestigateAppServerRouteRepository() { ...deleteInvestigationItemRoute, ...updateInvestigationItemRoute, ...getInvestigationItemsRoute, + ...getEventsRoute, ...getAllInvestigationStatsRoute, ...getAllInvestigationTagsRoute, }; diff --git a/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts b/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts index 2e882296adff..afb022cdc9b7 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/routes/types.ts @@ -14,6 +14,7 @@ import type { SavedObjectsClientContract, } from '@kbn/core/server'; import type { Logger } from '@kbn/logging'; +import { LicensingApiRequestHandlerContext } from '@kbn/licensing-plugin/server'; import type { InvestigateAppSetupDependencies, InvestigateAppStartDependencies } from '../types'; export type InvestigateAppRequestHandlerContext = Omit< @@ -33,6 +34,7 @@ export type InvestigateAppRequestHandlerContext = Omit< }; coreStart: CoreStart; }>; + licensing: Promise; }; export interface InvestigateAppRouteHandlerResources { diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_alerts_client.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_alerts_client.ts new file mode 100644 index 000000000000..bf1070307742 --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_alerts_client.ts @@ -0,0 +1,49 @@ +/* + * 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 { isEmpty } from 'lodash'; +import { ESSearchRequest, InferSearchResponseOf } from '@kbn/es-types'; +import { ParsedTechnicalFields } from '@kbn/rule-registry-plugin/common'; +import { InvestigateAppRouteHandlerResources } from '../routes/types'; + +export type AlertsClient = Awaited>; + +export async function getAlertsClient({ + plugins, + request, +}: Pick) { + const ruleRegistryPluginStart = await plugins.ruleRegistry.start(); + const alertsClient = await ruleRegistryPluginStart.getRacClientWithRequest(request); + const alertsIndices = await alertsClient.getAuthorizedAlertsIndices([ + 'logs', + 'infrastructure', + 'apm', + 'slo', + 'uptime', + 'observability', + ]); + + if (!alertsIndices || isEmpty(alertsIndices)) { + throw Error('No alert indices exist'); + } + + type RequiredParams = ESSearchRequest & { + size: number; + track_total_hits: boolean | number; + }; + + return { + search( + searchParams: TParams + ): Promise> { + return alertsClient.find({ + ...searchParams, + index: alertsIndices.join(','), + }) as Promise; + }, + }; +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/services/get_events.ts b/x-pack/plugins/observability_solution/investigate_app/server/services/get_events.ts new file mode 100644 index 000000000000..52eeea7a4cbc --- /dev/null +++ b/x-pack/plugins/observability_solution/investigate_app/server/services/get_events.ts @@ -0,0 +1,123 @@ +/* + * 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 datemath from '@elastic/datemath'; +import { estypes } from '@elastic/elasticsearch'; +import { + GetEventsParams, + GetEventsResponse, + getEventsResponseSchema, +} from '@kbn/investigation-shared'; +import { ScopedAnnotationsClient } from '@kbn/observability-plugin/server'; +import { + ALERT_REASON, + ALERT_RULE_CATEGORY, + ALERT_START, + ALERT_STATUS, + ALERT_UUID, +} from '@kbn/rule-data-utils'; +import { AlertsClient } from './get_alerts_client'; + +export function rangeQuery( + start: number, + end: number, + field = '@timestamp' +): estypes.QueryDslQueryContainer[] { + return [ + { + range: { + [field]: { + gte: start, + lte: end, + format: 'epoch_millis', + }, + }, + }, + ]; +} + +export async function getAnnotationEvents( + params: GetEventsParams, + annotationsClient: ScopedAnnotationsClient +): Promise { + const response = await annotationsClient.find({ + start: params?.rangeFrom, + end: params?.rangeTo, + filter: params?.filter, + size: 100, + }); + + // we will return only "point_in_time" annotations + const events = response.items + .filter((item) => !item.event?.end) + .map((item) => { + const hostName = item.host?.name; + const serviceName = item.service?.name; + const serviceVersion = item.service?.version; + const sloId = item.slo?.id; + const sloInstanceId = item.slo?.instanceId; + + return { + id: item.id, + title: item.annotation.title, + description: item.message, + timestamp: new Date(item['@timestamp']).getTime(), + eventType: 'annotation', + annotationType: item.annotation.type, + source: { + ...(hostName ? { 'host.name': hostName } : undefined), + ...(serviceName ? { 'service.name': serviceName } : undefined), + ...(serviceVersion ? { 'service.version': serviceVersion } : undefined), + ...(sloId ? { 'slo.id': sloId } : undefined), + ...(sloInstanceId ? { 'slo.instanceId': sloInstanceId } : undefined), + }, + }; + }); + + return getEventsResponseSchema.parse(events); +} + +export async function getAlertEvents( + params: GetEventsParams, + alertsClient: AlertsClient +): Promise { + const startInMs = datemath.parse(params?.rangeFrom ?? 'now-15m')!.valueOf(); + const endInMs = datemath.parse(params?.rangeTo ?? 'now')!.valueOf(); + const filterJSON = params?.filter ? JSON.parse(params.filter) : {}; + + const body = { + size: 100, + track_total_hits: false, + query: { + bool: { + filter: [ + ...rangeQuery(startInMs, endInMs, ALERT_START), + ...Object.keys(filterJSON).map((filterKey) => ({ + term: { [filterKey]: filterJSON[filterKey] }, + })), + ], + }, + }, + }; + + const response = await alertsClient.search(body); + + const events = response.hits.hits.map((hit) => { + const _source = hit._source; + + return { + id: _source[ALERT_UUID], + title: `${_source[ALERT_RULE_CATEGORY]} breached`, + description: _source[ALERT_REASON], + timestamp: new Date(_source['@timestamp']).getTime(), + eventType: 'alert', + alertStatus: _source[ALERT_STATUS], + }; + }); + + return getEventsResponseSchema.parse(events); +} diff --git a/x-pack/plugins/observability_solution/investigate_app/server/types.ts b/x-pack/plugins/observability_solution/investigate_app/server/types.ts index 6fa1196b23b7..fa4db6ccfcb0 100644 --- a/x-pack/plugins/observability_solution/investigate_app/server/types.ts +++ b/x-pack/plugins/observability_solution/investigate_app/server/types.ts @@ -5,13 +5,24 @@ * 2.0. */ +import { ObservabilityPluginSetup } from '@kbn/observability-plugin/server'; +import { + RuleRegistryPluginSetupContract, + RuleRegistryPluginStartContract, +} from '@kbn/rule-registry-plugin/server'; + /* eslint-disable @typescript-eslint/no-empty-interface*/ export interface ConfigSchema {} -export interface InvestigateAppSetupDependencies {} +export interface InvestigateAppSetupDependencies { + observability: ObservabilityPluginSetup; + ruleRegistry: RuleRegistryPluginSetupContract; +} -export interface InvestigateAppStartDependencies {} +export interface InvestigateAppStartDependencies { + ruleRegistry: RuleRegistryPluginStartContract; +} export interface InvestigateAppServerSetup {} diff --git a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json index 03651f3530c6..cd687f2dcfe7 100644 --- a/x-pack/plugins/observability_solution/investigate_app/tsconfig.json +++ b/x-pack/plugins/observability_solution/investigate_app/tsconfig.json @@ -58,5 +58,8 @@ "@kbn/lens-embeddable-utils", "@kbn/i18n-react", "@kbn/zod", + "@kbn/observability-plugin", + "@kbn/licensing-plugin", + "@kbn/rule-data-utils", ], } diff --git a/x-pack/plugins/observability_solution/observability/common/annotations.ts b/x-pack/plugins/observability_solution/observability/common/annotations.ts index 16c6e11b81e8..874234acc6ce 100644 --- a/x-pack/plugins/observability_solution/observability/common/annotations.ts +++ b/x-pack/plugins/observability_solution/observability/common/annotations.ts @@ -96,6 +96,8 @@ export const findAnnotationRt = t.partial({ sloId: t.string, sloInstanceId: t.string, serviceName: t.string, + filter: t.string, + size: t.number, }); export const updateAnnotationRt = t.intersection([ diff --git a/x-pack/plugins/observability_solution/observability/server/lib/annotations/create_annotations_client.ts b/x-pack/plugins/observability_solution/observability/server/lib/annotations/create_annotations_client.ts index 6cf3b6345982..5bd7395c3ca7 100644 --- a/x-pack/plugins/observability_solution/observability/server/lib/annotations/create_annotations_client.ts +++ b/x-pack/plugins/observability_solution/observability/server/lib/annotations/create_annotations_client.ts @@ -202,7 +202,12 @@ export function createAnnotationsClient(params: { }; }), find: ensureGoldLicense(async (findParams: FindAnnotationParams) => { - const { start, end, sloId, sloInstanceId, serviceName } = findParams ?? {}; + const { start, end, sloId, sloInstanceId, serviceName, filter, size } = findParams ?? {}; + const filterJSON = filter ? JSON.parse(filter) : {}; + + const termsFilter = Object.keys(filterJSON).map((filterKey) => ({ + term: { [filterKey]: filterJSON[filterKey] }, + })); const shouldClauses: QueryDslQueryContainer[] = []; if (sloId || sloInstanceId) { @@ -246,7 +251,7 @@ export function createAnnotationsClient(params: { const result = await esClient.search({ index: readIndex, - size: 10000, + size: size ?? 10000, ignore_unavailable: true, query: { bool: { @@ -259,22 +264,26 @@ export function createAnnotationsClient(params: { }, }, }, - { - bool: { - should: [ - ...(serviceName - ? [ - { - term: { - 'service.name': serviceName, - }, - }, - ] - : []), - ...shouldClauses, - ], - }, - }, + ...(Object.keys(filterJSON).length !== 0 + ? termsFilter + : [ + { + bool: { + should: [ + ...(serviceName + ? [ + { + term: { + 'service.name': serviceName, + }, + }, + ] + : []), + ...shouldClauses, + ], + }, + }, + ]), ], }, },