Skip to content

Commit

Permalink
[RCA] [Recent events] Create API endpoint to get events (#192947)
Browse files Browse the repository at this point in the history
Closes elastic/observability-dev#3924
Closes elastic/observability-dev#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 <[email protected]>
(cherry picked from commit 808212e)
  • Loading branch information
benakansara committed Sep 19, 2024
1 parent 04e1921 commit 1407654
Show file tree
Hide file tree
Showing 14 changed files with 359 additions and 20 deletions.
18 changes: 18 additions & 0 deletions packages/kbn-investigation-shared/src/rest_specs/event.ts
Original file line number Diff line number Diff line change
@@ -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<typeof eventResponseSchema>;

export { eventResponseSchema };
export type { EventResponse };
31 changes: 31 additions & 0 deletions packages/kbn-investigation-shared/src/rest_specs/get_events.ts
Original file line number Diff line number Diff line change
@@ -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<typeof getEventsParamsSchema.shape.query>;
type GetEventsResponse = z.output<typeof getEventsResponseSchema>;

export { getEventsParamsSchema, getEventsResponseSchema };
export type { GetEventsParams, GetEventsResponse };
4 changes: 4 additions & 0 deletions packages/kbn-investigation-shared/src/rest_specs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
51 changes: 51 additions & 0 deletions packages/kbn-investigation-shared/src/schema/event.ts
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions packages/kbn-investigation-shared/src/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"datasetQuality",
"unifiedSearch",
"security",
"observability",
"licensing",
"ruleRegistry"
],
"requiredBundles": [
"esql",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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,
Expand All @@ -328,6 +359,7 @@ export function getGlobalInvestigateAppServerRouteRepository() {
...deleteInvestigationItemRoute,
...updateInvestigationItemRoute,
...getInvestigationItemsRoute,
...getEventsRoute,
...getAllInvestigationStatsRoute,
...getAllInvestigationTagsRoute,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -33,6 +34,7 @@ export type InvestigateAppRequestHandlerContext = Omit<
};
coreStart: CoreStart;
}>;
licensing: Promise<LicensingApiRequestHandlerContext>;
};

export interface InvestigateAppRouteHandlerResources {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getAlertsClient>>;

export async function getAlertsClient({
plugins,
request,
}: Pick<InvestigateAppRouteHandlerResources, 'plugins' | 'request'>) {
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<TParams extends RequiredParams>(
searchParams: TParams
): Promise<InferSearchResponseOf<ParsedTechnicalFields, TParams>> {
return alertsClient.find({
...searchParams,
index: alertsIndices.join(','),
}) as Promise<any>;
},
};
}
Original file line number Diff line number Diff line change
@@ -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<GetEventsResponse> {
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<GetEventsResponse> {
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);
}
Loading

0 comments on commit 1407654

Please sign in to comment.