Skip to content

Commit 4bae8ff

Browse files
committed
Merge remote-tracking branch 'upstream/alerting_v2' into alerting-v2-kbn-data-streams
2 parents ebe03ac + 726afe3 commit 4bae8ff

32 files changed

Lines changed: 760 additions & 97 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,14 @@ const scheduleSchema = z
3333
.strict()
3434
.describe('Schedule configuration for the rule.');
3535

36+
export const ruleKindSchema = z.enum(['alert', 'signal']).describe('The kind of rule.');
37+
38+
export type RuleKind = z.infer<typeof ruleKindSchema>;
39+
3640
export const createRuleDataSchema = z
3741
.object({
3842
name: z.string().min(1).max(64).describe('Human-readable rule name.'),
43+
kind: ruleKindSchema,
3944
tags: z
4045
.array(z.string().max(64).describe('Rule tag.'))
4146
.max(100)

x-pack/platform/plugins/shared/alerting_v2/public/components/create_rule_page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { YamlRuleEditor } from '@kbn/yaml-rule-editor';
3030
import { RulesApi } from '../services/rules_api';
3131

3232
const DEFAULT_RULE_YAML = `name: Example rule
33+
kind: alert
3334
tags: []
3435
schedule:
3536
custom: 1m
@@ -41,6 +42,7 @@ groupingKey: []`;
4142

4243
const DEFAULT_RULE_VALUES: CreateRuleData = {
4344
name: 'Example rule',
45+
kind: 'alert',
4446
tags: [],
4547
schedule: { custom: '1m' },
4648
enabled: true,
@@ -114,6 +116,7 @@ export const CreateRulePage = () => {
114116
const nextPayload: CreateRuleData = {
115117
...DEFAULT_RULE_VALUES,
116118
name: rule.name,
119+
kind: rule.kind ?? DEFAULT_RULE_VALUES.kind,
117120
tags: rule.tags ?? DEFAULT_RULE_VALUES.tags,
118121
schedule: rule.schedule?.custom
119122
? { custom: rule.schedule.custom }

x-pack/platform/plugins/shared/alerting_v2/public/services/rules_api.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
import { inject, injectable } from 'inversify';
99
import type { HttpStart } from '@kbn/core/public';
1010
import { CoreStart } from '@kbn/core-di-browser';
11-
import type { CreateRuleData } from '@kbn/alerting-v2-schemas';
11+
import type { CreateRuleData, RuleKind } from '@kbn/alerting-v2-schemas';
1212
import { INTERNAL_ALERTING_V2_RULE_API_PATH } from '../constants';
1313

1414
export interface RuleListItem {
1515
id: string;
1616
name: string;
17+
kind: RuleKind;
1718
enabled?: boolean;
1819
query?: string;
1920
schedule?: { custom?: string };

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

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
* 2.0.
66
*/
77

8-
import { httpServerMock } from '@kbn/core-http-server-mocks';
9-
import { securityMock } from '@kbn/security-plugin/server/mocks';
8+
import type { UserProfileServiceStart } from '@kbn/core-user-profile-server';
9+
import type { UserService } from '../services/user_service/user_service';
1010
import type { CreateAlertActionBody } from '../../routes/schemas/alert_action_schema';
1111
import { createQueryService } from '../services/query_service/query_service.mock';
1212
import { createStorageService } from '../services/storage_service/storage_service.mock';
13+
import { createUserProfile, createUserService } from '../services/user_service/user_service.mock';
1314
import { AlertActionsClient } from './alert_actions_client';
1415
import {
1516
getBulkAlertEventsESQLResponse,
@@ -19,16 +20,17 @@ import {
1920

2021
describe('AlertActionsClient', () => {
2122
jest.useFakeTimers().setSystemTime(new Date('2025-01-01T11:12:13.000Z'));
22-
const request = httpServerMock.createKibanaRequest();
2323
const { queryService, mockEsClient: queryServiceEsClient } = createQueryService();
2424
const { storageService, mockEsClient: storageServiceEsClient } = createStorageService();
25-
const security = securityMock.createStart();
25+
let userService: UserService;
26+
let userProfile: jest.Mocked<UserProfileServiceStart>;
2627
let client: AlertActionsClient;
2728

2829
beforeEach(() => {
29-
security.authc.getCurrentUser = jest.fn().mockReturnValue({ username: 'test-user' });
30+
({ userService, userProfile } = createUserService());
31+
userProfile.getCurrent.mockResolvedValue(createUserProfile('test-uid'));
3032
storageServiceEsClient.bulk.mockResolvedValueOnce({ items: [], errors: false, took: 1 });
31-
client = new AlertActionsClient(request, queryService, storageService, security);
33+
client = new AlertActionsClient(queryService, storageService, userService);
3234
});
3335

3436
afterEach(() => {
@@ -62,7 +64,7 @@ describe('AlertActionsClient', () => {
6264
episode_id: 'episode-1',
6365
rule_id: 'test-rule-id',
6466
last_series_event_timestamp: '2025-01-01T00:00:00.000Z',
65-
actor: 'test-user',
67+
actor: 'test-uid',
6668
});
6769
expect(docs[0]).toHaveProperty('@timestamp');
6870
});
@@ -104,14 +106,14 @@ describe('AlertActionsClient', () => {
104106
expect(docs[0]).toMatchObject({ episode_id: 'episode-2' });
105107
});
106108

107-
it('should handle null username when security is not available', async () => {
109+
it('should handle null profile uid when security is not available', async () => {
108110
queryServiceEsClient.esql.query.mockResolvedValueOnce(getAlertEventESQLResponse());
109111

112+
userProfile.getCurrent.mockResolvedValueOnce(null);
110113
const clientWithoutSecurity = new AlertActionsClient(
111-
request,
112114
queryService,
113115
storageService,
114-
undefined
116+
userService
115117
);
116118

117119
await clientWithoutSecurity.createAction({

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

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,8 @@
66
*/
77

88
import Boom from '@hapi/boom';
9-
import { PluginStart } from '@kbn/core-di';
10-
import { Request } from '@kbn/core-di-server';
11-
import type { KibanaRequest } from '@kbn/core-http-server';
129
import { esql } from '@kbn/esql-language';
13-
import type { SecurityPluginStart } from '@kbn/security-plugin/server';
14-
import { inject, injectable, optional } from 'inversify';
10+
import { inject, injectable } from 'inversify';
1511
import { groupBy, omit } from 'lodash';
1612
import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions';
1713
import { ALERT_EVENTS_DATA_STREAM } from '../../resources/alert_events';
@@ -23,22 +19,23 @@ import { queryResponseToRecords } from '../services/query_service/query_response
2319
import { QueryService, type QueryServiceContract } from '../services/query_service/query_service';
2420
import type { StorageServiceContract } from '../services/storage_service/storage_service';
2521
import { StorageServiceScopedToken } from '../services/storage_service/tokens';
22+
import type { UserServiceContract } from '../services/user_service/user_service';
23+
import { UserService } from '../services/user_service/user_service';
2624

2725
@injectable()
2826
export class AlertActionsClient {
2927
constructor(
30-
@inject(Request) private readonly request: KibanaRequest,
3128
@inject(QueryService) private readonly queryService: QueryServiceContract,
3229
@inject(StorageServiceScopedToken) private readonly storageService: StorageServiceContract,
33-
@optional() @inject(PluginStart('security')) private readonly security?: SecurityPluginStart
30+
@inject(UserService) private readonly userService: UserServiceContract
3431
) {}
3532

3633
public async createAction(params: {
3734
groupHash: string;
3835
action: CreateAlertActionBody;
3936
}): Promise<void> {
40-
const [username, alertEvent] = await Promise.all([
41-
this.getUserName(),
37+
const [userProfileUid, alertEvent] = await Promise.all([
38+
this.getUserProfileUid(),
4239
this.findLastAlertEventRecordOrThrow({
4340
groupHash: params.groupHash,
4441
episodeId: 'episode_id' in params.action ? params.action.episode_id : undefined,
@@ -51,7 +48,7 @@ export class AlertActionsClient {
5148
this.buildAlertActionDocument({
5249
action: params.action,
5350
alertEvent,
54-
username,
51+
userProfileUid,
5552
}),
5653
],
5754
});
@@ -60,8 +57,8 @@ export class AlertActionsClient {
6057
public async createBulkActions(
6158
actions: BulkCreateAlertActionItemBody[]
6259
): Promise<{ processed: number; total: number }> {
63-
const [username, records] = await Promise.all([
64-
this.getUserName(),
60+
const [userProfileUid, records] = await Promise.all([
61+
this.getUserProfileUid(),
6562
this.fetchLastAlertEventRecordsForActions(actions),
6663
]);
6764

@@ -82,7 +79,7 @@ export class AlertActionsClient {
8279
return this.buildAlertActionDocument({
8380
action,
8481
alertEvent: matchingAlertEventRecord,
85-
username,
82+
userProfileUid,
8683
});
8784
}
8885
})
@@ -122,21 +119,21 @@ export class AlertActionsClient {
122119
);
123120
}
124121

125-
private async getUserName(): Promise<string | null> {
126-
return this.security?.authc.getCurrentUser(this.request)?.username ?? null;
122+
private async getUserProfileUid(): Promise<string | null> {
123+
return this.userService.getCurrentUserProfileUid();
127124
}
128125

129126
private buildAlertActionDocument(params: {
130127
action: CreateAlertActionBody;
131128
alertEvent: AlertEventRecord;
132-
username: string | null;
129+
userProfileUid: string | null;
133130
}): AlertAction {
134-
const { action, alertEvent, username } = params;
131+
const { action, alertEvent, userProfileUid } = params;
135132
const actionData = omit(action, ['episode_id', 'action_type']);
136133

137134
return {
138135
'@timestamp': new Date().toISOString(),
139-
actor: username,
136+
actor: userProfileUid,
140137
action_type: action.action_type,
141138
last_series_event_timestamp: alertEvent['@timestamp'],
142139
rule_id: alertEvent.rule_id,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 type { ElasticsearchClient } from '@kbn/core/server';
9+
import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks';
10+
import { createLoggerService } from '../services/logger_service/logger_service.mock';
11+
import { createQueryService } from '../services/query_service/query_service.mock';
12+
import { BasicTransitionStrategy } from './strategies/basic_strategy';
13+
import { TransitionStrategyFactory } from './strategies/strategy_resolver';
14+
import { DirectorService } from './director';
15+
16+
export function createDirectorService(): {
17+
directorService: DirectorService;
18+
mockEsClient: DeeplyMockedApi<ElasticsearchClient>;
19+
} {
20+
const { queryService, mockEsClient } = createQueryService();
21+
const { loggerService } = createLoggerService();
22+
23+
const basicStrategy = new BasicTransitionStrategy();
24+
const strategyFactory = new TransitionStrategyFactory(basicStrategy);
25+
const directorService = new DirectorService(strategyFactory, queryService, loggerService);
26+
27+
return {
28+
directorService,
29+
mockEsClient,
30+
};
31+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
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 type { BulkResponse } from '@elastic/elasticsearch/lib/api/types';
9+
import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks';
10+
import type { ElasticsearchClient } from '@kbn/core/server';
11+
import moment from 'moment';
12+
import { ALERT_ACTIONS_DATA_STREAM } from '../../resources/alert_actions';
13+
import { createLoggerService } from '../services/logger_service/logger_service.mock';
14+
import type { QueryServiceContract } from '../services/query_service/query_service';
15+
import { createQueryService } from '../services/query_service/query_service.mock';
16+
import type { StorageServiceContract } from '../services/storage_service/storage_service';
17+
import { createStorageService } from '../services/storage_service/storage_service.mock';
18+
import { LOOKBACK_WINDOW_MINUTES } from './constants';
19+
import { DispatcherService } from './dispatcher';
20+
import { createDispatchableAlertEventsResponse } from './fixtures/dispatcher';
21+
import { getDispatchableAlertEventsQuery } from './queries';
22+
import type { AlertEpisode } from './types';
23+
24+
describe('DispatcherService', () => {
25+
let dispatcherService: DispatcherService;
26+
let queryService: QueryServiceContract;
27+
let storageService: StorageServiceContract;
28+
let queryEsClient: DeeplyMockedApi<ElasticsearchClient>;
29+
let storageEsClient: jest.Mocked<ElasticsearchClient>;
30+
31+
beforeEach(() => {
32+
({ queryService, mockEsClient: queryEsClient } = createQueryService());
33+
({ storageService, mockEsClient: storageEsClient } = createStorageService());
34+
const { loggerService } = createLoggerService();
35+
dispatcherService = new DispatcherService(queryService, loggerService, storageService);
36+
});
37+
38+
afterEach(() => {
39+
jest.clearAllMocks();
40+
});
41+
42+
describe('run', () => {
43+
it('indexes fire-events for dispatchable alert episodes', async () => {
44+
const alertEpisodes: AlertEpisode[] = [
45+
{
46+
last_event_timestamp: '2026-01-22T07:10:00.000Z',
47+
rule_id: 'rule-1',
48+
group_hash: 'hash-1',
49+
episode_id: 'episode-1',
50+
episode_status: 'active',
51+
},
52+
{
53+
last_event_timestamp: '2026-01-22T07:15:00.000Z',
54+
rule_id: 'rule-2',
55+
group_hash: 'hash-2',
56+
episode_id: 'episode-2',
57+
episode_status: 'inactive',
58+
},
59+
];
60+
61+
queryEsClient.esql.query.mockResolvedValue(
62+
createDispatchableAlertEventsResponse(alertEpisodes)
63+
);
64+
storageEsClient.bulk.mockResolvedValue({
65+
items: [{ create: { _id: '1', status: 201 } }, { create: { _id: '2', status: 201 } }],
66+
errors: false,
67+
} as BulkResponse);
68+
69+
const previousStartedAt = new Date('2026-01-22T07:30:00.000Z');
70+
71+
const result = await dispatcherService.run({
72+
previousStartedAt,
73+
});
74+
75+
expect(result.startedAt).toBeInstanceOf(Date);
76+
77+
const expectedLookback = moment(previousStartedAt)
78+
.subtract(LOOKBACK_WINDOW_MINUTES, 'minutes')
79+
.toISOString();
80+
81+
expect(queryEsClient.esql.query).toHaveBeenCalledWith(
82+
{
83+
query: getDispatchableAlertEventsQuery().query,
84+
drop_null_columns: false,
85+
filter: {
86+
range: {
87+
'@timestamp': {
88+
gte: expectedLookback,
89+
},
90+
},
91+
},
92+
params: undefined,
93+
},
94+
{ signal: undefined }
95+
);
96+
97+
expect(storageEsClient.bulk).toHaveBeenCalledWith({
98+
operations: expect.any(Array),
99+
refresh: 'wait_for',
100+
});
101+
102+
const [{ operations }] = storageEsClient.bulk.mock.calls[0];
103+
const safeOperations = operations ?? [];
104+
const createOperations = safeOperations.filter((_, index) => index % 2 === 0);
105+
const docs = safeOperations.filter((_, index) => index % 2 === 1);
106+
expect(createOperations).toEqual(
107+
expect.arrayContaining([{ create: { _index: ALERT_ACTIONS_DATA_STREAM } }])
108+
);
109+
expect(docs).toHaveLength(alertEpisodes.length);
110+
111+
expect(docs).toEqual(
112+
expect.arrayContaining([
113+
expect.objectContaining({
114+
group_hash: 'hash-1',
115+
last_series_event_timestamp: '2026-01-22T07:10:00.000Z',
116+
actor: 'system',
117+
action_type: 'fire-event',
118+
rule_id: 'rule-1',
119+
source: 'internal',
120+
}),
121+
expect.objectContaining({
122+
group_hash: 'hash-2',
123+
last_series_event_timestamp: '2026-01-22T07:15:00.000Z',
124+
actor: 'system',
125+
action_type: 'fire-event',
126+
rule_id: 'rule-2',
127+
source: 'internal',
128+
}),
129+
])
130+
);
131+
});
132+
133+
it('handles empty alert episode responses', async () => {
134+
queryEsClient.esql.query.mockResolvedValue(createDispatchableAlertEventsResponse([]));
135+
136+
const result = await dispatcherService.run({
137+
previousStartedAt: new Date('2026-01-22T07:30:00.000Z'),
138+
});
139+
140+
expect(result.startedAt).toBeInstanceOf(Date);
141+
expect(storageEsClient.bulk).not.toHaveBeenCalled();
142+
});
143+
});
144+
});

0 commit comments

Comments
 (0)