Skip to content

Commit aad9636

Browse files
adcoelhokibanamachineCopilot
authored
[Alerting V2] bulk get alert actions (elastic#258353)
Closes elastic#258317 ## Summary The alert episodes table needs to display episode status for each row. To build that UI, we needed a bulk-get API for alert actions. ### Testing <details> <summary>Start by posting some mock action data.</summary> ``` POST .alerting-actions/_bulk {"create":{}} {"@timestamp":"2026-03-18T08:00:00.000Z","last_series_event_timestamp":"2026-03-18T07:55:00.000Z","actor":"user-1","action_type":"ack","group_hash":"gh-1","episode_id":"ep-001","rule_id":"rule-1"} {"create":{}} {"@timestamp":"2026-03-18T08:10:00.000Z","last_series_event_timestamp":"2026-03-18T07:55:00.000Z","actor":"user-1","action_type":"snooze","group_hash":"gh-1","episode_id":"ep-001","rule_id":"rule-1"} {"create":{}} {"@timestamp":"2026-03-18T08:30:00.000Z","last_series_event_timestamp":"2026-03-18T07:55:00.000Z","actor":"user-2","action_type":"deactivate","group_hash":"elasticgh-2","episode_id":"ep-002","rule_id":"rule-1","reason":"Known maintenance window"} {"create":{}} {"@timestamp":"2026-03-18T08:45:00.000Z","last_series_event_timestamp":"2026-03-18T07:55:00.000Z","actor":"user-2","action_type":"ack","group_hash":"elasticgh-2","episode_id":"ep-002","rule_id":"rule-1"} {"create":{}} {"@timestamp":"2026-03-18T09:00:00.000Z","last_series_event_timestamp":"2026-03-18T08:50:00.000Z","actor":"user-1","action_type":"ack","group_hash":"elasticgh-3","episode_id":"ep-003","rule_id":"rule-2"} {"create":{}} {"@timestamp":"2026-03-18T09:15:00.000Z","last_series_event_timestamp":"2026-03-18T08:50:00.000Z","actor":"user-1","action_type":"unack","group_hash":"elasticgh-3","episode_id":"ep-003","rule_id":"rule-2"} {"create":{}} {"@timestamp":"2026-03-18T09:30:00.000Z","last_series_event_timestamp":"2026-03-18T09:20:00.000Z","actor":"user-3","action_type":"snooze","group_hash":"elasticgh-4","episode_id":"ep-004","rule_id":"rule-2"} {"create":{}} {"@timestamp":"2026-03-18T09:50:00.000Z","last_series_event_timestamp":"2026-03-18T09:20:00.000Z","actor":"user-3","action_type":"unsnooze","group_hash":"elasticgh-4","episode_id":"ep-004","rule_id":"rule-2"} {"create":{}} {"@timestamp":"2026-03-18T10:00:00.000Z","last_series_event_timestamp":"2026-03-18T09:50:00.000Z","actor":"user-2","action_type":"deactivate","group_hash":"elasticgh-5","episode_id":"ep-005","rule_id":"rule-3","reason":"Duplicate alert"} {"create":{}} {"@timestamp":"2026-03-18T10:20:00.000Z","last_series_event_timestamp":"2026-03-18T09:50:00.000Z","actor":"user-1","action_type":"activate","group_hash":"elasticgh-5","episode_id":"ep-005","rule_id":"rule-3","reason":"Re-enabled after investigation"} {"create":{}} {"@timestamp":"2026-03-18T10:30:00.000Z","last_series_event_timestamp":"2026-03-18T10:25:00.000Z","actor":"user-1","action_type":"ack","group_hash":"elasticgh-6","episode_id":"ep-006","rule_id":"rule-3"} {"create":{}} {"@timestamp":"2026-03-18T10:45:00.000Z","last_series_event_timestamp":"2026-03-18T10:25:00.000Z","actor":"user-1","action_type":"snooze","group_hash":"elasticgh-6","episode_id":"ep-006","rule_id":"rule-3"} {"create":{}} {"@timestamp":"2026-03-18T10:55:00.000Z","last_series_event_timestamp":"2026-03-18T10:25:00.000Z","actor":"user-2","action_type":"deactivate","group_hash":"elasticgh-6","episode_id":"ep-006","rule_id":"rule-3","reason":"Root cause fixed"} {"create":{}} {"@timestamp":"2026-03-18T11:00:00.000Z","last_series_event_timestamp":"2026-03-18T10:55:00.000Z","actor":"user-3","action_type":"ack","group_hash":"elasticgh-7","episode_id":"ep-007","rule_id":"rule-4"} {"create":{}} {"@timestamp":"2026-03-18T11:30:00.000Z","last_series_event_timestamp":"2026-03-18T11:20:00.000Z","actor":"user-2","action_type":"snooze","group_hash":"elasticgh-8","episode_id":"ep-008","rule_id":"rule-4"} {"create":{}} {"@timestamp":"2026-03-18T11:45:00.000Z","last_series_event_timestamp":"2026-03-18T11:20:00.000Z","actor":"user-2","action_type":"ack","group_hash":"elasticgh-8","episode_id":"ep-008","rule_id":"rule-4"} {"create":{}} {"@timestamp":"2026-03-18T12:00:00.000Z","last_series_event_timestamp":"2026-03-18T11:50:00.000Z","actor":"user-1","action_type":"deactivate","group_hash":"elasticgh-9","episode_id":"ep-009","rule_id":"rule-5","reason":"Alert storm - suppressing"} {"create":{}} {"@timestamp":"2026-03-18T12:10:00.000Z","last_series_event_timestamp":"2026-03-18T11:50:00.000Z","actor":"user-1","action_type":"snooze","group_hash":"elasticgh-9","episode_id":"ep-009","rule_id":"rule-5"} {"create":{}} {"@timestamp":"2026-03-18T12:30:00.000Z","last_series_event_timestamp":"2026-03-18T12:20:00.000Z","actor":"user-3","action_type":"ack","group_hash":"elasticgh-10","episode_id":"ep-010","rule_id":"rule-5"} {"create":{}} {"@timestamp":"2026-03-18T12:40:00.000Z","last_series_event_timestamp":"2026-03-18T12:20:00.000Z","actor":"user-3","action_type":"unack","group_hash":"elasticgh-10","episode_id":"ep-010","rule_id":"rule-5"} {"create":{}} {"@timestamp":"2026-03-18T12:50:00.000Z","last_series_event_timestamp":"2026-03-18T12:20:00.000Z","actor":"user-3","action_type":"ack","group_hash":"elasticgh-10","episode_id":"ep-010","rule_id":"rule-5"} ``` </details> There are up to 10 episodes with actions, all with ids like `ep-001`. Query the new route and confirm that the results are as expected. ``` POST kbn:/internal/alerting/v2/alerts/action/_bulk_get { "episode_ids": ["ep-001", "ep-002", "ep-003", "foobar"] } ``` --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent fa3dd0b commit aad9636

7 files changed

Lines changed: 269 additions & 0 deletions

File tree

x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/alert_action_schema.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,39 @@ export const bulkCreateAlertActionBodySchema = z
8787
'Request body for bulk create alert actions. Array of 1 to 100 actions, each with group_hash and action payload.'
8888
);
8989
export type BulkCreateAlertActionBody = z.infer<typeof bulkCreateAlertActionBodySchema>;
90+
91+
export const bulkGetAlertActionsBodySchema = z
92+
.object({
93+
episode_ids: z
94+
.array(z.string())
95+
.min(1, 'At least one episode ID must be provided')
96+
.max(100, 'Cannot query more than 100 episode IDs in a single request')
97+
.describe('List of episode identifiers to fetch alert actions for.'),
98+
})
99+
.describe('Request body for bulk getting alert actions by episode IDs.');
100+
101+
export type BulkGetAlertActionsBody = z.infer<typeof bulkGetAlertActionsBodySchema>;
102+
103+
export const bulkGetAlertActionsResponseSchema = z
104+
.array(
105+
z.object({
106+
episode_id: z.string().describe('The episode identifier.'),
107+
rule_id: z.string().nullable().describe('The rule identifier, or null if not found.'),
108+
group_hash: z.string().nullable().describe('The alert group hash, or null if not found.'),
109+
last_ack_action: z
110+
.enum(['ack', 'unack'])
111+
.nullable()
112+
.describe('The last acknowledge action, or null if none.'),
113+
last_deactivate_action: z
114+
.enum(['activate', 'deactivate'])
115+
.nullable()
116+
.describe('The last deactivate action, or null if none.'),
117+
last_snooze_action: z
118+
.enum(['snooze', 'unsnooze'])
119+
.nullable()
120+
.describe('The last snooze action, or null if none.'),
121+
})
122+
)
123+
.describe('Response body for bulk getting alert actions by episode IDs.');
124+
125+
export type BulkGetAlertActionsResponse = z.infer<typeof bulkGetAlertActionsResponseSchema>;

