diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts index 6302b88c317e4..13cb86ec8552f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/alert_actions_client/alert_actions_client.ts @@ -17,11 +17,11 @@ import type { } from '../../routes/schemas/alert_action_schema'; import { queryResponseToRecords } from '../services/query_service/query_response_to_records'; import { type QueryServiceContract } from '../services/query_service/query_service'; +import { QueryServiceInternalToken } from '../services/query_service/tokens'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; 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 { QueryServiceInternalToken } from '../services/query_service/tokens'; @injectable() export class AlertActionsClient { @@ -96,10 +96,10 @@ export class AlertActionsClient { private async fetchLastAlertEventRecordsForActions( actions: BulkCreateAlertActionItemBody[] ): Promise { - let whereClause = esql.exp`TRUE`; + let whereClause = esql.exp`FALSE`; for (const action of actions) { whereClause = esql.exp`${whereClause} OR (group_hash == ${action.group_hash} AND ${ - 'episode_id' in action ? esql.exp`episode.id == ${action.episode_id}` : esql.exp`true` + 'episode_id' in action ? esql.exp`episode.id == ${action.episode_id}` : esql.exp`TRUE` })`; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.test.ts index b53b41b238745..161cf045e4023 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.test.ts @@ -9,7 +9,7 @@ import type { BulkResponse } from '@elastic/elasticsearch/lib/api/types'; import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks'; import type { ElasticsearchClient } from '@kbn/core/server'; import moment from 'moment'; -import { ALERT_ACTIONS_DATA_STREAM } from '../../resources/alert_actions'; +import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import type { QueryServiceContract } from '../services/query_service/query_service'; import { createQueryService } from '../services/query_service/query_service.mock'; @@ -17,9 +17,12 @@ import type { StorageServiceContract } from '../services/storage_service/storage import { createStorageService } from '../services/storage_service/storage_service.mock'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; import { DispatcherService } from './dispatcher'; -import { createDispatchableAlertEventsResponse } from './fixtures/dispatcher'; +import { + createAlertEpisodeSuppressionsResponse, + createDispatchableAlertEventsResponse, +} from './fixtures/dispatcher'; import { getDispatchableAlertEventsQuery } from './queries'; -import type { AlertEpisode } from './types'; +import type { AlertEpisode, AlertEpisodeSuppression } from './types'; describe('DispatcherService', () => { let dispatcherService: DispatcherService; @@ -40,7 +43,7 @@ describe('DispatcherService', () => { }); describe('run', () => { - it('indexes fire-events for dispatchable alert episodes', async () => { + it('indexes fire actions for dispatchable alert episodes when no suppressions exist', async () => { const alertEpisodes: AlertEpisode[] = [ { last_event_timestamp: '2026-01-22T07:10:00.000Z', @@ -58,9 +61,25 @@ describe('DispatcherService', () => { }, ]; - queryEsClient.esql.query.mockResolvedValue( - createDispatchableAlertEventsResponse(alertEpisodes) - ); + const suppressions: AlertEpisodeSuppression[] = [ + { + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'episode-1', + should_suppress: false, + }, + { + rule_id: 'rule-2', + group_hash: 'hash-2', + episode_id: 'episode-2', + should_suppress: false, + }, + ]; + + queryEsClient.esql.query + .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + storageEsClient.bulk.mockResolvedValue({ items: [{ create: { _id: '1', status: 201 } }, { create: { _id: '2', status: 201 } }], errors: false, @@ -78,6 +97,7 @@ describe('DispatcherService', () => { .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') .toISOString(); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(2); expect(queryEsClient.esql.query).toHaveBeenCalledWith( { query: getDispatchableAlertEventsQuery().query, @@ -114,7 +134,7 @@ describe('DispatcherService', () => { group_hash: 'hash-1', last_series_event_timestamp: '2026-01-22T07:10:00.000Z', actor: 'system', - action_type: 'fire-event', + action_type: 'fire', rule_id: 'rule-1', source: 'internal', }), @@ -122,7 +142,82 @@ describe('DispatcherService', () => { group_hash: 'hash-2', last_series_event_timestamp: '2026-01-22T07:15:00.000Z', actor: 'system', - action_type: 'fire-event', + action_type: 'fire', + rule_id: 'rule-2', + source: 'internal', + }), + ]) + ); + }); + + it('indexes suppress actions for suppressed alert episodes', async () => { + const alertEpisodes: AlertEpisode[] = [ + { + last_event_timestamp: '2026-01-22T07:10:00.000Z', + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-22T07:15:00.000Z', + rule_id: 'rule-2', + group_hash: 'hash-2', + episode_id: 'episode-2', + episode_status: 'active', + }, + ]; + + const suppressions: AlertEpisodeSuppression[] = [ + { + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'episode-1', + should_suppress: true, + }, + { + rule_id: 'rule-2', + group_hash: 'hash-2', + episode_id: 'episode-2', + should_suppress: false, + }, + ]; + + queryEsClient.esql.query + .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + + storageEsClient.bulk.mockResolvedValue({ + items: [{ create: { _id: '1', status: 201 } }, { create: { _id: '2', status: 201 } }], + errors: false, + } as BulkResponse); + + const result = await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:30:00.000Z'), + }); + + expect(result.startedAt).toBeInstanceOf(Date); + + const [{ operations }] = storageEsClient.bulk.mock.calls[0]; + const safeOperations = operations ?? []; + const docs = safeOperations.filter((_, index) => index % 2 === 1); + expect(docs).toHaveLength(2); + + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + group_hash: 'hash-1', + last_series_event_timestamp: '2026-01-22T07:10:00.000Z', + actor: 'system', + action_type: 'suppress', + rule_id: 'rule-1', + source: 'internal', + }), + expect.objectContaining({ + group_hash: 'hash-2', + last_series_event_timestamp: '2026-01-22T07:15:00.000Z', + actor: 'system', + action_type: 'fire', rule_id: 'rule-2', source: 'internal', }), @@ -138,7 +233,249 @@ describe('DispatcherService', () => { }); expect(result.startedAt).toBeInstanceOf(Date); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(1); expect(storageEsClient.bulk).not.toHaveBeenCalled(); }); + + it('dispatches correct fire/suppress actions across 5 rules with ack, unack, snooze, and deactivate suppressions', async () => { + // Dataset: 5 rules, 9 episodes total + // rule-001: single series, ack then unack → fire + // rule-002: single series, ack with no unack → suppress + // rule-003: two series (series-1 active, series-2 recovered + new episode) → all fire (no actions) + // rule-004: two series, both snoozed (null episode_id) → both suppress + // rule-005: two series, series-1 deactivated → suppress; series-2 no actions → fire + const alertEpisodes: AlertEpisode[] = [ + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + episode_id: 'rule-001-series-1-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-002', + group_hash: 'rule-002-series-1', + episode_id: 'rule-002-series-1-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-003', + group_hash: 'rule-003-series-1', + episode_id: 'rule-003-series-1-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:00:00.000Z', + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + episode_id: 'rule-003-series-2-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:05:00.000Z', + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + episode_id: 'rule-003-series-2-episode-1', + episode_status: 'inactive', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + episode_id: 'rule-003-series-2-episode-2', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-004', + group_hash: 'rule-004-series-1', + episode_id: 'rule-004-series-1-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-004', + group_hash: 'rule-004-series-2', + episode_id: 'rule-004-series-2-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-005', + group_hash: 'rule-005-series-1', + episode_id: 'rule-005-series-1-episode-1', + episode_status: 'active', + }, + { + last_event_timestamp: '2026-01-27T16:15:00.000Z', + rule_id: 'rule-005', + group_hash: 'rule-005-series-2', + episode_id: 'rule-005-series-2-episode-1', + episode_status: 'active', + }, + ]; + + // Suppression query results: + // - rule-001: ack at 16:03, then unack at 16:08 → should_suppress: false + // - rule-002: ack at 16:03, no unack → should_suppress: true + // - rule-003: no actions → no suppression records + // - rule-004: snoozed at 16:03 (null episode_id, applies to all) → should_suppress: true + // - rule-005 series-1: deactivated at 16:08 → should_suppress: true + // - rule-005 series-2: no actions → no suppression record + const suppressions: AlertEpisodeSuppression[] = [ + { + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + episode_id: 'rule-001-series-1-episode-1', + should_suppress: false, + }, + { + rule_id: 'rule-002', + group_hash: 'rule-002-series-1', + episode_id: 'rule-002-series-1-episode-1', + should_suppress: true, + }, + { + rule_id: 'rule-004', + group_hash: 'rule-004-series-1', + episode_id: null, + should_suppress: true, + }, + { + rule_id: 'rule-004', + group_hash: 'rule-004-series-2', + episode_id: null, + should_suppress: true, + }, + { + rule_id: 'rule-005', + group_hash: 'rule-005-series-1', + episode_id: 'rule-005-series-1-episode-1', + should_suppress: true, + }, + ]; + + queryEsClient.esql.query + .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + + storageEsClient.bulk.mockResolvedValue({ + items: Array.from({ length: 10 }, (_, i) => ({ + create: { _id: String(i + 1), status: 201 }, + })), + errors: false, + } as BulkResponse); + + const result = await dispatcherService.run({ + previousStartedAt: new Date('2026-01-25T00:00:00.000Z'), + }); + + expect(result.startedAt).toBeInstanceOf(Date); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(2); + + const [{ operations }] = storageEsClient.bulk.mock.calls[0]; + + const docs = (operations ?? []).filter((_, index) => index % 2 === 1) as AlertAction[]; + expect(docs).toHaveLength(10); + + const fireActions = docs.filter((doc) => doc.action_type === 'fire'); + const suppressActions = docs.filter((doc) => doc.action_type === 'suppress'); + expect(fireActions).toHaveLength(6); + expect(suppressActions).toHaveLength(4); + + // rule-001: fire (ack then unack cancels suppression) + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + actor: 'system', + source: 'internal', + }), + ]) + ); + + // rule-002: suppress (ack with no unack) + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-002', + group_hash: 'rule-002-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'suppress', + }), + ]) + ); + + // rule-003: all fire (no actions exist) + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:05:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + }), + ]) + ); + + // rule-004: both suppress (snoozed with null episode_id) + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-004', + group_hash: 'rule-004-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'suppress', + }), + expect.objectContaining({ + rule_id: 'rule-004', + group_hash: 'rule-004-series-2', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'suppress', + }), + ]) + ); + + // rule-005: series-1 suppress (deactivated), series-2 fire (no actions) + expect(docs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-005', + group_hash: 'rule-005-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'suppress', + }), + expect.objectContaining({ + rule_id: 'rule-005', + group_hash: 'rule-005-series-2', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + }), + ]) + ); + }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.ts index c02632313d599..995a4a9711217 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.ts @@ -18,8 +18,14 @@ import { QueryServiceInternalToken } from '../services/query_service/tokens'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { StorageServiceInternalToken } from '../services/storage_service/tokens'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; -import { getDispatchableAlertEventsQuery } from './queries'; -import type { AlertEpisode, DispatcherExecutionParams, DispatcherExecutionResult } from './types'; +import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery } from './queries'; +import type { + AlertEpisode, + AlertEpisodeSuppression, + DispatcherExecutionParams, + DispatcherExecutionResult, +} from './types'; +import { withDispatcherSpan } from './with_dispatcher_span'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -35,73 +41,120 @@ export class DispatcherService implements DispatcherServiceContract { public async run({ previousStartedAt = new Date(), - abortController, }: DispatcherExecutionParams): Promise { const startedAt = new Date(); - const lookback = moment(previousStartedAt) - .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') - .toISOString(); + + const alertEpisodes = await withDispatcherSpan('dispatcher:fetch-alert-episodes', () => + this.fetchAlertEpisodes(previousStartedAt) + ); + const suppressions = await withDispatcherSpan('dispatcher:fetch-suppressions', () => + this.fetchAlertEpisodeSuppressions(alertEpisodes) + ); + + const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); this.logger.debug({ - message: () => `Dispatcher started. Looking for alert episodes since ${lookback}`, + message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressed.length} suppressed, ${active.length} not suppressed`, }); - const { query } = getDispatchableAlertEventsQuery(); + const now = new Date(); + await withDispatcherSpan('dispatcher:bulk-index-actions', () => + this.storageService.bulkIndexDocs({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + ...suppressed.map((episode) => this.toAction({ episode, actionType: 'suppress', now })), + ...active.map((episode) => this.toAction({ episode, actionType: 'fire', now })), + ], + }) + ); + + return { startedAt }; + } + + private applySuppression( + episodes: AlertEpisode[], + suppressions: AlertEpisodeSuppression[] + ): { suppressed: AlertEpisode[]; active: AlertEpisode[] } { + const suppressionMap = new Map(); + + for (const s of suppressions) { + if (s.episode_id) { + suppressionMap.set(`${s.rule_id}:${s.group_hash}:${s.episode_id}`, s); + } else { + suppressionMap.set(`${s.rule_id}:${s.group_hash}:*`, s); + } + } + + const suppressed: AlertEpisode[] = []; + const active: AlertEpisode[] = []; + + for (const ep of episodes) { + const episodeKey = `${ep.rule_id}:${ep.group_hash}:${ep.episode_id}`; + const seriesKey = `${ep.rule_id}:${ep.group_hash}:*`; + + const episodeSuppression = suppressionMap.get(episodeKey); + const seriesSuppression = suppressionMap.get(seriesKey); + + if (episodeSuppression?.should_suppress || seriesSuppression?.should_suppress) { + suppressed.push(ep); + } else { + active.push(ep); + } + } + + return { suppressed, active }; + } + + private toAction({ + episode, + actionType, + now, + }: { + episode: AlertEpisode; + actionType: 'suppress' | 'fire'; + now: Date; + }): AlertAction { + return { + '@timestamp': now.toISOString(), + group_hash: episode.group_hash, + last_series_event_timestamp: episode.last_event_timestamp, + actor: 'system', + action_type: actionType, + rule_id: episode.rule_id, + source: 'internal', + }; + } + + private async fetchAlertEpisodeSuppressions( + alertEpisodes: AlertEpisode[] + ): Promise { + if (alertEpisodes.length === 0) { + return []; + } const result = await this.queryService.executeQuery({ - query, + query: getAlertEpisodeSuppressionsQuery(alertEpisodes).query, + }); + + return queryResponseToRecords(result); + } + + private async fetchAlertEpisodes(previousStartedAt: Date): Promise { + const lookback = moment(previousStartedAt) + .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') + .toISOString(); + + const result = await this.queryService.executeQuery({ + query: getDispatchableAlertEventsQuery().query, filter: { range: { '@timestamp': { - gte: moment(previousStartedAt) - .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') - .toISOString(), + gte: lookback, }, }, }, }); - const dispatchableAlertEvents = queryResponseToRecords({ - columns: result.columns, - values: result.values, - }); - this.logger.debug({ - message: () => - `Dispatcher found ${dispatchableAlertEvents.length} alert episodes to dispatch.`, - }); - - const ruleIds = Array.from(new Set(dispatchableAlertEvents.map((event) => event.rule_id))); - this.logger.debug({ - message: () => - `Dispatcher found ${ruleIds.length} unique rules with alert episodes to dispatch.`, - }); - - // TODO: - // Fetch policies associated to ruleIds to determine how to dispatch each alert episode - // Suppress dispatchable alert events based on policies - // Log suppressed alert events - // Call policy defined workflow to dispatch alert events - // insert fire-event for non-suppressed alert events - - const now = new Date().toISOString(); - await this.storageService.bulkIndexDocs({ - index: ALERT_ACTIONS_DATA_STREAM, - docs: dispatchableAlertEvents.map((alertEpisode) => ({ - '@timestamp': now, - group_hash: alertEpisode.group_hash, - last_series_event_timestamp: alertEpisode.last_event_timestamp, - actor: 'system', - action_type: 'fire-event', - rule_id: alertEpisode.rule_id, - source: 'internal', - })), - }); - - this.logger.debug({ - message: () => - `Dispatcher finished processing ${dispatchableAlertEvents.length} alert episodes.`, - }); - - return { startedAt }; + return queryResponseToRecords(result); } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/dispatcher.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/dispatcher.ts index cb231059f042d..23beae55c33d9 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/dispatcher.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/dispatcher.ts @@ -6,7 +6,7 @@ */ import type { EsqlQueryResponse } from '@elastic/elasticsearch/lib/api/types'; -import type { AlertEpisode } from '../types'; +import type { AlertEpisode, AlertEpisodeSuppression } from '../types'; export const createDispatchableAlertEventsResponse = ( alertEpisodes: AlertEpisode[] @@ -28,3 +28,22 @@ export const createDispatchableAlertEventsResponse = ( ]), }; }; + +export const createAlertEpisodeSuppressionsResponse = ( + suppressions: AlertEpisodeSuppression[] +): EsqlQueryResponse => { + return { + columns: [ + { name: 'rule_id', type: 'keyword' }, + { name: 'group_hash', type: 'keyword' }, + { name: 'episode_id', type: 'keyword' }, + { name: 'should_suppress', type: 'boolean' }, + ], + values: suppressions.map((suppression) => [ + suppression.rule_id, + suppression.group_hash, + suppression.episode_id, + suppression.should_suppress, + ]), + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/integration_tests/dispatcher.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/integration_tests/dispatcher.test.ts index 1389831cfde17..458d61860966d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/integration_tests/dispatcher.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/integration_tests/dispatcher.test.ts @@ -7,7 +7,7 @@ import type { TestElasticsearchUtils, TestKibanaUtils } from '@kbn/core-test-helpers-kbn-server'; import type { ElasticsearchClient } from '@kbn/core/server'; -import { ALERT_ACTIONS_DATA_STREAM } from '../../../resources/alert_actions'; +import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../../resources/alert_actions'; import { ALERT_EVENTS_DATA_STREAM, type AlertEvent } from '../../../resources/alert_events'; import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; import { createLoggerService } from '../../services/logger_service/logger_service.mock'; @@ -97,6 +97,219 @@ const ALERT_EVENTS_TEST_DATA: AlertEvent[] = [ }, ]; +/** + * 5 rules with various suppression scenarios: + * - rule-001: single series, ack then unack → fire + * - rule-002: single series, ack with no unack → suppress + * - rule-003: two series (no user actions) → all fire + * - rule-004: two series, both snoozed → both suppress + * - rule-005: series-1 deactivated → suppress; series-2 no actions → fire + */ +const SUPPRESSION_ALERT_EVENTS: AlertEvent[] = [ + // rule-001: single series, 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-001', version: 1 }, + group_hash: 'rule-001-series-1', + episode: { id: 'rule-001-series-1-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-002: single series, 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-002', version: 1 }, + group_hash: 'rule-002-series-1', + episode: { id: 'rule-002-series-1-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-003 series-1: 4 events, all active + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-003', version: 1 }, + group_hash: 'rule-003-series-1', + episode: { id: 'rule-003-series-1-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-003 series-2: episode-1 active then recovered, episode-2 active + { + '@timestamp': '2026-01-27T16:00:00.000Z', + type: 'alert', + rule: { id: 'rule-003', version: 1 }, + group_hash: 'rule-003-series-2', + episode: { id: 'rule-003-series-2-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-01-27T16:05:00.000Z', + type: 'alert', + rule: { id: 'rule-003', version: 1 }, + group_hash: 'rule-003-series-2', + episode: { id: 'rule-003-series-2-episode-1', status: 'inactive' }, + data: {}, + status: 'recovered', + source: 'internal', + }, + { + '@timestamp': '2026-01-27T16:10:00.000Z', + type: 'alert', + rule: { id: 'rule-003', version: 1 }, + group_hash: 'rule-003-series-2', + episode: { id: 'rule-003-series-2-episode-2', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-01-27T16:15:00.000Z', + type: 'alert', + rule: { id: 'rule-003', version: 1 }, + group_hash: 'rule-003-series-2', + episode: { id: 'rule-003-series-2-episode-2', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }, + // rule-004 series-1: 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-004', version: 1 }, + group_hash: 'rule-004-series-1', + episode: { id: 'rule-004-series-1-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-004 series-2: 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-004', version: 1 }, + group_hash: 'rule-004-series-2', + episode: { id: 'rule-004-series-2-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-005 series-1: 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-005', version: 1 }, + group_hash: 'rule-005-series-1', + episode: { id: 'rule-005-series-1-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), + // rule-005 series-2: 4 events + ...(['16:00', '16:05', '16:10', '16:15'] as const).map( + (time): AlertEvent => ({ + '@timestamp': `2026-01-27T${time}:00.000Z`, + type: 'alert', + rule: { id: 'rule-005', version: 1 }, + group_hash: 'rule-005-series-2', + episode: { id: 'rule-005-series-2-episode-1', status: 'active' }, + data: {}, + status: 'breached', + source: 'internal', + }) + ), +]; + +/** + * User actions from the dataset that drive suppression decisions: + * - rule-001: ack then unack (suppression cancelled) + * - rule-002: ack only (suppressed) + * - rule-004: snooze both series (suppressed, no episode_id → applies to all) + * - rule-005: deactivate series-1 (suppressed) + */ +const SUPPRESSION_USER_ACTIONS: AlertAction[] = [ + // rule-001: ack at 16:03 + { + '@timestamp': '2026-01-27T16:03:00.000Z', + actor: 'elastic', + action_type: 'ack', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + episode_id: 'rule-001-series-1-episode-1', + }, + // rule-001: unack at 16:08 (cancels the ack) + { + '@timestamp': '2026-01-27T16:08:00.000Z', + actor: 'elastic', + action_type: 'unack', + last_series_event_timestamp: '2026-01-27T16:05:00.000Z', + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + episode_id: 'rule-001-series-1-episode-1', + }, + // rule-002: ack at 16:03 (no unack → stays suppressed) + { + '@timestamp': '2026-01-27T16:03:00.000Z', + actor: 'elastic', + action_type: 'ack', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + rule_id: 'rule-002', + group_hash: 'rule-002-series-1', + episode_id: 'rule-002-series-1-episode-1', + }, + // rule-004 series-1: snooze at 16:03 (no episode_id, expiry far future) + { + '@timestamp': '2026-01-27T16:03:00.000Z', + actor: 'elastic', + action_type: 'snooze', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + expiry: '2026-01-28T16:03:00.000Z', + rule_id: 'rule-004', + group_hash: 'rule-004-series-1', + }, + // rule-004 series-2: snooze at 16:03 (no episode_id, expiry far future) + { + '@timestamp': '2026-01-27T16:03:00.000Z', + actor: 'elastic', + action_type: 'snooze', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + expiry: '2026-01-28T16:03:00.000Z', + rule_id: 'rule-004', + group_hash: 'rule-004-series-2', + }, + // rule-005 series-1: deactivate at 16:08 + { + '@timestamp': '2026-01-27T16:08:00.000Z', + actor: 'elastic', + action_type: 'deactivate', + last_series_event_timestamp: '2026-01-27T16:05:00.000Z', + rule_id: 'rule-005', + group_hash: 'rule-005-series-1', + episode_id: 'rule-005-series-1-episode-1', + }, +]; + describe('DispatcherService integration tests', () => { let esServer: TestElasticsearchUtils; let kibanaServer: TestKibanaUtils; @@ -151,7 +364,7 @@ describe('DispatcherService integration tests', () => { }); }); - describe('when there are alert events without prior fire-events', () => { + describe('when there are alert events without prior "fire" actions', () => { it('should dispatch all unique episodes', async () => { await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); @@ -178,7 +391,7 @@ describe('DispatcherService integration tests', () => { group_hash: 'rule-1-series-1', rule_id: 'rule-1', actor: 'system', - action_type: 'fire-event', + action_type: 'fire', source: 'internal', }); }); @@ -193,7 +406,7 @@ describe('DispatcherService integration tests', () => { }); }); - describe('when some episodes already have fire-events', () => { + describe('when some episodes already have fires', () => { it('should only dispatch the new events', async () => { await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); @@ -228,7 +441,7 @@ describe('DispatcherService integration tests', () => { query: { bool: { must: [ - { term: { action_type: 'fire-event' } }, + { term: { action_type: 'fire' } }, { range: { last_series_event_timestamp: { gte: '2026-01-22T07:55:00.000Z' } } }, ], }, @@ -245,28 +458,141 @@ describe('DispatcherService integration tests', () => { expect(timestamps).toEqual(['2026-01-22T07:55:00.000Z']); }); }); -}); -async function cleanupDataStreams(esClient: ElasticsearchClient): Promise { - try { - await esClient.deleteByQuery({ - index: ALERT_EVENTS_DATA_STREAM, - query: { match_all: {} }, - refresh: true, + describe('when alert episodes have user actions (ack, snooze, deactivate)', () => { + it('should dispatch fire actions for non-suppressed episodes and suppress actions for suppressed ones', async () => { + await seedAlertEvents(esClient, SUPPRESSION_ALERT_EVENTS); + await seedAlertActions(esClient, SUPPRESSION_USER_ACTIONS); + + const result = await dispatcherService.run({ + previousStartedAt: new Date('2026-01-25T00:00:00.000Z'), + }); + + expect(result.startedAt).toBeDefined(); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const actionsResponse = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { + bool: { + filter: [{ terms: { action_type: ['fire', 'suppress'] } }], + }, + }, + size: 100, + }); + + const dispatchedActions = actionsResponse.hits.hits.map( + (hit) => hit._source as Record + ); + + expect(dispatchedActions).toHaveLength(10); + + const fireActions = dispatchedActions.filter((a) => a.action_type === 'fire'); + const suppressActions = dispatchedActions.filter((a) => a.action_type === 'suppress'); + expect(fireActions).toHaveLength(6); + expect(suppressActions).toHaveLength(4); + + // rule-001: fire (ack then unack cancels suppression) + expect(dispatchedActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-001', + group_hash: 'rule-001-series-1', + action_type: 'fire', + actor: 'system', + source: 'internal', + }), + ]) + ); + + // rule-002: suppress (ack with no unack) + expect(dispatchedActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-002', + group_hash: 'rule-002-series-1', + action_type: 'suppress', + }), + ]) + ); + + // rule-003: all fire (no user actions) + expect(dispatchedActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-1', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:00:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:05:00.000Z', + action_type: 'fire', + }), + expect.objectContaining({ + rule_id: 'rule-003', + group_hash: 'rule-003-series-2', + last_series_event_timestamp: '2026-01-27T16:15:00.000Z', + action_type: 'fire', + }), + ]) + ); + + // rule-004: both suppress (snoozed with null episode_id) + expect(dispatchedActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-004', + group_hash: 'rule-004-series-1', + action_type: 'suppress', + }), + expect.objectContaining({ + rule_id: 'rule-004', + group_hash: 'rule-004-series-2', + action_type: 'suppress', + }), + ]) + ); + + // rule-005: series-1 suppress (deactivated), series-2 fire (no actions) + expect(dispatchedActions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + rule_id: 'rule-005', + group_hash: 'rule-005-series-1', + action_type: 'suppress', + }), + expect.objectContaining({ + rule_id: 'rule-005', + group_hash: 'rule-005-series-2', + action_type: 'fire', + }), + ]) + ); }); - } catch (error) { - // noop - } + }); +}); - try { - await esClient.deleteByQuery({ - index: ALERT_ACTIONS_DATA_STREAM, +async function cleanupDataStreams(esClient: ElasticsearchClient): Promise { + await esClient + .deleteByQuery({ + index: `${ALERT_EVENTS_DATA_STREAM},${ALERT_ACTIONS_DATA_STREAM}`, query: { match_all: {} }, refresh: true, + wait_for_completion: true, + }) + .catch((error) => { + // noop }); - } catch (error) { - // noop - } } async function seedAlertEvents(esClient: ElasticsearchClient, events: AlertEvent[]): Promise { @@ -280,3 +606,18 @@ async function seedAlertEvents(esClient: ElasticsearchClient, events: AlertEvent refresh: 'wait_for', }); } + +async function seedAlertActions( + esClient: ElasticsearchClient, + actions: AlertAction[] +): Promise { + const operations = actions.flatMap((doc) => [ + { create: { _index: ALERT_ACTIONS_DATA_STREAM } }, + doc, + ]); + + await esClient.bulk({ + operations, + refresh: 'wait_for', + }); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.ts index 814d53e7904fc..512ad460aa588 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.ts @@ -15,6 +15,7 @@ import { ALERT_EVENTS_DATA_STREAM, type AlertEventType, } from '../../resources/alert_events'; +import type { AlertEpisode } from './types'; export const getDispatchableAlertEventsQuery = (): EsqlRequest => { const alertEventType: AlertEventType = 'alert'; @@ -26,13 +27,51 @@ export const getDispatchableAlertEventsQuery = (): EsqlRequest => { episode_id = COALESCE(episode.id, episode_id), episode_status = episode.status | DROP episode.id, rule.id, episode.status - | INLINE STATS last_fired = max(last_series_event_timestamp) WHERE _index LIKE ${ALERT_ACTIONS_BACKING_INDEX} AND action_type == "fire-event" BY rule_id, group_hash + | INLINE STATS last_fired = max(last_series_event_timestamp) WHERE _index LIKE ${ALERT_ACTIONS_BACKING_INDEX} AND (action_type == "fire" OR action_type == "suppress") BY rule_id, group_hash | WHERE (last_fired IS NULL OR last_fired < @timestamp) or (_index LIKE ${ALERT_ACTIONS_BACKING_INDEX}) | STATS last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ${ALERT_EVENTS_BACKING_INDEX} BY rule_id, group_hash, episode_id, episode_status | WHERE last_event_timestamp IS NOT NULL | KEEP last_event_timestamp, rule_id, group_hash, episode_id, episode_status - | SORT last_event_timestamp desc + | SORT last_event_timestamp asc | LIMIT 10000`.toRequest(); }; + +export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): EsqlRequest => { + const minLastEventTimestamp = + alertEpisodes.reduce((min, ep) => { + const parsedTimestamp = new Date(ep.last_event_timestamp); + if (Number.isNaN(parsedTimestamp.getTime())) { + return min; + } + + const normalizedTimestamp = parsedTimestamp.toISOString(); + return min === undefined || normalizedTimestamp < min ? normalizedTimestamp : min; + }, undefined) ?? new Date(0).toISOString(); + + let whereClause = esql.exp`FALSE`; + for (const alertEpisode of alertEpisodes) { + whereClause = esql.exp`${whereClause} OR (rule_id == ${alertEpisode.rule_id} AND group_hash == ${alertEpisode.group_hash})`; + } + + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE ${whereClause} + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") + | WHERE action_type != "snooze" OR expiry > ${minLastEventTimestamp}::datetime + | INLINE STATS + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") + BY rule_id, group_hash + | 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 = MAX(last_snooze_action) + BY rule_id, group_hash, episode_id + | EVAL should_suppress = CASE( + last_snooze_action == "snooze", true, + last_ack_action == "ack", true, + last_deactivate_action == "deactivate", true, + false + ) + | KEEP rule_id, group_hash, episode_id, should_suppress`.toRequest(); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/schedule_task.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/schedule_task.ts index 768a80cdf7788..00045fa77737f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/schedule_task.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/schedule_task.ts @@ -8,7 +8,7 @@ import type { AlertingServerStartDependencies } from '../../types'; import { DISPATCHER_TASK_ID, DISPATCHER_TASK_TYPE } from './task_definition'; -export const INTERVAL = '30s'; +export const INTERVAL = '5s'; export async function scheduleDispatcherTask({ taskManager, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/types.ts index 79000db9893e1..3efb1ab7a658b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/types.ts @@ -21,6 +21,13 @@ export interface AlertEpisode { episode_status: 'inactive' | 'pending' | 'active' | 'recovering'; } +export interface AlertEpisodeSuppression { + rule_id: RuleId; + group_hash: string; + episode_id: string | null; + should_suppress: boolean; +} + export interface DispatcherExecutionParams { previousStartedAt?: Date; abortController?: AbortController; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/with_dispatcher_span.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/with_dispatcher_span.ts new file mode 100644 index 0000000000000..6018407456612 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/with_dispatcher_span.ts @@ -0,0 +1,15 @@ +/* + * 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 { withSpan } from '@kbn/apm-utils'; + +export async function withDispatcherSpan(name: string, cb: () => Promise): Promise { + return withSpan( + { name: `dispatcher:${name}`, type: 'dispatcher', labels: { plugin: 'alerting_v2' } }, + cb + ); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts index 05e5a979d76c1..a9db44424bdba 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts @@ -50,7 +50,7 @@ export const alertActionSchema = z.object({ last_series_event_timestamp: z.string(), expiry: z.string().optional(), actor: z.string().nullable(), - action_type: z.string(), // "fire-event" + action_type: z.string(), // "fire" | "suppress" episode_id: z.string().optional(), rule_id: z.string(), source: z.string().optional(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/bulk_create_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/bulk_create_alert_action_route.ts index 30955f6d9124c..87c992657d9be 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/bulk_create_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/bulk_create_alert_action_route.ts @@ -6,8 +6,9 @@ */ import Boom from '@hapi/boom'; -import { Request, Response } from '@kbn/core-di-server'; +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 { AlertActionsClient } from '../lib/alert_actions_client'; import { ALERTING_V2_API_PRIVILEGES } from '../lib/security/privileges'; @@ -18,7 +19,7 @@ import { } from './schemas/alert_action_schema'; @injectable() -export class BulkCreateAlertActionRoute { +export class BulkCreateAlertActionRoute implements RouteHandler { static method = 'post' as const; static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/action/_bulk`; static security: RouteSecurity = { @@ -29,7 +30,7 @@ export class BulkCreateAlertActionRoute { static options = { access: 'internal' } as const; static validate = { request: { - body: bulkCreateAlertActionBodySchema, + body: buildRouteValidationWithZod(bulkCreateAlertActionBodySchema), }, } as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_alert_action_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_alert_action_route.ts index 966c3ad8bf98e..5c971f3dc3470 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_alert_action_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/create_alert_action_route.ts @@ -6,8 +6,9 @@ */ import Boom from '@hapi/boom'; -import { Request, Response } from '@kbn/core-di-server'; +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 { AlertActionsClient } from '../lib/alert_actions_client'; import { ALERTING_V2_API_PRIVILEGES } from '../lib/security/privileges'; @@ -20,7 +21,7 @@ import { } from './schemas/alert_action_schema'; @injectable() -export class CreateAlertActionRoute { +export class CreateAlertActionRoute implements RouteHandler { static method = 'post' as const; static path = `${INTERNAL_ALERTING_V2_ALERT_API_PATH}/{group_hash}/action`; static security: RouteSecurity = { @@ -31,8 +32,8 @@ export class CreateAlertActionRoute { static options = { access: 'internal' } as const; static validate = { request: { - params: createAlertActionParamsSchema, - body: createAlertActionBodySchema, + params: buildRouteValidationWithZod(createAlertActionParamsSchema), + body: buildRouteValidationWithZod(createAlertActionBodySchema), }, } as const; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts index 26a8c4a3fd85d..ca841b44e7a5d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts @@ -11,7 +11,7 @@ import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/c import type { RouteHandler } from '@kbn/core-di-server'; import { Request, Response } from '@kbn/core-di-server'; import { inject, injectable } from 'inversify'; -import { DispatcherService } from '../lib/dispatcher/dispatcher'; +import { DispatcherService, type DispatcherServiceContract } from '../lib/dispatcher/dispatcher'; const runDispatchBodySchema = schema.object({ previousStartedAt: schema.maybe(schema.string({ minLength: 1 })), @@ -40,7 +40,7 @@ export class RunDispatchRoute implements RouteHandler { @inject(Request) private readonly request: KibanaRequest, @inject(Response) private readonly response: KibanaResponseFactory, - @inject(DispatcherService) private readonly dispatcherService: DispatcherService + @inject(DispatcherService) private readonly dispatcherService: DispatcherServiceContract ) {} async handle() { diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index f43e9178f3b7b..d845d851f2af0 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -50,6 +50,7 @@ "@kbn/test", "@kbn/esql-language", "@kbn/core-saved-objects-api-server-mocks", + "@kbn/apm-utils", "@kbn/core-user-profile-common", "@kbn/core-user-profile-server-mocks", "@kbn/core-user-profile-server", diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts index 74c3a04d636b7..a41abd317f9c1 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/bulk_create_alert_action.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; import type { RoleCredentials } from '../../services'; -import { createAlertEvent } from './fixtures'; +import { createAlertEvent, indexAlertEvents } from './fixtures'; const BULK_ALERT_ACTION_API_PATH = '/internal/alerting/v2/alerts/action/_bulk'; const ALERTS_EVENTS_INDEX = '.alerts-events'; @@ -20,66 +20,49 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const esClient = getService('es'); describe('Bulk Create Alert Action API', function () { + this.tags(['skipServerless']); let roleAuthc: RoleCredentials; before(async () => { roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin'); - - // Create alert events for two different group hashes - const alertEvent1 = createAlertEvent({ - group_hash: 'group-1', - episode: { id: 'episode-1', status: 'active' }, - }); - const alertEvent2 = createAlertEvent({ - group_hash: 'group-2', - episode: { id: 'episode-2', status: 'active' }, - }); - - await Promise.all([ - esClient.index({ - index: ALERTS_EVENTS_INDEX, - document: alertEvent1, - refresh: 'wait_for', - }), - esClient.index({ - index: ALERTS_EVENTS_INDEX, - document: alertEvent2, - refresh: 'wait_for', - }), - ]); }); after(async () => { await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); await Promise.all([ - esClient.deleteByQuery({ - index: ALERTS_EVENTS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }), - esClient.deleteByQuery({ - index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }), + esClient.deleteByQuery( + { + index: ALERTS_EVENTS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ), + esClient.deleteByQuery( + { + index: ALERTS_ACTIONS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ), ]); }); - afterEach(async () => { - await esClient.deleteByQuery({ - index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }); - }); - - async function getAllActions() { + async function getAllActions(ruleIds: string[]) { + await esClient.indices.refresh({ index: ALERTS_ACTIONS_INDEX }); const result = await esClient.search({ index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, + query: { + bool: { + must_not: [{ terms: { action_type: ['fire', 'suppress'] } }], + filter: [{ terms: { rule_id: ruleIds } }], + }, + }, sort: [{ '@timestamp': 'asc' }], size: 100, }); @@ -97,100 +80,175 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); it('should process single valid action and return counts', async () => { + const ruleId = 'bulk-single-rule'; + const groupHash = 'bulk-single-group'; + const episodeId = 'bulk-single-episode'; + await indexAlertEvents(esClient, [ + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: episodeId, status: 'active' }, + }), + ]); + const response = await supertestWithoutAuth .post(BULK_ALERT_ACTION_API_PATH) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) - .send([{ group_hash: 'group-1', action_type: 'ack', episode_id: 'episode-1' }]); + .send([{ group_hash: groupHash, action_type: 'ack', episode_id: episodeId }]); expect(response.status).to.be(200); expect(response.body).to.eql({ processed: 1, total: 1 }); - const actions = await getAllActions(); + const actions = await getAllActions([ruleId]); expect(actions.length).to.be(1); - expect(actions[0].group_hash).to.be('group-1'); + expect(actions[0].group_hash).to.be(groupHash); expect(actions[0].action_type).to.be('ack'); }); it('should process multiple valid actions and write all documents', async () => { + const ruleId = 'bulk-multi-rule'; + const groupHash1 = 'bulk-multi-group-1'; + const groupHash2 = 'bulk-multi-group-2'; + const episodeId1 = 'bulk-multi-episode-1'; + const episodeId2 = 'bulk-multi-episode-2'; + await indexAlertEvents(esClient, [ + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash1, + episode: { id: episodeId1, status: 'active' }, + }), + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash2, + episode: { id: episodeId2, status: 'active' }, + }), + ]); + const response = await supertestWithoutAuth .post(BULK_ALERT_ACTION_API_PATH) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send([ - { group_hash: 'group-1', action_type: 'ack', episode_id: 'episode-1' }, - { group_hash: 'group-2', action_type: 'snooze' }, + { group_hash: groupHash1, action_type: 'ack', episode_id: episodeId1 }, + { group_hash: groupHash2, action_type: 'snooze' }, ]); expect(response.status).to.be(200); expect(response.body).to.eql({ processed: 2, total: 2 }); - const actions = await getAllActions(); + const actions = await getAllActions([ruleId]); expect(actions.length).to.be(2); - const group1Action = actions.find((a) => a.group_hash === 'group-1'); - const group2Action = actions.find((a) => a.group_hash === 'group-2'); + const group1Action = actions.find((a) => a.group_hash === groupHash1); + const group2Action = actions.find((a) => a.group_hash === groupHash2); expect(group1Action!.action_type).to.be('ack'); expect(group2Action!.action_type).to.be('snooze'); }); it('should handle mixed valid/invalid group hashes with partial success', async () => { + const ruleId = 'bulk-mixed-rule'; + const groupHash1 = 'bulk-mixed-group-1'; + const groupHash2 = 'bulk-mixed-group-2'; + const episodeId1 = 'bulk-mixed-episode-1'; + const episodeId2 = 'bulk-mixed-episode-2'; + await indexAlertEvents(esClient, [ + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash1, + episode: { id: episodeId1, status: 'active' }, + }), + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash2, + episode: { id: episodeId2, status: 'active' }, + }), + ]); + const response = await supertestWithoutAuth .post(BULK_ALERT_ACTION_API_PATH) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send([ - { group_hash: 'group-1', action_type: 'ack', episode_id: 'episode-1' }, - { group_hash: 'unknown-group', action_type: 'ack', episode_id: 'episode-1' }, - { group_hash: 'group-2', action_type: 'unack', episode_id: 'episode-2' }, + { group_hash: groupHash1, action_type: 'ack', episode_id: episodeId1 }, + { + group_hash: 'bulk-mixed-unknown-group', + action_type: 'ack', + episode_id: 'bulk-mixed-unknown-episode', + }, + { group_hash: groupHash2, action_type: 'unack', episode_id: episodeId2 }, ]); expect(response.status).to.be(200); expect(response.body).to.eql({ processed: 2, total: 3 }); - const actions = await getAllActions(); + const actions = await getAllActions([ruleId]); expect(actions.length).to.be(2); const groupHashes = actions.map((a) => a.group_hash); - expect(groupHashes).to.contain('group-1'); - expect(groupHashes).to.contain('group-2'); - expect(groupHashes).not.to.contain('unknown-group'); + expect(groupHashes).to.contain(groupHash1); + expect(groupHashes).to.contain(groupHash2); + expect(groupHashes).not.to.contain('bulk-mixed-unknown-group'); }); it('should return processed 0 when all group hashes are invalid', async () => { + const ruleId = 'bulk-invalid-rule'; + const response = await supertestWithoutAuth .post(BULK_ALERT_ACTION_API_PATH) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send([ - { group_hash: 'unknown-1', action_type: 'ack', episode_id: 'episode-1' }, - { group_hash: 'unknown-2', action_type: 'snooze' }, + { + group_hash: 'bulk-invalid-unknown-1', + action_type: 'ack', + episode_id: 'bulk-invalid-unknown-episode-1', + }, + { group_hash: 'bulk-invalid-unknown-2', action_type: 'snooze' }, ]); expect(response.status).to.be(200); expect(response.body).to.eql({ processed: 0, total: 2 }); - const actions = await getAllActions(); + const actions = await getAllActions([ruleId]); expect(actions.length).to.be(0); }); it('should handle different action types in bulk', async () => { + const ruleId = 'bulk-types-rule'; + const groupHash1 = 'bulk-types-group-1'; + const groupHash2 = 'bulk-types-group-2'; + const episodeId1 = 'bulk-types-episode-1'; + const episodeId2 = 'bulk-types-episode-2'; + await indexAlertEvents(esClient, [ + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash1, + episode: { id: episodeId1, status: 'active' }, + }), + createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash2, + episode: { id: episodeId2, status: 'active' }, + }), + ]); + const response = await supertestWithoutAuth .post(BULK_ALERT_ACTION_API_PATH) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send([ - { group_hash: 'group-1', action_type: 'ack', episode_id: 'episode-1' }, - { group_hash: 'group-1', action_type: 'tag', tags: ['important', 'reviewed'] }, - { group_hash: 'group-2', action_type: 'snooze' }, - { group_hash: 'group-2', action_type: 'activate', reason: 'needs attention' }, + { group_hash: groupHash1, action_type: 'ack', episode_id: episodeId1 }, + { group_hash: groupHash1, action_type: 'tag', tags: ['important', 'reviewed'] }, + { group_hash: groupHash2, action_type: 'snooze' }, + { group_hash: groupHash2, action_type: 'activate', reason: 'needs attention' }, ]); expect(response.status).to.be(200); expect(response.body).to.eql({ processed: 4, total: 4 }); - const actions = await getAllActions(); + const actions = await getAllActions([ruleId]); expect(actions.length).to.be(4); const tagAction = actions.find((a) => a.action_type === 'tag'); diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts index 72185860c1104..86433e5868724 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/create_alert_action.ts @@ -5,11 +5,10 @@ * 2.0. */ -import type { AlertEvent } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; import expect from '@kbn/expect'; import type { DeploymentAgnosticFtrProviderContext } from '../../ftr_provider_context'; import type { RoleCredentials } from '../../services'; -import { createAlertEvent } from './fixtures'; +import { createAlertEvent, indexAlertEvents } from './fixtures'; const ALERT_ACTION_API_PATH = '/internal/alerting/v2/alerts'; const ALERTS_EVENTS_INDEX = '.alerts-events'; @@ -21,70 +20,49 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { const esClient = getService('es'); describe('Create Alert Action API', function () { + this.tags(['skipServerless']); let roleAuthc: RoleCredentials; - let alertEvent: AlertEvent; - let olderAlertEvent: AlertEvent; before(async () => { roleAuthc = await samlAuth.createM2mApiKeyWithRoleScope('admin'); - - olderAlertEvent = createAlertEvent({ - group_hash: 'test-group-hash', - episode: { id: 'episode-1', status: 'active' }, - '@timestamp': '2024-01-01T00:00:00.000Z', - }); - alertEvent = createAlertEvent({ - group_hash: 'test-group-hash', - episode: { id: 'episode-2', status: 'active' }, - '@timestamp': '2024-01-02T00:00:00.000Z', - }); - - await Promise.all([ - esClient.index({ - index: ALERTS_EVENTS_INDEX, - document: olderAlertEvent, - refresh: 'wait_for', - }), - esClient.index({ - index: ALERTS_EVENTS_INDEX, - document: alertEvent, - refresh: 'wait_for', - }), - ]); }); after(async () => { await samlAuth.invalidateM2mApiKeyWithRoleScope(roleAuthc); await Promise.all([ - esClient.deleteByQuery({ - index: ALERTS_EVENTS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }), - esClient.deleteByQuery({ - index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }), + esClient.deleteByQuery( + { + index: ALERTS_EVENTS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ), + esClient.deleteByQuery( + { + index: ALERTS_ACTIONS_INDEX, + query: { match_all: {} }, + refresh: true, + wait_for_completion: true, + conflicts: 'proceed', + }, + { ignore: [404] } + ), ]); }); - afterEach(async () => { - // Clean up actions after each test - await esClient.deleteByQuery({ - index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, - refresh: true, - wait_for_completion: true, - }); - }); - - async function getLatestAction() { + async function getLatestAction(ruleIds: string[]) { + await esClient.indices.refresh({ index: ALERTS_ACTIONS_INDEX }); const result = await esClient.search({ index: ALERTS_ACTIONS_INDEX, - query: { match_all: {} }, + query: { + bool: { + must_not: [{ terms: { action_type: ['fire', 'suppress'] } }], + filter: [{ terms: { rule_id: ruleIds } }], + }, + }, sort: [{ '@timestamp': 'desc' }], size: 1, }); @@ -92,58 +70,95 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { } it('should return 204 for ack action and write action document', async () => { + const ruleId = 'ack-test-rule'; + const groupHash = 'ack-test-group'; + const episodeId = 'ack-test-episode'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: episodeId, status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: 'episode-2' }); + .send({ action_type: 'ack', episode_id: episodeId }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('ack'); - expect(action!.episode_id).to.be(alertEvent.episode?.id); - expect(action!.rule_id).to.be(alertEvent.rule.id); - expect(action!.last_series_event_timestamp).to.be(alertEvent['@timestamp']); + expect(action!.episode_id).to.be(episodeId); + expect(action!.rule_id).to.be(ruleId); + expect(action!.last_series_event_timestamp).to.be(event['@timestamp']); }); it('should return 204 for unack action and write action document', async () => { + const ruleId = 'unack-test-rule'; + const groupHash = 'unack-test-group'; + const episodeId = 'unack-test-episode'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: episodeId, status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'unack', episode_id: 'episode-2' }); + .send({ action_type: 'unack', episode_id: episodeId }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('unack'); - expect(action!.episode_id).to.be(alertEvent.episode?.id); + expect(action!.episode_id).to.be(episodeId); }); it('should return 204 for tag action with tags and write action document', async () => { + const ruleId = 'tag-test-rule'; + const groupHash = 'tag-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'tag-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'tag', tags: ['tag1', 'tag2'] }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('tag'); expect(action!.tags).to.eql(['tag1', 'tag2']); }); it('should return 400 for tag action without tags', async () => { + const groupHash = 'tag-no-tags-test-group'; + const event = createAlertEvent({ + rule: { id: 'tag-no-tags-test-rule', version: 1 }, + group_hash: groupHash, + episode: { id: 'tag-no-tags-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'tag' }); @@ -152,70 +167,114 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); it('should return 204 for untag action with tags and write action document', async () => { + const ruleId = 'untag-test-rule'; + const groupHash = 'untag-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'untag-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'untag', tags: ['tag1'] }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('untag'); expect(action!.tags).to.eql(['tag1']); }); it('should return 204 for snooze action and write action document', async () => { + const ruleId = 'snooze-test-rule'; + const groupHash = 'snooze-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'snooze-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'snooze' }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('snooze'); }); it('should return 204 for unsnooze action and write action document', async () => { + const ruleId = 'unsnooze-test-rule'; + const groupHash = 'unsnooze-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'unsnooze-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'unsnooze' }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('unsnooze'); }); it('should return 204 for activate action with reason and write action document', async () => { + const ruleId = 'activate-test-rule'; + const groupHash = 'activate-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'activate-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'activate', reason: 'test reason' }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('activate'); expect(action!.reason).to.be('test reason'); }); it('should return 400 for activate action without reason', async () => { + const groupHash = 'activate-no-reason-test-group'; + const event = createAlertEvent({ + rule: { id: 'activate-no-reason-test-rule', version: 1 }, + group_hash: groupHash, + episode: { id: 'activate-no-reason-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'activate' }); @@ -224,24 +283,41 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { }); it('should return 204 for deactivate action with reason and write action document', async () => { + const ruleId = 'deactivate-test-rule'; + const groupHash = 'deactivate-test-group'; + const event = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: 'deactivate-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'deactivate', reason: 'test reason' }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('deactivate'); expect(action!.reason).to.be('test reason'); }); it('should return 400 for deactivate action without reason', async () => { + const groupHash = 'deactivate-no-reason-test-group'; + const event = createAlertEvent({ + rule: { id: 'deactivate-no-reason-test-rule', version: 1 }, + group_hash: groupHash, + episode: { id: 'deactivate-no-reason-test-episode', status: 'active' }, + }); + await indexAlertEvents(esClient, [event]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) .send({ action_type: 'deactivate' }); @@ -254,26 +330,45 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { .post(`${ALERT_ACTION_API_PATH}/unknown-group-hash/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: 'episode-2' }); + .send({ action_type: 'ack', episode_id: 'unknown-episode' }); expect(response.status).to.be(404); }); it('should filter by episode_id when provided in request body', async () => { + const ruleId = 'episode-filter-test-rule'; + const groupHash = 'episode-filter-test-group'; + const olderEpisodeId = 'episode-filter-older'; + const newerEpisodeId = 'episode-filter-newer'; + + const olderEvent = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: olderEpisodeId, status: 'active' }, + '@timestamp': '2024-01-01T00:00:00.000Z', + }); + const newerEvent = createAlertEvent({ + rule: { id: ruleId, version: 1 }, + group_hash: groupHash, + episode: { id: newerEpisodeId, status: 'active' }, + '@timestamp': '2024-01-02T00:00:00.000Z', + }); + await indexAlertEvents(esClient, [olderEvent, newerEvent]); + const response = await supertestWithoutAuth - .post(`${ALERT_ACTION_API_PATH}/test-group-hash/action`) + .post(`${ALERT_ACTION_API_PATH}/${groupHash}/action`) .set(roleAuthc.apiKeyHeader) .set(samlAuth.getInternalRequestHeader()) - .send({ action_type: 'ack', episode_id: 'episode-1' }); + .send({ action_type: 'ack', episode_id: olderEpisodeId }); expect(response.status).to.be(204); - const action = await getLatestAction(); + const action = await getLatestAction([ruleId]); expect(action).to.be.ok(); - expect(action!.group_hash).to.be('test-group-hash'); + expect(action!.group_hash).to.be(groupHash); expect(action!.action_type).to.be('ack'); - expect(action!.episode_id).to.be(olderAlertEvent.episode?.id); - expect(action!.last_series_event_timestamp).to.be(olderAlertEvent['@timestamp']); + expect(action!.episode_id).to.be(olderEpisodeId); + expect(action!.last_series_event_timestamp).to.be(olderEvent['@timestamp']); }); }); } diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/alert_event.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/alert_event.ts index b88acfc19ab55..d5aa6e5639752 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/alert_event.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/alert_event.ts @@ -5,8 +5,11 @@ * 2.0. */ +import type { Client } from '@elastic/elasticsearch'; import type { AlertEvent } from '@kbn/alerting-v2-plugin/server/resources/alert_events'; +const ALERTS_EVENTS_INDEX = '.alerts-events'; + export const createAlertEvent = (overrides?: Partial): AlertEvent => ({ '@timestamp': new Date().toISOString(), scheduled_timestamp: new Date().toISOString(), @@ -25,3 +28,11 @@ export const createAlertEvent = (overrides?: Partial): AlertEvent => }, ...overrides, }); + +export const indexAlertEvents = async (esClient: Client, events: AlertEvent[]): Promise => { + await Promise.all( + events.map((event) => + esClient.index({ index: ALERTS_EVENTS_INDEX, document: event, refresh: true }) + ) + ); +}; diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts index 5e1540a7da063..02d03f386c5cc 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/fixtures/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { createAlertEvent } from './alert_event'; +export { createAlertEvent, indexAlertEvents } from './alert_event';