Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,39 @@ export const bulkCreateAlertActionBodySchema = z
'Request body for bulk create alert actions. Array of 1 to 100 actions, each with group_hash and action payload.'
);
export type BulkCreateAlertActionBody = z.infer<typeof bulkCreateAlertActionBodySchema>;

export const bulkGetAlertActionsBodySchema = z
.object({
episode_ids: z
.array(z.string())
.min(1, 'At least one episode ID must be provided')
.max(100, 'Cannot query more than 100 episode IDs in a single request')
.describe('List of episode identifiers to fetch alert actions for.'),
})
.describe('Request body for bulk getting alert actions by episode IDs.');

export type BulkGetAlertActionsBody = z.infer<typeof bulkGetAlertActionsBodySchema>;

export const bulkGetAlertActionsResponseSchema = z
.array(
z.object({
episode_id: z.string().describe('The episode identifier.'),
rule_id: z.string().nullable().describe('The rule identifier, or null if not found.'),
group_hash: z.string().nullable().describe('The alert group hash, or null if not found.'),
last_ack_action: z
.enum(['ack', 'unack'])
.nullable()
.describe('The last acknowledge action, or null if none.'),
last_deactivate_action: z
.enum(['activate', 'deactivate'])
.nullable()
.describe('The last deactivate action, or null if none.'),
last_snooze_action: z
.enum(['snooze', 'unsnooze'])
.nullable()
.describe('The last snooze action, or null if none.'),
})
)
.describe('Response body for bulk getting alert actions by episode IDs.');

export type BulkGetAlertActionsResponse = z.infer<typeof bulkGetAlertActionsResponseSchema>;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { createUserProfile, createUserService } from '../services/user_service/u
import { AlertActionsClient } from './alert_actions_client';
import {
getBulkAlertEventsESQLResponse,
getBulkGetAlertActionsESQLResponse,
getAlertEventESQLResponse,
getEmptyESQLResponse,
} from './fixtures/query_responses';
Expand Down Expand Up @@ -187,4 +188,91 @@ describe('AlertActionsClient', () => {
expect(storageServiceEsClient.bulk).not.toHaveBeenCalled();
});
});