x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createUserProfile, createUserService } from '../services/user_service/u
1414
import { AlertActionsClient } from './alert_actions_client';
1515
import {
1616
getBulkAlertEventsESQLResponse,
17+
getBulkGetAlertActionsESQLResponse,
1718
getAlertEventESQLResponse,
1819
getEmptyESQLResponse,
1920
} from './fixtures/query_responses';
@@ -187,4 +188,91 @@ describe('AlertActionsClient', () => {
187188
expect(storageServiceEsClient.bulk).not.toHaveBeenCalled();
188189
});
189190
});
191+
192+
describe('bulkGet', () => {
193+
it('should return action states for multiple episode IDs', async () => {
194+
queryServiceEsClient.esql.query.mockResolvedValueOnce(
195+
getBulkGetAlertActionsESQLResponse([
196+
{
197+
episode_id: 'episode-1',
198+
rule_id: 'rule-1',
199+
group_hash: 'hash-1',
200+
last_ack_action: 'ack',
201+
last_snooze_action: 'snooze',
202+
},
203+
{
204+
episode_id: 'episode-2',
205+
rule_id: 'rule-2',
206+
group_hash: 'hash-2',
207+
last_deactivate_action: 'deactivate',
208+
},
209+
])
210+
);
211+
212+
const result = await client.bulkGet(['episode-1', 'episode-2']);
213+
214+
expect(result).toEqual([
215+
{
216+
episode_id: 'episode-1',
217+
rule_id: 'rule-1',
218+
group_hash: 'hash-1',
219+
last_ack_action: 'ack',
220+
last_deactivate_action: null,
221+
last_snooze_action: 'snooze',
222+
},
223+
{
224+
episode_id: 'episode-2',
225+
rule_id: 'rule-2',
226+
group_hash: 'hash-2',
227+
last_ack_action: null,
228+
last_deactivate_action: 'deactivate',
229+
last_snooze_action: null,
230+
},
231+
]);
232+
});
233+
234+
it('should return default records with nulls for episodes without actions', async () => {
235+
queryServiceEsClient.esql.query.mockResolvedValueOnce(getEmptyESQLResponse());
236+
237+
const result = await client.bulkGet(['unknown-episode']);
238+
239+
expect(result).toEqual([
240+
{
241+
episode_id: 'unknown-episode',
242+
rule_id: null,
243+
group_hash: null,
244+
last_ack_action: null,
245+
last_deactivate_action: null,
246+
last_snooze_action: null,
247+
},
248+
]);
249+
});
250+
251+
it('should include both matched and unmatched episodes', async () => {
252+
queryServiceEsClient.esql.query.mockResolvedValueOnce(
253+
getBulkGetAlertActionsESQLResponse([{ episode_id: 'episode-1', last_ack_action: 'ack' }])
254+
);
255+
256+
const result = await client.bulkGet(['episode-1', 'episode-2']);
257+
258+
expect(result).toEqual([
259+
{
260+
episode_id: 'episode-1',
261+
rule_id: 'test-rule-id',
262+
group_hash: 'test-group-hash',
263+
last_ack_action: 'ack',
264+
last_deactivate_action: null,
265+
last_snooze_action: null,
266+
},
267+
{
268+
episode_id: 'episode-2',
269+
rule_id: null,
270+
group_hash: null,
271+
last_ack_action: null,
272+
last_deactivate_action: null,
273+
last_snooze_action: null,
274+
},
275+
]);
276+
});
277+
});
190278
});

x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { inject, injectable } from 'inversify';
1111
import { groupBy, omit } from 'lodash';
1212
import type {
1313
BulkCreateAlertActionItemBody,
14+
BulkGetAlertActionsResponse,
1415
CreateAlertActionBody,
1516
} from '@kbn/alerting-v2-schemas';
1617
import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions';
@@ -22,6 +23,7 @@ import type { StorageServiceContract } from '../services/storage_service/storage
2223
import { StorageServiceScopedToken } from '../services/storage_service/tokens';
2324
import type { UserServiceContract } from '../services/user_service/user_service';
2425
import { UserService } from '../services/user_service/user_service';
26+
import { getBulkGetAlertActionsQuery } from './queries';
2527

2628
@injectable()
2729
export class AlertActionsClient {
@@ -55,6 +57,29 @@ export class AlertActionsClient {
5557
});
5658
}
5759

60+
public async bulkGet(episodeIds: string[]): Promise<BulkGetAlertActionsResponse> {
61+
const query = getBulkGetAlertActionsQuery(episodeIds);
62+
const records = queryResponseToRecords<BulkGetAlertActionsResponse[number]>(
63+
await this.queryService.executeQuery({ query: query.query })
64+
);
65+
66+
const returnedEpisodeIds = new Set(records.map((r) => r.episode_id));
67+
for (const episodeId of episodeIds) {
68+
if (!returnedEpisodeIds.has(episodeId)) {
69+
records.push({
70+
episode_id: episodeId,
71+
rule_id: null,
72+
group_hash: null,
73+
last_ack_action: null,
74+
last_deactivate_action: null,
75+
last_snooze_action: null,
76+
});
77+
}
78+
}
79+
80+
return records;
81+
}
82+
5883
public async createBulkActions(
5984
actions: BulkCreateAlertActionItemBody[]
6085
): Promise<{ processed: number; total: number }> {

x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/fixtures/query_responses.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ export function getEmptyESQLResponse(): EsqlQueryResponse {
3838
};
3939
}
4040