describe('bulkGet', () => {
it('should return action states for multiple episode IDs', async () => {
queryServiceEsClient.esql.query.mockResolvedValueOnce(
getBulkGetAlertActionsESQLResponse([
{
episode_id: 'episode-1',
rule_id: 'rule-1',
group_hash: 'hash-1',
last_ack_action: 'ack',
last_snooze_action: 'snooze',
},
{
episode_id: 'episode-2',
rule_id: 'rule-2',
group_hash: 'hash-2',
last_deactivate_action: 'deactivate',
},
])
);

const result = await client.bulkGet(['episode-1', 'episode-2']);

expect(result).toEqual([
{
episode_id: 'episode-1',
rule_id: 'rule-1',
group_hash: 'hash-1',
last_ack_action: 'ack',
last_deactivate_action: null,
last_snooze_action: 'snooze',
},
{
episode_id: 'episode-2',
rule_id: 'rule-2',
group_hash: 'hash-2',
last_ack_action: null,
last_deactivate_action: 'deactivate',
last_snooze_action: null,
},
]);
});

it('should return default records with nulls for episodes without actions', async () => {
queryServiceEsClient.esql.query.mockResolvedValueOnce(getEmptyESQLResponse());

const result = await client.bulkGet(['unknown-episode']);

expect(result).toEqual([
{
episode_id: 'unknown-episode',
rule_id: null,
group_hash: null,
last_ack_action: null,
last_deactivate_action: null,
last_snooze_action: null,
},
]);
});

it('should include both matched and unmatched episodes', async () => {
queryServiceEsClient.esql.query.mockResolvedValueOnce(
getBulkGetAlertActionsESQLResponse([{ episode_id: 'episode-1', last_ack_action: 'ack' }])
);

const result = await client.bulkGet(['episode-1', 'episode-2']);

expect(result).toEqual([
{
episode_id: 'episode-1',
rule_id: 'test-rule-id',
group_hash: 'test-group-hash',
last_ack_action: 'ack',
last_deactivate_action: null,
last_snooze_action: null,
},
{
episode_id: 'episode-2',
rule_id: null,
group_hash: null,
last_ack_action: null,
last_deactivate_action: null,
last_snooze_action: null,
},
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { inject, injectable } from 'inversify';
import { groupBy, omit } from 'lodash';
import type {
BulkCreateAlertActionItemBody,
BulkGetAlertActionsResponse,
CreateAlertActionBody,
} from '@kbn/alerting-v2-schemas';
import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions';
Expand All @@ -22,6 +23,7 @@ import type { StorageServiceContract } from '../services/storage_service/storage
import { StorageServiceScopedToken } from '../services/storage_service/tokens';
import type { UserServiceContract } from '../services/user_service/user_service';
import { UserService } from '../services/user_service/user_service';
import { getBulkGetAlertActionsQuery } from './queries';

@injectable()
export class AlertActionsClient {
Expand Down Expand Up @@ -55,6 +57,29 @@ export class AlertActionsClient {
});
}

public async bulkGet(episodeIds: string[]): Promise<BulkGetAlertActionsResponse> {
const query = getBulkGetAlertActionsQuery(episodeIds);
const records = queryResponseToRecords<BulkGetAlertActionsResponse[number]>(
await this.queryService.executeQuery({ query: query.query })
);

const returnedEpisodeIds = new Set(records.map((r) => r.episode_id));
for (const episodeId of episodeIds) {
if (!returnedEpisodeIds.has(episodeId)) {
records.push({
episode_id: episodeId,
rule_id: null,
group_hash: null,
last_ack_action: null,
last_deactivate_action: null,
last_snooze_action: null,
});
}
}

return records;
}

public async createBulkActions(
actions: BulkCreateAlertActionItemBody[]
): Promise<{ processed: number; total: number }> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,36 @@ export function getEmptyESQLResponse(): EsqlQueryResponse {
};
}

export function getBulkGetAlertActionsESQLResponse(
records: Array<{
episode_id?: string;
rule_id?: string;
group_hash?: string;
last_ack_action?: string | null;
last_deactivate_action?: string | null;
last_snooze_action?: string | null;
}>
): EsqlQueryResponse {
return {
columns: [
{ name: 'episode_id', type: 'keyword' },
{ name: 'rule_id', type: 'keyword' },
{ name: 'group_hash', type: 'keyword' },
{ name: 'last_ack_action', type: 'keyword' },
{ name: 'last_deactivate_action', type: 'keyword' },
{ name: 'last_snooze_action', type: 'keyword' },
],
values: records.map((record) => [
record.episode_id ?? 'episode-1',
record.rule_id ?? 'test-rule-id',
record.group_hash ?? 'test-group-hash',
record.last_ack_action ?? null,
record.last_deactivate_action ?? null,
record.last_snooze_action ?? null,
]),
};
}

export function getBulkAlertEventsESQLResponse(
records: Array<{
'@timestamp'?: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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 { esql, type EsqlRequest } from '@elastic/esql';
import { ALERT_ACTIONS_DATA_STREAM } from '../../resources/alert_actions';

export const getBulkGetAlertActionsQuery = (episodeIds: string[]): EsqlRequest => {
const episodeIdValues = episodeIds.map((id) => esql.str(id));

return esql`
FROM ${ALERT_ACTIONS_DATA_STREAM}
| WHERE episode_id IN (${episodeIdValues})
| WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze")
| STATS
last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"),
last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"),
last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze")
BY episode_id, rule_id, group_hash
| KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action
`.toRequest();
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*
* 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 Boom from '@hapi/boom';
import { Request, Response, type RouteHandler } from '@kbn/core-di-server';
import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server';
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
import { inject, injectable } from 'inversify';
import {
bulkGetAlertActionsBodySchema,
bulkGetAlertActionsResponseSchema,
type BulkGetAlertActionsBody,
} from '@kbn/alerting-v2-schemas';
import { AlertActionsClient } from '../../lib/alert_actions_client';
import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges';
import { INTERNAL_ALERTING_V2_ALERT_API_PATH } from '../constants';

@injectable()
export class BulkGetAlertActionsRoute implements RouteHandler {
static method = 'post' as const;
static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/action/_bulk_get`;
static security: RouteSecurity = {
authz: {
requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.read],
},
};
static options = { access: 'internal' } as const;
static validate = {
request: {
body: buildRouteValidationWithZod(bulkGetAlertActionsBodySchema),
},
response: {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice I need to start adding these into the other routes 👍🏻
Also we probably wants to rename /action to /actions but that's out of scope for this PR

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice I need to start adding these into the other routes

I will keep in mind and update whenever I need to change something too 👌

I had actually forgotten initially, but then, when working on a different PR, I needed to know the types and noticed they were missing.

200: {
body: buildRouteValidationWithZod(bulkGetAlertActionsResponseSchema),
},
},
} as const;
Comment on lines +23 to +41

constructor(
@inject(Request)
private readonly request: KibanaRequest<unknown, unknown, BulkGetAlertActionsBody>,
@inject(Response) private readonly response: KibanaResponseFactory,
@inject(AlertActionsClient) private readonly alertActionsClient: AlertActionsClient
) {}

async handle() {
try {
const results = await this.alertActionsClient.bulkGet(this.request.body.episode_ids);

return this.response.ok({ body: results });
} catch (e) {
const boom = Boom.isBoom(e) ? e : Boom.boomify(e);
return this.response.customError({
statusCode: boom.output.statusCode,
body: boom.output.payload,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { GetRuleRoute } from '../routes/rules/get_rule_route';
import { DeleteRuleRoute } from '../routes/rules/delete_rule_route';
import { CreateAlertActionRoute } from '../routes/alert_actions/create_alert_action_route';
import { BulkCreateAlertActionRoute } from '../routes/alert_actions/bulk_create_alert_action_route';
import { BulkGetAlertActionsRoute } from '../routes/alert_actions/bulk_get_alert_actions_route';
import { BulkActionNotificationPoliciesRoute } from '../routes/notification_policies/bulk_action_notification_policies_route';
import { CreateNotificationPolicyRoute } from '../routes/notification_policies/create_notification_policy_route';
import { DisableNotificationPolicyRoute } from '../routes/notification_policies/disable_notification_policy_route';
Expand All @@ -33,6 +34,7 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) {
bind(Route).toConstantValue(DeleteRuleRoute);
bind(Route).toConstantValue(CreateAlertActionRoute);
bind(Route).toConstantValue(BulkCreateAlertActionRoute);
bind(Route).toConstantValue(BulkGetAlertActionsRoute);
bind(Route).toConstantValue(CreateNotificationPolicyRoute);
bind(Route).toConstantValue(GetNotificationPolicyRoute);
bind(Route).toConstantValue(UpdateNotificationPolicyRoute);
Expand Down
3 changes: 2 additions & 1 deletion x-pack/platform/plugins/shared/alerting_v2/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"@kbn/eval-kql",
"@kbn/react-query",
"@kbn/es-query",
"@kbn/react-hooks"
"@kbn/react-hooks",
"@kbn/core-security-server-mocks"
],
"exclude": ["target/**/*"]
}
Loading