41+
export function getBulkGetAlertActionsESQLResponse(
42+
records: Array<{
43+
episode_id?: string;
44+
rule_id?: string;
45+
group_hash?: string;
46+
last_ack_action?: string | null;
47+
last_deactivate_action?: string | null;
48+
last_snooze_action?: string | null;
49+
}>
50+
): EsqlQueryResponse {
51+
return {
52+
columns: [
53+
{ name: 'episode_id', type: 'keyword' },
54+
{ name: 'rule_id', type: 'keyword' },
55+
{ name: 'group_hash', type: 'keyword' },
56+
{ name: 'last_ack_action', type: 'keyword' },
57+
{ name: 'last_deactivate_action', type: 'keyword' },
58+
{ name: 'last_snooze_action', type: 'keyword' },
59+
],
60+
values: records.map((record) => [
61+
record.episode_id ?? 'episode-1',
62+
record.rule_id ?? 'test-rule-id',
63+
record.group_hash ?? 'test-group-hash',
64+
record.last_ack_action ?? null,
65+
record.last_deactivate_action ?? null,
66+
record.last_snooze_action ?? null,
67+
]),
68+
};
69+
}
70+
4171
export function getBulkAlertEventsESQLResponse(
4272
records: Array<{
4373
'@timestamp'?: string;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { esql, type EsqlRequest } from '@elastic/esql';
9+
import { ALERT_ACTIONS_DATA_STREAM } from '../../resources/alert_actions';
10+
11+
export const getBulkGetAlertActionsQuery = (episodeIds: string[]): EsqlRequest => {
12+
const episodeIdValues = episodeIds.map((id) => esql.str(id));
13+
14+
return esql`
15+
FROM ${ALERT_ACTIONS_DATA_STREAM}
16+
| WHERE episode_id IN (${episodeIdValues})
17+
| WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze")
18+
| STATS
19+
last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"),
20+
last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"),
21+
last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze")
22+
BY episode_id, rule_id, group_hash
23+
| KEEP episode_id, rule_id, group_hash, last_ack_action, last_deactivate_action, last_snooze_action
24+
`.toRequest();
25+
};
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import Boom from '@hapi/boom';
9+
import { Request, Response, type RouteHandler } from '@kbn/core-di-server';
10+
import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server';
11+
import { buildRouteValidationWithZod } from '@kbn/zod-helpers';
12+
import { inject, injectable } from 'inversify';
13+
import {
14+
bulkGetAlertActionsBodySchema,
15+
bulkGetAlertActionsResponseSchema,
16+
type BulkGetAlertActionsBody,
17+
} from '@kbn/alerting-v2-schemas';
18+
import { AlertActionsClient } from '../../lib/alert_actions_client';
19+
import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges';
20+
import { INTERNAL_ALERTING_V2_ALERT_API_PATH } from '../constants';
21+
22+
@injectable()
23+
export class BulkGetAlertActionsRoute implements RouteHandler {
24+
static method = 'post' as const;
25+
static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/action/_bulk_get`;
26+
static security: RouteSecurity = {
27+
authz: {
28+
requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.alerts.read],
29+
},
30+
};
31+
static options = { access: 'internal' } as const;
32+
static validate = {
33+
request: {
34+
body: buildRouteValidationWithZod(bulkGetAlertActionsBodySchema),
35+
},
36+
response: {
37+
200: {
38+
body: buildRouteValidationWithZod(bulkGetAlertActionsResponseSchema),
39+
},
40+
},
41+
} as const;
42+
43+
constructor(
44+
@inject(Request)
45+
private readonly request: KibanaRequest<unknown, unknown, BulkGetAlertActionsBody>,
46+
@inject(Response) private readonly response: KibanaResponseFactory,
47+
@inject(AlertActionsClient) private readonly alertActionsClient: AlertActionsClient
48+
) {}
49+
50+
async handle() {
51+
try {
52+
const results = await this.alertActionsClient.bulkGet(this.request.body.episode_ids);
53+
54+
return this.response.ok({ body: results });
55+
} catch (e) {
56+
const boom = Boom.isBoom(e) ? e : Boom.boomify(e);
57+
return this.response.customError({
58+
statusCode: boom.output.statusCode,
59+
body: boom.output.payload,
60+
});
61+
}
62+
}
63+
}

x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { GetRuleRoute } from '../routes/rules/get_rule_route';
1414
import { DeleteRuleRoute } from '../routes/rules/delete_rule_route';
1515
import { CreateAlertActionRoute } from '../routes/alert_actions/create_alert_action_route';
1616
import { BulkCreateAlertActionRoute } from '../routes/alert_actions/bulk_create_alert_action_route';
17+
import { BulkGetAlertActionsRoute } from '../routes/alert_actions/bulk_get_alert_actions_route';
1718
import { BulkActionNotificationPoliciesRoute } from '../routes/notification_policies/bulk_action_notification_policies_route';
1819
import { CreateNotificationPolicyRoute } from '../routes/notification_policies/create_notification_policy_route';
1920
import { DisableNotificationPolicyRoute } from '../routes/notification_policies/disable_notification_policy_route';
@@ -33,6 +34,7 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) {
3334
bind(Route).toConstantValue(DeleteRuleRoute);
3435
bind(Route).toConstantValue(CreateAlertActionRoute);
3536
bind(Route).toConstantValue(BulkCreateAlertActionRoute);
37+
bind(Route).toConstantValue(BulkGetAlertActionsRoute);
3638
bind(Route).toConstantValue(CreateNotificationPolicyRoute);
3739
bind(Route).toConstantValue(GetNotificationPolicyRoute);
3840
bind(Route).toConstantValue(UpdateNotificationPolicyRoute);

0 commit comments

Comments
 (0)