From 246ef0b4b1b36028fae891b9968bb06689d66b5e Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 4 Feb 2026 10:51:13 -0500 Subject: [PATCH 01/54] Fix wiring --- .../alert_actions_client.ts | 30 ++++++++++++++++--- .../routes/bulk_create_alert_action_route.ts | 7 +++-- .../routes/create_alert_action_route.ts | 9 +++--- .../server/routes/run_dispatch_route.ts | 4 +-- 4 files changed, 37 insertions(+), 13 deletions(-) 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 fb776f2ccc82e..63ff1ce99ba44 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 @@ -19,8 +19,13 @@ import type { BulkCreateAlertActionItemBody, CreateAlertActionBody, } from '../../routes/schemas/alert_action_schema'; +import { + LoggerServiceToken, + type LoggerServiceContract, +} from '../services/logger_service/logger_service'; import { queryResponseToRecords } from '../services/query_service/query_response_to_records'; -import { QueryService, type QueryServiceContract } from '../services/query_service/query_service'; +import type { QueryServiceContract } from '../services/query_service/query_service'; +import { QueryServiceScopedToken } from '../services/query_service/tokens'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { StorageServiceScopedToken } from '../services/storage_service/tokens'; @@ -28,8 +33,9 @@ import { StorageServiceScopedToken } from '../services/storage_service/tokens'; export class AlertActionsClient { constructor( @inject(Request) private readonly request: KibanaRequest, - @inject(QueryService) private readonly queryService: QueryServiceContract, + @inject(QueryServiceScopedToken) private readonly queryService: QueryServiceContract, @inject(StorageServiceScopedToken) private readonly storageService: StorageServiceContract, + @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, @optional() @inject(PluginStart('security')) private readonly security?: SecurityPluginStart ) {} @@ -37,6 +43,10 @@ export class AlertActionsClient { groupHash: string; action: CreateAlertActionBody; }): Promise { + this.logger.debug({ + message: () => + `Creating alert action for group_hash [${params.groupHash}] with action type [${params.action.action_type}].`, + }); const [username, alertEvent] = await Promise.all([ this.getUserName(), this.findLastAlertEventRecordOrThrow({ @@ -151,16 +161,28 @@ export class AlertActionsClient { episodeId?: string; }): Promise { const { groupHash, episodeId } = params; + this.logger.debug({ + message: () => + `Fetching last alert event record for group_hash [${groupHash}]${ + episodeId ? ` and episode_id [${episodeId}]` : '' + }.`, + }); + const query = esql` FROM ${ALERT_EVENTS_DATA_STREAM} | WHERE type == "alert" AND group_hash == ${groupHash} AND ${ - episodeId ? esql.exp`episode_id == ${episodeId}` : esql.exp`true` + episodeId ? esql.exp`episode.id == ${episodeId}` : esql.exp`true` } | SORT @timestamp DESC - | RENAME rule.id AS rule_id + | RENAME rule.id AS rule_id, episode.id AS episode_id | KEEP @timestamp, group_hash, episode_id, rule_id | LIMIT 1`.toRequest(); + this.logger.debug({ + message: () => + `Executing ESQL query to find last alert event record: ${JSON.stringify(query.query)}`, + }); + const result = queryResponseToRecords( await this.queryService.executeQuery({ query: query.query }) ); 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() { From 1ee16201e824a4a1a2909d9b98bf48adf3fa9da3 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 4 Feb 2026 10:54:13 -0500 Subject: [PATCH 02/54] Fix bulk query after schema change --- .../alert_actions_client.ts | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) 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 63ff1ce99ba44..11088c6908233 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 @@ -43,10 +43,6 @@ export class AlertActionsClient { groupHash: string; action: CreateAlertActionBody; }): Promise { - this.logger.debug({ - message: () => - `Creating alert action for group_hash [${params.groupHash}] with action type [${params.action.action_type}].`, - }); const [username, alertEvent] = await Promise.all([ this.getUserName(), this.findLastAlertEventRecordOrThrow({ @@ -111,7 +107,7 @@ export class AlertActionsClient { let whereClause = esql.exp`TRUE`; 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` })`; } @@ -120,7 +116,7 @@ export class AlertActionsClient { | WHERE type == "alert" AND (${whereClause}) | STATS last_event_timestamp = MAX(@timestamp), - last_episode_id = LAST(episode_id, @timestamp), + last_episode_id = LAST(episode.id, @timestamp), rule_id = VALUES(rule.id) BY group_hash | KEEP last_event_timestamp, rule_id, group_hash, last_episode_id @@ -161,13 +157,6 @@ export class AlertActionsClient { episodeId?: string; }): Promise { const { groupHash, episodeId } = params; - this.logger.debug({ - message: () => - `Fetching last alert event record for group_hash [${groupHash}]${ - episodeId ? ` and episode_id [${episodeId}]` : '' - }.`, - }); - const query = esql` FROM ${ALERT_EVENTS_DATA_STREAM} | WHERE type == "alert" AND group_hash == ${groupHash} AND ${ @@ -178,11 +167,6 @@ export class AlertActionsClient { | KEEP @timestamp, group_hash, episode_id, rule_id | LIMIT 1`.toRequest(); - this.logger.debug({ - message: () => - `Executing ESQL query to find last alert event record: ${JSON.stringify(query.query)}`, - }); - const result = queryResponseToRecords( await this.queryService.executeQuery({ query: query.query }) ); From 770fafea69897c305edba6877e27fb481ea00f94 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 4 Feb 2026 10:54:48 -0500 Subject: [PATCH 03/54] Fix bulk query after schema change --- .../server/lib/alert_actions_client/alert_actions_client.ts | 5 ----- 1 file changed, 5 deletions(-) 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 11088c6908233..ad26969c98afa 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 @@ -19,10 +19,6 @@ import type { BulkCreateAlertActionItemBody, CreateAlertActionBody, } from '../../routes/schemas/alert_action_schema'; -import { - LoggerServiceToken, - type LoggerServiceContract, -} from '../services/logger_service/logger_service'; import { queryResponseToRecords } from '../services/query_service/query_response_to_records'; import type { QueryServiceContract } from '../services/query_service/query_service'; import { QueryServiceScopedToken } from '../services/query_service/tokens'; @@ -35,7 +31,6 @@ export class AlertActionsClient { @inject(Request) private readonly request: KibanaRequest, @inject(QueryServiceScopedToken) private readonly queryService: QueryServiceContract, @inject(StorageServiceScopedToken) private readonly storageService: StorageServiceContract, - @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, @optional() @inject(PluginStart('security')) private readonly security?: SecurityPluginStart ) {} From 30698943ee588da9f9cd66c60aedaad09347a2b4 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 11:40:19 -0500 Subject: [PATCH 04/54] wip --- .../server/lib/dispatcher/dispatcher.ts | 88 ++++++++----------- .../server/lib/dispatcher/queries.ts | 29 +++++- .../server/lib/dispatcher/types.ts | 7 ++ 3 files changed, 73 insertions(+), 51 deletions(-) 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..a05085f836e8a 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,13 @@ 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'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -35,58 +40,17 @@ 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(); - - this.logger.debug({ - message: () => `Dispatcher started. Looking for alert episodes since ${lookback}`, - }); - - const { query } = getDispatchableAlertEventsQuery(); - - const result = await this.queryService.executeQuery({ - query, - filter: { - range: { - '@timestamp': { - gte: moment(previousStartedAt) - .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') - .toISOString(), - }, - }, - }, - }); - - 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.`, - }); + const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); - // 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 suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); const now = new Date().toISOString(); await this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, - docs: dispatchableAlertEvents.map((alertEpisode) => ({ + docs: alertEpisodes.map((alertEpisode) => ({ '@timestamp': now, group_hash: alertEpisode.group_hash, last_series_event_timestamp: alertEpisode.last_event_timestamp, @@ -97,11 +61,35 @@ export class DispatcherService implements DispatcherServiceContract { })), }); - this.logger.debug({ - message: () => - `Dispatcher finished processing ${dispatchableAlertEvents.length} alert episodes.`, + return { startedAt }; + } + + private async fetchAlertEpisodeSuppressions( + alertEpisodes: AlertEpisode[] + ): Promise { + return queryResponseToRecords( + await this.queryService.executeQuery({ + query: getAlertEpisodeSuppressionsQuery(alertEpisodes).query, + }) + ); + } + + 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: lookback, + }, + }, + }, }); - return { startedAt }; + return queryResponseToRecords(result); } } 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..ce9492699b5c2 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'; @@ -33,6 +34,32 @@ export const getDispatchableAlertEventsQuery = (): EsqlRequest => { 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(); }; + +// expiry > now() to be adjusted to expiry > min(alertEpisodes.last_event_timestamp) +export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): EsqlRequest => { + let whereClause = esql.exp`TRUE`; + + 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 ("snooze", "unsnooze", "ack", "unack", "activate", "deactivate") + | WHERE action_type != "snooze" OR expiry > now() + | STATS + last_snooze_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), + last_ack_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), + last_deactivate_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("activate", "deactivate") + BY rule_id, group_hash, episode_id + | EVAL should_suppress = CASE( + last_snooze_action_type == "snooze", true, + last_ack_action_type == "ack", true, + last_deactivate_action_type == "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/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; From 9ce04f719f363a09bfcb2ebcb01085cee33a352a Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 11:43:06 -0500 Subject: [PATCH 05/54] add agent --- .../alerts-events-and-actions-dataset.md | 553 ++++++++++++++++++ .../dispatcher/agent/suppression-queries.md | 382 ++++++++++++ 2 files changed, 935 insertions(+) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md new file mode 100644 index 0000000000000..457ec2e7b7a5a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md @@ -0,0 +1,553 @@ +# Alerts Events and Actions Dataset + +This dataset contains sample alert events (`.alerts-events`) and user actions (`.alerts-actions`) for 4 rules, stored as an Elasticsearch bulk request. Alert events represent rule evaluation results emitted every 5 minutes, each belonging to a group (series) and an episode that tracks the lifecycle of a breach. Actions represent user or system responses to those alerts, such as acknowledging (`ack`), unacknowledging (`unack`), or snoozing (`snooze`) a specific series. The table below summarizes the full dataset: rows with an `event_timestamp` are alert events, while rows with an `action_timestamp` are actions applied between two consecutive events. + +- **rule-001**: single series, acknowledged then unacknowledged between consecutive events. +- **rule-002**: single series, acknowledged with no further action changes. +- **rule-003**: two series -- series-1 stays active throughout; series-2 recovers and then starts a new episode. +- **rule-004**: two series, both snoozed (with an expiry) shortly after the first event. +- **rule-005**: two series -- series-1 is deactivated between consecutive events; series-2 stays active throughout. + +| event_timestamp | rule_id | group_hash | episode_id | episode_status | status | action_timestamp | action_type | last_series_event_timestamp | expiry | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 16:00 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | +| | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | | | 16:03 | ack | 16:00 | | +| 16:05 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | +| | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | | | 16:08 | unack | 16:05 | | +| 16:10 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | +| 16:15 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | +| 16:00 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | +| | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | | | 16:03 | ack | 16:00 | | +| 16:05 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | +| 16:10 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | +| 16:15 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | +| 16:00 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | +| 16:05 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | +| 16:10 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | +| 16:15 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | +| 16:00 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | active | breached | | | | | +| 16:05 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | inactive | recovered | | | | | +| 16:10 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | active | breached | | | | | +| 16:15 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | active | breached | | | | | +| 16:00 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | +| | rule-004 | rule-004-series-1 | | | | 16:03 | snooze | 16:00 | 2026-01-28 16:03 | +| 16:05 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | +| 16:10 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | +| 16:15 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | +| 16:00 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | +| | rule-004 | rule-004-series-2 | | | | 16:03 | snooze | 16:00 | 2026-01-28 16:03 | +| 16:05 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | +| 16:10 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | +| 16:15 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | +| 16:00 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | +| 16:05 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | +| | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | | | 16:08 | deactivate | 16:05 | | +| 16:10 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | +| 16:15 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | +| 16:00 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | +| 16:05 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | +| 16:10 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | +| 16:15 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | + +``` +POST _bulk +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-001" }, + "group_hash": "rule-001-series-1", + "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@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" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-001" }, + "group_hash": "rule-001-series-1", + "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@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" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-001" }, + "group_hash": "rule-001-series-1", + "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-001" }, + "group_hash": "rule-001-series-1", + "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-002" }, + "group_hash": "rule-002-series-1", + "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@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" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-002" }, + "group_hash": "rule-002-series-1", + "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-002" }, + "group_hash": "rule-002-series-1", + "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-002" }, + "group_hash": "rule-002-series-1", + "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-1", + "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-1", + "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-1", + "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-1", + "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-2", + "episode": { "id": "rule-003-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-2", + "episode": { "id": "rule-003-series-2-episode-1", "status": "inactive" }, + "status": "recovered" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-2", + "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-2", + "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-1", + "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@timestamp": "2026-01-27T16:03:00.000Z", + "actor": "elastic", + "action_type": "snooze", + "expiry": "2026-01-28T16:03:00.000Z", + "last_series_event_timestamp": "2026-01-27T16:00:00.000Z", + "rule_id": "rule-004", + "group_hash": "rule-004-series-1" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-1", + "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-1", + "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-1", + "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-2", + "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@timestamp": "2026-01-27T16:03:00.000Z", + "actor": "elastic", + "action_type": "snooze", + "expiry": "2026-01-28T16:03:00.000Z", + "last_series_event_timestamp": "2026-01-27T16:00:00.000Z", + "rule_id": "rule-004", + "group_hash": "rule-004-series-2" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-2", + "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-2", + "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-2", + "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-1", + "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-1", + "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-actions" } } +{ + "@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" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-1", + "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-1", + "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-2", + "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-2", + "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-2", + "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-2", + "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, + "status": "breached" +} +``` + +## Sequence Diagrams (per rule) + +### rule-001 + +Single series, acknowledged then unacknowledged between consecutive events. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant U as User + participant S1 as rule-001-series-1 + + RE->>S1: 16:00 breached (ep-1, active) + U->>S1: 16:03 ack + RE->>S1: 16:05 breached (ep-1, active) + U->>S1: 16:08 unack + RE->>S1: 16:10 breached (ep-1, active) + RE->>S1: 16:15 breached (ep-1, active) +``` + +### rule-002 + +Single series, acknowledged with no further action changes. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant U as User + participant S1 as rule-002-series-1 + + RE->>S1: 16:00 breached (ep-1, active) + U->>S1: 16:03 ack + RE->>S1: 16:05 breached (ep-1, active) + RE->>S1: 16:10 breached (ep-1, active) + RE->>S1: 16:15 breached (ep-1, active) +``` + +### rule-003 + +Two series -- series-1 stays active throughout; series-2 recovers and then starts a new episode. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant S1 as rule-003-series-1 + participant S2 as rule-003-series-2 + + RE->>S1: 16:00 breached (ep-1, active) + RE->>S2: 16:00 breached (ep-1, active) + RE->>S1: 16:05 breached (ep-1, active) + RE->>S2: 16:05 recovered (ep-1, inactive) + Note over S2: Episode 1 ends (recovered) + RE->>S1: 16:10 breached (ep-1, active) + RE->>S2: 16:10 breached (ep-2, active) + Note over S2: New episode 2 starts + RE->>S1: 16:15 breached (ep-1, active) + RE->>S2: 16:15 breached (ep-2, active) +``` + +### rule-004 + +Two series, both snoozed (with an expiry) shortly after the first event. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant U as User + participant S1 as rule-004-series-1 + participant S2 as rule-004-series-2 + + RE->>S1: 16:00 breached (ep-1, active) + RE->>S2: 16:00 breached (ep-1, active) + U->>S1: 16:03 snooze (expiry: 2026-01-28 16:03) + U->>S2: 16:03 snooze (expiry: 2026-01-28 16:03) + RE->>S1: 16:05 breached (ep-1, active) + RE->>S2: 16:05 breached (ep-1, active) + RE->>S1: 16:10 breached (ep-1, active) + RE->>S2: 16:10 breached (ep-1, active) + RE->>S1: 16:15 breached (ep-1, active) + RE->>S2: 16:15 breached (ep-1, active) +``` + +### rule-005 + +Two series -- series-1 is deactivated between consecutive events; series-2 stays active throughout. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant U as User + participant S1 as rule-005-series-1 + participant S2 as rule-005-series-2 + + RE->>S1: 16:00 breached (ep-1, active) + RE->>S2: 16:00 breached (ep-1, active) + RE->>S1: 16:05 breached (ep-1, active) + RE->>S2: 16:05 breached (ep-1, active) + U->>S1: 16:08 deactivate + RE->>S1: 16:10 breached (ep-1, active) + RE->>S2: 16:10 breached (ep-1, active) + RE->>S1: 16:15 breached (ep-1, active) + RE->>S2: 16:15 breached (ep-1, active) +``` + + +## Combined Sequence Diagram + +All rules and series in a single diagram, grouped by rule. + +```mermaid +sequenceDiagram + participant RE as RuleEngine + participant U as User + participant R1S1 as rule-001-series-1 + participant R2S1 as rule-002-series-1 + participant R3S1 as rule-003-series-1 + participant R3S2 as rule-003-series-2 + participant R4S1 as rule-004-series-1 + participant R4S2 as rule-004-series-2 + participant R5S1 as rule-005-series-1 + participant R5S2 as rule-005-series-2 + + rect rgb(240, 240, 255) + Note over RE,R1S1: rule-001 + RE->>R1S1: 16:00 breached (ep-1, active) + U->>R1S1: 16:03 ack + RE->>R1S1: 16:05 breached (ep-1, active) + U->>R1S1: 16:08 unack + RE->>R1S1: 16:10 breached (ep-1, active) + RE->>R1S1: 16:15 breached (ep-1, active) + end + + rect rgb(240, 255, 240) + Note over RE,R2S1: rule-002 + RE->>R2S1: 16:00 breached (ep-1, active) + U->>R2S1: 16:03 ack + RE->>R2S1: 16:05 breached (ep-1, active) + RE->>R2S1: 16:10 breached (ep-1, active) + RE->>R2S1: 16:15 breached (ep-1, active) + end + + rect rgb(255, 240, 240) + Note over RE,R3S2: rule-003 + RE->>R3S1: 16:00 breached (ep-1, active) + RE->>R3S2: 16:00 breached (ep-1, active) + RE->>R3S1: 16:05 breached (ep-1, active) + RE->>R3S2: 16:05 recovered (ep-1, inactive) + Note over R3S2: Episode 1 ends + RE->>R3S1: 16:10 breached (ep-1, active) + RE->>R3S2: 16:10 breached (ep-2, active) + Note over R3S2: New episode 2 + RE->>R3S1: 16:15 breached (ep-1, active) + RE->>R3S2: 16:15 breached (ep-2, active) + end + + rect rgb(255, 255, 230) + Note over RE,R4S2: rule-004 + RE->>R4S1: 16:00 breached (ep-1, active) + RE->>R4S2: 16:00 breached (ep-1, active) + U->>R4S1: 16:03 snooze (expiry: 2026-01-28 16:03) + U->>R4S2: 16:03 snooze (expiry: 2026-01-28 16:03) + RE->>R4S1: 16:05 breached (ep-1, active) + RE->>R4S2: 16:05 breached (ep-1, active) + RE->>R4S1: 16:10 breached (ep-1, active) + RE->>R4S2: 16:10 breached (ep-1, active) + RE->>R4S1: 16:15 breached (ep-1, active) + RE->>R4S2: 16:15 breached (ep-1, active) + end + + rect rgb(240, 255, 255) + Note over RE,R5S2: rule-005 + RE->>R5S1: 16:00 breached (ep-1, active) + RE->>R5S2: 16:00 breached (ep-1, active) + RE->>R5S1: 16:05 breached (ep-1, active) + RE->>R5S2: 16:05 breached (ep-1, active) + U->>R5S1: 16:08 deactivate + RE->>R5S1: 16:10 breached (ep-1, active) + RE->>R5S2: 16:10 breached (ep-1, active) + RE->>R5S1: 16:15 breached (ep-1, active) + RE->>R5S2: 16:15 breached (ep-1, active) + end +``` \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md new file mode 100644 index 0000000000000..e259b826d5a56 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md @@ -0,0 +1,382 @@ +# Suppression Queries + +Given the dispatcher query results (the alert episodes the dispatcher is working with), we need to determine for each episode whether it should be suppressed or not. + +## Dispatcher query results + +Query: +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-events,.alerts-actions METADATA _index + | WHERE (_index LIKE ".ds-.alerts-actions-*") OR (_index LIKE ".ds-.alerts-events-*" and type == "alert") + | EVAL + rule_id = COALESCE(rule.id, rule_id), + 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 ".ds-.alerts-actions-*" AND action_type == "fire-event" BY rule_id, group_hash + | WHERE (last_fired IS NULL OR last_fired < @timestamp) or (_index LIKE ".ds-.alerts-actions-*") + | STATS + last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" + 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 asc + | LIMIT 10000 + """ +} +``` + +The dispatcher query (see [alerts-events-and-actions-dataset.md](./alerts-events-and-actions-dataset.md)) returns the following 10 alert episodes: + +``` +last_event_timestamp,rule_id,group_hash,episode_id,episode_status +2026-01-27T16:00:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-1,active +2026-01-27T16:05:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-1,inactive +2026-01-27T16:15:00.000Z,rule-003,rule-003-series-1,rule-003-series-1-episode-1,active +2026-01-27T16:15:00.000Z,rule-005,rule-005-series-2,rule-005-series-2-episode-1,active +2026-01-27T16:15:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-2,active +2026-01-27T16:15:00.000Z,rule-004,rule-004-series-1,rule-004-series-1-episode-1,active +2026-01-27T16:15:00.000Z,rule-005,rule-005-series-1,rule-005-series-1-episode-1,active +2026-01-27T16:15:00.000Z,rule-004,rule-004-series-2,rule-004-series-2-episode-1,active +2026-01-27T16:15:00.000Z,rule-002,rule-002-series-1,rule-002-series-1-episode-1,active +2026-01-27T16:15:00.000Z,rule-001,rule-001-series-1,rule-001-series-1-episode-1,active +``` + +## Suppression types + +A suppression can happen because of three action types, each with different scoping: + +| Suppression type | Scope | Suppressed when | +| --- | --- | --- | +| **Ack** | `(rule_id, group_hash, episode_id)` | Last action in `ack`/`unack` pair is `ack` | +| **Deactivate** | `(rule_id, group_hash, episode_id)` | Last action in `deactivate`/`activate` pair is `deactivate` | +| **Snooze** | `(rule_id, group_hash)` | Last action in `snooze`/`unsnooze` pair is `snooze` AND expiry > alert event timestamp | + +> Note: Snooze has no `episode_id` — it applies to the entire `group_hash` (all episodes for that series). + +## Expected suppression results + +Based on the actions in the dataset: + +| rule_id | group_hash | episode_id | suppressed? | reason | +| --- | --- | --- | --- | --- | +| rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | no | ack at 16:03 then unack at 16:08 | +| rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | **yes** | ack at 16:03, no unack after | +| rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | no | no actions | +| rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | no | no actions | +| rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | no | no actions | +| rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | **yes** | snoozed at 16:03, expiry 2026-01-28 > event time 16:15 | +| rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | **yes** | snoozed at 16:03, expiry 2026-01-28 > event time 16:15 | +| rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | **yes** | deactivated at 16:08, no activate after | +| rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | no | no actions | + + +--- + +## Query 1: Ack suppression + +Ack suppression is scoped to `(rule_id, group_hash, episode_id)`. An episode is suppressed if the last action between `ack` and `unack` is `ack`. + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE action_type IN ("ack", "unack") + | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash, episode_id + | EVAL suppressed_by_ack = last_action_type == "ack" + | KEEP rule_id, group_hash, episode_id, suppressed_by_ack + """ +} +``` + +### Expected result + +``` +rule_id,group_hash,episode_id,suppressed_by_ack +rule-001,rule-001-series-1,rule-001-series-1-episode-1,false +rule-002,rule-002-series-1,rule-002-series-1-episode-1,true +``` + +> Episodes not present in the result have no `ack`/`unack` actions and should be assumed **not suppressed** by ack. + +- **rule-001**: ack at 16:03, then unack at 16:08 → last action is `unack` → `suppressed_by_ack = false` +- **rule-002**: ack at 16:03, no unack → last action is `ack` → `suppressed_by_ack = true` + + +--- + +## Query 2: Deactivate suppression + +Deactivate suppression is scoped to `(rule_id, group_hash, episode_id)`. An episode is suppressed if the last action between `deactivate` and `activate` is `deactivate`. + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE action_type IN ("deactivate", "activate") + | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash, episode_id + | EVAL suppressed_by_deactivate = last_action_type == "deactivate" + | KEEP rule_id, group_hash, episode_id, suppressed_by_deactivate + """ +} +``` + +### Expected result + +``` +rule_id,group_hash,episode_id,suppressed_by_deactivate +rule-005,rule-005-series-1,rule-005-series-1-episode-1,true +``` + +> Episodes not present in the result have no `deactivate`/`activate` actions and should be assumed **not suppressed** by deactivate. + +- **rule-005 series-1**: deactivate at 16:08, no activate after → last action is `deactivate` → `suppressed_by_deactivate = true` + + +--- + +## Query 3: Snooze suppression + +Snooze suppression is scoped to `(rule_id, group_hash)` — it has no `episode_id`. All episodes of a snoozed series are suppressed. A series is suppressed if the last action between `snooze` and `unsnooze` is `snooze` **and** the snooze expiry is still valid. + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE (action_type == "snooze" AND expiry > now()) OR action_type == "unsnooze" + | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash + | EVAL suppressed_by_snooze = last_action_type == "snooze" + | KEEP rule_id, group_hash, suppressed_by_snooze + """ +} +``` + +### Note on expiry comparison + +The snooze expiry for rule-004 is `2026-01-28T16:03:00.000Z`. If running today (`now()` > expiry), the `expiry > now()` filter will exclude the snooze action and the query will return no results — the snooze appears expired. + +In the real dispatcher context, the comparison should be `expiry > last_event_timestamp` (the alert episode's event timestamp), not `now()`. Since the alert events for rule-004 are at `2026-01-27T16:15:00.000Z`, and the expiry is `2026-01-28T16:03:00.000Z`, the snooze is still active for those episodes. + +For testing with this dataset, you can replace `now()` with a hardcoded timestamp: + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE (action_type == "snooze" AND expiry > "2026-01-27T16:15:00.000Z"::datetime) OR action_type == "unsnooze" + | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash + | EVAL suppressed_by_snooze = last_action_type == "snooze" + | KEEP rule_id, group_hash, suppressed_by_snooze + """ +} +``` + +### Expected result (with valid expiry) + +``` +rule_id,group_hash,suppressed_by_snooze +rule-004,rule-004-series-1,true +rule-004,rule-004-series-2,true +``` + +> Series not present in the result have no active `snooze`/`unsnooze` actions and should be assumed **not suppressed** by snooze. + +- **rule-004 series-1**: snooze at 16:03 with expiry 2026-01-28, no unsnooze → last action is `snooze` → `suppressed_by_snooze = true` +- **rule-004 series-2**: snooze at 16:03 with expiry 2026-01-28, no unsnooze → last action is `snooze` → `suppressed_by_snooze = true` + + +--- + +## Combined query: all 3 suppression types at once + +The challenge with combining all three suppression types is the different grouping granularity: +- **Ack** and **Deactivate** group BY `(rule_id, group_hash, episode_id)` +- **Snooze** groups BY `(rule_id, group_hash)` only — snooze actions have no `episode_id` + +A single `STATS ... BY rule_id, group_hash, episode_id` groups snooze rows under `episode_id = NULL`, separate from ack/deactivate rows that carry an `episode_id`. This works correctly: snooze results appear at the `group_hash` level (with `episode_id = NULL`), while ack/deactivate results appear at the `episode_id` level. + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") + | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:15:00.000Z"::datetime + | STATS + last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), + last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), + last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") + BY 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, last_ack_action, last_deactivate_action, last_snooze_action + """ +} +``` + +### Expected result + +``` +rule_id,group_hash,episode_id,should_suppress,last_ack_action,last_deactivate_action,last_snooze_action +rule-001,rule-001-series-1,rule-001-series-1-episode-1,false,unack,, +rule-002,rule-002-series-1,rule-002-series-1-episode-1,true,ack,, +rule-004,rule-004-series-1,,true,,,snooze +rule-004,rule-004-series-2,,true,,,snooze +rule-005,rule-005-series-1,rule-005-series-1-episode-1,true,,deactivate, +``` + +> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. + +- **rule-001**: last ack action is `unack` → `should_suppress = false` +- **rule-002**: last ack action is `ack` → `should_suppress = true` +- **rule-004 series-1**: last snooze action is `snooze` (expiry still valid) → `should_suppress = true` (episode_id is NULL — snooze applies to all episodes in the series) +- **rule-004 series-2**: same as series-1 → `should_suppress = true` +- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` + +### Note on snooze `episode_id` + +Snooze rows appear with `episode_id = NULL` because snooze actions are scoped to `group_hash`, not to a specific episode. The consumer of these results needs to match snooze suppression by `(rule_id, group_hash)` rather than by `episode_id`. + +### Edge case: mixed snooze + ack/deactivate on the same group_hash + +If a `group_hash` has both snooze and ack/deactivate actions, the simple query above produces separate rows: one with `episode_id = NULL` (snooze) and one with the actual `episode_id` (ack/deactivate). The episode-level row won't reflect the snooze status. + +To propagate snooze status to episode-level rows, use `INLINE STATS` to first compute snooze at the `group_hash` level before the final `STATS`: + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") + | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:15:00.000Z"::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, last_ack_action, last_deactivate_action, last_snooze_action + """ +} +``` + +With `INLINE STATS`, the snooze status is computed first and added to every row for that `(rule_id, group_hash)`. Then when `STATS` groups by `episode_id`, each episode-level group carries the snooze status. This ensures that if a series is both snoozed and acked, the episode-level row correctly shows `should_suppress = true` (from snooze). + +With the current dataset both queries produce the same result since no group_hash has both snooze and ack/deactivate actions. + + +--- + +## Combined query with alerts-events: dynamic snooze expiry comparison + +The combined queries above use a hardcoded timestamp (`"2026-01-27T16:15:00.000Z"::datetime`) for the snooze expiry comparison. In the real dispatcher context, the snooze expiry should be compared against the **alert episode's event timestamp** — not a static value or `now()`. + +To achieve this, we query both `.alerts-events` and `.alerts-actions` in a single ES|QL query: + +1. Read from both indices (same as the dispatcher query) +2. Apply the **fire-event filter** — use `INLINE STATS` to find the last `fire-event` per `(rule_id, group_hash)` and only consider alert events that haven't been fired yet +3. Compute `last_event_timestamp` per `(rule_id, group_hash)` from the alert events using `INLINE STATS` +4. Filter down to action rows only, and use the dynamically computed `last_event_timestamp` for the snooze expiry comparison +5. Compute all 3 suppression types as before + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-events, .alerts-actions METADATA _index + | WHERE (_index LIKE ".ds-.alerts-actions-*") + OR (_index LIKE ".ds-.alerts-events-*" AND type == "alert") + | EVAL + rule_id = COALESCE(rule.id, rule_id), + episode_id = COALESCE(episode.id, episode_id) + | DROP rule.id, episode.id + | INLINE STATS + last_fired = MAX(last_series_event_timestamp) WHERE _index LIKE ".ds-.alerts-actions-*" AND action_type == "fire-event" + BY rule_id, group_hash + | WHERE (last_fired IS NULL OR last_fired < @timestamp) OR (_index LIKE ".ds-.alerts-actions-*") + | INLINE STATS + last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" + BY rule_id, group_hash + | WHERE _index LIKE ".ds-.alerts-actions-*" + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") + | WHERE action_type != "snooze" OR expiry > last_event_timestamp + | 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), + last_event_timestamp = MAX(last_event_timestamp) + 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, last_ack_action, last_deactivate_action, last_snooze_action, last_event_timestamp + """ +} +``` + +### How it works step by step + +1. **Read both indices** — `FROM .alerts-events, .alerts-actions METADATA _index` reads from both datasets. The `METADATA _index` allows us to distinguish which index each row comes from. + +2. **Normalize field names** — Alert events store the rule id as `rule.id` and episode id as `episode.id`, while actions store them as `rule_id` and `episode_id`. The `COALESCE` + `DROP` normalizes these into consistent field names. + +3. **Fire-event filter** — The `INLINE STATS last_fired = MAX(last_series_event_timestamp) WHERE action_type == "fire-event"` computes the last fired event timestamp per `(rule_id, group_hash)`. The subsequent `WHERE` clause filters out alert events that have already been fired by a previous dispatcher run, while keeping all action rows. With the current dataset, there are no `fire-event` actions, so `last_fired` is NULL and all events pass through. + +4. **Compute `last_event_timestamp`** — `INLINE STATS last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" BY rule_id, group_hash` attaches the latest alert event timestamp to every row for that `(rule_id, group_hash)`. This is the timestamp we compare against the snooze expiry. + +5. **Filter to actions only** — After computing `last_event_timestamp`, we keep only the action rows (`WHERE _index LIKE ".ds-.alerts-actions-*"`) and filter to the relevant suppression action types. + +6. **Dynamic snooze expiry check** — `WHERE action_type != "snooze" OR expiry > last_event_timestamp` replaces the hardcoded timestamp. Snooze actions are only kept if their expiry is after the latest alert event timestamp for that group_hash. + +7. **Compute suppression** — Same logic as the previous combined queries: `INLINE STATS` for snooze at the `group_hash` level, then `STATS` for ack/deactivate at the `episode_id` level. + +### Note on fire-event filter + +The current dataset has no `fire-event` actions (those are written by the dispatcher after processing). As a result, `last_fired` is NULL for all `(rule_id, group_hash)` pairs, and the filter passes all events through. In production, this filter ensures only unfired alert events contribute to the `last_event_timestamp` computation — avoiding re-processing episodes already handled by a previous dispatcher run. + +### Expected result + +``` +rule_id,group_hash,episode_id,should_suppress,last_ack_action,last_deactivate_action,last_snooze_action,last_event_timestamp +rule-001,rule-001-series-1,rule-001-series-1-episode-1,false,unack,,,2026-01-27T16:15:00.000Z +rule-002,rule-002-series-1,rule-002-series-1-episode-1,true,ack,,,2026-01-27T16:15:00.000Z +rule-004,rule-004-series-1,,true,,,snooze,2026-01-27T16:15:00.000Z +rule-004,rule-004-series-2,,true,,,snooze,2026-01-27T16:15:00.000Z +rule-005,rule-005-series-1,rule-005-series-1-episode-1,true,,deactivate,,2026-01-27T16:15:00.000Z +``` + +> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. + +The results are identical to the previous combined queries, but now with `last_event_timestamp` included to show which alert event timestamp was used for the snooze comparison. The key difference is that the snooze expiry is compared dynamically against the actual alert event timestamps from the `.alerts-events` index, rather than requiring a hardcoded value. + +- **rule-001**: last ack action is `unack` → `should_suppress = false` (last_event_timestamp = 16:15) +- **rule-002**: last ack action is `ack` → `should_suppress = true` (last_event_timestamp = 16:15) +- **rule-004 series-1**: snooze expiry `2026-01-28T16:03:00.000Z` > last_event_timestamp `2026-01-27T16:15:00.000Z` → snooze is active → `should_suppress = true` +- **rule-004 series-2**: same as series-1 → `should_suppress = true` +- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` (last_event_timestamp = 16:15) From d8cf2bb1fd864dd7eed487ac641b94a71b739058 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 12:51:57 -0500 Subject: [PATCH 06/54] Suppress alert --- .../server/lib/dispatcher/dispatcher.ts | 55 ++++++++++++++----- .../server/lib/dispatcher/queries.ts | 30 ++++++---- 2 files changed, 58 insertions(+), 27 deletions(-) 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 a05085f836e8a..71c1836ae9374 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 @@ -44,21 +44,42 @@ export class DispatcherService implements DispatcherServiceContract { const startedAt = new Date(); const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); - const suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); + const suppressedEpisodes = alertEpisodes.filter((episode) => + suppressions.some( + (s) => + s.should_suppress && + s.rule_id === episode.rule_id && + s.group_hash === episode.group_hash && + (s.episode_id == null || s.episode_id === episode.episode_id) + ) + ); + const nonSuppressedEpisodes = alertEpisodes.filter( + (episode) => !suppressedEpisodes.includes(episode) + ); + + this.logger.info({ + message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressedEpisodes.length} suppressed, ${nonSuppressedEpisodes.length} not suppressed`, + }); + const now = new Date().toISOString(); + const toFireEventAction = (alertEpisode: AlertEpisode, isSuppressed: boolean): AlertAction => ({ + '@timestamp': now, + group_hash: alertEpisode.group_hash, + last_series_event_timestamp: alertEpisode.last_event_timestamp, + actor: 'system', + action_type: isSuppressed ? 'suppress' : 'fire', + rule_id: alertEpisode.rule_id, + source: 'internal', + }); + await this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, - docs: alertEpisodes.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', - })), + docs: [ + ...suppressedEpisodes.map((episode) => toFireEventAction(episode, true)), + ...nonSuppressedEpisodes.map((episode) => toFireEventAction(episode, false)), + ], }); return { startedAt }; @@ -67,11 +88,15 @@ export class DispatcherService implements DispatcherServiceContract { private async fetchAlertEpisodeSuppressions( alertEpisodes: AlertEpisode[] ): Promise { - return queryResponseToRecords( - await this.queryService.executeQuery({ - query: getAlertEpisodeSuppressionsQuery(alertEpisodes).query, - }) - ); + if (alertEpisodes.length === 0) { + return []; + } + + const result = await this.queryService.executeQuery({ + query: getAlertEpisodeSuppressionsQuery(alertEpisodes).query, + }); + + return queryResponseToRecords(result); } private async fetchAlertEpisodes(previousStartedAt: Date): Promise { 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 ce9492699b5c2..e546fb351576e 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 @@ -27,7 +27,7 @@ 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} @@ -38,28 +38,34 @@ export const getDispatchableAlertEventsQuery = (): EsqlRequest => { | LIMIT 10000`.toRequest(); }; -// expiry > now() to be adjusted to expiry > min(alertEpisodes.last_event_timestamp) export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): EsqlRequest => { - let whereClause = esql.exp`TRUE`; + const minLastEventTimestamp = alertEpisodes.reduce( + (min, ep) => (ep.last_event_timestamp < min ? ep.last_event_timestamp : min), + alertEpisodes[0].last_event_timestamp + ); + 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 ("snooze", "unsnooze", "ack", "unack", "activate", "deactivate") - | WHERE action_type != "snooze" OR expiry > now() + | 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_snooze_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze"), - last_ack_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), - last_deactivate_action_type = LAST(action_type, @timestamp) WHERE action_type IN ("activate", "deactivate") + 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_type == "snooze", true, - last_ack_action_type == "ack", true, - last_deactivate_action_type == "deactivate", true, + 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(); }; From a154115d25f0b4f9068114a657a2d52ac4709072 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 13:51:27 -0500 Subject: [PATCH 07/54] Add apm span --- .../server/lib/dispatcher/dispatcher.ts | 25 ++++++++++++------- .../lib/dispatcher/with_dispatcher_span.ts | 12 +++++++++ .../plugins/shared/alerting_v2/tsconfig.json | 3 ++- 3 files changed, 30 insertions(+), 10 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/with_dispatcher_span.ts 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 71c1836ae9374..83de8314df10f 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 @@ -25,6 +25,7 @@ import type { DispatcherExecutionParams, DispatcherExecutionResult, } from './types'; +import { withDispatcherSpan } from './with_dispatcher_span'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -43,8 +44,12 @@ export class DispatcherService implements DispatcherServiceContract { }: DispatcherExecutionParams): Promise { const startedAt = new Date(); - const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); - const suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); + const alertEpisodes = await withDispatcherSpan('dispatcher:fetch-alert-episodes', () => + this.fetchAlertEpisodes(previousStartedAt) + ); + const suppressions = await withDispatcherSpan('dispatcher:fetch-suppressions', () => + this.fetchAlertEpisodeSuppressions(alertEpisodes) + ); const suppressedEpisodes = alertEpisodes.filter((episode) => suppressions.some( @@ -74,13 +79,15 @@ export class DispatcherService implements DispatcherServiceContract { source: 'internal', }); - await this.storageService.bulkIndexDocs({ - index: ALERT_ACTIONS_DATA_STREAM, - docs: [ - ...suppressedEpisodes.map((episode) => toFireEventAction(episode, true)), - ...nonSuppressedEpisodes.map((episode) => toFireEventAction(episode, false)), - ], - }); + await withDispatcherSpan('dispatcher:bulk-index-actions', () => + this.storageService.bulkIndexDocs({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + ...suppressedEpisodes.map((episode) => toFireEventAction(episode, true)), + ...nonSuppressedEpisodes.map((episode) => toFireEventAction(episode, false)), + ], + }) + ); return { startedAt }; } 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..c3c4be3d01205 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/with_dispatcher_span.ts @@ -0,0 +1,12 @@ +/* + * 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, type: 'dispatcher', labels: { plugin: 'alerting_v2' } }, cb); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 530c220253e3b..d9e194bbc12b3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -48,7 +48,8 @@ "@kbn/core-test-helpers-kbn-server", "@kbn/test", "@kbn/esql-language", - "@kbn/core-saved-objects-api-server-mocks" + "@kbn/core-saved-objects-api-server-mocks", + "@kbn/apm-utils" ], "exclude": ["target/**/*"] } From 221699874abbf9dcbc58d1a757b09b33c3f61284 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 13:59:08 -0500 Subject: [PATCH 08/54] use partition --- .../server/lib/dispatcher/dispatcher.ts | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) 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 83de8314df10f..2aa1b30d3100f 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 @@ -6,6 +6,7 @@ */ import { inject, injectable } from 'inversify'; +import { partition } from 'lodash'; import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import { @@ -51,7 +52,7 @@ export class DispatcherService implements DispatcherServiceContract { this.fetchAlertEpisodeSuppressions(alertEpisodes) ); - const suppressedEpisodes = alertEpisodes.filter((episode) => + const [suppressedEpisodes, nonSuppressedEpisodes] = partition(alertEpisodes, (episode) => suppressions.some( (s) => s.should_suppress && @@ -60,31 +61,22 @@ export class DispatcherService implements DispatcherServiceContract { (s.episode_id == null || s.episode_id === episode.episode_id) ) ); - const nonSuppressedEpisodes = alertEpisodes.filter( - (episode) => !suppressedEpisodes.includes(episode) - ); this.logger.info({ message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressedEpisodes.length} suppressed, ${nonSuppressedEpisodes.length} not suppressed`, }); - const now = new Date().toISOString(); - const toFireEventAction = (alertEpisode: AlertEpisode, isSuppressed: boolean): AlertAction => ({ - '@timestamp': now, - group_hash: alertEpisode.group_hash, - last_series_event_timestamp: alertEpisode.last_event_timestamp, - actor: 'system', - action_type: isSuppressed ? 'suppress' : 'fire', - rule_id: alertEpisode.rule_id, - source: 'internal', - }); - + const now = new Date(); await withDispatcherSpan('dispatcher:bulk-index-actions', () => this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, docs: [ - ...suppressedEpisodes.map((episode) => toFireEventAction(episode, true)), - ...nonSuppressedEpisodes.map((episode) => toFireEventAction(episode, false)), + ...suppressedEpisodes.map((episode) => + this.toAction({ episode, isSuppressed: true, now }) + ), + ...nonSuppressedEpisodes.map((episode) => + this.toAction({ episode, isSuppressed: false, now }) + ), ], }) ); @@ -92,6 +84,26 @@ export class DispatcherService implements DispatcherServiceContract { return { startedAt }; } + private toAction({ + episode, + isSuppressed, + now, + }: { + episode: AlertEpisode; + isSuppressed: boolean; + now: Date; + }): AlertAction { + return { + '@timestamp': now.toISOString(), + group_hash: episode.group_hash, + last_series_event_timestamp: episode.last_event_timestamp, + actor: 'system', + action_type: isSuppressed ? 'suppress' : 'fire', + rule_id: episode.rule_id, + source: 'internal', + }; + } + private async fetchAlertEpisodeSuppressions( alertEpisodes: AlertEpisode[] ): Promise { From fd6c77b1e5d1c0a6b45186ac356060dcdde913b6 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 13:59:20 -0500 Subject: [PATCH 09/54] debug log --- .../shared/alerting_v2/server/lib/dispatcher/dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2aa1b30d3100f..5dbbaa5c7a81c 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 @@ -62,7 +62,7 @@ export class DispatcherService implements DispatcherServiceContract { ) ); - this.logger.info({ + this.logger.debug({ message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressedEpisodes.length} suppressed, ${nonSuppressedEpisodes.length} not suppressed`, }); From 72e7c751d7b191c1fa7fc42ad7c0348bed14d895 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 15:00:37 -0500 Subject: [PATCH 10/54] Update agent docs --- .../alerts-events-and-actions-dataset.md | 131 +++++++++- .../dispatcher/agent/suppression-queries.md | 224 ++++++++++-------- 2 files changed, 256 insertions(+), 99 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md index 457ec2e7b7a5a..27d0837d46946 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md @@ -550,4 +550,133 @@ sequenceDiagram RE->>R5S1: 16:15 breached (ep-1, active) RE->>R5S2: 16:15 breached (ep-1, active) end -``` \ No newline at end of file +``` + +## Dataset follow-up + +A follow-up `_bulk` request that adds one more evaluation tick at `16:20` for every currently active episode across all existing rules, plus a brand-new rule (`rule-006`) with a single series appearing for the first time. + +``` +POST _bulk +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-001" }, + "group_hash": "rule-001-series-1", + "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-002" }, + "group_hash": "rule-002-series-1", + "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-1", + "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-003" }, + "group_hash": "rule-003-series-2", + "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-1", + "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-004" }, + "group_hash": "rule-004-series-2", + "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-1", + "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-005" }, + "group_hash": "rule-005-series-2", + "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, + "status": "breached" +} +{ "create": { "_index": ".alerts-events" } } +{ + "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", + "rule": { "id": "rule-006" }, + "group_hash": "rule-006-series-1", + "episode": { "id": "rule-006-series-1-episode-1", "status": "active" }, + "status": "breached" +} +``` + + +## Trigger dispatcher manually + +Use the following workflow to exercise the dispatcher against the two datasets above and verify the suppress/fire decisions at each step. + +### Step 1 -- Ingest the initial dataset + +Run the first `POST _bulk` request (the one containing events from `16:00` to `16:15` plus all actions) to populate `.alerts-events` and `.alerts-actions`. + +### Step 2 -- Run the dispatcher (first pass) + +```bash +curl --request POST \ + --url http://localhost:5601/internal/alerting/v2/dispatcher/_run \ + --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \ + --header 'Content-Type: application/json' \ + --header 'kbn-xsrf: oui' \ + --header 'x-elastic-internal-origin: kibana' \ + --data '{ + "previousStartedAt": "2026-01-25T00:00:00.000Z" +}' +``` + +### Step 3 -- Assert first-pass results + +Verify that the dispatcher produced the expected suppress/fire events for every series across rule-001 through rule-005. + +### Step 4 -- Ingest the follow-up dataset + +Run the second `POST _bulk` request (the "Dataset follow-up" section -- events at `16:20` for all active episodes plus the new rule-006). + +### Step 5 -- Run the dispatcher (second pass) + +```bash +curl --request POST \ + --url http://localhost:5601/internal/alerting/v2/dispatcher/_run \ + --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \ + --header 'Content-Type: application/json' \ + --header 'kbn-xsrf: oui' \ + --header 'x-elastic-internal-origin: kibana' \ + --data '{ + "previousStartedAt": "2026-01-27T16:15:00.000Z" +}' +``` + +### Step 6 -- Assert second-pass results + +Verify that the dispatcher produced the expected suppress/fire events for the `16:20` tick, including the newly introduced rule-006 series. \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md index e259b826d5a56..238e3c92466fb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md @@ -16,7 +16,7 @@ POST /_query?format=csv 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 ".ds-.alerts-actions-*" AND action_type == "fire-event" BY rule_id, group_hash + | INLINE STATS last_fired = max(last_series_event_timestamp) WHERE _index LIKE ".ds-.alerts-actions-*" 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 ".ds-.alerts-actions-*") | STATS last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" @@ -194,107 +194,18 @@ rule-004,rule-004-series-2,true --- -## Combined query: all 3 suppression types at once - -The challenge with combining all three suppression types is the different grouping granularity: -- **Ack** and **Deactivate** group BY `(rule_id, group_hash, episode_id)` -- **Snooze** groups BY `(rule_id, group_hash)` only — snooze actions have no `episode_id` - -A single `STATS ... BY rule_id, group_hash, episode_id` groups snooze rows under `episode_id = NULL`, separate from ack/deactivate rows that carry an `episode_id`. This works correctly: snooze results appear at the `group_hash` level (with `episode_id = NULL`), while ack/deactivate results appear at the `episode_id` level. - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") - | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:15:00.000Z"::datetime - | STATS - last_ack_action = LAST(action_type, @timestamp) WHERE action_type IN ("ack", "unack"), - last_deactivate_action = LAST(action_type, @timestamp) WHERE action_type IN ("deactivate", "activate"), - last_snooze_action = LAST(action_type, @timestamp) WHERE action_type IN ("snooze", "unsnooze") - BY 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, last_ack_action, last_deactivate_action, last_snooze_action - """ -} -``` - -### Expected result - -``` -rule_id,group_hash,episode_id,should_suppress,last_ack_action,last_deactivate_action,last_snooze_action -rule-001,rule-001-series-1,rule-001-series-1-episode-1,false,unack,, -rule-002,rule-002-series-1,rule-002-series-1-episode-1,true,ack,, -rule-004,rule-004-series-1,,true,,,snooze -rule-004,rule-004-series-2,,true,,,snooze -rule-005,rule-005-series-1,rule-005-series-1-episode-1,true,,deactivate, -``` - -> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. - -- **rule-001**: last ack action is `unack` → `should_suppress = false` -- **rule-002**: last ack action is `ack` → `should_suppress = true` -- **rule-004 series-1**: last snooze action is `snooze` (expiry still valid) → `should_suppress = true` (episode_id is NULL — snooze applies to all episodes in the series) -- **rule-004 series-2**: same as series-1 → `should_suppress = true` -- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` - -### Note on snooze `episode_id` - -Snooze rows appear with `episode_id = NULL` because snooze actions are scoped to `group_hash`, not to a specific episode. The consumer of these results needs to match snooze suppression by `(rule_id, group_hash)` rather than by `episode_id`. - -### Edge case: mixed snooze + ack/deactivate on the same group_hash - -If a `group_hash` has both snooze and ack/deactivate actions, the simple query above produces separate rows: one with `episode_id = NULL` (snooze) and one with the actual `episode_id` (ack/deactivate). The episode-level row won't reflect the snooze status. - -To propagate snooze status to episode-level rows, use `INLINE STATS` to first compute snooze at the `group_hash` level before the final `STATS`: - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") - | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:15:00.000Z"::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, last_ack_action, last_deactivate_action, last_snooze_action - """ -} -``` - -With `INLINE STATS`, the snooze status is computed first and added to every row for that `(rule_id, group_hash)`. Then when `STATS` groups by `episode_id`, each episode-level group carries the snooze status. This ensures that if a series is both snoozed and acked, the episode-level row correctly shows `should_suppress = true` (from snooze). - -With the current dataset both queries produce the same result since no group_hash has both snooze and ack/deactivate actions. - +## Combined query with alerts-events: dynamic snooze expiry comparison ---- -## Combined query with alerts-events: dynamic snooze expiry comparison +> [!CAUTION] +> This approach was explored but **not chosen** for the final implementation. It requires reading all `.alerts-events` in the same query, which is expensive and redundant since the dispatcher already fetches the alert episodes in a prior query. The chosen approach uses the dispatcher's alert episodes to build a scoped filter on `.alerts-actions` only — see [Chosen approach: two-query strategy with episode-based filtering](#chosen-approach-two-query-strategy-with-episode-based-filtering) below. The combined queries above use a hardcoded timestamp (`"2026-01-27T16:15:00.000Z"::datetime`) for the snooze expiry comparison. In the real dispatcher context, the snooze expiry should be compared against the **alert episode's event timestamp** — not a static value or `now()`. To achieve this, we query both `.alerts-events` and `.alerts-actions` in a single ES|QL query: 1. Read from both indices (same as the dispatcher query) -2. Apply the **fire-event filter** — use `INLINE STATS` to find the last `fire-event` per `(rule_id, group_hash)` and only consider alert events that haven't been fired yet +2. Apply the **fire filter** — use `INLINE STATS` to find the last `fire` action per `(rule_id, group_hash)` and only consider alert events that haven't been fired yet 3. Compute `last_event_timestamp` per `(rule_id, group_hash)` from the alert events using `INLINE STATS` 4. Filter down to action rows only, and use the dynamically computed `last_event_timestamp` for the snooze expiry comparison 5. Compute all 3 suppression types as before @@ -311,7 +222,7 @@ POST /_query?format=csv episode_id = COALESCE(episode.id, episode_id) | DROP rule.id, episode.id | INLINE STATS - last_fired = MAX(last_series_event_timestamp) WHERE _index LIKE ".ds-.alerts-actions-*" AND action_type == "fire-event" + last_fired = MAX(last_series_event_timestamp) WHERE _index LIKE ".ds-.alerts-actions-*" 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 ".ds-.alerts-actions-*") | INLINE STATS @@ -346,7 +257,7 @@ POST /_query?format=csv 2. **Normalize field names** — Alert events store the rule id as `rule.id` and episode id as `episode.id`, while actions store them as `rule_id` and `episode_id`. The `COALESCE` + `DROP` normalizes these into consistent field names. -3. **Fire-event filter** — The `INLINE STATS last_fired = MAX(last_series_event_timestamp) WHERE action_type == "fire-event"` computes the last fired event timestamp per `(rule_id, group_hash)`. The subsequent `WHERE` clause filters out alert events that have already been fired by a previous dispatcher run, while keeping all action rows. With the current dataset, there are no `fire-event` actions, so `last_fired` is NULL and all events pass through. +3. **Fire filter** — The `INLINE STATS last_fired = MAX(last_series_event_timestamp) WHERE (action_type == "fire" OR action_type == "suppress")` computes the last processed event timestamp per `(rule_id, group_hash)`. Both `fire` and `suppress` actions indicate that the dispatcher has already processed the series. The subsequent `WHERE` clause filters out alert events that have already been processed by a previous dispatcher run, while keeping all action rows. With the current dataset, there are no `fire` or `suppress` actions, so `last_fired` is NULL and all events pass through. 4. **Compute `last_event_timestamp`** — `INLINE STATS last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" BY rule_id, group_hash` attaches the latest alert event timestamp to every row for that `(rule_id, group_hash)`. This is the timestamp we compare against the snooze expiry. @@ -356,9 +267,9 @@ POST /_query?format=csv 7. **Compute suppression** — Same logic as the previous combined queries: `INLINE STATS` for snooze at the `group_hash` level, then `STATS` for ack/deactivate at the `episode_id` level. -### Note on fire-event filter +### Note on fire filter -The current dataset has no `fire-event` actions (those are written by the dispatcher after processing). As a result, `last_fired` is NULL for all `(rule_id, group_hash)` pairs, and the filter passes all events through. In production, this filter ensures only unfired alert events contribute to the `last_event_timestamp` computation — avoiding re-processing episodes already handled by a previous dispatcher run. +The current dataset has no `fire` or `suppress` actions (those are written by the dispatcher after processing). As a result, `last_fired` is NULL for all `(rule_id, group_hash)` pairs, and the filter passes all events through. In production, this filter ensures only unfired alert events contribute to the `last_event_timestamp` computation — avoiding re-processing episodes already handled by a previous dispatcher run. ### Expected result @@ -380,3 +291,120 @@ The results are identical to the previous combined queries, but now with `last_e - **rule-004 series-1**: snooze expiry `2026-01-28T16:03:00.000Z` > last_event_timestamp `2026-01-27T16:15:00.000Z` → snooze is active → `should_suppress = true` - **rule-004 series-2**: same as series-1 → `should_suppress = true` - **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` (last_event_timestamp = 16:15) + + +--- + +## Chosen approach: two-query strategy with episode-based filtering + +Instead of a single query that reads both `.alerts-events` and `.alerts-actions`, the dispatcher runs two sequential queries: + +1. **Dispatcher query** (documented at the top of this file) — returns the alert episodes with their `last_event_timestamp`, `rule_id`, `group_hash`, `episode_id`, and `episode_status`. +2. **Suppression query** — reads only `.alerts-actions`, filtered by the `(rule_id, group_hash)` pairs extracted from the alert episodes returned in step 1. + +### Why this approach + +The dispatcher already has the alert episodes in memory after step 1. We can use them to build a targeted `WHERE` clause that scopes the `.alerts-actions` query to only the relevant series. This avoids reading `.alerts-events` a second time, which is both expensive and redundant. It also removes the need for `METADATA _index`, `COALESCE` normalization, and the `INLINE STATS` fire filter — all of which were required by the single-query approach. + +The snooze expiry comparison uses `minLastEventTimestamp` — the minimum `last_event_timestamp` across all alert episodes — as a conservative pre-filter. This ensures no valid snooze is accidentally excluded by the ES|QL query. If a more precise per-episode snooze expiry check is needed, the dispatcher can refine it in code after the query returns, since it already holds the per-episode timestamps. + +### TypeScript implementation + +```typescript +export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): EsqlRequest => { + const minLastEventTimestamp = alertEpisodes.reduce( + (min, ep) => (ep.last_event_timestamp < min ? ep.last_event_timestamp : min), + alertEpisodes[0].last_event_timestamp + ); + + 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(); +}; +``` + +### Equivalent ES|QL for our dataset + +Using the 8 unique `(rule_id, group_hash)` pairs from the dispatcher results and `minLastEventTimestamp = "2026-01-27T16:00:00.000Z"`: + +``` +POST /_query?format=csv +{ + "query": """ + FROM .alerts-actions + | WHERE (rule_id == "rule-001" AND group_hash == "rule-001-series-1") + OR (rule_id == "rule-002" AND group_hash == "rule-002-series-1") + OR (rule_id == "rule-003" AND group_hash == "rule-003-series-1") + OR (rule_id == "rule-003" AND group_hash == "rule-003-series-2") + OR (rule_id == "rule-004" AND group_hash == "rule-004-series-1") + OR (rule_id == "rule-004" AND group_hash == "rule-004-series-2") + OR (rule_id == "rule-005" AND group_hash == "rule-005-series-1") + OR (rule_id == "rule-005" AND group_hash == "rule-005-series-2") + | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") + | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:00:00.000Z"::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 + """ +} +``` + +### Note on `minLastEventTimestamp` + +The `minLastEventTimestamp` is the minimum `last_event_timestamp` across all alert episodes returned by the dispatcher query. In our dataset, the dispatcher returns episodes with `last_event_timestamp` values of `16:00` (rule-003-series-2-episode-1) and `16:15` (all others), so `minLastEventTimestamp = "2026-01-27T16:00:00.000Z"`. + +This value is used as a conservative pre-filter for snooze expiry: `WHERE action_type != "snooze" OR expiry > minLastEventTimestamp`. By using the earliest timestamp, we ensure no snooze that is still valid for any episode gets filtered out. If a snooze has expired for a later episode but not for an earlier one, it is still included — the dispatcher can perform a per-episode refinement in code using the `last_event_timestamp` it already holds from step 1. + +### Expected result + +``` +rule_id,group_hash,episode_id,should_suppress +rule-001,rule-001-series-1,rule-001-series-1-episode-1,false +rule-002,rule-002-series-1,rule-002-series-1-episode-1,true +rule-004,rule-004-series-1,,true +rule-004,rule-004-series-2,,true +rule-005,rule-005-series-1,rule-005-series-1-episode-1,true +``` + +> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. + +- **rule-001**: last ack action is `unack` → `should_suppress = false` +- **rule-002**: last ack action is `ack` → `should_suppress = true` +- **rule-004 series-1**: last snooze action is `snooze` (expiry `2026-01-28T16:03:00.000Z` > `minLastEventTimestamp`) → `should_suppress = true` (episode_id is NULL — snooze applies to all episodes in the series) +- **rule-004 series-2**: same as series-1 → `should_suppress = true` +- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` + +The results match the previous combined queries. The key difference is that `last_event_timestamp` is no longer in the output — the dispatcher already has it from the first query. From 933adba0167b054f4bd1a261c418e5ad12c23a1a Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 15:00:52 -0500 Subject: [PATCH 11/54] Update tests --- .../server/lib/dispatcher/dispatcher.test.ts | 112 ++++++++++++++++-- .../lib/dispatcher/fixtures/dispatcher.ts | 21 +++- 2 files changed, 124 insertions(+), 9 deletions(-) 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..e64b5505b86f9 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 @@ -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,82 @@ 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', + }), + 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', + }), + ]) + ); + }); + + 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', }), @@ -122,7 +217,7 @@ 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', }), @@ -138,6 +233,7 @@ describe('DispatcherService', () => { }); expect(result.startedAt).toBeInstanceOf(Date); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(1); expect(storageEsClient.bulk).not.toHaveBeenCalled(); }); }); 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, + ]), + }; +}; From 01c86fade8bc2cb4468e7341a93da5616d2081ef Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 6 Feb 2026 15:10:02 -0500 Subject: [PATCH 12/54] Add comprehensive test scenario --- .../server/lib/dispatcher/dispatcher.test.ts | 232 +++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) 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 e64b5505b86f9..e14f64fff7af4 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'; @@ -236,5 +236,235 @@ describe('DispatcherService', () => { expect(queryEsClient.esql.query).toHaveBeenCalledTimes(1); expect(storageEsClient.bulk).not.toHaveBeenCalled(); }); + + // Based on agent/alerts-events-and-actions-dataset.md + 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: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: 9 }, (_, 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(9); + + // 5 fire, 4 suppress + const fireActions = docs.filter((doc) => doc.action_type === 'fire'); + const suppressActions = docs.filter((doc) => doc.action_type === 'suppress'); + expect(fireActions).toHaveLength(5); + 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: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', + }), + ]) + ); + }); }); }); From b3ecb51bb6a83782a783735aeb68ba289b4e1119 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 9 Feb 2026 09:37:44 -0500 Subject: [PATCH 13/54] Remove llm docs --- .../alerts-events-and-actions-dataset.md | 682 ------------------ .../dispatcher/agent/suppression-queries.md | 410 ----------- 2 files changed, 1092 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md deleted file mode 100644 index 27d0837d46946..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/alerts-events-and-actions-dataset.md +++ /dev/null @@ -1,682 +0,0 @@ -# Alerts Events and Actions Dataset - -This dataset contains sample alert events (`.alerts-events`) and user actions (`.alerts-actions`) for 4 rules, stored as an Elasticsearch bulk request. Alert events represent rule evaluation results emitted every 5 minutes, each belonging to a group (series) and an episode that tracks the lifecycle of a breach. Actions represent user or system responses to those alerts, such as acknowledging (`ack`), unacknowledging (`unack`), or snoozing (`snooze`) a specific series. The table below summarizes the full dataset: rows with an `event_timestamp` are alert events, while rows with an `action_timestamp` are actions applied between two consecutive events. - -- **rule-001**: single series, acknowledged then unacknowledged between consecutive events. -- **rule-002**: single series, acknowledged with no further action changes. -- **rule-003**: two series -- series-1 stays active throughout; series-2 recovers and then starts a new episode. -- **rule-004**: two series, both snoozed (with an expiry) shortly after the first event. -- **rule-005**: two series -- series-1 is deactivated between consecutive events; series-2 stays active throughout. - -| event_timestamp | rule_id | group_hash | episode_id | episode_status | status | action_timestamp | action_type | last_series_event_timestamp | expiry | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | -| 16:00 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | -| | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | | | 16:03 | ack | 16:00 | | -| 16:05 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | -| | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | | | 16:08 | unack | 16:05 | | -| 16:10 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | -| 16:15 | rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | active | breached | | | | | -| 16:00 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | -| | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | | | 16:03 | ack | 16:00 | | -| 16:05 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | -| 16:10 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | -| 16:15 | rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | active | breached | | | | | -| 16:00 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | -| 16:05 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | -| 16:10 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | -| 16:15 | rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | active | breached | | | | | -| 16:00 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | active | breached | | | | | -| 16:05 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | inactive | recovered | | | | | -| 16:10 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | active | breached | | | | | -| 16:15 | rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | active | breached | | | | | -| 16:00 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | -| | rule-004 | rule-004-series-1 | | | | 16:03 | snooze | 16:00 | 2026-01-28 16:03 | -| 16:05 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | -| 16:10 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | -| 16:15 | rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | active | breached | | | | | -| 16:00 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | -| | rule-004 | rule-004-series-2 | | | | 16:03 | snooze | 16:00 | 2026-01-28 16:03 | -| 16:05 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | -| 16:10 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | -| 16:15 | rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | active | breached | | | | | -| 16:00 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | -| 16:05 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | -| | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | | | 16:08 | deactivate | 16:05 | | -| 16:10 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | -| 16:15 | rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | active | breached | | | | | -| 16:00 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | -| 16:05 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | -| 16:10 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | -| 16:15 | rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | active | breached | | | | | - -``` -POST _bulk -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-001" }, - "group_hash": "rule-001-series-1", - "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@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" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-001" }, - "group_hash": "rule-001-series-1", - "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@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" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-001" }, - "group_hash": "rule-001-series-1", - "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-001" }, - "group_hash": "rule-001-series-1", - "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-002" }, - "group_hash": "rule-002-series-1", - "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@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" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-002" }, - "group_hash": "rule-002-series-1", - "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-002" }, - "group_hash": "rule-002-series-1", - "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-002" }, - "group_hash": "rule-002-series-1", - "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-1", - "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-1", - "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-1", - "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-1", - "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-2", - "episode": { "id": "rule-003-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-2", - "episode": { "id": "rule-003-series-2-episode-1", "status": "inactive" }, - "status": "recovered" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-2", - "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-2", - "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-1", - "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@timestamp": "2026-01-27T16:03:00.000Z", - "actor": "elastic", - "action_type": "snooze", - "expiry": "2026-01-28T16:03:00.000Z", - "last_series_event_timestamp": "2026-01-27T16:00:00.000Z", - "rule_id": "rule-004", - "group_hash": "rule-004-series-1" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-1", - "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-1", - "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-1", - "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-2", - "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@timestamp": "2026-01-27T16:03:00.000Z", - "actor": "elastic", - "action_type": "snooze", - "expiry": "2026-01-28T16:03:00.000Z", - "last_series_event_timestamp": "2026-01-27T16:00:00.000Z", - "rule_id": "rule-004", - "group_hash": "rule-004-series-2" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-2", - "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-2", - "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-2", - "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-1", - "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-1", - "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-actions" } } -{ - "@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" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-1", - "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-1", - "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:00:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-2", - "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:05:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-2", - "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:10:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-2", - "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:15:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-2", - "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, - "status": "breached" -} -``` - -## Sequence Diagrams (per rule) - -### rule-001 - -Single series, acknowledged then unacknowledged between consecutive events. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant U as User - participant S1 as rule-001-series-1 - - RE->>S1: 16:00 breached (ep-1, active) - U->>S1: 16:03 ack - RE->>S1: 16:05 breached (ep-1, active) - U->>S1: 16:08 unack - RE->>S1: 16:10 breached (ep-1, active) - RE->>S1: 16:15 breached (ep-1, active) -``` - -### rule-002 - -Single series, acknowledged with no further action changes. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant U as User - participant S1 as rule-002-series-1 - - RE->>S1: 16:00 breached (ep-1, active) - U->>S1: 16:03 ack - RE->>S1: 16:05 breached (ep-1, active) - RE->>S1: 16:10 breached (ep-1, active) - RE->>S1: 16:15 breached (ep-1, active) -``` - -### rule-003 - -Two series -- series-1 stays active throughout; series-2 recovers and then starts a new episode. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant S1 as rule-003-series-1 - participant S2 as rule-003-series-2 - - RE->>S1: 16:00 breached (ep-1, active) - RE->>S2: 16:00 breached (ep-1, active) - RE->>S1: 16:05 breached (ep-1, active) - RE->>S2: 16:05 recovered (ep-1, inactive) - Note over S2: Episode 1 ends (recovered) - RE->>S1: 16:10 breached (ep-1, active) - RE->>S2: 16:10 breached (ep-2, active) - Note over S2: New episode 2 starts - RE->>S1: 16:15 breached (ep-1, active) - RE->>S2: 16:15 breached (ep-2, active) -``` - -### rule-004 - -Two series, both snoozed (with an expiry) shortly after the first event. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant U as User - participant S1 as rule-004-series-1 - participant S2 as rule-004-series-2 - - RE->>S1: 16:00 breached (ep-1, active) - RE->>S2: 16:00 breached (ep-1, active) - U->>S1: 16:03 snooze (expiry: 2026-01-28 16:03) - U->>S2: 16:03 snooze (expiry: 2026-01-28 16:03) - RE->>S1: 16:05 breached (ep-1, active) - RE->>S2: 16:05 breached (ep-1, active) - RE->>S1: 16:10 breached (ep-1, active) - RE->>S2: 16:10 breached (ep-1, active) - RE->>S1: 16:15 breached (ep-1, active) - RE->>S2: 16:15 breached (ep-1, active) -``` - -### rule-005 - -Two series -- series-1 is deactivated between consecutive events; series-2 stays active throughout. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant U as User - participant S1 as rule-005-series-1 - participant S2 as rule-005-series-2 - - RE->>S1: 16:00 breached (ep-1, active) - RE->>S2: 16:00 breached (ep-1, active) - RE->>S1: 16:05 breached (ep-1, active) - RE->>S2: 16:05 breached (ep-1, active) - U->>S1: 16:08 deactivate - RE->>S1: 16:10 breached (ep-1, active) - RE->>S2: 16:10 breached (ep-1, active) - RE->>S1: 16:15 breached (ep-1, active) - RE->>S2: 16:15 breached (ep-1, active) -``` - - -## Combined Sequence Diagram - -All rules and series in a single diagram, grouped by rule. - -```mermaid -sequenceDiagram - participant RE as RuleEngine - participant U as User - participant R1S1 as rule-001-series-1 - participant R2S1 as rule-002-series-1 - participant R3S1 as rule-003-series-1 - participant R3S2 as rule-003-series-2 - participant R4S1 as rule-004-series-1 - participant R4S2 as rule-004-series-2 - participant R5S1 as rule-005-series-1 - participant R5S2 as rule-005-series-2 - - rect rgb(240, 240, 255) - Note over RE,R1S1: rule-001 - RE->>R1S1: 16:00 breached (ep-1, active) - U->>R1S1: 16:03 ack - RE->>R1S1: 16:05 breached (ep-1, active) - U->>R1S1: 16:08 unack - RE->>R1S1: 16:10 breached (ep-1, active) - RE->>R1S1: 16:15 breached (ep-1, active) - end - - rect rgb(240, 255, 240) - Note over RE,R2S1: rule-002 - RE->>R2S1: 16:00 breached (ep-1, active) - U->>R2S1: 16:03 ack - RE->>R2S1: 16:05 breached (ep-1, active) - RE->>R2S1: 16:10 breached (ep-1, active) - RE->>R2S1: 16:15 breached (ep-1, active) - end - - rect rgb(255, 240, 240) - Note over RE,R3S2: rule-003 - RE->>R3S1: 16:00 breached (ep-1, active) - RE->>R3S2: 16:00 breached (ep-1, active) - RE->>R3S1: 16:05 breached (ep-1, active) - RE->>R3S2: 16:05 recovered (ep-1, inactive) - Note over R3S2: Episode 1 ends - RE->>R3S1: 16:10 breached (ep-1, active) - RE->>R3S2: 16:10 breached (ep-2, active) - Note over R3S2: New episode 2 - RE->>R3S1: 16:15 breached (ep-1, active) - RE->>R3S2: 16:15 breached (ep-2, active) - end - - rect rgb(255, 255, 230) - Note over RE,R4S2: rule-004 - RE->>R4S1: 16:00 breached (ep-1, active) - RE->>R4S2: 16:00 breached (ep-1, active) - U->>R4S1: 16:03 snooze (expiry: 2026-01-28 16:03) - U->>R4S2: 16:03 snooze (expiry: 2026-01-28 16:03) - RE->>R4S1: 16:05 breached (ep-1, active) - RE->>R4S2: 16:05 breached (ep-1, active) - RE->>R4S1: 16:10 breached (ep-1, active) - RE->>R4S2: 16:10 breached (ep-1, active) - RE->>R4S1: 16:15 breached (ep-1, active) - RE->>R4S2: 16:15 breached (ep-1, active) - end - - rect rgb(240, 255, 255) - Note over RE,R5S2: rule-005 - RE->>R5S1: 16:00 breached (ep-1, active) - RE->>R5S2: 16:00 breached (ep-1, active) - RE->>R5S1: 16:05 breached (ep-1, active) - RE->>R5S2: 16:05 breached (ep-1, active) - U->>R5S1: 16:08 deactivate - RE->>R5S1: 16:10 breached (ep-1, active) - RE->>R5S2: 16:10 breached (ep-1, active) - RE->>R5S1: 16:15 breached (ep-1, active) - RE->>R5S2: 16:15 breached (ep-1, active) - end -``` - -## Dataset follow-up - -A follow-up `_bulk` request that adds one more evaluation tick at `16:20` for every currently active episode across all existing rules, plus a brand-new rule (`rule-006`) with a single series appearing for the first time. - -``` -POST _bulk -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-001" }, - "group_hash": "rule-001-series-1", - "episode": { "id": "rule-001-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-002" }, - "group_hash": "rule-002-series-1", - "episode": { "id": "rule-002-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-1", - "episode": { "id": "rule-003-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-003" }, - "group_hash": "rule-003-series-2", - "episode": { "id": "rule-003-series-2-episode-2", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-1", - "episode": { "id": "rule-004-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-004" }, - "group_hash": "rule-004-series-2", - "episode": { "id": "rule-004-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-1", - "episode": { "id": "rule-005-series-1-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-005" }, - "group_hash": "rule-005-series-2", - "episode": { "id": "rule-005-series-2-episode-1", "status": "active" }, - "status": "breached" -} -{ "create": { "_index": ".alerts-events" } } -{ - "@timestamp": "2026-01-27T16:20:00.000Z", "source": "internal", "type": "alert", - "rule": { "id": "rule-006" }, - "group_hash": "rule-006-series-1", - "episode": { "id": "rule-006-series-1-episode-1", "status": "active" }, - "status": "breached" -} -``` - - -## Trigger dispatcher manually - -Use the following workflow to exercise the dispatcher against the two datasets above and verify the suppress/fire decisions at each step. - -### Step 1 -- Ingest the initial dataset - -Run the first `POST _bulk` request (the one containing events from `16:00` to `16:15` plus all actions) to populate `.alerts-events` and `.alerts-actions`. - -### Step 2 -- Run the dispatcher (first pass) - -```bash -curl --request POST \ - --url http://localhost:5601/internal/alerting/v2/dispatcher/_run \ - --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \ - --header 'Content-Type: application/json' \ - --header 'kbn-xsrf: oui' \ - --header 'x-elastic-internal-origin: kibana' \ - --data '{ - "previousStartedAt": "2026-01-25T00:00:00.000Z" -}' -``` - -### Step 3 -- Assert first-pass results - -Verify that the dispatcher produced the expected suppress/fire events for every series across rule-001 through rule-005. - -### Step 4 -- Ingest the follow-up dataset - -Run the second `POST _bulk` request (the "Dataset follow-up" section -- events at `16:20` for all active episodes plus the new rule-006). - -### Step 5 -- Run the dispatcher (second pass) - -```bash -curl --request POST \ - --url http://localhost:5601/internal/alerting/v2/dispatcher/_run \ - --header 'Authorization: Basic ZWxhc3RpYzpjaGFuZ2VtZQ==' \ - --header 'Content-Type: application/json' \ - --header 'kbn-xsrf: oui' \ - --header 'x-elastic-internal-origin: kibana' \ - --data '{ - "previousStartedAt": "2026-01-27T16:15:00.000Z" -}' -``` - -### Step 6 -- Assert second-pass results - -Verify that the dispatcher produced the expected suppress/fire events for the `16:20` tick, including the newly introduced rule-006 series. \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md deleted file mode 100644 index 238e3c92466fb..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/agent/suppression-queries.md +++ /dev/null @@ -1,410 +0,0 @@ -# Suppression Queries - -Given the dispatcher query results (the alert episodes the dispatcher is working with), we need to determine for each episode whether it should be suppressed or not. - -## Dispatcher query results - -Query: -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-events,.alerts-actions METADATA _index - | WHERE (_index LIKE ".ds-.alerts-actions-*") OR (_index LIKE ".ds-.alerts-events-*" and type == "alert") - | EVAL - rule_id = COALESCE(rule.id, rule_id), - 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 ".ds-.alerts-actions-*" 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 ".ds-.alerts-actions-*") - | STATS - last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" - 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 asc - | LIMIT 10000 - """ -} -``` - -The dispatcher query (see [alerts-events-and-actions-dataset.md](./alerts-events-and-actions-dataset.md)) returns the following 10 alert episodes: - -``` -last_event_timestamp,rule_id,group_hash,episode_id,episode_status -2026-01-27T16:00:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-1,active -2026-01-27T16:05:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-1,inactive -2026-01-27T16:15:00.000Z,rule-003,rule-003-series-1,rule-003-series-1-episode-1,active -2026-01-27T16:15:00.000Z,rule-005,rule-005-series-2,rule-005-series-2-episode-1,active -2026-01-27T16:15:00.000Z,rule-003,rule-003-series-2,rule-003-series-2-episode-2,active -2026-01-27T16:15:00.000Z,rule-004,rule-004-series-1,rule-004-series-1-episode-1,active -2026-01-27T16:15:00.000Z,rule-005,rule-005-series-1,rule-005-series-1-episode-1,active -2026-01-27T16:15:00.000Z,rule-004,rule-004-series-2,rule-004-series-2-episode-1,active -2026-01-27T16:15:00.000Z,rule-002,rule-002-series-1,rule-002-series-1-episode-1,active -2026-01-27T16:15:00.000Z,rule-001,rule-001-series-1,rule-001-series-1-episode-1,active -``` - -## Suppression types - -A suppression can happen because of three action types, each with different scoping: - -| Suppression type | Scope | Suppressed when | -| --- | --- | --- | -| **Ack** | `(rule_id, group_hash, episode_id)` | Last action in `ack`/`unack` pair is `ack` | -| **Deactivate** | `(rule_id, group_hash, episode_id)` | Last action in `deactivate`/`activate` pair is `deactivate` | -| **Snooze** | `(rule_id, group_hash)` | Last action in `snooze`/`unsnooze` pair is `snooze` AND expiry > alert event timestamp | - -> Note: Snooze has no `episode_id` — it applies to the entire `group_hash` (all episodes for that series). - -## Expected suppression results - -Based on the actions in the dataset: - -| rule_id | group_hash | episode_id | suppressed? | reason | -| --- | --- | --- | --- | --- | -| rule-001 | rule-001-series-1 | rule-001-series-1-episode-1 | no | ack at 16:03 then unack at 16:08 | -| rule-002 | rule-002-series-1 | rule-002-series-1-episode-1 | **yes** | ack at 16:03, no unack after | -| rule-003 | rule-003-series-1 | rule-003-series-1-episode-1 | no | no actions | -| rule-003 | rule-003-series-2 | rule-003-series-2-episode-1 | no | no actions | -| rule-003 | rule-003-series-2 | rule-003-series-2-episode-2 | no | no actions | -| rule-004 | rule-004-series-1 | rule-004-series-1-episode-1 | **yes** | snoozed at 16:03, expiry 2026-01-28 > event time 16:15 | -| rule-004 | rule-004-series-2 | rule-004-series-2-episode-1 | **yes** | snoozed at 16:03, expiry 2026-01-28 > event time 16:15 | -| rule-005 | rule-005-series-1 | rule-005-series-1-episode-1 | **yes** | deactivated at 16:08, no activate after | -| rule-005 | rule-005-series-2 | rule-005-series-2-episode-1 | no | no actions | - - ---- - -## Query 1: Ack suppression - -Ack suppression is scoped to `(rule_id, group_hash, episode_id)`. An episode is suppressed if the last action between `ack` and `unack` is `ack`. - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE action_type IN ("ack", "unack") - | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash, episode_id - | EVAL suppressed_by_ack = last_action_type == "ack" - | KEEP rule_id, group_hash, episode_id, suppressed_by_ack - """ -} -``` - -### Expected result - -``` -rule_id,group_hash,episode_id,suppressed_by_ack -rule-001,rule-001-series-1,rule-001-series-1-episode-1,false -rule-002,rule-002-series-1,rule-002-series-1-episode-1,true -``` - -> Episodes not present in the result have no `ack`/`unack` actions and should be assumed **not suppressed** by ack. - -- **rule-001**: ack at 16:03, then unack at 16:08 → last action is `unack` → `suppressed_by_ack = false` -- **rule-002**: ack at 16:03, no unack → last action is `ack` → `suppressed_by_ack = true` - - ---- - -## Query 2: Deactivate suppression - -Deactivate suppression is scoped to `(rule_id, group_hash, episode_id)`. An episode is suppressed if the last action between `deactivate` and `activate` is `deactivate`. - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE action_type IN ("deactivate", "activate") - | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash, episode_id - | EVAL suppressed_by_deactivate = last_action_type == "deactivate" - | KEEP rule_id, group_hash, episode_id, suppressed_by_deactivate - """ -} -``` - -### Expected result - -``` -rule_id,group_hash,episode_id,suppressed_by_deactivate -rule-005,rule-005-series-1,rule-005-series-1-episode-1,true -``` - -> Episodes not present in the result have no `deactivate`/`activate` actions and should be assumed **not suppressed** by deactivate. - -- **rule-005 series-1**: deactivate at 16:08, no activate after → last action is `deactivate` → `suppressed_by_deactivate = true` - - ---- - -## Query 3: Snooze suppression - -Snooze suppression is scoped to `(rule_id, group_hash)` — it has no `episode_id`. All episodes of a snoozed series are suppressed. A series is suppressed if the last action between `snooze` and `unsnooze` is `snooze` **and** the snooze expiry is still valid. - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE (action_type == "snooze" AND expiry > now()) OR action_type == "unsnooze" - | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash - | EVAL suppressed_by_snooze = last_action_type == "snooze" - | KEEP rule_id, group_hash, suppressed_by_snooze - """ -} -``` - -### Note on expiry comparison - -The snooze expiry for rule-004 is `2026-01-28T16:03:00.000Z`. If running today (`now()` > expiry), the `expiry > now()` filter will exclude the snooze action and the query will return no results — the snooze appears expired. - -In the real dispatcher context, the comparison should be `expiry > last_event_timestamp` (the alert episode's event timestamp), not `now()`. Since the alert events for rule-004 are at `2026-01-27T16:15:00.000Z`, and the expiry is `2026-01-28T16:03:00.000Z`, the snooze is still active for those episodes. - -For testing with this dataset, you can replace `now()` with a hardcoded timestamp: - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE (action_type == "snooze" AND expiry > "2026-01-27T16:15:00.000Z"::datetime) OR action_type == "unsnooze" - | STATS last_action_type = LAST(action_type, @timestamp) BY rule_id, group_hash - | EVAL suppressed_by_snooze = last_action_type == "snooze" - | KEEP rule_id, group_hash, suppressed_by_snooze - """ -} -``` - -### Expected result (with valid expiry) - -``` -rule_id,group_hash,suppressed_by_snooze -rule-004,rule-004-series-1,true -rule-004,rule-004-series-2,true -``` - -> Series not present in the result have no active `snooze`/`unsnooze` actions and should be assumed **not suppressed** by snooze. - -- **rule-004 series-1**: snooze at 16:03 with expiry 2026-01-28, no unsnooze → last action is `snooze` → `suppressed_by_snooze = true` -- **rule-004 series-2**: snooze at 16:03 with expiry 2026-01-28, no unsnooze → last action is `snooze` → `suppressed_by_snooze = true` - - ---- - -## Combined query with alerts-events: dynamic snooze expiry comparison - - -> [!CAUTION] -> This approach was explored but **not chosen** for the final implementation. It requires reading all `.alerts-events` in the same query, which is expensive and redundant since the dispatcher already fetches the alert episodes in a prior query. The chosen approach uses the dispatcher's alert episodes to build a scoped filter on `.alerts-actions` only — see [Chosen approach: two-query strategy with episode-based filtering](#chosen-approach-two-query-strategy-with-episode-based-filtering) below. - -The combined queries above use a hardcoded timestamp (`"2026-01-27T16:15:00.000Z"::datetime`) for the snooze expiry comparison. In the real dispatcher context, the snooze expiry should be compared against the **alert episode's event timestamp** — not a static value or `now()`. - -To achieve this, we query both `.alerts-events` and `.alerts-actions` in a single ES|QL query: - -1. Read from both indices (same as the dispatcher query) -2. Apply the **fire filter** — use `INLINE STATS` to find the last `fire` action per `(rule_id, group_hash)` and only consider alert events that haven't been fired yet -3. Compute `last_event_timestamp` per `(rule_id, group_hash)` from the alert events using `INLINE STATS` -4. Filter down to action rows only, and use the dynamically computed `last_event_timestamp` for the snooze expiry comparison -5. Compute all 3 suppression types as before - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-events, .alerts-actions METADATA _index - | WHERE (_index LIKE ".ds-.alerts-actions-*") - OR (_index LIKE ".ds-.alerts-events-*" AND type == "alert") - | EVAL - rule_id = COALESCE(rule.id, rule_id), - episode_id = COALESCE(episode.id, episode_id) - | DROP rule.id, episode.id - | INLINE STATS - last_fired = MAX(last_series_event_timestamp) WHERE _index LIKE ".ds-.alerts-actions-*" 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 ".ds-.alerts-actions-*") - | INLINE STATS - last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" - BY rule_id, group_hash - | WHERE _index LIKE ".ds-.alerts-actions-*" - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") - | WHERE action_type != "snooze" OR expiry > last_event_timestamp - | 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), - last_event_timestamp = MAX(last_event_timestamp) - 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, last_ack_action, last_deactivate_action, last_snooze_action, last_event_timestamp - """ -} -``` - -### How it works step by step - -1. **Read both indices** — `FROM .alerts-events, .alerts-actions METADATA _index` reads from both datasets. The `METADATA _index` allows us to distinguish which index each row comes from. - -2. **Normalize field names** — Alert events store the rule id as `rule.id` and episode id as `episode.id`, while actions store them as `rule_id` and `episode_id`. The `COALESCE` + `DROP` normalizes these into consistent field names. - -3. **Fire filter** — The `INLINE STATS last_fired = MAX(last_series_event_timestamp) WHERE (action_type == "fire" OR action_type == "suppress")` computes the last processed event timestamp per `(rule_id, group_hash)`. Both `fire` and `suppress` actions indicate that the dispatcher has already processed the series. The subsequent `WHERE` clause filters out alert events that have already been processed by a previous dispatcher run, while keeping all action rows. With the current dataset, there are no `fire` or `suppress` actions, so `last_fired` is NULL and all events pass through. - -4. **Compute `last_event_timestamp`** — `INLINE STATS last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ".ds-.alerts-events-*" BY rule_id, group_hash` attaches the latest alert event timestamp to every row for that `(rule_id, group_hash)`. This is the timestamp we compare against the snooze expiry. - -5. **Filter to actions only** — After computing `last_event_timestamp`, we keep only the action rows (`WHERE _index LIKE ".ds-.alerts-actions-*"`) and filter to the relevant suppression action types. - -6. **Dynamic snooze expiry check** — `WHERE action_type != "snooze" OR expiry > last_event_timestamp` replaces the hardcoded timestamp. Snooze actions are only kept if their expiry is after the latest alert event timestamp for that group_hash. - -7. **Compute suppression** — Same logic as the previous combined queries: `INLINE STATS` for snooze at the `group_hash` level, then `STATS` for ack/deactivate at the `episode_id` level. - -### Note on fire filter - -The current dataset has no `fire` or `suppress` actions (those are written by the dispatcher after processing). As a result, `last_fired` is NULL for all `(rule_id, group_hash)` pairs, and the filter passes all events through. In production, this filter ensures only unfired alert events contribute to the `last_event_timestamp` computation — avoiding re-processing episodes already handled by a previous dispatcher run. - -### Expected result - -``` -rule_id,group_hash,episode_id,should_suppress,last_ack_action,last_deactivate_action,last_snooze_action,last_event_timestamp -rule-001,rule-001-series-1,rule-001-series-1-episode-1,false,unack,,,2026-01-27T16:15:00.000Z -rule-002,rule-002-series-1,rule-002-series-1-episode-1,true,ack,,,2026-01-27T16:15:00.000Z -rule-004,rule-004-series-1,,true,,,snooze,2026-01-27T16:15:00.000Z -rule-004,rule-004-series-2,,true,,,snooze,2026-01-27T16:15:00.000Z -rule-005,rule-005-series-1,rule-005-series-1-episode-1,true,,deactivate,,2026-01-27T16:15:00.000Z -``` - -> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. - -The results are identical to the previous combined queries, but now with `last_event_timestamp` included to show which alert event timestamp was used for the snooze comparison. The key difference is that the snooze expiry is compared dynamically against the actual alert event timestamps from the `.alerts-events` index, rather than requiring a hardcoded value. - -- **rule-001**: last ack action is `unack` → `should_suppress = false` (last_event_timestamp = 16:15) -- **rule-002**: last ack action is `ack` → `should_suppress = true` (last_event_timestamp = 16:15) -- **rule-004 series-1**: snooze expiry `2026-01-28T16:03:00.000Z` > last_event_timestamp `2026-01-27T16:15:00.000Z` → snooze is active → `should_suppress = true` -- **rule-004 series-2**: same as series-1 → `should_suppress = true` -- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` (last_event_timestamp = 16:15) - - ---- - -## Chosen approach: two-query strategy with episode-based filtering - -Instead of a single query that reads both `.alerts-events` and `.alerts-actions`, the dispatcher runs two sequential queries: - -1. **Dispatcher query** (documented at the top of this file) — returns the alert episodes with their `last_event_timestamp`, `rule_id`, `group_hash`, `episode_id`, and `episode_status`. -2. **Suppression query** — reads only `.alerts-actions`, filtered by the `(rule_id, group_hash)` pairs extracted from the alert episodes returned in step 1. - -### Why this approach - -The dispatcher already has the alert episodes in memory after step 1. We can use them to build a targeted `WHERE` clause that scopes the `.alerts-actions` query to only the relevant series. This avoids reading `.alerts-events` a second time, which is both expensive and redundant. It also removes the need for `METADATA _index`, `COALESCE` normalization, and the `INLINE STATS` fire filter — all of which were required by the single-query approach. - -The snooze expiry comparison uses `minLastEventTimestamp` — the minimum `last_event_timestamp` across all alert episodes — as a conservative pre-filter. This ensures no valid snooze is accidentally excluded by the ES|QL query. If a more precise per-episode snooze expiry check is needed, the dispatcher can refine it in code after the query returns, since it already holds the per-episode timestamps. - -### TypeScript implementation - -```typescript -export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): EsqlRequest => { - const minLastEventTimestamp = alertEpisodes.reduce( - (min, ep) => (ep.last_event_timestamp < min ? ep.last_event_timestamp : min), - alertEpisodes[0].last_event_timestamp - ); - - 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(); -}; -``` - -### Equivalent ES|QL for our dataset - -Using the 8 unique `(rule_id, group_hash)` pairs from the dispatcher results and `minLastEventTimestamp = "2026-01-27T16:00:00.000Z"`: - -``` -POST /_query?format=csv -{ - "query": """ - FROM .alerts-actions - | WHERE (rule_id == "rule-001" AND group_hash == "rule-001-series-1") - OR (rule_id == "rule-002" AND group_hash == "rule-002-series-1") - OR (rule_id == "rule-003" AND group_hash == "rule-003-series-1") - OR (rule_id == "rule-003" AND group_hash == "rule-003-series-2") - OR (rule_id == "rule-004" AND group_hash == "rule-004-series-1") - OR (rule_id == "rule-004" AND group_hash == "rule-004-series-2") - OR (rule_id == "rule-005" AND group_hash == "rule-005-series-1") - OR (rule_id == "rule-005" AND group_hash == "rule-005-series-2") - | WHERE action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze") - | WHERE action_type != "snooze" OR expiry > "2026-01-27T16:00:00.000Z"::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 - """ -} -``` - -### Note on `minLastEventTimestamp` - -The `minLastEventTimestamp` is the minimum `last_event_timestamp` across all alert episodes returned by the dispatcher query. In our dataset, the dispatcher returns episodes with `last_event_timestamp` values of `16:00` (rule-003-series-2-episode-1) and `16:15` (all others), so `minLastEventTimestamp = "2026-01-27T16:00:00.000Z"`. - -This value is used as a conservative pre-filter for snooze expiry: `WHERE action_type != "snooze" OR expiry > minLastEventTimestamp`. By using the earliest timestamp, we ensure no snooze that is still valid for any episode gets filtered out. If a snooze has expired for a later episode but not for an earlier one, it is still included — the dispatcher can perform a per-episode refinement in code using the `last_event_timestamp` it already holds from step 1. - -### Expected result - -``` -rule_id,group_hash,episode_id,should_suppress -rule-001,rule-001-series-1,rule-001-series-1-episode-1,false -rule-002,rule-002-series-1,rule-002-series-1-episode-1,true -rule-004,rule-004-series-1,,true -rule-004,rule-004-series-2,,true -rule-005,rule-005-series-1,rule-005-series-1-episode-1,true -``` - -> Episodes/series not present in the result have no suppression actions and should be assumed **not suppressed**. - -- **rule-001**: last ack action is `unack` → `should_suppress = false` -- **rule-002**: last ack action is `ack` → `should_suppress = true` -- **rule-004 series-1**: last snooze action is `snooze` (expiry `2026-01-28T16:03:00.000Z` > `minLastEventTimestamp`) → `should_suppress = true` (episode_id is NULL — snooze applies to all episodes in the series) -- **rule-004 series-2**: same as series-1 → `should_suppress = true` -- **rule-005 series-1**: last deactivate action is `deactivate` → `should_suppress = true` - -The results match the previous combined queries. The key difference is that `last_event_timestamp` is no longer in the output — the dispatcher already has it from the first query. From 5f7099c16da6591184424c69796a739b4fabf5ec Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 9 Feb 2026 09:48:59 -0500 Subject: [PATCH 14/54] Fix dispatcher --- .../lib/dispatcher/integration_tests/dispatcher.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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..3f7017f7981bd 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 @@ -151,7 +151,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 +178,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 +193,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 +228,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' } } }, ], }, From 7d41c1de897cf58ca10d6ce80ffe019a46f460ee Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 9 Feb 2026 11:19:58 -0500 Subject: [PATCH 15/54] Add integration tests --- .../server/lib/dispatcher/dispatcher.test.ts | 19 +- .../integration_tests/dispatcher.test.ts | 377 +++++++++++++++++- .../server/resources/alert_actions.ts | 2 +- 3 files changed, 376 insertions(+), 22 deletions(-) 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 e14f64fff7af4..8535a15f37314 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 @@ -237,7 +237,6 @@ describe('DispatcherService', () => { expect(storageEsClient.bulk).not.toHaveBeenCalled(); }); - // Based on agent/alerts-events-and-actions-dataset.md 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 @@ -267,6 +266,13 @@ describe('DispatcherService', () => { 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', @@ -372,12 +378,11 @@ describe('DispatcherService', () => { const [{ operations }] = storageEsClient.bulk.mock.calls[0]; const docs = (operations ?? []).filter((_, index) => index % 2 === 1) as AlertAction[]; - expect(docs).toHaveLength(9); + expect(docs).toHaveLength(10); - // 5 fire, 4 suppress const fireActions = docs.filter((doc) => doc.action_type === 'fire'); const suppressActions = docs.filter((doc) => doc.action_type === 'suppress'); - expect(fireActions).toHaveLength(5); + expect(fireActions).toHaveLength(6); expect(suppressActions).toHaveLength(4); // rule-001: fire (ack then unack cancels suppression) @@ -415,6 +420,12 @@ describe('DispatcherService', () => { 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', 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 3f7017f7981bd..eaf6c0f9870ad 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,221 @@ const ALERT_EVENTS_TEST_DATA: AlertEvent[] = [ }, ]; +/** + * Test dataset from .llm-docs/alerts-events-and-actions-dataset.md + * + * 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; @@ -245,28 +460,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 +608,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/resources/alert_actions.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_actions.ts index 806533f41808e..a0539b9c362c4 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 @@ -49,7 +49,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(), From 9eba0ea36139c083f6c953c8dbf0fe4850034bb5 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 09:37:34 -0500 Subject: [PATCH 16/54] Improve suppression algo --- .../server/lib/dispatcher/dispatcher.ts | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) 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 5dbbaa5c7a81c..2dbc941bea0f2 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 @@ -51,19 +51,10 @@ export class DispatcherService implements DispatcherServiceContract { const suppressions = await withDispatcherSpan('dispatcher:fetch-suppressions', () => this.fetchAlertEpisodeSuppressions(alertEpisodes) ); - - const [suppressedEpisodes, nonSuppressedEpisodes] = partition(alertEpisodes, (episode) => - suppressions.some( - (s) => - s.should_suppress && - s.rule_id === episode.rule_id && - s.group_hash === episode.group_hash && - (s.episode_id == null || s.episode_id === episode.episode_id) - ) - ); + const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); this.logger.debug({ - message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressedEpisodes.length} suppressed, ${nonSuppressedEpisodes.length} not suppressed`, + message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressed.length} suppressed, ${active.length} not suppressed`, }); const now = new Date(); @@ -71,12 +62,8 @@ export class DispatcherService implements DispatcherServiceContract { this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, docs: [ - ...suppressedEpisodes.map((episode) => - this.toAction({ episode, isSuppressed: true, now }) - ), - ...nonSuppressedEpisodes.map((episode) => - this.toAction({ episode, isSuppressed: false, now }) - ), + ...suppressed.map((episode) => this.toAction({ episode, isSuppressed: true, now })), + ...active.map((episode) => this.toAction({ episode, isSuppressed: false, now })), ], }) ); @@ -84,6 +71,40 @@ export class DispatcherService implements DispatcherServiceContract { 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, isSuppressed, From fd4fbd0d39568ee0ee16750dfd2460e23888b572 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 09:55:40 -0500 Subject: [PATCH 17/54] remove import --- .../shared/alerting_v2/server/lib/dispatcher/dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2dbc941bea0f2..9b0959a4b716e 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 @@ -6,7 +6,6 @@ */ import { inject, injectable } from 'inversify'; -import { partition } from 'lodash'; import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import { @@ -51,6 +50,7 @@ export class DispatcherService implements DispatcherServiceContract { const suppressions = await withDispatcherSpan('dispatcher:fetch-suppressions', () => this.fetchAlertEpisodeSuppressions(alertEpisodes) ); + const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); this.logger.debug({ From b3570f3effffcedbb65869affed8b6f61b5b664a Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 10:01:51 -0500 Subject: [PATCH 18/54] provide actionType --- .../lib/alert_actions_client/alert_actions_client.ts | 2 +- .../alerting_v2/server/lib/dispatcher/dispatcher.ts | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 0b50eaba04028..3a79a8b1cf198 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 @@ -99,7 +99,7 @@ export class AlertActionsClient { let whereClause = esql.exp`TRUE`; 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.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/dispatcher.ts index 9b0959a4b716e..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 @@ -62,8 +62,8 @@ export class DispatcherService implements DispatcherServiceContract { this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, docs: [ - ...suppressed.map((episode) => this.toAction({ episode, isSuppressed: true, now })), - ...active.map((episode) => this.toAction({ episode, isSuppressed: false, now })), + ...suppressed.map((episode) => this.toAction({ episode, actionType: 'suppress', now })), + ...active.map((episode) => this.toAction({ episode, actionType: 'fire', now })), ], }) ); @@ -107,11 +107,11 @@ export class DispatcherService implements DispatcherServiceContract { private toAction({ episode, - isSuppressed, + actionType, now, }: { episode: AlertEpisode; - isSuppressed: boolean; + actionType: 'suppress' | 'fire'; now: Date; }): AlertAction { return { @@ -119,7 +119,7 @@ export class DispatcherService implements DispatcherServiceContract { group_hash: episode.group_hash, last_series_event_timestamp: episode.last_event_timestamp, actor: 'system', - action_type: isSuppressed ? 'suppress' : 'fire', + action_type: actionType, rule_id: episode.rule_id, source: 'internal', }; From 7878155e7e4b8be5349d35eaed6536d9a71eef63 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 10:24:21 -0500 Subject: [PATCH 19/54] Update mocks --- .../shared/alerting_v2/server/lib/dispatcher/dispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 8535a15f37314..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 @@ -362,7 +362,7 @@ describe('DispatcherService', () => { .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); storageEsClient.bulk.mockResolvedValue({ - items: Array.from({ length: 9 }, (_, i) => ({ + items: Array.from({ length: 10 }, (_, i) => ({ create: { _id: String(i + 1), status: 201 }, })), errors: false, From bbc6fc00f533cdf85f5123c004cd4f585880f37b Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 12:51:38 -0500 Subject: [PATCH 20/54] POC notificationm policy --- .../server/lib/dispatcher/dispatcher.ts | 91 +++++++++++++++++++ .../server/lib/dispatcher/faker_service.ts | 50 ++++++++++ .../server/lib/dispatcher/types.ts | 47 ++++++++-- .../server/lib/rules_client/rules_client.ts | 12 +-- 4 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts 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 995a4a9711217..852fcd83a3c9f 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 @@ -6,7 +6,9 @@ */ import { inject, injectable } from 'inversify'; +import { get } from 'lodash'; import moment from 'moment'; +import objectHash from 'object-hash'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import { LoggerServiceToken, @@ -18,12 +20,19 @@ 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 { getFakeNotificationPoliciesByIds, getFakeRulesByIds } from './faker_service'; import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery } from './queries'; import type { AlertEpisode, AlertEpisodeSuppression, DispatcherExecutionParams, DispatcherExecutionResult, + MatchedPair, + NotificationGroup, + NotificationPolicy, + NotificationPolicyId, + Rule, + RuleId, } from './types'; import { withDispatcherSpan } from './with_dispatcher_span'; @@ -53,6 +62,15 @@ export class DispatcherService implements DispatcherServiceContract { const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); + const uniqueRuleIds = [...new Set(active.map((ep) => ep.rule_id))]; + const rules = await getFakeRulesByIds(uniqueRuleIds); + + const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; + const policies = await getFakeNotificationPoliciesByIds(uniquePolicyIds); + + const matched = this.evaluateMatchers(active, rules, policies); + const notificationGroups = this.buildNotificationGroups(matched); + this.logger.debug({ message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressed.length} suppressed, ${active.length} not suppressed`, }); @@ -71,6 +89,79 @@ export class DispatcherService implements DispatcherServiceContract { return { startedAt }; } + private buildNotificationGroups(matched: MatchedPair[]): NotificationGroup[] { + const groupMap = new Map(); + + for (const { episode, policy } of matched) { + let groupKey: Record = {}; + if (policy.groupBy.length === 0) { + // No grouping: each episode dispatches individually. + // Use the episode's identity as the group key for throttle tracking. + groupKey = { + groupHash: episode.group_hash, + episodeId: episode.episode_id, + }; + } else { + for (const field of policy.groupBy) { + // TODO: replace {} with episode.data when ESQL flattened support is added + groupKey[field] = get({}, field); + } + } + + const compositeKey = objectHash({ + ruleId: episode.rule_id, + policyId: policy.id, + groupKey, + }); + + if (!groupMap.has(compositeKey)) { + groupMap.set(compositeKey, { + ruleId: episode.rule_id, + policyId: policy.id, + workflowId: policy.workflowId, + groupKey, + episodes: [], + }); + } + + groupMap.get(compositeKey)!.episodes.push(episode); + } + + return [...groupMap.values()]; + } + + private evaluateMatchers( + activeEpisodes: AlertEpisode[], + rules: Map, + policies: Map + ): MatchedPair[] { + const matched: MatchedPair[] = []; + + for (const episode of activeEpisodes) { + const rule = rules.get(episode.rule_id); + if (!rule) continue; + + for (const policyId of rule.notificationPolicyIds) { + const policy = policies.get(policyId); + if (!policy) continue; + + // Empty matcher = catch-all, always matches + if (!policy.matcher) { + this.logger.debug({ + message: `Episode ${episode.episode_id} matches policy ${policyId} (catch-all)`, + }); + matched.push({ episode, policy }); + continue; + } + + // TODO: Handle matcher evaluation here + // matched.push({ episode, policy }); + } + } + + return matched; + } + private applySuppression( episodes: AlertEpisode[], suppressions: AlertEpisodeSuppression[] diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts new file mode 100644 index 0000000000000..8fea8f4f9ad8b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -0,0 +1,50 @@ +/* + * 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 type { NotificationPolicy, NotificationPolicyId, Rule, RuleId } from './types'; + +const NOTIFICATION_POLICY_IDS: NotificationPolicyId[] = ['policy_123', 'policy_456', 'policy_789']; + +export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise> { + const now = new Date().toISOString(); + const rules = ruleIds.reduce((acc, ruleId) => { + acc[ruleId] = { + id: ruleId, + name: `Rule ${ruleId}`, + description: `Description for rule ${ruleId}`, + notificationPolicyIds: NOTIFICATION_POLICY_IDS.slice( + 0, + Math.floor(Math.random() * NOTIFICATION_POLICY_IDS.length) + 1 + ), + enabled: true, + createdAt: now, + updatedAt: now, + }; + return acc; + }, {} as Record); + + return new Map(Object.entries(rules)); +} + +export async function getFakeNotificationPoliciesByIds( + notificationPolicyIds: NotificationPolicyId[] +): Promise> { + const policies = notificationPolicyIds.reduce((acc, policyId) => { + acc[policyId] = { + id: policyId, + name: `Policy ${policyId}`, + matcher: '', + groupBy: ['data.env'], + throttle: { + interval: '1h', + }, + workflowId: 'workflow_123', + }; + return acc; + }, {} as Record); + return new Map(Object.entries(policies)); +} 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 3efb1ab7a658b..2fda25a688532 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 @@ -6,12 +6,8 @@ */ export type RuleId = string; - -export interface Policy { - id: string; - name: string; - // other policy fields as needed -} +export type NotificationPolicyId = string; +export type WorkflowId = string; export interface AlertEpisode { last_event_timestamp: string; @@ -40,3 +36,42 @@ export interface DispatcherExecutionResult { export interface DispatcherTaskState { previousStartedAt?: string; } + +export interface Rule { + id: RuleId; + name: string; + description: string; + notificationPolicyIds: NotificationPolicyId[]; + enabled: boolean; + createdAt: string; + updatedAt: string; +} + +export interface NotificationPolicy { + id: NotificationPolicyId; + name: string; + /** CEL expression evaluated against the alert episode context. + * An empty string matches all episodes (catch-all). */ + matcher: string; // e.g. 'data.severity == "critical" && data.env != "dev"' + /** data.* fields used to group episodes into a single notification */ + groupBy: string[]; + /** Minimum interval between notifications for the same group */ + throttle: { + interval: string; // e.g. '1h', '30m', '5m' + }; + /** Target workflow to dispatch matched episodes to */ + workflowId: WorkflowId; +} + +export interface MatchedPair { + episode: AlertEpisode; + policy: NotificationPolicy; +} + +export interface NotificationGroup { + ruleId: RuleId; + policyId: NotificationPolicyId; + workflowId: WorkflowId; + groupKey: Record; + episodes: AlertEpisode[]; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts index bc5c7645a9d46..c8d4c3929c631 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts @@ -5,17 +5,17 @@ * 2.0. */ -import { getSpaceIdFromPath } from '@kbn/spaces-utils'; import Boom from '@hapi/boom'; +import { createRuleDataSchema, updateRuleDataSchema } from '@kbn/alerting-v2-schemas'; +import { PluginStart } from '@kbn/core-di'; +import { CoreStart, Request } from '@kbn/core-di-server'; +import type { HttpServiceStart, KibanaRequest } from '@kbn/core-http-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { KibanaRequest as CoreKibanaRequest } from '@kbn/core/server'; -import type { HttpServiceStart, KibanaRequest } from '@kbn/core-http-server'; +import { getSpaceIdFromPath } from '@kbn/spaces-utils'; import type { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; -import { inject, injectable } from 'inversify'; -import { PluginStart } from '@kbn/core-di'; -import { CoreStart, Request } from '@kbn/core-di-server'; import { stringifyZodError } from '@kbn/zod-helpers'; -import { createRuleDataSchema, updateRuleDataSchema } from '@kbn/alerting-v2-schemas'; +import { inject, injectable } from 'inversify'; import { type RuleSavedObjectAttributes } from '../../saved_objects'; import { ensureRuleExecutorTaskScheduled, getRuleExecutorTaskId } from '../rule_executor/schedule'; From 0b24ee55f17d5dba2c2fcaa8a0cc1c05c5655ee6 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Tue, 10 Feb 2026 20:25:05 -0500 Subject: [PATCH 21/54] poc --- .../alert_actions_client.ts | 2 +- .../server/lib/dispatcher/dispatcher.ts | 138 ++++++++++++++---- .../server/lib/dispatcher/faker_service.ts | 18 ++- .../server/lib/dispatcher/queries.ts | 14 +- .../server/lib/dispatcher/types.ts | 11 +- .../server/resources/alert_actions.ts | 4 +- 6 files changed, 153 insertions(+), 34 deletions(-) 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 3a79a8b1cf198..39513a4c91fdd 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 @@ -96,7 +96,7 @@ 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` 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 852fcd83a3c9f..f82b4ade2901a 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 @@ -6,7 +6,6 @@ */ import { inject, injectable } from 'inversify'; -import { get } from 'lodash'; import moment from 'moment'; import objectHash from 'object-hash'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; @@ -20,21 +19,30 @@ 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 { getFakeNotificationPoliciesByIds, getFakeRulesByIds } from './faker_service'; -import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery } from './queries'; +import { + executeFakeWorkflow, + getFakeNotificationPoliciesByIds, + getFakeRulesByIds, +} from './faker_service'; +import { + getAlertEpisodeSuppressionsQuery, + getDispatchableAlertEventsQuery, + getLastNotifiedTimestampsQuery, +} from './queries'; import type { AlertEpisode, AlertEpisodeSuppression, DispatcherExecutionParams, DispatcherExecutionResult, + LastNotifiedRecord, MatchedPair, NotificationGroup, + NotificationGroupId, NotificationPolicy, NotificationPolicyId, Rule, RuleId, } from './types'; -import { withDispatcherSpan } from './with_dispatcher_span'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -53,12 +61,8 @@ export class DispatcherService implements DispatcherServiceContract { }: DispatcherExecutionParams): Promise { const startedAt = new Date(); - const alertEpisodes = await withDispatcherSpan('dispatcher:fetch-alert-episodes', () => - this.fetchAlertEpisodes(previousStartedAt) - ); - const suppressions = await withDispatcherSpan('dispatcher:fetch-suppressions', () => - this.fetchAlertEpisodeSuppressions(alertEpisodes) - ); + const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); + const suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); @@ -71,24 +75,77 @@ export class DispatcherService implements DispatcherServiceContract { const matched = this.evaluateMatchers(active, rules, policies); const notificationGroups = this.buildNotificationGroups(matched); + const { dispatch, throttled } = await this.applyThrottling( + notificationGroups, + policies, + startedAt + ); + + for (const group of dispatch) { + await executeFakeWorkflow(group); + } + this.logger.debug({ message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressed.length} suppressed, ${active.length} not suppressed`, }); 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 })), - ], - }) - ); + await this.storageService.bulkIndexDocs({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + ...suppressed.map((episode) => this.toAction({ episode, actionType: 'suppress', now })), + ...throttled.flatMap((group) => + group.episodes.map((episode) => this.toAction({ episode, actionType: 'suppress', now })) + ), + ...dispatch.flatMap((group) => + group.episodes.map((episode) => this.toAction({ episode, actionType: 'fire', now })) + ), + // This is used to determine if the group should be throttled in a following run + ...dispatch.map((group) => ({ + '@timestamp': now.toISOString(), + actor: 'system', + action_type: 'notified', + rule_id: group.ruleId, + group_hash: 'irrelevant', // irrelevant + last_series_event_timestamp: now.toISOString(), // irrelevant + notification_group_id: group.id, + source: 'internal', + })), + ], + }); return { startedAt }; } + private async applyThrottling( + groups: NotificationGroup[], + policies: Map, + now: Date + ): Promise<{ dispatch: NotificationGroup[]; throttled: NotificationGroup[] }> { + const dispatch: NotificationGroup[] = []; + const throttled: NotificationGroup[] = []; + + const lastNotifiedMap = await this.fetchLastNotifiedTimestamps(groups.map((group) => group.id)); + + for (const group of groups) { + const policy = policies.get(group.policyId)!; + const lastNotified = lastNotifiedMap.get(group.id); + + if ( + lastNotified && + policy.throttle && + policy.throttle.interval && + isWithinInterval(lastNotified, policy.throttle.interval, now) + ) { + throttled.push(group); + } else { + dispatch.push(group); + } + } + + return { dispatch, throttled }; + } + private buildNotificationGroups(matched: MatchedPair[]): NotificationGroup[] { const groupMap = new Map(); @@ -102,20 +159,23 @@ export class DispatcherService implements DispatcherServiceContract { episodeId: episode.episode_id, }; } else { - for (const field of policy.groupBy) { - // TODO: replace {} with episode.data when ESQL flattened support is added - groupKey[field] = get({}, field); - } + // for (const field of policy.groupBy) { + // // TODO: replace {} with episode.data when ESQL flattened support is added + // groupKey[field] = get({}, field); + // } + throw new Error('Grouping by fields is not supported yet'); } - const compositeKey = objectHash({ + // This is used to identify the notification group in the alert-actions + const notificationGroupId = objectHash({ ruleId: episode.rule_id, policyId: policy.id, groupKey, }); - if (!groupMap.has(compositeKey)) { - groupMap.set(compositeKey, { + if (!groupMap.has(notificationGroupId)) { + groupMap.set(notificationGroupId, { + id: notificationGroupId, ruleId: episode.rule_id, policyId: policy.id, workflowId: policy.workflowId, @@ -124,7 +184,7 @@ export class DispatcherService implements DispatcherServiceContract { }); } - groupMap.get(compositeKey)!.episodes.push(episode); + groupMap.get(notificationGroupId)!.episodes.push(episode); } return [...groupMap.values()]; @@ -200,10 +260,12 @@ export class DispatcherService implements DispatcherServiceContract { episode, actionType, now, + reason, }: { episode: AlertEpisode; - actionType: 'suppress' | 'fire'; + actionType: 'suppress' | 'fire' | 'notified'; now: Date; + reason?: string; }): AlertAction { return { '@timestamp': now.toISOString(), @@ -213,6 +275,7 @@ export class DispatcherService implements DispatcherServiceContract { action_type: actionType, rule_id: episode.rule_id, source: 'internal', + reason, }; } @@ -248,4 +311,23 @@ export class DispatcherService implements DispatcherServiceContract { return queryResponseToRecords(result); } + + private async fetchLastNotifiedTimestamps( + notificationGroupIds: NotificationGroupId[] + ): Promise> { + const result = await this.queryService.executeQuery({ + query: getLastNotifiedTimestampsQuery(notificationGroupIds).query, + }); + + const records = queryResponseToRecords(result); + const lastNotifiedMap = new Map( + records.map((record) => [record.notification_group_id, new Date(record.last_notified)]) + ); + return lastNotifiedMap; + } +} + +function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { + const intervalMillis = moment.duration(interval).asMilliseconds(); + return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index 8fea8f4f9ad8b..310dd7696de77 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -5,7 +5,13 @@ * 2.0. */ -import type { NotificationPolicy, NotificationPolicyId, Rule, RuleId } from './types'; +import type { + NotificationGroup, + NotificationPolicy, + NotificationPolicyId, + Rule, + RuleId, +} from './types'; const NOTIFICATION_POLICY_IDS: NotificationPolicyId[] = ['policy_123', 'policy_456', 'policy_789']; @@ -48,3 +54,13 @@ export async function getFakeNotificationPoliciesByIds( }, {} as Record); return new Map(Object.entries(policies)); } + +export async function executeFakeWorkflow(group: NotificationGroup): Promise { + console.log( + `Executing workflow ${group.workflowId} for group ${group.id} : ${JSON.stringify( + group, + null, + 2 + )}` + ); +} 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 e546fb351576e..5b4f7cc8a2780 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,7 +15,7 @@ import { ALERT_EVENTS_DATA_STREAM, type AlertEventType, } from '../../resources/alert_events'; -import type { AlertEpisode } from './types'; +import type { AlertEpisode, NotificationGroupId } from './types'; export const getDispatchableAlertEventsQuery = (): EsqlRequest => { const alertEventType: AlertEventType = 'alert'; @@ -69,3 +69,15 @@ export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): ) | KEEP rule_id, group_hash, episode_id, should_suppress`.toRequest(); }; + +export const getLastNotifiedTimestampsQuery = ( + notificationGroupIds: NotificationGroupId[] +): EsqlRequest => { + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} + | WHERE action_type == "notified" AND notification_group_id IN (${notificationGroupIds.join( + ',' + )}) + | STATS last_notified = MAX(@timestamp) BY notification_group_id + | KEEP notification_group_id, last_notified + `.toRequest(); +}; 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 2fda25a688532..73a454c3d8235 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 @@ -8,6 +8,7 @@ export type RuleId = string; export type NotificationPolicyId = string; export type WorkflowId = string; +export type NotificationGroupId = string; export interface AlertEpisode { last_event_timestamp: string; @@ -56,8 +57,8 @@ export interface NotificationPolicy { /** data.* fields used to group episodes into a single notification */ groupBy: string[]; /** Minimum interval between notifications for the same group */ - throttle: { - interval: string; // e.g. '1h', '30m', '5m' + throttle?: { + interval?: string; // e.g. '1h', '30m', '5m' }; /** Target workflow to dispatch matched episodes to */ workflowId: WorkflowId; @@ -69,9 +70,15 @@ export interface MatchedPair { } export interface NotificationGroup { + id: NotificationGroupId; ruleId: RuleId; policyId: NotificationPolicyId; workflowId: WorkflowId; groupKey: Record; episodes: AlertEpisode[]; } + +export interface LastNotifiedRecord { + notification_group_id: NotificationGroupId; + last_notified: string; +} 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 a9db44424bdba..52b870eca38de 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 @@ -40,6 +40,7 @@ const mappings: MappingsDefinition = { group_hash: { type: 'keyword' }, episode_id: { type: 'keyword' }, rule_id: { type: 'keyword' }, + notification_group_id: { type: 'keyword' }, source: { type: 'keyword' }, }, }; @@ -50,9 +51,10 @@ export const alertActionSchema = z.object({ last_series_event_timestamp: z.string(), expiry: z.string().optional(), actor: z.string().nullable(), - action_type: z.string(), // "fire" | "suppress" + action_type: z.string(), // "fire" | "suppress" | "notified" episode_id: z.string().optional(), rule_id: z.string(), + notification_group_id: z.string().optional(), source: z.string().optional(), tags: z.array(z.string()).optional(), reason: z.string().optional(), From 3187391923bd88ba7105fe08f1a61e4f23ee7aa7 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 09:27:07 -0500 Subject: [PATCH 22/54] make matcher optional --- .../server/lib/dispatcher/faker_service.ts | 43 ++++++++++++------- .../server/lib/dispatcher/types.ts | 4 +- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index 310dd7696de77..cde98432e1d6c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -13,7 +13,7 @@ import type { RuleId, } from './types'; -const NOTIFICATION_POLICY_IDS: NotificationPolicyId[] = ['policy_123', 'policy_456', 'policy_789']; +const NOTIFICATION_POLICY_IDS: NotificationPolicyId[] = ['policy_123', 'policy_456']; export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise> { const now = new Date().toISOString(); @@ -22,10 +22,9 @@ export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise = { + policy_123: { + id: 'policy_123', + name: 'Policy matching critical alerts in non-dev environments', + matcher: 'data.severity == "critical" && data.env != "dev"', // require flatten data support + groupBy: [], // not implemted yet, require flattened data support + throttle: { + interval: '1h', + }, + workflowId: 'workflow_123', + }, + policy_456: { + id: 'policy_456', + name: 'Policy matching all alerts but throttled to 5 minutes', + matcher: undefined, // catch-all + groupBy: [], // not implemted yet, require flattened data support + throttle: { + interval: '5m', + }, + workflowId: 'workflow_456', + }, +}; + export async function getFakeNotificationPoliciesByIds( notificationPolicyIds: NotificationPolicyId[] ): Promise> { const policies = notificationPolicyIds.reduce((acc, policyId) => { - acc[policyId] = { - id: policyId, - name: `Policy ${policyId}`, - matcher: '', - groupBy: ['data.env'], - throttle: { - interval: '1h', - }, - workflowId: 'workflow_123', - }; + acc[policyId] = FAKE_POLICIES[policyId]; return acc; }, {} as Record); return new Map(Object.entries(policies)); 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 73a454c3d8235..09422d1cb0c21 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 @@ -52,8 +52,8 @@ export interface NotificationPolicy { id: NotificationPolicyId; name: string; /** CEL expression evaluated against the alert episode context. - * An empty string matches all episodes (catch-all). */ - matcher: string; // e.g. 'data.severity == "critical" && data.env != "dev"' + * An empty matcher matches all episodes (catch-all). */ + matcher?: string; // e.g. 'data.severity == "critical" && data.env != "dev"' /** data.* fields used to group episodes into a single notification */ groupBy: string[]; /** Minimum interval between notifications for the same group */ From 3a276be6bfcc7c235643f0af0d7d35478b2c1560 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 09:51:14 -0500 Subject: [PATCH 23/54] Add reason of suppression --- .../server/lib/dispatcher/dispatcher.ts | 56 +++++++++++++++---- .../server/lib/dispatcher/queries.ts | 2 +- .../server/lib/dispatcher/types.ts | 3 + .../server/resources/alert_actions.ts | 1 + 4 files changed, 50 insertions(+), 12 deletions(-) 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 f82b4ade2901a..594e63fba8fe9 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 @@ -85,20 +85,32 @@ export class DispatcherService implements DispatcherServiceContract { await executeFakeWorkflow(group); } - this.logger.debug({ - message: `Dispatcher processed ${alertEpisodes.length} alert episodes: ${suppressed.length} suppressed, ${active.length} not suppressed`, - }); - const now = new Date(); await this.storageService.bulkIndexDocs({ index: ALERT_ACTIONS_DATA_STREAM, docs: [ - ...suppressed.map((episode) => this.toAction({ episode, actionType: 'suppress', now })), + ...suppressed.map((episode) => + this.toAction({ episode, actionType: 'suppress', now, reason: episode.reason }) + ), ...throttled.flatMap((group) => - group.episodes.map((episode) => this.toAction({ episode, actionType: 'suppress', now })) + group.episodes.map((episode) => + this.toAction({ + episode, + actionType: 'suppress', + now, + reason: `throttled by policy ${group.policyId}`, + }) + ) ), ...dispatch.flatMap((group) => - group.episodes.map((episode) => this.toAction({ episode, actionType: 'fire', now })) + group.episodes.map((episode) => + this.toAction({ + episode, + actionType: 'fire', + now, + reason: `dispatched by policy ${group.policyId}`, + }) + ) ), // This is used to determine if the group should be throttled in a following run ...dispatch.map((group) => ({ @@ -108,7 +120,7 @@ export class DispatcherService implements DispatcherServiceContract { rule_id: group.ruleId, group_hash: 'irrelevant', // irrelevant last_series_event_timestamp: now.toISOString(), // irrelevant - notification_group_id: group.id, + notification_group_id: group.id, // important to track the group for throttling source: 'internal', })), ], @@ -184,6 +196,14 @@ export class DispatcherService implements DispatcherServiceContract { }); } + this.logger.debug({ + message: `Adding episode ${episode.episode_id} with group key ${JSON.stringify( + groupKey, + null, + 2 + )} to group ${notificationGroupId}`, + }); + groupMap.get(notificationGroupId)!.episodes.push(episode); } @@ -214,6 +234,10 @@ export class DispatcherService implements DispatcherServiceContract { continue; } + this.logger.debug({ + message: `Episode ${episode.episode_id} matches policy ${policyId} with matcher ${policy.matcher} but matcher is not supported yet`, + }); + // TODO: Handle matcher evaluation here // matched.push({ episode, policy }); } @@ -225,7 +249,7 @@ export class DispatcherService implements DispatcherServiceContract { private applySuppression( episodes: AlertEpisode[], suppressions: AlertEpisodeSuppression[] - ): { suppressed: AlertEpisode[]; active: AlertEpisode[] } { + ): { suppressed: Array; active: AlertEpisode[] } { const suppressionMap = new Map(); for (const s of suppressions) { @@ -236,7 +260,7 @@ export class DispatcherService implements DispatcherServiceContract { } } - const suppressed: AlertEpisode[] = []; + const suppressed: Array = []; const active: AlertEpisode[] = []; for (const ep of episodes) { @@ -247,7 +271,10 @@ export class DispatcherService implements DispatcherServiceContract { const seriesSuppression = suppressionMap.get(seriesKey); if (episodeSuppression?.should_suppress || seriesSuppression?.should_suppress) { - suppressed.push(ep); + const matchingSuppression = episodeSuppression?.should_suppress + ? episodeSuppression + : seriesSuppression!; + suppressed.push({ ...ep, reason: getSuppressionReason(matchingSuppression) }); } else { active.push(ep); } @@ -331,3 +358,10 @@ function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): bo const intervalMillis = moment.duration(interval).asMilliseconds(); return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); } + +function getSuppressionReason(suppression: AlertEpisodeSuppression): string { + if (suppression.last_snooze_action === 'snooze') return 'snooze'; + if (suppression.last_ack_action === 'ack') return 'ack'; + if (suppression.last_deactivate_action === 'deactivate') return 'deactivate'; + return 'unknown suppression reason'; +} 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 5b4f7cc8a2780..bfce22775050a 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 @@ -67,7 +67,7 @@ export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): last_deactivate_action == "deactivate", true, false ) - | KEEP rule_id, group_hash, episode_id, should_suppress`.toRequest(); + | KEEP rule_id, group_hash, episode_id, should_suppress, last_ack_action, last_deactivate_action, last_snooze_action`.toRequest(); }; export const getLastNotifiedTimestampsQuery = ( 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 09422d1cb0c21..a70845997fa13 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 @@ -23,6 +23,9 @@ export interface AlertEpisodeSuppression { group_hash: string; episode_id: string | null; should_suppress: boolean; + last_ack_action?: string | null; + last_deactivate_action?: string | null; + last_snooze_action?: string | null; } export interface DispatcherExecutionParams { 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 52b870eca38de..a59c0129d5ccf 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 @@ -42,6 +42,7 @@ const mappings: MappingsDefinition = { rule_id: { type: 'keyword' }, notification_group_id: { type: 'keyword' }, source: { type: 'keyword' }, + reason: { type: 'text' }, }, }; From e04bc72c9099adcbfd255c45193bfd8566ca8526 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 10:31:43 -0500 Subject: [PATCH 24/54] Add reason for notified group action --- .../shared/alerting_v2/server/lib/dispatcher/dispatcher.ts | 1 + 1 file changed, 1 insertion(+) 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 594e63fba8fe9..3e62031ce25a5 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 @@ -122,6 +122,7 @@ export class DispatcherService implements DispatcherServiceContract { last_series_event_timestamp: now.toISOString(), // irrelevant notification_group_id: group.id, // important to track the group for throttling source: 'internal', + reason: `notified by policy ${group.policyId} with throttle interval`, })), ], }); From 93c64bb54137f7ba8a5ded059d51a6bed8d6f8f6 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 11:11:48 -0500 Subject: [PATCH 25/54] Fix throttling --- .../alerting_v2/server/lib/dispatcher/dispatcher.ts | 5 ++--- .../alerting_v2/server/lib/dispatcher/faker_service.ts | 10 +++------- 2 files changed, 5 insertions(+), 10 deletions(-) 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 3e62031ce25a5..809c761e3d7d9 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 @@ -173,8 +173,7 @@ export class DispatcherService implements DispatcherServiceContract { }; } else { // for (const field of policy.groupBy) { - // // TODO: replace {} with episode.data when ESQL flattened support is added - // groupKey[field] = get({}, field); + // groupKey[field] = get(episode.data, field); // } throw new Error('Grouping by fields is not supported yet'); } @@ -357,7 +356,7 @@ export class DispatcherService implements DispatcherServiceContract { function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { const intervalMillis = moment.duration(interval).asMilliseconds(); - return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); + return lastNotifiedAt.getTime() + intervalMillis <= now.getTime(); } function getSuppressionReason(suppression: AlertEpisodeSuppression): string { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index cde98432e1d6c..f1403eda07d0e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -13,8 +13,6 @@ import type { RuleId, } from './types'; -const NOTIFICATION_POLICY_IDS: NotificationPolicyId[] = ['policy_123', 'policy_456']; - export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise> { const now = new Date().toISOString(); const rules = ruleIds.reduce((acc, ruleId) => { @@ -22,9 +20,7 @@ export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise = { policy_123: { id: 'policy_123', name: 'Policy matching critical alerts in non-dev environments', - matcher: 'data.severity == "critical" && data.env != "dev"', // require flatten data support + matcher: undefined, // catch-all, matcher is not supported yet groupBy: [], // not implemted yet, require flattened data support throttle: { interval: '1h', @@ -49,7 +45,7 @@ const FAKE_POLICIES: Record = { policy_456: { id: 'policy_456', name: 'Policy matching all alerts but throttled to 5 minutes', - matcher: undefined, // catch-all + matcher: 'false', // matcher is not supported yet groupBy: [], // not implemted yet, require flattened data support throttle: { interval: '5m', From bf9594133bc86686b6932a4778d8b96772364c3b Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 11:47:31 -0500 Subject: [PATCH 26/54] Return early --- .../alerting_v2/server/lib/dispatcher/dispatcher.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) 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 809c761e3d7d9..0515d4cd210e2 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 @@ -9,6 +9,7 @@ import { inject, injectable } from 'inversify'; import moment from 'moment'; import objectHash from 'object-hash'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; +import { parseDurationToMs } from '../duration'; import { LoggerServiceToken, type LoggerServiceContract, @@ -62,6 +63,10 @@ export class DispatcherService implements DispatcherServiceContract { const startedAt = new Date(); const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); + if (alertEpisodes.length === 0) { + return { startedAt }; + } + const suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); @@ -98,7 +103,7 @@ export class DispatcherService implements DispatcherServiceContract { episode, actionType: 'suppress', now, - reason: `throttled by policy ${group.policyId}`, + reason: `suppressed by throttled policy ${group.policyId}`, }) ) ), @@ -355,8 +360,8 @@ export class DispatcherService implements DispatcherServiceContract { } function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { - const intervalMillis = moment.duration(interval).asMilliseconds(); - return lastNotifiedAt.getTime() + intervalMillis <= now.getTime(); + const intervalMillis = parseDurationToMs(interval); + return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); } function getSuppressionReason(suppression: AlertEpisodeSuppression): string { From 7e56d933718a02fa7d8067fab425dba92c539a30 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 12:04:59 -0500 Subject: [PATCH 27/54] Remove log --- .../server/lib/dispatcher/dispatcher.ts | 15 --------------- 1 file changed, 15 deletions(-) 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 0515d4cd210e2..a35917050038f 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 @@ -201,14 +201,6 @@ export class DispatcherService implements DispatcherServiceContract { }); } - this.logger.debug({ - message: `Adding episode ${episode.episode_id} with group key ${JSON.stringify( - groupKey, - null, - 2 - )} to group ${notificationGroupId}`, - }); - groupMap.get(notificationGroupId)!.episodes.push(episode); } @@ -232,17 +224,10 @@ export class DispatcherService implements DispatcherServiceContract { // Empty matcher = catch-all, always matches if (!policy.matcher) { - this.logger.debug({ - message: `Episode ${episode.episode_id} matches policy ${policyId} (catch-all)`, - }); matched.push({ episode, policy }); continue; } - this.logger.debug({ - message: `Episode ${episode.episode_id} matches policy ${policyId} with matcher ${policy.matcher} but matcher is not supported yet`, - }); - // TODO: Handle matcher evaluation here // matched.push({ episode, policy }); } From 07e674bc601e92c50d1c318e68443b9a75133c5c Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Wed, 11 Feb 2026 16:05:49 -0500 Subject: [PATCH 28/54] trigger workflow (break task tho) --- .../plugins/shared/alerting_v2/kibana.jsonc | 3 +- .../server/lib/dispatcher/dispatcher.ts | 19 +++--- .../server/lib/dispatcher/faker_service.ts | 30 +++------ .../server/lib/dispatcher/queries.test.ts | 24 +++++++ .../server/lib/dispatcher/queries.ts | 7 ++- .../server/lib/dispatcher/task_definition.ts | 2 +- .../server/lib/dispatcher/tokens.ts | 23 +++++++ .../lib/dispatcher/workflow_dispatcher.ts | 37 +++++++++++ .../server/routes/run_dispatch_route.ts | 8 ++- .../alerting_v2/server/setup/bind_on_setup.ts | 4 +- .../alerting_v2/server/setup/bind_services.ts | 63 ++++++++++++++++--- .../shared/alerting_v2/server/types.ts | 2 + .../plugins/shared/alerting_v2/tsconfig.json | 3 +- 13 files changed, 174 insertions(+), 51 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc index 67b7e82e84c56..5fe1a938e721e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc +++ b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc @@ -14,7 +14,8 @@ "features", "spaces", "data", - "security" + "security", + "workflowsManagement" ], "optionalPlugins": ["management"], "extraPublicDirs": [] 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 a35917050038f..00f80a05a3a19 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 @@ -5,6 +5,8 @@ * 2.0. */ +import type { KibanaRequest } from '@kbn/core-http-server'; +import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; import { inject, injectable } from 'inversify'; import moment from 'moment'; import objectHash from 'object-hash'; @@ -20,11 +22,7 @@ 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 { - executeFakeWorkflow, - getFakeNotificationPoliciesByIds, - getFakeRulesByIds, -} from './faker_service'; +import { getFakeNotificationPoliciesByIds, getFakeRulesByIds } from './faker_service'; import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery, @@ -44,6 +42,7 @@ import type { Rule, RuleId, } from './types'; +import { dispatchWorkflow } from './workflow_dispatcher'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -54,7 +53,9 @@ export class DispatcherService implements DispatcherServiceContract { constructor( @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract, @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, - @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract + @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract, + @inject(Request) private readonly request: KibanaRequest, + private readonly workflowsManagement: WorkflowsManagementApi ) {} public async run({ @@ -87,7 +88,7 @@ export class DispatcherService implements DispatcherServiceContract { ); for (const group of dispatch) { - await executeFakeWorkflow(group); + await dispatchWorkflow(group, this.request, this.workflowsManagement); } const now = new Date(); @@ -161,6 +162,10 @@ export class DispatcherService implements DispatcherServiceContract { } } + this.logger.debug({ + message: () => + `Applied throttling to ${throttled.length} groups and dispatched ${dispatch.length} groups`, + }); return { dispatch, throttled }; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index f1403eda07d0e..5e47fd2deec08 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -5,13 +5,7 @@ * 2.0. */ -import type { - NotificationGroup, - NotificationPolicy, - NotificationPolicyId, - Rule, - RuleId, -} from './types'; +import type { NotificationPolicy, NotificationPolicyId, Rule, RuleId } from './types'; export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise> { const now = new Date().toISOString(); @@ -34,23 +28,23 @@ export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise = { policy_123: { id: 'policy_123', - name: 'Policy matching critical alerts in non-dev environments', + name: 'Policy matching all alerts but throttled to 1 hour', matcher: undefined, // catch-all, matcher is not supported yet groupBy: [], // not implemted yet, require flattened data support throttle: { interval: '1h', }, - workflowId: 'workflow_123', + workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', }, policy_456: { id: 'policy_456', - name: 'Policy matching all alerts but throttled to 5 minutes', - matcher: 'false', // matcher is not supported yet + name: 'Policy matching all alerts but not throttled', + matcher: undefined, // catch-all, matcher is not supported yet groupBy: [], // not implemted yet, require flattened data support throttle: { - interval: '5m', + interval: undefined, }, - workflowId: 'workflow_456', + workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', }, }; @@ -63,13 +57,3 @@ export async function getFakeNotificationPoliciesByIds( }, {} as Record); return new Map(Object.entries(policies)); } - -export async function executeFakeWorkflow(group: NotificationGroup): Promise { - console.log( - `Executing workflow ${group.workflowId} for group ${group.id} : ${JSON.stringify( - group, - null, - 2 - )}` - ); -} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts new file mode 100644 index 0000000000000..06a11c71ae0a5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts @@ -0,0 +1,24 @@ +/* + * 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 { getLastNotifiedTimestampsQuery } from './queries'; + +describe('getLastNotifiedTimestampsQuery', () => { + it('builds a query for a single notification group', () => { + const req = getLastNotifiedTimestampsQuery(['group-1']); + + expect(req.query).toContain('notification_group_id IN ("group-1")'); + expect(req.query).toContain('.alerts-actions'); + expect(req.query).toContain('last_notified = MAX(@timestamp)'); + }); + + it('builds a query for multiple notification groups', () => { + const req = getLastNotifiedTimestampsQuery(['group-1', 'group-2']); + + expect(req.query).toContain('notification_group_id IN ("group-1", "group-2")'); + }); +}); 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 bfce22775050a..fc3cabe1d0f8d 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 @@ -73,10 +73,11 @@ export const getAlertEpisodeSuppressionsQuery = (alertEpisodes: AlertEpisode[]): export const getLastNotifiedTimestampsQuery = ( notificationGroupIds: NotificationGroupId[] ): EsqlRequest => { + const values = notificationGroupIds.map((id) => esql.str(id)); + const whereClause = esql.exp`action_type == "notified" AND notification_group_id IN (${values})`; + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} - | WHERE action_type == "notified" AND notification_group_id IN (${notificationGroupIds.join( - ',' - )}) + | WHERE ${whereClause} | STATS last_notified = MAX(@timestamp) BY notification_group_id | KEEP notification_group_id, last_notified `.toRequest(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts index 65a8d947578ce..df1837264beca 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts @@ -23,5 +23,5 @@ export const DispatcherTaskDefinition: AlertingTaskDefinition; + +/** + * DispatcherService singleton + */ +export const DispatcherServiceInternalToken = Symbol.for( + 'alerting_v2.DispatcherServiceInternal' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts new file mode 100644 index 0000000000000..22ef013ac5d0e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts @@ -0,0 +1,37 @@ +/* + * 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 type { KibanaRequest } from '@kbn/core-http-server'; +import type { WorkflowYaml } from '@kbn/workflows'; +import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; +import type { NotificationGroup } from './types'; + +export async function dispatchWorkflow( + group: NotificationGroup, + request: KibanaRequest, + workflowsManagement: WorkflowsManagementApi +): Promise { + const spaceId = 'default'; + + const workflow = await workflowsManagement.getWorkflow(group.workflowId, 'default'); + if (!workflow) { + return; + } + + await workflowsManagement.runWorkflow( + { + id: workflow.id, + name: workflow.name, + enabled: workflow.enabled, + definition: workflow.definition as WorkflowYaml, + yaml: workflow.yaml, + }, + spaceId, + group, + request + ); +} 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 ca841b44e7a5d..7411a59bbfba1 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 @@ -7,11 +7,12 @@ import Boom from '@hapi/boom'; import { schema, type TypeOf } from '@kbn/config-schema'; -import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; import type { RouteHandler } from '@kbn/core-di-server'; import { Request, Response } from '@kbn/core-di-server'; +import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; import { inject, injectable } from 'inversify'; -import { DispatcherService, type DispatcherServiceContract } from '../lib/dispatcher/dispatcher'; +import { type DispatcherServiceContract } from '../lib/dispatcher/dispatcher'; +import { DispatcherServiceScopedToken } from '../lib/dispatcher/tokens'; const runDispatchBodySchema = schema.object({ previousStartedAt: schema.maybe(schema.string({ minLength: 1 })), @@ -40,7 +41,8 @@ export class RunDispatchRoute implements RouteHandler { @inject(Request) private readonly request: KibanaRequest, @inject(Response) private readonly response: KibanaResponseFactory, - @inject(DispatcherService) private readonly dispatcherService: DispatcherServiceContract + @inject(DispatcherServiceScopedToken) + private readonly dispatcherService: DispatcherServiceContract ) {} async handle() { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts index 63cd75b3c34cc..a7b5f68440d96 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_on_setup.ts @@ -5,12 +5,12 @@ * 2.0. */ -import type { ContainerModuleLoadOptions } from 'inversify'; import { Logger, OnSetup, PluginSetup } from '@kbn/core-di'; import { CoreSetup } from '@kbn/core-di-server'; +import type { ContainerModuleLoadOptions } from 'inversify'; import { registerFeaturePrivileges } from '../lib/security/privileges'; -import { registerSavedObjects } from '../saved_objects'; import { TaskDefinition } from '../lib/services/task_run_scope_service/create_task_runner'; +import { registerSavedObjects } from '../saved_objects'; export function bindOnSetup({ bind }: ContainerModuleLoadOptions) { bind(OnSetup).toConstantValue((container) => { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index a0dd733a48cdc..31583d180953a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -5,37 +5,43 @@ * 2.0. */ +import { PluginSetup } from '@kbn/core-di'; import { CoreStart, Request } from '@kbn/core-di-server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; +import { DirectorService } from '../lib/director/director'; +import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; +import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; -import { RulesClient } from '../lib/rules_client'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; +import { RulesClient } from '../lib/rules_client'; +import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; import { LoggerService, LoggerServiceToken } from '../lib/services/logger_service/logger_service'; +import { NotificationPolicySavedObjectService } from '../lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { QueryService } from '../lib/services/query_service/query_service'; import { QueryServiceInternalToken, QueryServiceScopedToken, } from '../lib/services/query_service/tokens'; +import { ResourceManager } from '../lib/services/resource_service/resource_manager'; import { AlertingRetryService } from '../lib/services/retry_service'; +import { RetryServiceToken } from '../lib/services/retry_service/tokens'; import { RulesSavedObjectService } from '../lib/services/rules_saved_object_service/rules_saved_object_service'; -import { NotificationPolicySavedObjectService } from '../lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { StorageService } from '../lib/services/storage_service/storage_service'; import { StorageServiceInternalToken, StorageServiceScopedToken, } from '../lib/services/storage_service/tokens'; -import { RetryServiceToken } from '../lib/services/retry_service/tokens'; -import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; -import { DirectorService } from '../lib/director/director'; -import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; -import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; -import { ResourceManager } from '../lib/services/resource_service/resource_manager'; -import { UserService } from '../lib/services/user_service/user_service'; import { createTaskRunnerFactory, TaskRunnerFactoryToken, } from '../lib/services/task_run_scope_service/create_task_runner'; +import { UserService } from '../lib/services/user_service/user_service'; +import type { AlertingServerSetupDependencies } from '../types'; +import { + DispatcherServiceInternalToken, + DispatcherServiceScopedToken, +} from '../lib/dispatcher/tokens'; export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(AlertActionsClient).toSelf().inRequestScope(); @@ -105,7 +111,44 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { }) .inSingletonScope(); - bind(DispatcherService).toSelf().inSingletonScope(); + bind(DispatcherServiceScopedToken) + .toDynamicValue(({ get }) => { + const workflowsManagement = get( + PluginSetup('workflowsManagement') + ); + const queryService = get(QueryServiceScopedToken); + const loggerService = get(LoggerServiceToken); + const storageService = get(StorageServiceInternalToken); + const request = get(Request); + + return new DispatcherService( + queryService, + loggerService, + storageService, + request, + workflowsManagement.management + ); + }) + .inRequestScope(); + + bind(DispatcherServiceInternalToken) + .toDynamicValue(({ get }) => { + const workflowsManagement = get( + PluginSetup('workflowsManagement') + ); + const queryService = get(QueryServiceInternalToken); + const loggerService = get(LoggerServiceToken); + const storageService = get(StorageServiceInternalToken); + const request = get(Request); + return new DispatcherService( + queryService, + loggerService, + storageService, + request, + workflowsManagement.management + ); + }) + .inSingletonScope(); bind(DirectorService).toSelf().inSingletonScope(); bind(TransitionStrategyFactory).toSelf().inSingletonScope(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts index c007b565c7c5b..04a8e426f881d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts @@ -14,6 +14,7 @@ import type { FeaturesPluginStart, FeaturesPluginSetup } from '@kbn/features-plu import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; +import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; export type AlertingServerSetup = void; export type AlertingServerStart = void; @@ -22,6 +23,7 @@ export interface AlertingServerSetupDependencies { taskManager: TaskManagerSetupContract; features: FeaturesPluginSetup; spaces: SpacesPluginSetup; + workflowsManagement: WorkflowsServerPluginSetup; } export interface AlertingServerStartDependencies { diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index d6ec2efdefcea..1810dc9f4ccd8 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -54,7 +54,8 @@ "@kbn/core-user-profile-common", "@kbn/core-user-profile-server-mocks", "@kbn/core-user-profile-server", - "@kbn/es-mappings" + "@kbn/es-mappings", + "@kbn/workflows-management-plugin" ], "exclude": ["target/**/*"] } From 8b70bc1f5c2d3a418efb24d403adda498a93d4e7 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 15:14:55 -0500 Subject: [PATCH 29/54] Fix merge conflicts --- .../server/lib/dispatcher/task_runner.ts | 6 ++++-- .../lib/dispatcher/workflow_dispatcher.ts | 4 ++-- .../server/routes/run_dispatch_route.ts | 3 ++- .../alerting_v2/server/setup/bind_services.ts | 19 ++++++------------- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.ts index 75d4082a53aee..ab55405a72dd7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.ts @@ -7,7 +7,8 @@ import type { RunContext, RunResult } from '@kbn/task-manager-plugin/server/task'; import { inject, injectable } from 'inversify'; -import { DispatcherService, type DispatcherServiceContract } from './dispatcher'; +import { type DispatcherServiceContract } from './dispatcher'; +import { DispatcherServiceInternalToken } from './tokens'; import type { DispatcherExecutionParams, DispatcherExecutionResult, @@ -19,7 +20,8 @@ type TaskRunParams = Pick; @injectable() export class DispatcherTaskRunner { constructor( - @inject(DispatcherService) private readonly dispatcherService: DispatcherServiceContract + @inject(DispatcherServiceInternalToken) + private readonly dispatcherService: DispatcherServiceContract ) {} public async run({ taskInstance, abortController }: TaskRunParams): Promise { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts index 22ef013ac5d0e..63d6e5fb75801 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts @@ -17,12 +17,12 @@ export async function dispatchWorkflow( ): Promise { const spaceId = 'default'; - const workflow = await workflowsManagement.getWorkflow(group.workflowId, 'default'); + const workflow = await workflowsManagement.getWorkflow(group.workflowId, spaceId); if (!workflow) { return; } - await workflowsManagement.runWorkflow( + void workflowsManagement.runWorkflow( { id: workflow.id, name: workflow.name, 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 ac2a52f685b6b..7411a59bbfba1 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 @@ -41,7 +41,8 @@ export class RunDispatchRoute implements RouteHandler { @inject(Request) private readonly request: KibanaRequest, @inject(Response) private readonly response: KibanaResponseFactory, - @inject(DispatcherService) private readonly dispatcherService: DispatcherServiceContract + @inject(DispatcherServiceScopedToken) + private readonly dispatcherService: DispatcherServiceContract ) {} async handle() { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index e9d3a8a5903ae..43c414278028b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -11,8 +11,14 @@ import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; import { DirectorService } from '../lib/director/director'; import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; +import { CountTimeframeStrategy } from '../lib/director/strategies/count_timeframe_strategy'; import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; +import { TransitionStrategyToken } from '../lib/director/strategies/types'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; +import { + DispatcherServiceInternalToken, + DispatcherServiceScopedToken, +} from '../lib/dispatcher/tokens'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; import { RulesClient } from '../lib/rules_client'; import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; @@ -32,25 +38,12 @@ import { StorageServiceInternalToken, StorageServiceScopedToken, } from '../lib/services/storage_service/tokens'; -import { RetryServiceToken } from '../lib/services/retry_service/tokens'; -import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; -import { DirectorService } from '../lib/director/director'; -import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; -import { CountTimeframeStrategy } from '../lib/director/strategies/count_timeframe_strategy'; -import { ResourceManager } from '../lib/services/resource_service/resource_manager'; -import { UserService } from '../lib/services/user_service/user_service'; -import { TransitionStrategyToken } from '../lib/director/strategies/types'; -import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { createTaskRunnerFactory, TaskRunnerFactoryToken, } from '../lib/services/task_run_scope_service/create_task_runner'; import { UserService } from '../lib/services/user_service/user_service'; import type { AlertingServerSetupDependencies } from '../types'; -import { - DispatcherServiceInternalToken, - DispatcherServiceScopedToken, -} from '../lib/dispatcher/tokens'; export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(AlertActionsClient).toSelf().inRequestScope(); From 51a375808b6686ac6c7f45736119ed483e22bd45 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 16:54:18 -0500 Subject: [PATCH 30/54] Store api key in notification policy --- .../server/lib/dispatcher/dispatcher.ts | 23 ++++++-- .../server/lib/dispatcher/faker_service.ts | 2 + .../server/lib/dispatcher/task_definition.ts | 2 +- .../server/lib/dispatcher/types.ts | 2 + .../notification_policy_client.test.ts | 40 ++++++++++++-- .../notification_policy_client.ts | 52 +++++++++++++++---- .../notification_policy_mappings.ts | 1 + .../v1.ts | 1 + .../alerting_v2/server/setup/bind_services.ts | 4 -- 9 files changed, 107 insertions(+), 20 deletions(-) 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 00f80a05a3a19..a43b7fa0f77c4 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 @@ -5,7 +5,8 @@ * 2.0. */ -import type { KibanaRequest } from '@kbn/core-http-server'; +import type { FakeRawRequest, KibanaRequest } from '@kbn/core-http-server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; import { inject, injectable } from 'inversify'; import moment from 'moment'; @@ -54,7 +55,6 @@ export class DispatcherService implements DispatcherServiceContract { @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract, @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract, - @inject(Request) private readonly request: KibanaRequest, private readonly workflowsManagement: WorkflowsManagementApi ) {} @@ -88,7 +88,16 @@ export class DispatcherService implements DispatcherServiceContract { ); for (const group of dispatch) { - await dispatchWorkflow(group, this.request, this.workflowsManagement); + const policy = policies.get(group.policyId); + if (!policy?.apiKey) { + this.logger.warn({ + message: () => + `Skipping dispatch for group ${group.id}: notification policy ${group.policyId} has no API key`, + }); + continue; + } + const fakeRequest = this.craftFakeRequest(policy.apiKey); + await dispatchWorkflow(group, fakeRequest, this.workflowsManagement); } const now = new Date(); @@ -301,6 +310,14 @@ export class DispatcherService implements DispatcherServiceContract { }; } + private craftFakeRequest(apiKey: string): KibanaRequest { + const fakeRawRequest: FakeRawRequest = { + headers: { authorization: `ApiKey ${apiKey}` }, + path: '/', + }; + return kibanaRequestFactory(fakeRawRequest); + } + private async fetchAlertEpisodeSuppressions( alertEpisodes: AlertEpisode[] ): Promise { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index 5e47fd2deec08..d43e48139534a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -35,6 +35,7 @@ const FAKE_POLICIES: Record = { interval: '1h', }, workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', + apiKey: undefined, // no API key for fake policies; dispatch will be skipped unless a real policy is created via API }, policy_456: { id: 'policy_456', @@ -45,6 +46,7 @@ const FAKE_POLICIES: Record = { interval: undefined, }, workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', + apiKey: undefined, // no API key for fake policies; dispatch will be skipped unless a real policy is created via API }, }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts index df1837264beca..65a8d947578ce 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_definition.ts @@ -23,5 +23,5 @@ export const DispatcherTaskDefinition: AlertingTaskDefinition { let mockSavedObjectsClient: jest.Mocked; let userService: UserService; let userProfile: jest.Mocked; + const mockRequest = httpServerMock.createKibanaRequest(); + const mockSecurity = { + authc: { + apiKeys: { + grantAsInternalUser: jest.fn().mockResolvedValue({ + id: 'api-key-id', + name: 'test-api-key', + api_key: 'test-api-key-secret', + encoded: 'dGVzdC1lbmNvZGVk', + }), + }, + }, + } as unknown as SecurityPluginStart; beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00.000Z')); @@ -37,7 +52,12 @@ describe('NotificationPolicyClient', () => { createNotificationPolicySavedObjectService()); ({ userService, userProfile } = createUserService()); - client = new NotificationPolicyClient(notificationPolicySavedObjectService, userService); + client = new NotificationPolicyClient( + notificationPolicySavedObjectService, + userService, + mockRequest, + mockSecurity + ); userProfile.getCurrent.mockResolvedValue(createUserProfile('elastic_profile_uid')); @@ -87,6 +107,7 @@ describe('NotificationPolicyClient', () => { name: 'my-policy', description: 'my-policy description', workflow_id: 'my-workflow', + apiKey: 'dGVzdC1lbmNvZGVk', createdBy: 'elastic_profile_uid', updatedBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', @@ -108,6 +129,7 @@ describe('NotificationPolicyClient', () => { updatedAt: '2025-01-01T00:00:00.000Z', }) ); + expect(res).not.toHaveProperty('apiKey'); }); it('creates a notification policy without custom id', async () => { @@ -177,6 +199,7 @@ describe('NotificationPolicyClient', () => { name: 'test-policy', description: 'test-policy description', workflow_id: 'test-workflow', + apiKey: 'existing-encoded-key', createdBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', updatedBy: 'elastic_profile_uid', @@ -200,8 +223,15 @@ describe('NotificationPolicyClient', () => { expect(res).toEqual({ id: 'policy-id-get-1', version: 'WzEsMV0=', - ...existingAttributes, + name: existingAttributes.name, + description: existingAttributes.description, + workflow_id: existingAttributes.workflow_id, + createdBy: existingAttributes.createdBy, + createdAt: existingAttributes.createdAt, + updatedBy: existingAttributes.updatedBy, + updatedAt: existingAttributes.updatedAt, }); + expect(res).not.toHaveProperty('apiKey'); }); it('throws 404 when notification policy is not found', async () => { @@ -226,6 +256,7 @@ describe('NotificationPolicyClient', () => { name: 'original-policy', description: 'original-policy description', workflow_id: 'original-workflow', + apiKey: 'old-encoded-key', createdBy: 'creator_profile_uid', createdAt: '2024-12-01T00:00:00.000Z', updatedBy: 'updater_profile_uid', @@ -258,9 +289,9 @@ describe('NotificationPolicyClient', () => { name: 'updated-policy', description: 'original-policy description', workflow_id: 'updated-workflow', + apiKey: 'dGVzdC1lbmNvZGVk', updatedBy: 'elastic_profile_uid', updatedAt: '2025-01-01T00:00:00.000Z', - // Preserves original createdBy and createdAt createdBy: 'creator_profile_uid', createdAt: '2024-12-01T00:00:00.000Z', }), @@ -277,6 +308,7 @@ describe('NotificationPolicyClient', () => { updatedAt: '2025-01-01T00:00:00.000Z', }) ); + expect(res).not.toHaveProperty('apiKey'); }); it('throws 404 when notification policy is not found', async () => { @@ -302,6 +334,7 @@ describe('NotificationPolicyClient', () => { name: 'original-policy', description: 'original-policy description', workflow_id: 'original-workflow', + apiKey: 'old-encoded-key', createdBy: 'creator_profile_uid', createdAt: '2024-12-01T00:00:00.000Z', updatedBy: 'updater_profile_uid', @@ -339,6 +372,7 @@ describe('NotificationPolicyClient', () => { name: 'policy-to-delete', description: 'policy-to-delete description', workflow_id: 'workflow-to-delete', + apiKey: 'encoded-key-to-delete', createdBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', updatedBy: 'elastic_profile_uid', diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index d65ca0d794526..300c0bc429d4d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -6,10 +6,15 @@ */ import Boom from '@hapi/boom'; +import { PluginStart } from '@kbn/core-di'; +import { Request } from '@kbn/core-di-server'; +import type { KibanaRequest } from '@kbn/core-http-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; +import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import { inject, injectable } from 'inversify'; import { omit } from 'lodash'; import { type NotificationPolicySavedObjectAttributes } from '../../saved_objects'; +import type { AlertingServerStartDependencies } from '../../types'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { NotificationPolicySavedObjectService } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { UserServiceContract } from '../services/user_service/user_service'; @@ -25,7 +30,10 @@ export class NotificationPolicyClient { constructor( @inject(NotificationPolicySavedObjectService) private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract, - @inject(UserService) private readonly userService: UserServiceContract + @inject(UserService) private readonly userService: UserServiceContract, + @inject(Request) private readonly request: KibanaRequest, + @inject(PluginStart('security')) + private readonly security: SecurityPluginStart ) {} public async createNotificationPolicy( @@ -33,11 +41,13 @@ export class NotificationPolicyClient { ): Promise { const userProfileUid = await this.getUserProfileUid(); const now = new Date().toISOString(); + const apiKey = await this.generateApiKey(params.data.name); const attributes: NotificationPolicySavedObjectAttributes = { name: params.data.name, description: params.data.description, workflow_id: params.data.workflow_id, + apiKey, createdBy: userProfileUid, createdAt: now, updatedBy: userProfileUid, @@ -50,7 +60,7 @@ export class NotificationPolicyClient { id: params.options?.id, }); - return { id, version, ...attributes }; + return { id, version, ...omit(attributes, 'apiKey') }; } catch (e) { if (SavedObjectsErrorHelpers.isConflictError(e)) { const conflictId = params.options?.id ?? 'unknown'; @@ -63,7 +73,7 @@ export class NotificationPolicyClient { public async getNotificationPolicy({ id }: { id: string }): Promise { try { const doc = await this.notificationPolicySavedObjectService.get(id); - return { id, version: doc.version, ...doc.attributes }; + return { id, version: doc.version, ...omit(doc.attributes, 'apiKey') }; } catch (e) { if (SavedObjectsErrorHelpers.isNotFoundError(e)) { throw Boom.notFound(`Notification policy with id "${id}" not found`); @@ -78,15 +88,14 @@ export class NotificationPolicyClient { const userProfileUid = await this.getUserProfileUid(); const now = new Date().toISOString(); - const existingNotificationPolicy = await this.getNotificationPolicy({ id: params.options.id }); - const existingAttrs: NotificationPolicySavedObjectAttributes = omit( - existingNotificationPolicy, - ['id', 'version'] - ); + const { attributes: existingAttrs } = await this.fetchRawNotificationPolicy(params.options.id); + + const apiKey = await this.generateApiKey(params.data.name ?? existingAttrs.name); const nextAttrs: NotificationPolicySavedObjectAttributes = { ...existingAttrs, ...params.data, + apiKey, updatedBy: userProfileUid, updatedAt: now, }; @@ -98,7 +107,7 @@ export class NotificationPolicyClient { version: params.options.version, }); - return { id: params.options.id, version: updated.version, ...nextAttrs }; + return { id: params.options.id, version: updated.version, ...omit(nextAttrs, 'apiKey') }; } catch (e) { if (SavedObjectsErrorHelpers.isConflictError(e)) { throw Boom.conflict( @@ -114,7 +123,32 @@ export class NotificationPolicyClient { await this.notificationPolicySavedObjectService.delete({ id }); } + private async fetchRawNotificationPolicy(id: string): Promise<{ + attributes: NotificationPolicySavedObjectAttributes; + version?: string; + }> { + try { + const doc = await this.notificationPolicySavedObjectService.get(id); + return { attributes: doc.attributes, version: doc.version }; + } catch (e) { + if (SavedObjectsErrorHelpers.isNotFoundError(e)) { + throw Boom.notFound(`Notification policy with id "${id}" not found`); + } + throw e; + } + } + private async getUserProfileUid(): Promise { return this.userService.getCurrentUserProfileUid(); } + + private async generateApiKey(policyName: string): Promise { + const result = await this.security.authc.apiKeys.grantAsInternalUser(this.request, { + name: `alerting_v2:notification_policy:${policyName}`, + role_descriptors: {}, + metadata: { managed: true }, + }); + + return result?.api_key ?? null; + } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts index cb6dd4e9241e1..77da5228600e5 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts @@ -16,6 +16,7 @@ export const notificationPolicyMappings: SavedObjectsTypeMappingDefinition = { name: { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } }, description: { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } }, workflow_id: { type: 'keyword' }, + apiKey: { type: 'binary' }, createdBy: { type: 'keyword' }, createdAt: { type: 'date' }, updatedBy: { type: 'keyword' }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts index e93e4874fcef1..f4154692d7825 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts @@ -14,6 +14,7 @@ export const notificationPolicySavedObjectAttributesSchema = schema.object({ name: schema.string(), description: schema.string(), workflow_id: schema.string(), + apiKey: schema.nullable(schema.string()), createdBy: schema.nullable(schema.string()), updatedBy: schema.nullable(schema.string()), createdAt: schema.string(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 43c414278028b..cf97af8635ebe 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -121,13 +121,11 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const queryService = get(QueryServiceScopedToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); - const request = get(Request); return new DispatcherService( queryService, loggerService, storageService, - request, workflowsManagement.management ); }) @@ -141,12 +139,10 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const queryService = get(QueryServiceInternalToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); - const request = get(Request); return new DispatcherService( queryService, loggerService, storageService, - request, workflowsManagement.management ); }) From dc0f87d7307be897716679df0a3a5eaac53c64af Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 17:20:56 -0500 Subject: [PATCH 31/54] Use a new internal rules so client for fetching rules by ids from dispatcher --- .../server/lib/dispatcher/dispatcher.test.ts | 91 +++++++++++++++++-- .../server/lib/dispatcher/dispatcher.ts | 29 +++++- .../server/lib/dispatcher/faker_service.ts | 20 +--- .../lib/dispatcher/fixtures/dispatcher.ts | 14 ++- .../server/lib/dispatcher/tokens.ts | 8 ++ .../notification_policy_client.test.ts | 4 +- .../notification_policy_client.ts | 3 +- .../alerting_v2/server/setup/bind_services.ts | 25 ++++- 8 files changed, 155 insertions(+), 39 deletions(-) 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 161cf045e4023..2fd6fc73060fa 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 @@ -10,9 +10,11 @@ import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mock import type { ElasticsearchClient } from '@kbn/core/server'; import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; +import type { RuleSavedObjectAttributes } from '../../saved_objects'; 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'; +import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { createStorageService } from '../services/storage_service/storage_service.mock'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; @@ -20,22 +22,65 @@ import { DispatcherService } from './dispatcher'; import { createAlertEpisodeSuppressionsResponse, createDispatchableAlertEventsResponse, + createLastNotifiedTimestampsResponse, } from './fixtures/dispatcher'; import { getDispatchableAlertEventsQuery } from './queries'; import type { AlertEpisode, AlertEpisodeSuppression } from './types'; +const createMockRuleSoAttributes = ( + overrides: Partial = {} +): RuleSavedObjectAttributes => ({ + kind: 'alert', + metadata: { name: 'Test rule' }, + time_field: '@timestamp', + schedule: { every: '1m' }, + evaluation: { query: { base: 'FROM logs-*' } }, + notification_policies: [{ ref: 'policy_456' }], + enabled: true, + createdBy: null, + updatedBy: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, +}); + +const createMockRulesSoService = ( + ruleIds: string[], + overrides?: Partial +): RulesSavedObjectServiceContract => ({ + bulkGetByIds: jest.fn().mockResolvedValue( + ruleIds.map((id) => ({ + id, + attributes: createMockRuleSoAttributes(overrides), + })) + ), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + find: jest.fn(), +}); + describe('DispatcherService', () => { let dispatcherService: DispatcherService; let queryService: QueryServiceContract; let storageService: StorageServiceContract; let queryEsClient: DeeplyMockedApi; let storageEsClient: jest.Mocked; + let rulesSoService: RulesSavedObjectServiceContract; beforeEach(() => { ({ queryService, mockEsClient: queryEsClient } = createQueryService()); ({ storageService, mockEsClient: storageEsClient } = createStorageService()); const { loggerService } = createLoggerService(); - dispatcherService = new DispatcherService(queryService, loggerService, storageService); + rulesSoService = createMockRulesSoService(['rule-1', 'rule-2']); + dispatcherService = new DispatcherService( + queryService, + loggerService, + storageService, + undefined as any, + rulesSoService + ); }); afterEach(() => { @@ -78,7 +123,8 @@ describe('DispatcherService', () => { queryEsClient.esql.query .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) - .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)) + .mockResolvedValueOnce(createLastNotifiedTimestampsResponse()); storageEsClient.bulk.mockResolvedValue({ items: [{ create: { _id: '1', status: 201 } }, { create: { _id: '2', status: 201 } }], @@ -97,7 +143,7 @@ describe('DispatcherService', () => { .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') .toISOString(); - expect(queryEsClient.esql.query).toHaveBeenCalledTimes(2); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(3); expect(queryEsClient.esql.query).toHaveBeenCalledWith( { query: getDispatchableAlertEventsQuery().query, @@ -126,7 +172,11 @@ describe('DispatcherService', () => { expect(createOperations).toEqual( expect.arrayContaining([{ create: { _index: ALERT_ACTIONS_DATA_STREAM } }]) ); - expect(docs).toHaveLength(alertEpisodes.length); + + const fireActions = docs.filter((d: any) => d.action_type === 'fire'); + const notifiedActions = docs.filter((d: any) => d.action_type === 'notified'); + expect(fireActions).toHaveLength(alertEpisodes.length); + expect(notifiedActions.length).toBeGreaterThan(0); expect(docs).toEqual( expect.arrayContaining([ @@ -185,7 +235,8 @@ describe('DispatcherService', () => { queryEsClient.esql.query .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) - .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)) + .mockResolvedValueOnce(createLastNotifiedTimestampsResponse()); storageEsClient.bulk.mockResolvedValue({ items: [{ create: { _id: '1', status: 201 } }, { create: { _id: '2', status: 201 } }], @@ -201,7 +252,11 @@ describe('DispatcherService', () => { const [{ operations }] = storageEsClient.bulk.mock.calls[0]; const safeOperations = operations ?? []; const docs = safeOperations.filter((_, index) => index % 2 === 1); - expect(docs).toHaveLength(2); + + const suppressDocs = docs.filter((d: any) => d.action_type === 'suppress'); + const fireDocs = docs.filter((d: any) => d.action_type === 'fire'); + expect(suppressDocs).toHaveLength(1); + expect(fireDocs).toHaveLength(1); expect(docs).toEqual( expect.arrayContaining([ @@ -238,6 +293,22 @@ describe('DispatcherService', () => { }); it('dispatches correct fire/suppress actions across 5 rules with ack, unack, snooze, and deactivate suppressions', async () => { + rulesSoService = createMockRulesSoService([ + 'rule-001', + 'rule-002', + 'rule-003', + 'rule-004', + 'rule-005', + ]); + const { loggerService } = createLoggerService(); + dispatcherService = new DispatcherService( + queryService, + loggerService, + storageService, + undefined as any, + rulesSoService + ); + // Dataset: 5 rules, 9 episodes total // rule-001: single series, ack then unack → fire // rule-002: single series, ack with no unack → suppress @@ -359,7 +430,8 @@ describe('DispatcherService', () => { queryEsClient.esql.query .mockResolvedValueOnce(createDispatchableAlertEventsResponse(alertEpisodes)) - .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)); + .mockResolvedValueOnce(createAlertEpisodeSuppressionsResponse(suppressions)) + .mockResolvedValueOnce(createLastNotifiedTimestampsResponse()); storageEsClient.bulk.mockResolvedValue({ items: Array.from({ length: 10 }, (_, i) => ({ @@ -373,17 +445,18 @@ describe('DispatcherService', () => { }); expect(result.startedAt).toBeInstanceOf(Date); - expect(queryEsClient.esql.query).toHaveBeenCalledTimes(2); + expect(queryEsClient.esql.query).toHaveBeenCalledTimes(3); 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'); + const notifiedActions = docs.filter((doc) => doc.action_type === 'notified'); expect(fireActions).toHaveLength(6); expect(suppressActions).toHaveLength(4); + expect(notifiedActions.length).toBeGreaterThan(0); // rule-001: fire (ack then unack cancels suppression) expect(docs).toEqual( 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 a43b7fa0f77c4..b3de1a18f0305 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 @@ -22,8 +22,9 @@ import type { QueryServiceContract } from '../services/query_service/query_servi import { QueryServiceInternalToken } from '../services/query_service/tokens'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { StorageServiceInternalToken } from '../services/storage_service/tokens'; +import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; -import { getFakeNotificationPoliciesByIds, getFakeRulesByIds } from './faker_service'; +import { getFakeNotificationPoliciesByIds } from './faker_service'; import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery, @@ -55,7 +56,8 @@ export class DispatcherService implements DispatcherServiceContract { @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract, @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract, - private readonly workflowsManagement: WorkflowsManagementApi + private readonly workflowsManagement: WorkflowsManagementApi, + private readonly rulesSavedObjectService: RulesSavedObjectServiceContract ) {} public async run({ @@ -73,7 +75,7 @@ export class DispatcherService implements DispatcherServiceContract { const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); const uniqueRuleIds = [...new Set(active.map((ep) => ep.rule_id))]; - const rules = await getFakeRulesByIds(uniqueRuleIds); + const rules = await this.fetchRules(uniqueRuleIds); const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; const policies = await getFakeNotificationPoliciesByIds(uniquePolicyIds); @@ -310,6 +312,27 @@ export class DispatcherService implements DispatcherServiceContract { }; } + private async fetchRules(ruleIds: RuleId[]): Promise> { + const result = await this.rulesSavedObjectService.bulkGetByIds(ruleIds); + const rules = new Map(); + + for (const doc of result) { + if ('error' in doc) continue; + + rules.set(doc.id, { + id: doc.id, + name: doc.attributes.metadata.name, + description: doc.attributes.metadata.owner ?? '', + notificationPolicyIds: doc.attributes.notification_policies?.map((p) => p.ref) ?? [], + enabled: doc.attributes.enabled, + createdAt: doc.attributes.createdAt, + updatedAt: doc.attributes.updatedAt, + }); + } + + return rules; + } + private craftFakeRequest(apiKey: string): KibanaRequest { const fakeRawRequest: FakeRawRequest = { headers: { authorization: `ApiKey ${apiKey}` }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts index d43e48139534a..fb49be1143088 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts @@ -5,25 +5,7 @@ * 2.0. */ -import type { NotificationPolicy, NotificationPolicyId, Rule, RuleId } from './types'; - -export async function getFakeRulesByIds(ruleIds: RuleId[]): Promise> { - const now = new Date().toISOString(); - const rules = ruleIds.reduce((acc, ruleId) => { - acc[ruleId] = { - id: ruleId, - name: `Rule ${ruleId}`, - description: `Description for rule ${ruleId}`, - notificationPolicyIds: ['policy_123', 'policy_456'], - enabled: true, - createdAt: now, - updatedAt: now, - }; - return acc; - }, {} as Record); - - return new Map(Object.entries(rules)); -} +import type { NotificationPolicy, NotificationPolicyId } from './types'; const FAKE_POLICIES: Record = { policy_123: { 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 23beae55c33d9..fe3f5b9d3f7c4 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, AlertEpisodeSuppression } from '../types'; +import type { AlertEpisode, AlertEpisodeSuppression, LastNotifiedRecord } from '../types'; export const createDispatchableAlertEventsResponse = ( alertEpisodes: AlertEpisode[] @@ -47,3 +47,15 @@ export const createAlertEpisodeSuppressionsResponse = ( ]), }; }; + +export const createLastNotifiedTimestampsResponse = ( + records: LastNotifiedRecord[] = [] +): EsqlQueryResponse => { + return { + columns: [ + { name: 'notification_group_id', type: 'keyword' }, + { name: 'last_notified', type: 'date' }, + ], + values: records.map((r) => [r.notification_group_id, r.last_notified]), + }; +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts index b7e05f3ca12cd..7d89eb7568d55 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts @@ -7,6 +7,7 @@ import type { ServiceIdentifier } from 'inversify'; import type { DispatcherService } from './dispatcher'; +import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; /** * DispatcherService scoped to the current request @@ -21,3 +22,10 @@ export const DispatcherServiceScopedToken = Symbol.for( export const DispatcherServiceInternalToken = Symbol.for( 'alerting_v2.DispatcherServiceInternal' ) as ServiceIdentifier; + +/** + * RulesSavedObjectService singleton (internal user, no request scope) + */ +export const RulesSavedObjectServiceInternalToken = Symbol.for( + 'alerting_v2.RulesSavedObjectServiceInternal' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts index 52dca955b3d4a..00db578e56444 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts @@ -107,7 +107,7 @@ describe('NotificationPolicyClient', () => { name: 'my-policy', description: 'my-policy description', workflow_id: 'my-workflow', - apiKey: 'dGVzdC1lbmNvZGVk', + apiKey: 'YXBpLWtleS1pZDp0ZXN0LWFwaS1rZXktc2VjcmV0', createdBy: 'elastic_profile_uid', updatedBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', @@ -289,7 +289,7 @@ describe('NotificationPolicyClient', () => { name: 'updated-policy', description: 'original-policy description', workflow_id: 'updated-workflow', - apiKey: 'dGVzdC1lbmNvZGVk', + apiKey: 'YXBpLWtleS1pZDp0ZXN0LWFwaS1rZXktc2VjcmV0', updatedBy: 'elastic_profile_uid', updatedAt: '2025-01-01T00:00:00.000Z', createdBy: 'creator_profile_uid', diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index 300c0bc429d4d..259303fee5fe8 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -149,6 +149,7 @@ export class NotificationPolicyClient { metadata: { managed: true }, }); - return result?.api_key ?? null; + if (!result) return null; + return Buffer.from(`${result.id}:${result.api_key}`).toString('base64'); } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index cf97af8635ebe..397fb7b577a4e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { PluginSetup } from '@kbn/core-di'; +import { PluginSetup, PluginStart } from '@kbn/core-di'; import { CoreStart, Request } from '@kbn/core-di-server'; +import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; import { DirectorService } from '../lib/director/director'; @@ -18,6 +19,7 @@ import { DispatcherService } from '../lib/dispatcher/dispatcher'; import { DispatcherServiceInternalToken, DispatcherServiceScopedToken, + RulesSavedObjectServiceInternalToken, } from '../lib/dispatcher/tokens'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; import { RulesClient } from '../lib/rules_client'; @@ -43,7 +45,8 @@ import { TaskRunnerFactoryToken, } from '../lib/services/task_run_scope_service/create_task_runner'; import { UserService } from '../lib/services/user_service/user_service'; -import type { AlertingServerSetupDependencies } from '../types'; +import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import type { AlertingServerSetupDependencies, AlertingServerStartDependencies } from '../types'; export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(AlertActionsClient).toSelf().inRequestScope(); @@ -79,6 +82,16 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { ); bind(RulesSavedObjectService).toSelf().inRequestScope(); + bind(RulesSavedObjectServiceInternalToken) + .toDynamicValue(({ get }) => { + const savedObjects = get(CoreStart('savedObjects')); + const spaces = get(PluginStart('spaces')); + const internalClient = savedObjects.createInternalRepository([ + RULE_SAVED_OBJECT_TYPE, + ]) as unknown as SavedObjectsClientContract; + return new RulesSavedObjectService(() => internalClient, spaces); + }) + .inSingletonScope(); bind(NotificationPolicySavedObjectService).toSelf().inRequestScope(); bind(QueryServiceScopedToken) @@ -121,12 +134,14 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const queryService = get(QueryServiceScopedToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); + const rulesSoService = get(RulesSavedObjectServiceInternalToken); return new DispatcherService( queryService, loggerService, storageService, - workflowsManagement.management + workflowsManagement.management, + rulesSoService ); }) .inRequestScope(); @@ -139,11 +154,13 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const queryService = get(QueryServiceInternalToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); + const rulesSoService = get(RulesSavedObjectServiceInternalToken); return new DispatcherService( queryService, loggerService, storageService, - workflowsManagement.management + workflowsManagement.management, + rulesSoService ); }) .inSingletonScope(); From 101a90d5510fc27f48f26a31bf90cbddcdf64f77 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 20:11:03 -0500 Subject: [PATCH 32/54] Introduce notification policy internal so for usage in the dispatcher --- .../server/lib/dispatcher/dispatcher.test.ts | 34 ++++++++++++++- .../server/lib/dispatcher/dispatcher.ts | 30 +++++++++++-- .../server/lib/dispatcher/faker_service.ts | 43 ------------------- .../server/lib/dispatcher/tokens.ts | 8 ++++ ...otification_policy_saved_object_service.ts | 32 ++++++++++++++ .../alerting_v2/server/setup/bind_services.ts | 21 +++++++-- 6 files changed, 117 insertions(+), 51 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts 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 2fd6fc73060fa..0e081e781aa49 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 @@ -14,6 +14,7 @@ import type { RuleSavedObjectAttributes } from '../../saved_objects'; 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'; +import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { createStorageService } from '../services/storage_service/storage_service.mock'; @@ -61,6 +62,30 @@ const createMockRulesSoService = ( find: jest.fn(), }); +const createMockNpSoService = ( + policyIds: string[] +): NotificationPolicySavedObjectServiceContract => ({ + bulkGetByIds: jest.fn().mockResolvedValue( + policyIds.map((id) => ({ + id, + attributes: { + name: `Policy ${id}`, + description: `Description for ${id}`, + workflow_id: 'workflow-test-id', + apiKey: null, + createdBy: null, + updatedBy: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + })) + ), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}); + describe('DispatcherService', () => { let dispatcherService: DispatcherService; let queryService: QueryServiceContract; @@ -68,18 +93,21 @@ describe('DispatcherService', () => { let queryEsClient: DeeplyMockedApi; let storageEsClient: jest.Mocked; let rulesSoService: RulesSavedObjectServiceContract; + let npSoService: NotificationPolicySavedObjectServiceContract; beforeEach(() => { ({ queryService, mockEsClient: queryEsClient } = createQueryService()); ({ storageService, mockEsClient: storageEsClient } = createStorageService()); const { loggerService } = createLoggerService(); rulesSoService = createMockRulesSoService(['rule-1', 'rule-2']); + npSoService = createMockNpSoService(['policy_456']); dispatcherService = new DispatcherService( queryService, loggerService, storageService, undefined as any, - rulesSoService + rulesSoService, + npSoService ); }); @@ -300,13 +328,15 @@ describe('DispatcherService', () => { 'rule-004', 'rule-005', ]); + npSoService = createMockNpSoService(['policy_456']); const { loggerService } = createLoggerService(); dispatcherService = new DispatcherService( queryService, loggerService, storageService, undefined as any, - rulesSoService + rulesSoService, + npSoService ); // Dataset: 5 rules, 9 episodes total 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 b3de1a18f0305..5f8ce6cd780bf 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 @@ -22,9 +22,9 @@ import type { QueryServiceContract } from '../services/query_service/query_servi import { QueryServiceInternalToken } from '../services/query_service/tokens'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { StorageServiceInternalToken } from '../services/storage_service/tokens'; +import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; -import { getFakeNotificationPoliciesByIds } from './faker_service'; import { getAlertEpisodeSuppressionsQuery, getDispatchableAlertEventsQuery, @@ -57,7 +57,8 @@ export class DispatcherService implements DispatcherServiceContract { @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract, private readonly workflowsManagement: WorkflowsManagementApi, - private readonly rulesSavedObjectService: RulesSavedObjectServiceContract + private readonly rulesSavedObjectService: RulesSavedObjectServiceContract, + private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract ) {} public async run({ @@ -78,7 +79,7 @@ export class DispatcherService implements DispatcherServiceContract { const rules = await this.fetchRules(uniqueRuleIds); const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; - const policies = await getFakeNotificationPoliciesByIds(uniquePolicyIds); + const policies = await this.fetchNotificationPolicies(uniquePolicyIds); const matched = this.evaluateMatchers(active, rules, policies); const notificationGroups = this.buildNotificationGroups(matched); @@ -333,6 +334,29 @@ export class DispatcherService implements DispatcherServiceContract { return rules; } + private async fetchNotificationPolicies( + policyIds: NotificationPolicyId[] + ): Promise> { + const result = await this.notificationPolicySavedObjectService.bulkGetByIds(policyIds); + const policies = new Map(); + + for (const doc of result) { + if ('error' in doc) continue; + + policies.set(doc.id, { + id: doc.id, + name: doc.attributes.name, + workflowId: doc.attributes.workflow_id, + apiKey: doc.attributes.apiKey ?? undefined, + matcher: undefined, + groupBy: [], + throttle: undefined, + }); + } + + return policies; + } + private craftFakeRequest(apiKey: string): KibanaRequest { const fakeRawRequest: FakeRawRequest = { headers: { authorization: `ApiKey ${apiKey}` }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts deleted file mode 100644 index fb49be1143088..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/faker_service.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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 type { NotificationPolicy, NotificationPolicyId } from './types'; - -const FAKE_POLICIES: Record = { - policy_123: { - id: 'policy_123', - name: 'Policy matching all alerts but throttled to 1 hour', - matcher: undefined, // catch-all, matcher is not supported yet - groupBy: [], // not implemted yet, require flattened data support - throttle: { - interval: '1h', - }, - workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', - apiKey: undefined, // no API key for fake policies; dispatch will be skipped unless a real policy is created via API - }, - policy_456: { - id: 'policy_456', - name: 'Policy matching all alerts but not throttled', - matcher: undefined, // catch-all, matcher is not supported yet - groupBy: [], // not implemted yet, require flattened data support - throttle: { - interval: undefined, - }, - workflowId: 'workflow-dbaaec7e-77a2-40eb-bbe9-9c26620b7850', - apiKey: undefined, // no API key for fake policies; dispatch will be skipped unless a real policy is created via API - }, -}; - -export async function getFakeNotificationPoliciesByIds( - notificationPolicyIds: NotificationPolicyId[] -): Promise> { - const policies = notificationPolicyIds.reduce((acc, policyId) => { - acc[policyId] = FAKE_POLICIES[policyId]; - return acc; - }, {} as Record); - return new Map(Object.entries(policies)); -} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts index 7d89eb7568d55..5d4a369b0db50 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts @@ -7,6 +7,7 @@ import type { ServiceIdentifier } from 'inversify'; import type { DispatcherService } from './dispatcher'; +import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; /** @@ -29,3 +30,10 @@ export const DispatcherServiceInternalToken = Symbol.for( export const RulesSavedObjectServiceInternalToken = Symbol.for( 'alerting_v2.RulesSavedObjectServiceInternal' ) as ServiceIdentifier; + +/** + * NotificationPolicySavedObjectService singleton (internal user, no request scope) + */ +export const NotificationPolicySavedObjectServiceInternalToken = Symbol.for( + 'alerting_v2.NotificationPolicySavedObjectServiceInternal' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts index c1a13a15f515d..c192e80f07335 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/notification_policy_saved_object_service.ts @@ -12,11 +12,23 @@ import { SavedObjectsClientFactory } from '@kbn/core-di-server'; import { inject, injectable } from 'inversify'; import type { SavedObjectsClientContract } from '@kbn/core/server'; import { SavedObjectsUtils } from '@kbn/core/server'; +import type { SavedObjectError } from '@kbn/core/types'; import { NOTIFICATION_POLICY_SAVED_OBJECT_TYPE } from '../../../saved_objects'; import type { NotificationPolicySavedObjectAttributes } from '../../../saved_objects'; import type { AlertingServerStartDependencies } from '../../../types'; import { spaceIdToNamespace } from '../../space_id_to_namespace'; +export type NotificationPolicySavedObjectsBulkGetResultItem = + | { + id: string; + attributes: NotificationPolicySavedObjectAttributes; + version?: string; + } + | { + id: string; + error: SavedObjectError; + }; + export interface NotificationPolicySavedObjectServiceContract { create(params: { attrs: NotificationPolicySavedObjectAttributes; @@ -26,6 +38,7 @@ export interface NotificationPolicySavedObjectServiceContract { id: string, spaceId?: string ): Promise<{ id: string; attributes: NotificationPolicySavedObjectAttributes; version?: string }>; + bulkGetByIds(ids: string[]): Promise; update(params: { id: string; attrs: NotificationPolicySavedObjectAttributes; @@ -88,6 +101,25 @@ export class NotificationPolicySavedObjectService return { id: doc.id, attributes: doc.attributes, version: doc.version }; } + public async bulkGetByIds( + ids: string[] + ): Promise { + if (ids.length === 0) { + return []; + } + + const result = await this.client.bulkGet( + ids.map((id) => ({ type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, id })) + ); + + return result.saved_objects.map((doc) => { + if ('error' in doc && doc.error) { + return { id: doc.id, error: doc.error }; + } + return { id: doc.id, attributes: doc.attributes, version: doc.version }; + }); + } + public async update({ id, attrs, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 397fb7b577a4e..1932791d7f9de 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -19,6 +19,7 @@ import { DispatcherService } from '../lib/dispatcher/dispatcher'; import { DispatcherServiceInternalToken, DispatcherServiceScopedToken, + NotificationPolicySavedObjectServiceInternalToken, RulesSavedObjectServiceInternalToken, } from '../lib/dispatcher/tokens'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; @@ -45,7 +46,7 @@ import { TaskRunnerFactoryToken, } from '../lib/services/task_run_scope_service/create_task_runner'; import { UserService } from '../lib/services/user_service/user_service'; -import { RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; +import { NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; import type { AlertingServerSetupDependencies, AlertingServerStartDependencies } from '../types'; export function bindServices({ bind }: ContainerModuleLoadOptions) { @@ -93,6 +94,16 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { }) .inSingletonScope(); bind(NotificationPolicySavedObjectService).toSelf().inRequestScope(); + bind(NotificationPolicySavedObjectServiceInternalToken) + .toDynamicValue(({ get }) => { + const savedObjects = get(CoreStart('savedObjects')); + const spaces = get(PluginStart('spaces')); + const internalClient = savedObjects.createInternalRepository([ + NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, + ]) as unknown as SavedObjectsClientContract; + return new NotificationPolicySavedObjectService(() => internalClient, spaces); + }) + .inSingletonScope(); bind(QueryServiceScopedToken) .toDynamicValue(({ get }) => { @@ -135,13 +146,15 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); const rulesSoService = get(RulesSavedObjectServiceInternalToken); + const npSoService = get(NotificationPolicySavedObjectServiceInternalToken); return new DispatcherService( queryService, loggerService, storageService, workflowsManagement.management, - rulesSoService + rulesSoService, + npSoService ); }) .inRequestScope(); @@ -155,12 +168,14 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); const rulesSoService = get(RulesSavedObjectServiceInternalToken); + const npSoService = get(NotificationPolicySavedObjectServiceInternalToken); return new DispatcherService( queryService, loggerService, storageService, workflowsManagement.management, - rulesSoService + rulesSoService, + npSoService ); }) .inSingletonScope(); From 6cf679ae6ba796f1a1f6f65d4e53f3c2864b716c Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 20:56:25 -0500 Subject: [PATCH 33/54] Refactor dispatcher with step pipeline --- .../server/lib/dispatcher/dispatcher.ts | 424 ++---------------- .../lib/dispatcher/execution_pipeline.test.ts | 146 ++++++ .../lib/dispatcher/execution_pipeline.ts | 63 +++ .../lib/dispatcher/fixtures/test_utils.ts | 118 +++++ .../steps/apply_suppression_step.test.ts | 167 +++++++ .../steps/apply_suppression_step.ts | 70 +++ .../steps/apply_throttling_step.test.ts | 96 ++++ .../dispatcher/steps/apply_throttling_step.ts | 100 +++++ .../steps/build_groups_step.test.ts | 101 +++++ .../lib/dispatcher/steps/build_groups_step.ts | 64 +++ .../dispatcher/steps/dispatch_step.test.ts | 100 +++++ .../lib/dispatcher/steps/dispatch_step.ts | 49 ++ .../steps/evaluate_matchers_step.test.ts | 99 ++++ .../steps/evaluate_matchers_step.ts | 57 +++ .../steps/fetch_episodes_step.test.ts | 55 +++ .../dispatcher/steps/fetch_episodes_step.ts | 51 +++ .../steps/fetch_policies_step.test.ts | 98 ++++ .../dispatcher/steps/fetch_policies_step.ts | 54 +++ .../dispatcher/steps/fetch_rules_step.test.ts | 84 ++++ .../lib/dispatcher/steps/fetch_rules_step.ts | 49 ++ .../steps/fetch_suppressions_step.test.ts | 64 +++ .../steps/fetch_suppressions_step.ts | 36 ++ .../server/lib/dispatcher/steps/index.ts | 17 + .../dispatcher/steps/record_actions_step.ts | 92 ++++ .../server/lib/dispatcher/types.ts | 30 ++ 25 files changed, 1896 insertions(+), 388 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts 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 5f8ce6cd780bf..0dd17ec31a416 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 @@ -5,46 +5,32 @@ * 2.0. */ -import type { FakeRawRequest, KibanaRequest } from '@kbn/core-http-server'; -import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; import { inject, injectable } from 'inversify'; -import moment from 'moment'; -import objectHash from 'object-hash'; -import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; -import { parseDurationToMs } from '../duration'; import { LoggerServiceToken, type LoggerServiceContract, } from '../services/logger_service/logger_service'; -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 { StorageServiceInternalToken } from '../services/storage_service/tokens'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; -import { LOOKBACK_WINDOW_MINUTES } from './constants'; +import { DispatcherPipeline } from './execution_pipeline'; import { - getAlertEpisodeSuppressionsQuery, - getDispatchableAlertEventsQuery, - getLastNotifiedTimestampsQuery, -} from './queries'; -import type { - AlertEpisode, - AlertEpisodeSuppression, - DispatcherExecutionParams, - DispatcherExecutionResult, - LastNotifiedRecord, - MatchedPair, - NotificationGroup, - NotificationGroupId, - NotificationPolicy, - NotificationPolicyId, - Rule, - RuleId, -} from './types'; -import { dispatchWorkflow } from './workflow_dispatcher'; + FetchEpisodesStep, + FetchSuppressionsStep, + ApplySuppressionStep, + FetchRulesStep, + FetchPoliciesStep, + EvaluateMatchersStep, + BuildGroupsStep, + ApplyThrottlingStep, + DispatchStep, + RecordActionsStep, +} from './steps'; +import type { DispatcherExecutionParams, DispatcherExecutionResult } from './types'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; @@ -52,375 +38,37 @@ export interface DispatcherServiceContract { @injectable() export class DispatcherService implements DispatcherServiceContract { + private readonly pipeline: DispatcherPipeline; + constructor( - @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract, - @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, - @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract, - private readonly workflowsManagement: WorkflowsManagementApi, - private readonly rulesSavedObjectService: RulesSavedObjectServiceContract, - private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract - ) {} + @inject(QueryServiceInternalToken) queryService: QueryServiceContract, + @inject(LoggerServiceToken) logger: LoggerServiceContract, + @inject(StorageServiceInternalToken) storageService: StorageServiceContract, + workflowsManagement: WorkflowsManagementApi, + rulesSavedObjectService: RulesSavedObjectServiceContract, + notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract + ) { + this.pipeline = new DispatcherPipeline(logger, [ + new FetchEpisodesStep(queryService), + new FetchSuppressionsStep(queryService), + new ApplySuppressionStep(), + new FetchRulesStep(rulesSavedObjectService), + new FetchPoliciesStep(notificationPolicySavedObjectService), + new EvaluateMatchersStep(), + new BuildGroupsStep(), + new ApplyThrottlingStep(queryService, logger), + new DispatchStep(workflowsManagement, logger), + new RecordActionsStep(storageService), + ]); + } public async run({ previousStartedAt = new Date(), }: DispatcherExecutionParams): Promise { const startedAt = new Date(); - const alertEpisodes = await this.fetchAlertEpisodes(previousStartedAt); - if (alertEpisodes.length === 0) { - return { startedAt }; - } - - const suppressions = await this.fetchAlertEpisodeSuppressions(alertEpisodes); - - const { suppressed, active } = this.applySuppression(alertEpisodes, suppressions); - - const uniqueRuleIds = [...new Set(active.map((ep) => ep.rule_id))]; - const rules = await this.fetchRules(uniqueRuleIds); - - const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; - const policies = await this.fetchNotificationPolicies(uniquePolicyIds); - - const matched = this.evaluateMatchers(active, rules, policies); - const notificationGroups = this.buildNotificationGroups(matched); - - const { dispatch, throttled } = await this.applyThrottling( - notificationGroups, - policies, - startedAt - ); - - for (const group of dispatch) { - const policy = policies.get(group.policyId); - if (!policy?.apiKey) { - this.logger.warn({ - message: () => - `Skipping dispatch for group ${group.id}: notification policy ${group.policyId} has no API key`, - }); - continue; - } - const fakeRequest = this.craftFakeRequest(policy.apiKey); - await dispatchWorkflow(group, fakeRequest, this.workflowsManagement); - } - - const now = new Date(); - await this.storageService.bulkIndexDocs({ - index: ALERT_ACTIONS_DATA_STREAM, - docs: [ - ...suppressed.map((episode) => - this.toAction({ episode, actionType: 'suppress', now, reason: episode.reason }) - ), - ...throttled.flatMap((group) => - group.episodes.map((episode) => - this.toAction({ - episode, - actionType: 'suppress', - now, - reason: `suppressed by throttled policy ${group.policyId}`, - }) - ) - ), - ...dispatch.flatMap((group) => - group.episodes.map((episode) => - this.toAction({ - episode, - actionType: 'fire', - now, - reason: `dispatched by policy ${group.policyId}`, - }) - ) - ), - // This is used to determine if the group should be throttled in a following run - ...dispatch.map((group) => ({ - '@timestamp': now.toISOString(), - actor: 'system', - action_type: 'notified', - rule_id: group.ruleId, - group_hash: 'irrelevant', // irrelevant - last_series_event_timestamp: now.toISOString(), // irrelevant - notification_group_id: group.id, // important to track the group for throttling - source: 'internal', - reason: `notified by policy ${group.policyId} with throttle interval`, - })), - ], - }); + await this.pipeline.execute({ startedAt, previousStartedAt }); return { startedAt }; } - - private async applyThrottling( - groups: NotificationGroup[], - policies: Map, - now: Date - ): Promise<{ dispatch: NotificationGroup[]; throttled: NotificationGroup[] }> { - const dispatch: NotificationGroup[] = []; - const throttled: NotificationGroup[] = []; - - const lastNotifiedMap = await this.fetchLastNotifiedTimestamps(groups.map((group) => group.id)); - - for (const group of groups) { - const policy = policies.get(group.policyId)!; - const lastNotified = lastNotifiedMap.get(group.id); - - if ( - lastNotified && - policy.throttle && - policy.throttle.interval && - isWithinInterval(lastNotified, policy.throttle.interval, now) - ) { - throttled.push(group); - } else { - dispatch.push(group); - } - } - - this.logger.debug({ - message: () => - `Applied throttling to ${throttled.length} groups and dispatched ${dispatch.length} groups`, - }); - return { dispatch, throttled }; - } - - private buildNotificationGroups(matched: MatchedPair[]): NotificationGroup[] { - const groupMap = new Map(); - - for (const { episode, policy } of matched) { - let groupKey: Record = {}; - if (policy.groupBy.length === 0) { - // No grouping: each episode dispatches individually. - // Use the episode's identity as the group key for throttle tracking. - groupKey = { - groupHash: episode.group_hash, - episodeId: episode.episode_id, - }; - } else { - // for (const field of policy.groupBy) { - // groupKey[field] = get(episode.data, field); - // } - throw new Error('Grouping by fields is not supported yet'); - } - - // This is used to identify the notification group in the alert-actions - const notificationGroupId = objectHash({ - ruleId: episode.rule_id, - policyId: policy.id, - groupKey, - }); - - if (!groupMap.has(notificationGroupId)) { - groupMap.set(notificationGroupId, { - id: notificationGroupId, - ruleId: episode.rule_id, - policyId: policy.id, - workflowId: policy.workflowId, - groupKey, - episodes: [], - }); - } - - groupMap.get(notificationGroupId)!.episodes.push(episode); - } - - return [...groupMap.values()]; - } - - private evaluateMatchers( - activeEpisodes: AlertEpisode[], - rules: Map, - policies: Map - ): MatchedPair[] { - const matched: MatchedPair[] = []; - - for (const episode of activeEpisodes) { - const rule = rules.get(episode.rule_id); - if (!rule) continue; - - for (const policyId of rule.notificationPolicyIds) { - const policy = policies.get(policyId); - if (!policy) continue; - - // Empty matcher = catch-all, always matches - if (!policy.matcher) { - matched.push({ episode, policy }); - continue; - } - - // TODO: Handle matcher evaluation here - // matched.push({ episode, policy }); - } - } - - return matched; - } - - private applySuppression( - episodes: AlertEpisode[], - suppressions: AlertEpisodeSuppression[] - ): { suppressed: Array; 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: Array = []; - 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) { - const matchingSuppression = episodeSuppression?.should_suppress - ? episodeSuppression - : seriesSuppression!; - suppressed.push({ ...ep, reason: getSuppressionReason(matchingSuppression) }); - } else { - active.push(ep); - } - } - - return { suppressed, active }; - } - - private toAction({ - episode, - actionType, - now, - reason, - }: { - episode: AlertEpisode; - actionType: 'suppress' | 'fire' | 'notified'; - now: Date; - reason?: string; - }): 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', - reason, - }; - } - - private async fetchRules(ruleIds: RuleId[]): Promise> { - const result = await this.rulesSavedObjectService.bulkGetByIds(ruleIds); - const rules = new Map(); - - for (const doc of result) { - if ('error' in doc) continue; - - rules.set(doc.id, { - id: doc.id, - name: doc.attributes.metadata.name, - description: doc.attributes.metadata.owner ?? '', - notificationPolicyIds: doc.attributes.notification_policies?.map((p) => p.ref) ?? [], - enabled: doc.attributes.enabled, - createdAt: doc.attributes.createdAt, - updatedAt: doc.attributes.updatedAt, - }); - } - - return rules; - } - - private async fetchNotificationPolicies( - policyIds: NotificationPolicyId[] - ): Promise> { - const result = await this.notificationPolicySavedObjectService.bulkGetByIds(policyIds); - const policies = new Map(); - - for (const doc of result) { - if ('error' in doc) continue; - - policies.set(doc.id, { - id: doc.id, - name: doc.attributes.name, - workflowId: doc.attributes.workflow_id, - apiKey: doc.attributes.apiKey ?? undefined, - matcher: undefined, - groupBy: [], - throttle: undefined, - }); - } - - return policies; - } - - private craftFakeRequest(apiKey: string): KibanaRequest { - const fakeRawRequest: FakeRawRequest = { - headers: { authorization: `ApiKey ${apiKey}` }, - path: '/', - }; - return kibanaRequestFactory(fakeRawRequest); - } - - private async fetchAlertEpisodeSuppressions( - alertEpisodes: AlertEpisode[] - ): Promise { - if (alertEpisodes.length === 0) { - return []; - } - - const result = await this.queryService.executeQuery({ - 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: lookback, - }, - }, - }, - }); - - return queryResponseToRecords(result); - } - - private async fetchLastNotifiedTimestamps( - notificationGroupIds: NotificationGroupId[] - ): Promise> { - const result = await this.queryService.executeQuery({ - query: getLastNotifiedTimestampsQuery(notificationGroupIds).query, - }); - - const records = queryResponseToRecords(result); - const lastNotifiedMap = new Map( - records.map((record) => [record.notification_group_id, new Date(record.last_notified)]) - ); - return lastNotifiedMap; - } -} - -function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { - const intervalMillis = parseDurationToMs(interval); - return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); -} - -function getSuppressionReason(suppression: AlertEpisodeSuppression): string { - if (suppression.last_snooze_action === 'snooze') return 'snooze'; - if (suppression.last_ack_action === 'ack') return 'ack'; - if (suppression.last_deactivate_action === 'deactivate') return 'deactivate'; - return 'unknown suppression reason'; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts new file mode 100644 index 0000000000000..2223599f0c746 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts @@ -0,0 +1,146 @@ +/* + * 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 { DispatcherPipeline } from './execution_pipeline'; +import type { DispatcherPipelineState } from './types'; +import { createLoggerService } from '../services/logger_service/logger_service.mock'; +import { createDispatcherPipelineInput, createMockDispatcherStep } from './fixtures/test_utils'; + +jest.mock('./with_dispatcher_span', () => ({ + withDispatcherSpan: (_name: string, cb: () => Promise) => cb(), +})); + +describe('DispatcherPipeline', () => { + describe('execute', () => { + it('executes all steps in order when all continue', async () => { + const { loggerService } = createLoggerService(); + const executionOrder: string[] = []; + + const step1 = createMockDispatcherStep('step1', async () => { + executionOrder.push('step1'); + return { type: 'continue' }; + }); + + const step2 = createMockDispatcherStep('step2', async () => { + executionOrder.push('step2'); + return { type: 'continue' }; + }); + + const step3 = createMockDispatcherStep('step3', async () => { + executionOrder.push('step3'); + return { type: 'continue' }; + }); + + const pipeline = new DispatcherPipeline(loggerService, [step1, step2, step3]); + const input = createDispatcherPipelineInput(); + + const result = await pipeline.execute(input); + + expect(result.completed).toBe(true); + expect(result.haltReason).toBeUndefined(); + expect(executionOrder).toEqual(['step1', 'step2', 'step3']); + }); + + it('stops execution when a step returns halt', async () => { + const { loggerService } = createLoggerService(); + const executionOrder: string[] = []; + + const step1 = createMockDispatcherStep('step1', async () => { + executionOrder.push('step1'); + return { type: 'continue' }; + }); + + const step2 = createMockDispatcherStep('step2', async () => { + executionOrder.push('step2'); + return { type: 'halt', reason: 'no_episodes' }; + }); + + const step3 = createMockDispatcherStep('step3', async () => { + executionOrder.push('step3'); + return { type: 'continue' }; + }); + + const pipeline = new DispatcherPipeline(loggerService, [step1, step2, step3]); + const input = createDispatcherPipelineInput(); + + const result = await pipeline.execute(input); + + expect(result.completed).toBe(false); + expect(result.haltReason).toBe('no_episodes'); + expect(executionOrder).toEqual(['step1', 'step2']); + expect(step3.execute).not.toHaveBeenCalled(); + }); + + it('accumulates state across steps correctly', async () => { + const { loggerService } = createLoggerService(); + const statesReceived: DispatcherPipelineState[] = []; + + const step1 = createMockDispatcherStep('step1', async (state) => { + statesReceived.push({ ...state }); + return { type: 'continue', data: { episodes: [] } }; + }); + + const step2 = createMockDispatcherStep('step2', async (state) => { + statesReceived.push({ ...state }); + return { type: 'continue', data: { active: [], suppressed: [] } }; + }); + + const step3 = createMockDispatcherStep('step3', async (state) => { + statesReceived.push({ ...state }); + return { type: 'continue' }; + }); + + const pipeline = new DispatcherPipeline(loggerService, [step1, step2, step3]); + const input = createDispatcherPipelineInput(); + + const result = await pipeline.execute(input); + + expect(statesReceived[0]).toEqual({ input }); + expect(statesReceived[0].episodes).toBeUndefined(); + + expect(statesReceived[1].input).toEqual(input); + expect(statesReceived[1].episodes).toBeDefined(); + expect(statesReceived[1].active).toBeUndefined(); + + expect(statesReceived[2].input).toEqual(input); + expect(statesReceived[2].episodes).toBeDefined(); + expect(statesReceived[2].active).toBeDefined(); + + expect(result.finalState.episodes).toBeDefined(); + expect(result.finalState.active).toBeDefined(); + }); + + it('propagates errors from steps', async () => { + const { loggerService } = createLoggerService(); + + const step1 = createMockDispatcherStep('step1', async () => { + throw new Error('Step failed'); + }); + + const step2 = createMockDispatcherStep('step2', async () => { + return { type: 'continue' }; + }); + + const pipeline = new DispatcherPipeline(loggerService, [step1, step2]); + const input = createDispatcherPipelineInput(); + + await expect(pipeline.execute(input)).rejects.toThrow('Step failed'); + expect(step2.execute).not.toHaveBeenCalled(); + }); + + it('returns completed result when no steps', async () => { + const { loggerService } = createLoggerService(); + const pipeline = new DispatcherPipeline(loggerService, []); + const input = createDispatcherPipelineInput(); + + const result = await pipeline.execute(input); + + expect(result.completed).toBe(true); + expect(result.finalState).toEqual({ input }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts new file mode 100644 index 0000000000000..d801d3b143047 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LoggerServiceContract } from '../services/logger_service/logger_service'; +import type { + DispatcherHaltReason, + DispatcherPipelineInput, + DispatcherPipelineState, + DispatcherStep, +} from './types'; +import { withDispatcherSpan } from './with_dispatcher_span'; + +export interface DispatcherPipelineResult { + readonly completed: boolean; + readonly haltReason?: DispatcherHaltReason; + readonly finalState: DispatcherPipelineState; +} + +export interface DispatcherPipelineContract { + execute(input: DispatcherPipelineInput): Promise; +} + +export class DispatcherPipeline implements DispatcherPipelineContract { + constructor( + private readonly logger: LoggerServiceContract, + private readonly steps: DispatcherStep[] + ) {} + + public async execute(input: DispatcherPipelineInput): Promise { + let pipelineState: DispatcherPipelineState = { input }; + + for (const step of this.steps) { + this.logger.debug({ message: `Dispatcher: Executing step: ${step.name}` }); + + const output = await withDispatcherSpan(step.name, () => step.execute(pipelineState)); + + if (output.type === 'halt') { + this.logger.debug({ + message: `Dispatcher: Pipeline halted at step: ${step.name}, reason: ${output.reason}`, + }); + + return { + completed: false, + haltReason: output.reason, + finalState: pipelineState, + }; + } + + if (output.data) { + pipelineState = { ...pipelineState, ...output.data }; + } + } + + return { + completed: true, + finalState: pipelineState, + }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts new file mode 100644 index 0000000000000..a18b7759743bf --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts @@ -0,0 +1,118 @@ +/* + * 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 type { + AlertEpisode, + AlertEpisodeSuppression, + DispatcherPipelineInput, + DispatcherPipelineState, + DispatcherStep, + DispatcherStepOutput, + MatchedPair, + NotificationGroup, + NotificationPolicy, + Rule, +} from '../types'; + +export function createDispatcherPipelineInput( + overrides: Partial = {} +): DispatcherPipelineInput { + return { + startedAt: new Date('2026-01-22T08:00:00.000Z'), + previousStartedAt: new Date('2026-01-22T07:30:00.000Z'), + ...overrides, + }; +} + +export function createDispatcherPipelineState( + state?: Partial +): DispatcherPipelineState { + return { + input: createDispatcherPipelineInput(), + ...state, + }; +} + +export function createAlertEpisode(overrides: Partial = {}): AlertEpisode { + return { + last_event_timestamp: '2026-01-22T07:10:00.000Z', + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'episode-1', + episode_status: 'active', + ...overrides, + }; +} + +export function createAlertEpisodeSuppression( + overrides: Partial = {} +): AlertEpisodeSuppression { + return { + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'episode-1', + should_suppress: false, + ...overrides, + }; +} + +export function createRule(overrides: Partial = {}): Rule { + return { + id: 'rule-1', + name: 'Test rule', + description: '', + notificationPolicyIds: ['policy-1'], + enabled: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +export function createNotificationPolicy( + overrides: Partial = {} +): NotificationPolicy { + return { + id: 'policy-1', + name: 'Test policy', + workflowId: 'workflow-1', + groupBy: [], + ...overrides, + }; +} + +export function createMatchedPair(overrides: Partial = {}): MatchedPair { + return { + episode: createAlertEpisode(), + policy: createNotificationPolicy(), + ...overrides, + }; +} + +export function createNotificationGroup( + overrides: Partial = {} +): NotificationGroup { + return { + id: 'group-1', + ruleId: 'rule-1', + policyId: 'policy-1', + workflowId: 'workflow-1', + groupKey: {}, + episodes: [createAlertEpisode()], + ...overrides, + }; +} + +export function createMockDispatcherStep( + name: string, + executeFn: (state: Readonly) => Promise +): DispatcherStep { + return { + name, + execute: jest.fn(executeFn), + }; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts new file mode 100644 index 0000000000000..92531bc84161c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts @@ -0,0 +1,167 @@ +/* + * 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 { ApplySuppressionStep, applySuppression } from './apply_suppression_step'; +import { + createAlertEpisode, + createAlertEpisodeSuppression, + createDispatcherPipelineState, +} from '../fixtures/test_utils'; + +describe('ApplySuppressionStep', () => { + const step = new ApplySuppressionStep(); + + it('separates suppressed and active episodes', async () => { + const ep1 = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const ep2 = createAlertEpisode({ rule_id: 'r2', group_hash: 'h2', episode_id: 'e2' }); + + const state = createDispatcherPipelineState({ + episodes: [ep1, ep2], + suppressions: [ + createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: true, + last_ack_action: 'ack', + }), + createAlertEpisodeSuppression({ + rule_id: 'r2', + group_hash: 'h2', + episode_id: 'e2', + should_suppress: false, + }), + ], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + + expect(result.data?.suppressed).toHaveLength(1); + expect(result.data?.suppressed?.[0]).toEqual( + expect.objectContaining({ rule_id: 'r1', reason: 'ack' }) + ); + expect(result.data?.active).toHaveLength(1); + expect(result.data?.active?.[0]).toEqual(expect.objectContaining({ rule_id: 'r2' })); + }); + + it('treats all episodes as active when there are no suppressions', async () => { + const state = createDispatcherPipelineState({ + episodes: [createAlertEpisode(), createAlertEpisode({ episode_id: 'e2' })], + suppressions: [], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.active).toHaveLength(2); + expect(result.data?.suppressed).toHaveLength(0); + }); + + it('handles empty episodes', async () => { + const state = createDispatcherPipelineState({ episodes: [], suppressions: [] }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.active).toHaveLength(0); + expect(result.data?.suppressed).toHaveLength(0); + }); +}); + +describe('applySuppression', () => { + it('suppresses by episode-level match', () => { + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const suppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: true, + last_ack_action: 'ack', + }); + + const { suppressed, active } = applySuppression([episode], [suppression]); + + expect(suppressed).toHaveLength(1); + expect(suppressed[0].reason).toBe('ack'); + expect(active).toHaveLength(0); + }); + + it('suppresses by series-level match (null episode_id)', () => { + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const suppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: null, + should_suppress: true, + last_snooze_action: 'snooze', + }); + + const { suppressed, active } = applySuppression([episode], [suppression]); + + expect(suppressed).toHaveLength(1); + expect(suppressed[0].reason).toBe('snooze'); + expect(active).toHaveLength(0); + }); + + it('uses deactivate reason when deactivated', () => { + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const suppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: true, + last_deactivate_action: 'deactivate', + }); + + const { suppressed } = applySuppression([episode], [suppression]); + + expect(suppressed[0].reason).toBe('deactivate'); + }); + + it('prefers episode-level suppression over series-level', () => { + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const episodeSuppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: true, + last_ack_action: 'ack', + }); + const seriesSuppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: null, + should_suppress: true, + last_snooze_action: 'snooze', + }); + + const { suppressed } = applySuppression([episode], [episodeSuppression, seriesSuppression]); + + expect(suppressed).toHaveLength(1); + expect(suppressed[0].reason).toBe('ack'); + }); + + it('does not suppress when should_suppress is false', () => { + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const suppression = createAlertEpisodeSuppression({ + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: false, + }); + + const { suppressed, active } = applySuppression([episode], [suppression]); + + expect(suppressed).toHaveLength(0); + expect(active).toHaveLength(1); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts new file mode 100644 index 0000000000000..d0ee72fbe5847 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts @@ -0,0 +1,70 @@ +/* + * 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 type { + AlertEpisode, + AlertEpisodeSuppression, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; + +export class ApplySuppressionStep implements DispatcherStep { + public readonly name = 'apply_suppression'; + + public async execute(state: Readonly): Promise { + const { episodes = [], suppressions = [] } = state; + + const { suppressed, active } = applySuppression(episodes, suppressions); + + return { type: 'continue', data: { suppressed, active } }; + } +} + +export function applySuppression( + episodes: readonly AlertEpisode[], + suppressions: readonly AlertEpisodeSuppression[] +): { suppressed: Array; 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: Array = []; + 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) { + const matchingSuppression = episodeSuppression?.should_suppress + ? episodeSuppression + : seriesSuppression!; + suppressed.push({ ...ep, reason: getSuppressionReason(matchingSuppression) }); + } else { + active.push(ep); + } + } + + return { suppressed, active }; +} + +function getSuppressionReason(suppression: AlertEpisodeSuppression): string { + if (suppression.last_snooze_action === 'snooze') return 'snooze'; + if (suppression.last_ack_action === 'ack') return 'ack'; + if (suppression.last_deactivate_action === 'deactivate') return 'deactivate'; + return 'unknown suppression reason'; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.test.ts new file mode 100644 index 0000000000000..6b281f58ffe5c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.test.ts @@ -0,0 +1,96 @@ +/* + * 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 { applyThrottling } from './apply_throttling_step'; +import { createNotificationGroup, createNotificationPolicy } from '../fixtures/test_utils'; + +describe('applyThrottling', () => { + it('dispatches group when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1', throttle: { interval: '1h' } }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map(), + new Date('2026-01-22T10:00:00.000Z') + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('throttles group when last notified within interval', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1', throttle: { interval: '1h' } }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map([['g1', new Date('2026-01-22T09:30:00.000Z')]]), + new Date('2026-01-22T10:00:00.000Z') + ); + + expect(dispatch).toHaveLength(0); + expect(throttled).toHaveLength(1); + }); + + it('dispatches group when last notified outside interval', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1', throttle: { interval: '1h' } }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map([['g1', new Date('2026-01-22T08:00:00.000Z')]]), + new Date('2026-01-22T10:00:00.000Z') + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches group when policy has no throttle', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map([['g1', new Date('2026-01-22T09:59:00.000Z')]]), + new Date('2026-01-22T10:00:00.000Z') + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('handles mixed dispatch and throttle across groups', () => { + const g1 = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const g2 = createNotificationGroup({ id: 'g2', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1', throttle: { interval: '1h' } }); + + const { dispatch, throttled } = applyThrottling( + [g1, g2], + new Map([['p1', policy]]), + new Map([['g1', new Date('2026-01-22T09:30:00.000Z')]]), + new Date('2026-01-22T10:00:00.000Z') + ); + + expect(dispatch).toHaveLength(1); + expect(dispatch[0].id).toBe('g2'); + expect(throttled).toHaveLength(1); + expect(throttled[0].id).toBe('g1'); + }); + + it('returns empty arrays when no groups', () => { + const { dispatch, throttled } = applyThrottling([], new Map(), new Map(), new Date()); + + expect(dispatch).toHaveLength(0); + expect(throttled).toHaveLength(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts new file mode 100644 index 0000000000000..20af5d886311f --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts @@ -0,0 +1,100 @@ +/* + * 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 type { + LastNotifiedRecord, + NotificationGroup, + NotificationGroupId, + NotificationPolicy, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; +import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; +import { getLastNotifiedTimestampsQuery } from '../queries'; +import { parseDurationToMs } from '../../duration'; + +export class ApplyThrottlingStep implements DispatcherStep { + public readonly name = 'apply_throttling'; + + constructor( + private readonly queryService: QueryServiceContract, + private readonly logger: LoggerServiceContract + ) {} + + public async execute(state: Readonly): Promise { + const { groups = [], policies = new Map(), input } = state; + + if (groups.length === 0) { + return { type: 'continue', data: { dispatch: [], throttled: [] } }; + } + + const lastNotifiedMap = await this.fetchLastNotifiedTimestamps(groups.map((g) => g.id)); + + const { dispatch, throttled } = applyThrottling( + groups, + policies, + lastNotifiedMap, + input.startedAt + ); + + this.logger.debug({ + message: () => + `Applied throttling to ${throttled.length} groups and dispatched ${dispatch.length} groups`, + }); + + return { type: 'continue', data: { dispatch, throttled } }; + } + + private async fetchLastNotifiedTimestamps( + notificationGroupIds: NotificationGroupId[] + ): Promise> { + const result = await this.queryService.executeQuery({ + query: getLastNotifiedTimestampsQuery(notificationGroupIds).query, + }); + + const records = queryResponseToRecords(result); + return new Map( + records.map((record) => [record.notification_group_id, new Date(record.last_notified)]) + ); + } +} + +export function applyThrottling( + groups: readonly NotificationGroup[], + policies: ReadonlyMap, + lastNotifiedMap: ReadonlyMap, + now: Date +): { dispatch: NotificationGroup[]; throttled: NotificationGroup[] } { + const dispatch: NotificationGroup[] = []; + const throttled: NotificationGroup[] = []; + + for (const group of groups) { + const policy = policies.get(group.policyId)!; + const lastNotified = lastNotifiedMap.get(group.id); + + if ( + lastNotified && + policy.throttle && + policy.throttle.interval && + isWithinInterval(lastNotified, policy.throttle.interval, now) + ) { + throttled.push(group); + } else { + dispatch.push(group); + } + } + + return { dispatch, throttled }; +} + +function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { + const intervalMillis = parseDurationToMs(interval); + return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts new file mode 100644 index 0000000000000..af4ecee76f12a --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts @@ -0,0 +1,101 @@ +/* + * 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 { BuildGroupsStep, buildNotificationGroups } from './build_groups_step'; +import { + createAlertEpisode, + createDispatcherPipelineState, + createMatchedPair, + createNotificationPolicy, +} from '../fixtures/test_utils'; + +describe('BuildGroupsStep', () => { + const step = new BuildGroupsStep(); + + it('returns notification groups from matched pairs', async () => { + const state = createDispatcherPipelineState({ + matched: [ + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }), + policy: createNotificationPolicy({ id: 'p1', workflowId: 'w1' }), + }), + ], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.groups).toHaveLength(1); + expect(result.data?.groups?.[0].ruleId).toBe('r1'); + expect(result.data?.groups?.[0].policyId).toBe('p1'); + expect(result.data?.groups?.[0].episodes).toHaveLength(1); + }); + + it('returns empty groups when no matched pairs', async () => { + const state = createDispatcherPipelineState({ matched: [] }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.groups).toHaveLength(0); + }); +}); + +describe('buildNotificationGroups', () => { + it('creates separate groups for different episodes with no groupBy', () => { + const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e2' }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(2); + }); + + it('groups episodes from same rule+policy+groupKey into same group', () => { + const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + const matched = [ + createMatchedPair({ episode, policy }), + createMatchedPair({ episode, policy }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(1); + expect(groups[0].episodes).toHaveLength(2); + }); + + it('assigns deterministic group IDs', () => { + const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); + + const groups1 = buildNotificationGroups([createMatchedPair({ episode, policy })]); + const groups2 = buildNotificationGroups([createMatchedPair({ episode, policy })]); + + expect(groups1[0].id).toBe(groups2[0].id); + }); + + it('throws when groupBy fields are provided', () => { + const policy = createNotificationPolicy({ id: 'p1', groupBy: ['field1'] }); + const episode = createAlertEpisode(); + + expect(() => buildNotificationGroups([createMatchedPair({ episode, policy })])).toThrow( + 'Grouping by fields is not supported yet' + ); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts new file mode 100644 index 0000000000000..f4b735a334bbb --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts @@ -0,0 +1,64 @@ +/* + * 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 objectHash from 'object-hash'; +import type { + MatchedPair, + NotificationGroup, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; + +export class BuildGroupsStep implements DispatcherStep { + public readonly name = 'build_groups'; + + public async execute(state: Readonly): Promise { + const { matched = [] } = state; + + const groups = buildNotificationGroups(matched); + + return { type: 'continue', data: { groups } }; + } +} + +export function buildNotificationGroups(matched: readonly MatchedPair[]): NotificationGroup[] { + const groupMap = new Map(); + + for (const { episode, policy } of matched) { + let groupKey: Record = {}; + if (policy.groupBy.length === 0) { + groupKey = { + groupHash: episode.group_hash, + episodeId: episode.episode_id, + }; + } else { + throw new Error('Grouping by fields is not supported yet'); + } + + const notificationGroupId = objectHash({ + ruleId: episode.rule_id, + policyId: policy.id, + groupKey, + }); + + if (!groupMap.has(notificationGroupId)) { + groupMap.set(notificationGroupId, { + id: notificationGroupId, + ruleId: episode.rule_id, + policyId: policy.id, + workflowId: policy.workflowId, + groupKey, + episodes: [], + }); + } + + groupMap.get(notificationGroupId)!.episodes.push(episode); + } + + return [...groupMap.values()]; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts new file mode 100644 index 0000000000000..c8525b65cc9d5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts @@ -0,0 +1,100 @@ +/* + * 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 type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; +import { DispatchStep } from './dispatch_step'; +import { createLoggerService } from '../../services/logger_service/logger_service.mock'; +import { + createDispatcherPipelineState, + createNotificationGroup, + createNotificationPolicy, +} from '../fixtures/test_utils'; + +jest.mock('../workflow_dispatcher', () => ({ + dispatchWorkflow: jest.fn(), +})); + +const { dispatchWorkflow } = jest.requireMock('../workflow_dispatcher'); + +const createMockWorkflowsManagement = (): jest.Mocked => + ({ + getWorkflow: jest.fn(), + runWorkflow: jest.fn(), + } as any); + +describe('DispatchStep', () => { + afterEach(() => jest.clearAllMocks()); + + it('dispatches workflows for groups with API keys', async () => { + const { loggerService } = createLoggerService(); + const wfm = createMockWorkflowsManagement(); + const step = new DispatchStep(wfm, loggerService); + + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1', apiKey: 'abc123' }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(dispatchWorkflow).toHaveBeenCalledTimes(1); + expect(dispatchWorkflow).toHaveBeenCalledWith(group, expect.any(Object), wfm); + }); + + it('skips dispatch when policy has no API key', async () => { + const { loggerService } = createLoggerService(); + const wfm = createMockWorkflowsManagement(); + const step = new DispatchStep(wfm, loggerService); + + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const policy = createNotificationPolicy({ id: 'p1' }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(dispatchWorkflow).not.toHaveBeenCalled(); + }); + + it('skips dispatch when policy is not found', async () => { + const { loggerService } = createLoggerService(); + const wfm = createMockWorkflowsManagement(); + const step = new DispatchStep(wfm, loggerService); + + const group = createNotificationGroup({ id: 'g1', policyId: 'missing' }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + policies: new Map(), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(dispatchWorkflow).not.toHaveBeenCalled(); + }); + + it('continues with no-op when dispatch is empty', async () => { + const { loggerService } = createLoggerService(); + const wfm = createMockWorkflowsManagement(); + const step = new DispatchStep(wfm, loggerService); + + const state = createDispatcherPipelineState({ dispatch: [] }); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(dispatchWorkflow).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts new file mode 100644 index 0000000000000..8d19d9ab6c3bb --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { FakeRawRequest, KibanaRequest } from '@kbn/core-http-server'; +import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; +import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; +import type { DispatcherStep, DispatcherPipelineState, DispatcherStepOutput } from '../types'; +import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; +import { dispatchWorkflow } from '../workflow_dispatcher'; + +export class DispatchStep implements DispatcherStep { + public readonly name = 'dispatch'; + + constructor( + private readonly workflowsManagement: WorkflowsManagementApi, + private readonly logger: LoggerServiceContract + ) {} + + public async execute(state: Readonly): Promise { + const { dispatch = [], policies = new Map() } = state; + + for (const group of dispatch) { + const policy = policies.get(group.policyId); + if (!policy?.apiKey) { + this.logger.warn({ + message: () => + `Skipping dispatch for group ${group.id}: notification policy ${group.policyId} has no API key`, + }); + continue; + } + const fakeRequest = craftFakeRequest(policy.apiKey); + await dispatchWorkflow(group, fakeRequest, this.workflowsManagement); + } + + return { type: 'continue' }; + } +} + +function craftFakeRequest(apiKey: string): KibanaRequest { + const fakeRawRequest: FakeRawRequest = { + headers: { authorization: `ApiKey ${apiKey}` }, + path: '/', + }; + return kibanaRequestFactory(fakeRawRequest); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts new file mode 100644 index 0000000000000..bcb05495367ac --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { EvaluateMatchersStep, evaluateMatchers } from './evaluate_matchers_step'; +import { + createAlertEpisode, + createDispatcherPipelineState, + createNotificationPolicy, + createRule, +} from '../fixtures/test_utils'; + +describe('EvaluateMatchersStep', () => { + const step = new EvaluateMatchersStep(); + + it('returns matched pairs for episodes with catch-all policies', async () => { + const episode = createAlertEpisode({ rule_id: 'r1' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ id: 'p1' }); + + const state = createDispatcherPipelineState({ + active: [episode], + rules: new Map([['r1', rule]]), + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.matched).toHaveLength(1); + expect(result.data?.matched?.[0].episode).toBe(episode); + expect(result.data?.matched?.[0].policy).toBe(policy); + }); + + it('returns empty when no episodes', async () => { + const state = createDispatcherPipelineState({ + active: [], + rules: new Map(), + policies: new Map(), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.matched).toHaveLength(0); + }); +}); + +describe('evaluateMatchers', () => { + it('matches episode to all catch-all policies on its rule', () => { + const episode = createAlertEpisode({ rule_id: 'r1' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1', 'p2'] }); + const p1 = createNotificationPolicy({ id: 'p1' }); + const p2 = createNotificationPolicy({ id: 'p2' }); + + const matched = evaluateMatchers( + [episode], + new Map([['r1', rule]]), + new Map([ + ['p1', p1], + ['p2', p2], + ]) + ); + + expect(matched).toHaveLength(2); + }); + + it('skips episodes whose rule is not found', () => { + const episode = createAlertEpisode({ rule_id: 'unknown-rule' }); + + const matched = evaluateMatchers([episode], new Map(), new Map()); + + expect(matched).toHaveLength(0); + }); + + it('skips policies that are not found', () => { + const episode = createAlertEpisode({ rule_id: 'r1' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['missing-policy'] }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map()); + + expect(matched).toHaveLength(0); + }); + + it('does not match when a matcher is set (non-catch-all)', () => { + const episode = createAlertEpisode({ rule_id: 'r1' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ id: 'p1', matcher: 'data.severity == "critical"' }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); + + expect(matched).toHaveLength(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts new file mode 100644 index 0000000000000..5e5ff0a03cf0b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts @@ -0,0 +1,57 @@ +/* + * 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 type { + AlertEpisode, + MatchedPair, + NotificationPolicy, + NotificationPolicyId, + Rule, + RuleId, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; + +export class EvaluateMatchersStep implements DispatcherStep { + public readonly name = 'evaluate_matchers'; + + public async execute(state: Readonly): Promise { + const { active = [], rules = new Map(), policies = new Map() } = state; + + const matched = evaluateMatchers(active, rules, policies); + + return { type: 'continue', data: { matched } }; + } +} + +export function evaluateMatchers( + activeEpisodes: readonly AlertEpisode[], + rules: ReadonlyMap, + policies: ReadonlyMap +): MatchedPair[] { + const matched: MatchedPair[] = []; + + for (const episode of activeEpisodes) { + const rule = rules.get(episode.rule_id); + if (!rule) continue; + + for (const policyId of rule.notificationPolicyIds) { + const policy = policies.get(policyId); + if (!policy) continue; + + if (!policy.matcher) { + matched.push({ episode, policy }); + continue; + } + + // TODO: Handle matcher evaluation here + } + } + + return matched; +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.test.ts new file mode 100644 index 0000000000000..49767d0dc63af --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { FetchEpisodesStep } from './fetch_episodes_step'; +import { createQueryService } from '../../services/query_service/query_service.mock'; +import { createDispatchableAlertEventsResponse } from '../fixtures/dispatcher'; +import { createAlertEpisode, createDispatcherPipelineState } from '../fixtures/test_utils'; + +describe('FetchEpisodesStep', () => { + it('returns episodes and continues when episodes are found', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchEpisodesStep(queryService); + + const episodes = [ + createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }), + createAlertEpisode({ rule_id: 'r2', group_hash: 'h2', episode_id: 'e2' }), + ]; + + mockEsClient.esql.query.mockResolvedValueOnce(createDispatchableAlertEventsResponse(episodes)); + + const state = createDispatcherPipelineState(); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.episodes).toHaveLength(2); + expect(result.data?.episodes?.[0].rule_id).toBe('r1'); + }); + + it('halts with no_episodes when none are found', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchEpisodesStep(queryService); + + mockEsClient.esql.query.mockResolvedValueOnce(createDispatchableAlertEventsResponse([])); + + const state = createDispatcherPipelineState(); + const result = await step.execute(state); + + expect(result).toEqual({ type: 'halt', reason: 'no_episodes' }); + }); + + it('propagates query errors', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchEpisodesStep(queryService); + + mockEsClient.esql.query.mockRejectedValueOnce(new Error('ES error')); + + const state = createDispatcherPipelineState(); + await expect(step.execute(state)).rejects.toThrow('ES error'); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts new file mode 100644 index 0000000000000..67232a29ec210 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import moment from 'moment'; +import type { + AlertEpisode, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; +import { LOOKBACK_WINDOW_MINUTES } from '../constants'; +import { getDispatchableAlertEventsQuery } from '../queries'; + +export class FetchEpisodesStep implements DispatcherStep { + public readonly name = 'fetch_episodes'; + + constructor(private readonly queryService: QueryServiceContract) {} + + public async execute(state: Readonly): Promise { + const { previousStartedAt } = state.input; + + const lookback = moment(previousStartedAt) + .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') + .toISOString(); + + const result = await this.queryService.executeQuery({ + query: getDispatchableAlertEventsQuery().query, + filter: { + range: { + '@timestamp': { + gte: lookback, + }, + }, + }, + }); + + const episodes = queryResponseToRecords(result); + + if (episodes.length === 0) { + return { type: 'halt', reason: 'no_episodes' }; + } + + return { type: 'continue', data: { episodes } }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts new file mode 100644 index 0000000000000..7a428ad30f5a5 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts @@ -0,0 +1,98 @@ +/* + * 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 { FetchPoliciesStep } from './fetch_policies_step'; +import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { createDispatcherPipelineState, createRule } from '../fixtures/test_utils'; + +const createMockNpSoService = (): jest.Mocked => ({ + bulkGetByIds: jest.fn(), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), +}); + +describe('FetchPoliciesStep', () => { + it('fetches unique policies from rules', async () => { + const mockService = createMockNpSoService(); + mockService.bulkGetByIds.mockResolvedValue([ + { + id: 'p1', + attributes: { + name: 'Policy 1', + workflow_id: 'w1', + apiKey: 'key123', + }, + }, + ] as any); + + const step = new FetchPoliciesStep(mockService); + const state = createDispatcherPipelineState({ + rules: new Map([ + ['r1', createRule({ id: 'r1', notificationPolicyIds: ['p1'] })], + ['r2', createRule({ id: 'r2', notificationPolicyIds: ['p1'] })], + ]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.policies?.size).toBe(1); + expect(result.data?.policies?.get('p1')?.name).toBe('Policy 1'); + expect(result.data?.policies?.get('p1')?.apiKey).toBe('key123'); + expect(mockService.bulkGetByIds).toHaveBeenCalledWith(['p1']); + }); + + it('returns empty map when rules is empty', async () => { + const mockService = createMockNpSoService(); + const step = new FetchPoliciesStep(mockService); + + const state = createDispatcherPipelineState({ rules: new Map() }); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.policies?.size).toBe(0); + expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + }); + + it('returns empty map when rules have no policy IDs', async () => { + const mockService = createMockNpSoService(); + const step = new FetchPoliciesStep(mockService); + + const state = createDispatcherPipelineState({ + rules: new Map([['r1', createRule({ id: 'r1', notificationPolicyIds: [] })]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.policies?.size).toBe(0); + expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + }); + + it('skips documents with errors', async () => { + const mockService = createMockNpSoService(); + mockService.bulkGetByIds.mockResolvedValue([ + { id: 'p1', error: { statusCode: 404, message: 'Not found' } }, + ] as any); + + const step = new FetchPoliciesStep(mockService); + const state = createDispatcherPipelineState({ + rules: new Map([['r1', createRule({ id: 'r1', notificationPolicyIds: ['p1'] })]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.policies?.size).toBe(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts new file mode 100644 index 0000000000000..d21c6d3f9fe39 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts @@ -0,0 +1,54 @@ +/* + * 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 type { + NotificationPolicy, + NotificationPolicyId, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; + +export class FetchPoliciesStep implements DispatcherStep { + public readonly name = 'fetch_policies'; + + constructor( + private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract + ) {} + + public async execute(state: Readonly): Promise { + const { rules } = state; + if (!rules || rules.size === 0) { + return { type: 'continue', data: { policies: new Map() } }; + } + + const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; + if (uniquePolicyIds.length === 0) { + return { type: 'continue', data: { policies: new Map() } }; + } + + const result = await this.notificationPolicySavedObjectService.bulkGetByIds(uniquePolicyIds); + const policies = new Map(); + + for (const doc of result) { + if ('error' in doc) continue; + + policies.set(doc.id, { + id: doc.id, + name: doc.attributes.name, + workflowId: doc.attributes.workflow_id, + apiKey: doc.attributes.apiKey ?? undefined, + matcher: undefined, + groupBy: [], + throttle: undefined, + }); + } + + return { type: 'continue', data: { policies } }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts new file mode 100644 index 0000000000000..2a0955e64bf30 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts @@ -0,0 +1,84 @@ +/* + * 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 { FetchRulesStep } from './fetch_rules_step'; +import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; +import { createAlertEpisode, createDispatcherPipelineState } from '../fixtures/test_utils'; + +const createMockRulesSoService = (): jest.Mocked => ({ + bulkGetByIds: jest.fn(), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + find: jest.fn(), +}); + +describe('FetchRulesStep', () => { + it('fetches rules for unique rule IDs from active episodes', async () => { + const mockService = createMockRulesSoService(); + mockService.bulkGetByIds.mockResolvedValue([ + { + id: 'r1', + attributes: { + metadata: { name: 'Rule 1' }, + notification_policies: [{ ref: 'p1' }], + enabled: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + }, + ] as any); + + const step = new FetchRulesStep(mockService); + const state = createDispatcherPipelineState({ + active: [ + createAlertEpisode({ rule_id: 'r1' }), + createAlertEpisode({ rule_id: 'r1', episode_id: 'e2' }), + ], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.rules?.size).toBe(1); + expect(result.data?.rules?.get('r1')?.name).toBe('Rule 1'); + expect(mockService.bulkGetByIds).toHaveBeenCalledWith(['r1']); + }); + + it('returns empty map when no active episodes', async () => { + const mockService = createMockRulesSoService(); + const step = new FetchRulesStep(mockService); + + const state = createDispatcherPipelineState({ active: [] }); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.rules?.size).toBe(0); + expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + }); + + it('skips documents with errors', async () => { + const mockService = createMockRulesSoService(); + mockService.bulkGetByIds.mockResolvedValue([ + { id: 'r1', error: { statusCode: 404, message: 'Not found' } }, + ] as any); + + const step = new FetchRulesStep(mockService); + const state = createDispatcherPipelineState({ + active: [createAlertEpisode({ rule_id: 'r1' })], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.rules?.size).toBe(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts new file mode 100644 index 0000000000000..16c9b7d2a05b1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Rule, + RuleId, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; + +export class FetchRulesStep implements DispatcherStep { + public readonly name = 'fetch_rules'; + + constructor(private readonly rulesSavedObjectService: RulesSavedObjectServiceContract) {} + + public async execute(state: Readonly): Promise { + const { active = [] } = state; + + const uniqueRuleIds = [...new Set(active.map((ep) => ep.rule_id))]; + if (uniqueRuleIds.length === 0) { + return { type: 'continue', data: { rules: new Map() } }; + } + + const result = await this.rulesSavedObjectService.bulkGetByIds(uniqueRuleIds); + const rules = new Map(); + + for (const doc of result) { + if ('error' in doc) continue; + + rules.set(doc.id, { + id: doc.id, + name: doc.attributes.metadata.name, + description: doc.attributes.metadata.owner ?? '', + notificationPolicyIds: doc.attributes.notification_policies?.map((p) => p.ref) ?? [], + enabled: doc.attributes.enabled, + createdAt: doc.attributes.createdAt, + updatedAt: doc.attributes.updatedAt, + }); + } + + return { type: 'continue', data: { rules } }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.test.ts new file mode 100644 index 0000000000000..f35dd6a1e20fb --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.test.ts @@ -0,0 +1,64 @@ +/* + * 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 { FetchSuppressionsStep } from './fetch_suppressions_step'; +import { createQueryService } from '../../services/query_service/query_service.mock'; +import { createAlertEpisodeSuppressionsResponse } from '../fixtures/dispatcher'; +import { createAlertEpisode, createDispatcherPipelineState } from '../fixtures/test_utils'; + +describe('FetchSuppressionsStep', () => { + it('fetches suppressions for provided episodes', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchSuppressionsStep(queryService); + + mockEsClient.esql.query.mockResolvedValueOnce( + createAlertEpisodeSuppressionsResponse([ + { + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + should_suppress: true, + }, + ]) + ); + + const state = createDispatcherPipelineState({ + episodes: [createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' })], + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.suppressions).toHaveLength(1); + expect(result.data?.suppressions?.[0].should_suppress).toBe(true); + }); + + it('returns empty suppressions when no episodes exist', async () => { + const { queryService } = createQueryService(); + const step = new FetchSuppressionsStep(queryService); + + const state = createDispatcherPipelineState({ episodes: [] }); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.suppressions).toHaveLength(0); + }); + + it('returns empty suppressions when episodes is undefined', async () => { + const { queryService } = createQueryService(); + const step = new FetchSuppressionsStep(queryService); + + const state = createDispatcherPipelineState(); + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + if (result.type !== 'continue') return; + expect(result.data?.suppressions).toHaveLength(0); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts new file mode 100644 index 0000000000000..9837617ef3e26 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts @@ -0,0 +1,36 @@ +/* + * 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 type { + AlertEpisodeSuppression, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; +import { getAlertEpisodeSuppressionsQuery } from '../queries'; + +export class FetchSuppressionsStep implements DispatcherStep { + public readonly name = 'fetch_suppressions'; + + constructor(private readonly queryService: QueryServiceContract) {} + + public async execute(state: Readonly): Promise { + const { episodes } = state; + if (!episodes || episodes.length === 0) { + return { type: 'continue', data: { suppressions: [] } }; + } + + const result = await this.queryService.executeQuery({ + query: getAlertEpisodeSuppressionsQuery(episodes).query, + }); + + const suppressions = queryResponseToRecords(result); + return { type: 'continue', data: { suppressions } }; + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts new file mode 100644 index 0000000000000..c5b824d077dd7 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts @@ -0,0 +1,17 @@ +/* + * 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. + */ + +export { FetchEpisodesStep } from './fetch_episodes_step'; +export { FetchSuppressionsStep } from './fetch_suppressions_step'; +export { ApplySuppressionStep } from './apply_suppression_step'; +export { FetchRulesStep } from './fetch_rules_step'; +export { FetchPoliciesStep } from './fetch_policies_step'; +export { EvaluateMatchersStep } from './evaluate_matchers_step'; +export { BuildGroupsStep } from './build_groups_step'; +export { ApplyThrottlingStep } from './apply_throttling_step'; +export { DispatchStep } from './dispatch_step'; +export { RecordActionsStep } from './record_actions_step'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts new file mode 100644 index 0000000000000..d8dae815a4bbc --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts @@ -0,0 +1,92 @@ +/* + * 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 { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../../resources/alert_actions'; +import type { + AlertEpisode, + DispatcherStep, + DispatcherPipelineState, + DispatcherStepOutput, +} from '../types'; +import type { StorageServiceContract } from '../../services/storage_service/storage_service'; + +export class RecordActionsStep implements DispatcherStep { + public readonly name = 'record_actions'; + + constructor(private readonly storageService: StorageServiceContract) {} + + public async execute(state: Readonly): Promise { + const { suppressed = [], throttled = [], dispatch = [] } = state; + + const now = new Date(); + + await this.storageService.bulkIndexDocs({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + ...suppressed.map((episode) => + toAction({ episode, actionType: 'suppress', now, reason: episode.reason }) + ), + ...throttled.flatMap((group) => + group.episodes.map((episode) => + toAction({ + episode, + actionType: 'suppress', + now, + reason: `suppressed by throttled policy ${group.policyId}`, + }) + ) + ), + ...dispatch.flatMap((group) => + group.episodes.map((episode) => + toAction({ + episode, + actionType: 'fire', + now, + reason: `dispatched by policy ${group.policyId}`, + }) + ) + ), + ...dispatch.map((group) => ({ + '@timestamp': now.toISOString(), + actor: 'system', + action_type: 'notified', + rule_id: group.ruleId, + group_hash: 'irrelevant', + last_series_event_timestamp: now.toISOString(), + notification_group_id: group.id, + source: 'internal', + reason: `notified by policy ${group.policyId} with throttle interval`, + })), + ], + }); + + return { type: 'continue' }; + } +} + +function toAction({ + episode, + actionType, + now, + reason, +}: { + episode: AlertEpisode; + actionType: 'suppress' | 'fire' | 'notified'; + now: Date; + reason?: string; +}): 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', + reason, + }; +} 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 b65ebeacc3c8e..3030033a5d0db 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 @@ -87,3 +87,33 @@ export interface LastNotifiedRecord { notification_group_id: NotificationGroupId; last_notified: string; } + +export interface DispatcherPipelineInput { + readonly startedAt: Date; + readonly previousStartedAt: Date; +} + +export interface DispatcherPipelineState { + readonly input: DispatcherPipelineInput; + readonly episodes?: AlertEpisode[]; + readonly suppressions?: AlertEpisodeSuppression[]; + readonly active?: AlertEpisode[]; + readonly suppressed?: Array; + readonly rules?: Map; + readonly policies?: Map; + readonly matched?: MatchedPair[]; + readonly groups?: NotificationGroup[]; + readonly dispatch?: NotificationGroup[]; + readonly throttled?: NotificationGroup[]; +} + +export type DispatcherHaltReason = 'no_episodes'; + +export type DispatcherStepOutput = + | { type: 'continue'; data?: Partial> } + | { type: 'halt'; reason: DispatcherHaltReason }; + +export interface DispatcherStep { + readonly name: string; + execute(state: Readonly): Promise; +} From e7265e1abff8ca4e33939de13dc565a55918a9ad Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 21:00:42 -0500 Subject: [PATCH 34/54] Add missing test --- .../server/lib/dispatcher/queries.test.ts | 165 +++++++++++++++++- 1 file changed, 164 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts index 06a11c71ae0a5..2a00d29c4e704 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/queries.test.ts @@ -5,7 +5,152 @@ * 2.0. */ -import { getLastNotifiedTimestampsQuery } from './queries'; +import { + getDispatchableAlertEventsQuery, + getAlertEpisodeSuppressionsQuery, + getLastNotifiedTimestampsQuery, +} from './queries'; +import { createAlertEpisode } from './fixtures/test_utils'; + +describe('getDispatchableAlertEventsQuery', () => { + it('returns a valid ES|QL request', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req).toHaveProperty('query'); + expect(typeof req.query).toBe('string'); + }); + + it('queries both alert events and alert actions data streams', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('.alerts-events'); + expect(req.query).toContain('.alerts-actions'); + }); + + it('filters for alert event type', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('type == "alert"'); + }); + + it('coalesces rule_id and episode_id from both schemas', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('COALESCE(rule.id, rule_id)'); + expect(req.query).toContain('COALESCE(episode.id, episode_id)'); + }); + + it('computes last_fired via INLINE STATS for fire/suppress actions', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('last_fired = MAX(last_series_event_timestamp)'); + expect(req.query).toContain('action_type == "fire" OR action_type == "suppress"'); + }); + + it('aggregates by rule_id, group_hash, episode_id, episode_status', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('BY rule_id, group_hash, episode_id, episode_status'); + }); + + it('keeps the expected output columns', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain( + 'KEEP last_event_timestamp, rule_id, group_hash, episode_id, episode_status' + ); + }); + + it('sorts by timestamp ascending with a limit', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('SORT last_event_timestamp ASC'); + expect(req.query).toContain('LIMIT 10000'); + }); +}); + +describe('getAlertEpisodeSuppressionsQuery', () => { + it('builds a WHERE clause matching each episode rule_id and group_hash', () => { + const episodes = [ + createAlertEpisode({ rule_id: 'rule-1', group_hash: 'hash-1' }), + createAlertEpisode({ rule_id: 'rule-2', group_hash: 'hash-2' }), + ]; + + const req = getAlertEpisodeSuppressionsQuery(episodes); + + expect(req.query).toContain('rule_id == "rule-1" AND group_hash == "hash-1"'); + expect(req.query).toContain('rule_id == "rule-2" AND group_hash == "hash-2"'); + }); + + it('queries the alert actions data stream', () => { + const req = getAlertEpisodeSuppressionsQuery([createAlertEpisode()]); + + expect(req.query).toContain('.alerts-actions'); + }); + + it('filters for suppression action types', () => { + const req = getAlertEpisodeSuppressionsQuery([createAlertEpisode()]); + + expect(req.query).toContain( + 'action_type IN ("ack", "unack", "deactivate", "activate", "snooze", "unsnooze")' + ); + }); + + it('uses the minimum last_event_timestamp for snooze expiry filtering', () => { + const episodes = [ + createAlertEpisode({ last_event_timestamp: '2026-01-22T10:00:00.000Z' }), + createAlertEpisode({ last_event_timestamp: '2026-01-22T08:00:00.000Z' }), + ]; + + const req = getAlertEpisodeSuppressionsQuery(episodes); + + expect(req.query).toContain('expiry > "2026-01-22T08:00:00.000Z"::DATETIME'); + }); + + it('falls back to epoch when all timestamps are invalid', () => { + const episodes = [createAlertEpisode({ last_event_timestamp: 'not-a-date' })]; + + const req = getAlertEpisodeSuppressionsQuery(episodes); + + expect(req.query).toContain('expiry > "1970-01-01T00:00:00.000Z"::DATETIME'); + }); + + it('skips invalid timestamps when computing minimum', () => { + const episodes = [ + createAlertEpisode({ last_event_timestamp: 'not-a-date' }), + createAlertEpisode({ last_event_timestamp: '2026-01-22T09:00:00.000Z' }), + ]; + + const req = getAlertEpisodeSuppressionsQuery(episodes); + + expect(req.query).toContain('expiry > "2026-01-22T09:00:00.000Z"::DATETIME'); + }); + + it('computes should_suppress with snooze, ack, and deactivate precedence', () => { + const req = getAlertEpisodeSuppressionsQuery([createAlertEpisode()]); + + expect(req.query).toContain('EVAL should_suppress = CASE('); + expect(req.query).toContain('last_snooze_action == "snooze", TRUE'); + expect(req.query).toContain('last_ack_action == "ack", TRUE'); + expect(req.query).toContain('last_deactivate_action == "deactivate", TRUE'); + }); + + it('keeps the expected output columns', () => { + const req = getAlertEpisodeSuppressionsQuery([createAlertEpisode()]); + + expect(req.query).toContain( + 'KEEP rule_id, group_hash, episode_id, should_suppress, last_ack_action, last_deactivate_action, last_snooze_action' + ); + }); + + it('handles a single episode', () => { + const req = getAlertEpisodeSuppressionsQuery([ + createAlertEpisode({ rule_id: 'only-rule', group_hash: 'only-hash' }), + ]); + + expect(req.query).toContain('rule_id == "only-rule" AND group_hash == "only-hash"'); + }); +}); describe('getLastNotifiedTimestampsQuery', () => { it('builds a query for a single notification group', () => { @@ -21,4 +166,22 @@ describe('getLastNotifiedTimestampsQuery', () => { expect(req.query).toContain('notification_group_id IN ("group-1", "group-2")'); }); + + it('filters for notified action type', () => { + const req = getLastNotifiedTimestampsQuery(['group-1']); + + expect(req.query).toContain('action_type == "notified"'); + }); + + it('keeps the expected output columns', () => { + const req = getLastNotifiedTimestampsQuery(['group-1']); + + expect(req.query).toContain('KEEP notification_group_id, last_notified'); + }); + + it('groups by notification_group_id', () => { + const req = getLastNotifiedTimestampsQuery(['group-1']); + + expect(req.query).toContain('BY notification_group_id'); + }); }); From 431e88c51e7de0a0ebab4db8f283d72cbccb9f61 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 19 Feb 2026 21:11:57 -0500 Subject: [PATCH 35/54] fix integration test --- .../integration_tests/dispatcher.test.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) 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 458d61860966d..846a62c79f9e5 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 @@ -19,6 +19,8 @@ import { StorageService, type StorageServiceContract, } from '../../services/storage_service/storage_service'; +import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; import { DispatcherService, type DispatcherServiceContract } from '../dispatcher'; import { setupTestServers } from './setup_test_servers'; @@ -344,7 +346,29 @@ describe('DispatcherService integration tests', () => { queryService = new QueryService(esClient, mockLoggerService); storageService = new StorageService(esClient, mockLoggerService); - dispatcherService = new DispatcherService(queryService, mockLoggerService, storageService); + const mockRulesSoService: RulesSavedObjectServiceContract = { + bulkGetByIds: jest.fn().mockResolvedValue([]), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + find: jest.fn(), + }; + const mockNpSoService: NotificationPolicySavedObjectServiceContract = { + bulkGetByIds: jest.fn().mockResolvedValue([]), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + dispatcherService = new DispatcherService( + queryService, + mockLoggerService, + storageService, + undefined as any, + mockRulesSoService, + mockNpSoService + ); }); describe('when there are no alert events', () => { From 5cb4afc5c87726aba7376a661993454c2e1384e7 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 20 Feb 2026 02:12:33 +0000 Subject: [PATCH 36/54] Changes from node scripts/lint_ts_projects --fix --- x-pack/platform/plugins/shared/alerting_v2/tsconfig.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 418a29e7609c7..302f4512a5ed1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -56,7 +56,9 @@ "@kbn/core-user-profile-server", "@kbn/es-mappings", "@kbn/workflows-management-plugin", - "@kbn/std" + "@kbn/std", + "@kbn/core-http-server-utils", + "@kbn/workflows" ], "exclude": ["target/**/*"] } From 98ae1c41b9f6703ab223b5a2a5cf0606e500ed03 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 09:23:53 -0500 Subject: [PATCH 37/54] evaluate kql in matcher step --- .../steps/evaluate_matchers_step.test.ts | 65 ++++++++++++++++++- .../steps/evaluate_matchers_step.ts | 7 +- .../server/lib/dispatcher/types.ts | 4 +- .../plugins/shared/alerting_v2/tsconfig.json | 3 +- 4 files changed, 72 insertions(+), 7 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts index bcb05495367ac..5b62cdfc3450b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts @@ -87,10 +87,69 @@ describe('evaluateMatchers', () => { expect(matched).toHaveLength(0); }); - it('does not match when a matcher is set (non-catch-all)', () => { - const episode = createAlertEpisode({ rule_id: 'r1' }); + it('does not match when KQL matcher evaluates to false', () => { + const episode = createAlertEpisode({ rule_id: 'r1', episode_status: 'inactive' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ id: 'p1', matcher: 'episode_status: active' }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); + + expect(matched).toHaveLength(0); + }); + + it('matches when KQL matcher evaluates to true', () => { + const episode = createAlertEpisode({ rule_id: 'r1', episode_status: 'active' }); const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); - const policy = createNotificationPolicy({ id: 'p1', matcher: 'data.severity == "critical"' }); + const policy = createNotificationPolicy({ id: 'p1', matcher: 'episode_status: active' }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); + + expect(matched).toHaveLength(1); + expect(matched[0].episode).toBe(episode); + expect(matched[0].policy).toBe(policy); + }); + + it('matches with complex KQL using AND operator', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + episode_status: 'active', + group_hash: 'critical-group', + }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'episode_status: active and group_hash: critical-group', + }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); + + expect(matched).toHaveLength(1); + }); + + it('matches with complex KQL using OR operator', () => { + const episode = createAlertEpisode({ rule_id: 'r1', episode_status: 'recovering' }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'episode_status: active or episode_status: recovering', + }); + + const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); + + expect(matched).toHaveLength(1); + }); + + it('does not match when AND condition is partially met', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + episode_status: 'active', + group_hash: 'normal-group', + }); + const rule = createRule({ id: 'r1', notificationPolicyIds: ['p1'] }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'episode_status: active and group_hash: critical-group', + }); const matched = evaluateMatchers([episode], new Map([['r1', rule]]), new Map([['p1', policy]])); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts index 5e5ff0a03cf0b..a683cdb377723 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { evaluateKql } from '@kbn/eval-kql'; + import type { AlertEpisode, MatchedPair, @@ -49,7 +51,10 @@ export function evaluateMatchers( continue; } - // TODO: Handle matcher evaluation here + const isMatch = evaluateKql(policy.matcher, episode); + if (isMatch) { + matched.push({ episode, policy }); + } } } 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 3030033a5d0db..fa7447bc40e37 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 @@ -54,9 +54,9 @@ export interface Rule { export interface NotificationPolicy { id: NotificationPolicyId; name: string; - /** CEL expression evaluated against the alert episode context. + /** KQL expression evaluated against the alert episode context. * An empty matcher matches all episodes (catch-all). */ - matcher?: string; // e.g. 'data.severity == "critical" && data.env != "dev"' + matcher?: string; // e.g. 'data.severity == "critical" AND data.env != "dev"' /** data.* fields used to group episodes into a single notification */ groupBy: string[]; /** Minimum interval between notifications for the same group */ diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 302f4512a5ed1..7d0dc3ec275eb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -58,7 +58,8 @@ "@kbn/workflows-management-plugin", "@kbn/std", "@kbn/core-http-server-utils", - "@kbn/workflows" + "@kbn/workflows", + "@kbn/eval-kql" ], "exclude": ["target/**/*"] } From 5f1e0fe99a94a215296698ffc73b7d62301f096e Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 09:55:12 -0500 Subject: [PATCH 38/54] use debug logger --- .../alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts index 8d19d9ab6c3bb..18779d009a945 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts @@ -26,7 +26,7 @@ export class DispatchStep implements DispatcherStep { for (const group of dispatch) { const policy = policies.get(group.policyId); if (!policy?.apiKey) { - this.logger.warn({ + this.logger.debug({ message: () => `Skipping dispatch for group ${group.id}: notification policy ${group.policyId} has no API key`, }); From af4f64fe54c1e77a063898993e8b21ae17a0948b Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 11:17:09 -0500 Subject: [PATCH 39/54] Rename active to dispatchable --- .../server/lib/dispatcher/dispatcher.test.ts | 2 +- .../lib/dispatcher/execution_pipeline.test.ts | 8 ++++---- .../steps/apply_suppression_step.test.ts | 20 +++++++++---------- .../steps/apply_suppression_step.ts | 12 +++++------ .../steps/evaluate_matchers_step.test.ts | 4 ++-- .../steps/evaluate_matchers_step.ts | 8 ++++---- .../dispatcher/steps/fetch_rules_step.test.ts | 6 +++--- .../lib/dispatcher/steps/fetch_rules_step.ts | 4 ++-- .../server/lib/dispatcher/types.ts | 2 +- 9 files changed, 33 insertions(+), 33 deletions(-) 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 0e081e781aa49..3b15082da68b2 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 @@ -12,9 +12,9 @@ import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import type { RuleSavedObjectAttributes } from '../../saved_objects'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; +import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { QueryServiceContract } from '../services/query_service/query_service'; import { createQueryService } from '../services/query_service/query_service.mock'; -import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { createStorageService } from '../services/storage_service/storage_service.mock'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts index 2223599f0c746..b93713a0eb6ac 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.test.ts @@ -86,7 +86,7 @@ describe('DispatcherPipeline', () => { const step2 = createMockDispatcherStep('step2', async (state) => { statesReceived.push({ ...state }); - return { type: 'continue', data: { active: [], suppressed: [] } }; + return { type: 'continue', data: { dispatchable: [], suppressed: [] } }; }); const step3 = createMockDispatcherStep('step3', async (state) => { @@ -104,14 +104,14 @@ describe('DispatcherPipeline', () => { expect(statesReceived[1].input).toEqual(input); expect(statesReceived[1].episodes).toBeDefined(); - expect(statesReceived[1].active).toBeUndefined(); + expect(statesReceived[1].dispatchable).toBeUndefined(); expect(statesReceived[2].input).toEqual(input); expect(statesReceived[2].episodes).toBeDefined(); - expect(statesReceived[2].active).toBeDefined(); + expect(statesReceived[2].dispatchable).toBeDefined(); expect(result.finalState.episodes).toBeDefined(); - expect(result.finalState.active).toBeDefined(); + expect(result.finalState.dispatchable).toBeDefined(); }); it('propagates errors from steps', async () => { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts index 92531bc84161c..98562db5a0dce 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.test.ts @@ -47,8 +47,8 @@ describe('ApplySuppressionStep', () => { expect(result.data?.suppressed?.[0]).toEqual( expect.objectContaining({ rule_id: 'r1', reason: 'ack' }) ); - expect(result.data?.active).toHaveLength(1); - expect(result.data?.active?.[0]).toEqual(expect.objectContaining({ rule_id: 'r2' })); + expect(result.data?.dispatchable).toHaveLength(1); + expect(result.data?.dispatchable?.[0]).toEqual(expect.objectContaining({ rule_id: 'r2' })); }); it('treats all episodes as active when there are no suppressions', async () => { @@ -61,7 +61,7 @@ describe('ApplySuppressionStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; - expect(result.data?.active).toHaveLength(2); + expect(result.data?.dispatchable).toHaveLength(2); expect(result.data?.suppressed).toHaveLength(0); }); @@ -72,7 +72,7 @@ describe('ApplySuppressionStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; - expect(result.data?.active).toHaveLength(0); + expect(result.data?.dispatchable).toHaveLength(0); expect(result.data?.suppressed).toHaveLength(0); }); }); @@ -88,11 +88,11 @@ describe('applySuppression', () => { last_ack_action: 'ack', }); - const { suppressed, active } = applySuppression([episode], [suppression]); + const { suppressed, dispatchable } = applySuppression([episode], [suppression]); expect(suppressed).toHaveLength(1); expect(suppressed[0].reason).toBe('ack'); - expect(active).toHaveLength(0); + expect(dispatchable).toHaveLength(0); }); it('suppresses by series-level match (null episode_id)', () => { @@ -105,11 +105,11 @@ describe('applySuppression', () => { last_snooze_action: 'snooze', }); - const { suppressed, active } = applySuppression([episode], [suppression]); + const { suppressed, dispatchable } = applySuppression([episode], [suppression]); expect(suppressed).toHaveLength(1); expect(suppressed[0].reason).toBe('snooze'); - expect(active).toHaveLength(0); + expect(dispatchable).toHaveLength(0); }); it('uses deactivate reason when deactivated', () => { @@ -159,9 +159,9 @@ describe('applySuppression', () => { should_suppress: false, }); - const { suppressed, active } = applySuppression([episode], [suppression]); + const { suppressed, dispatchable } = applySuppression([episode], [suppression]); expect(suppressed).toHaveLength(0); - expect(active).toHaveLength(1); + expect(dispatchable).toHaveLength(1); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts index d0ee72fbe5847..4af4f7ef5195e 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts @@ -19,16 +19,16 @@ export class ApplySuppressionStep implements DispatcherStep { public async execute(state: Readonly): Promise { const { episodes = [], suppressions = [] } = state; - const { suppressed, active } = applySuppression(episodes, suppressions); + const { suppressed, dispatchable } = applySuppression(episodes, suppressions); - return { type: 'continue', data: { suppressed, active } }; + return { type: 'continue', data: { suppressed, dispatchable } }; } } export function applySuppression( episodes: readonly AlertEpisode[], suppressions: readonly AlertEpisodeSuppression[] -): { suppressed: Array; active: AlertEpisode[] } { +): { suppressed: Array; dispatchable: AlertEpisode[] } { const suppressionMap = new Map(); for (const s of suppressions) { @@ -40,7 +40,7 @@ export function applySuppression( } const suppressed: Array = []; - const active: AlertEpisode[] = []; + const dispatchable: AlertEpisode[] = []; for (const ep of episodes) { const episodeKey = `${ep.rule_id}:${ep.group_hash}:${ep.episode_id}`; @@ -55,11 +55,11 @@ export function applySuppression( : seriesSuppression!; suppressed.push({ ...ep, reason: getSuppressionReason(matchingSuppression) }); } else { - active.push(ep); + dispatchable.push(ep); } } - return { suppressed, active }; + return { suppressed, dispatchable }; } function getSuppressionReason(suppression: AlertEpisodeSuppression): string { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts index 5b62cdfc3450b..3cb065bbfb13d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.test.ts @@ -22,7 +22,7 @@ describe('EvaluateMatchersStep', () => { const policy = createNotificationPolicy({ id: 'p1' }); const state = createDispatcherPipelineState({ - active: [episode], + dispatchable: [episode], rules: new Map([['r1', rule]]), policies: new Map([['p1', policy]]), }); @@ -38,7 +38,7 @@ describe('EvaluateMatchersStep', () => { it('returns empty when no episodes', async () => { const state = createDispatcherPipelineState({ - active: [], + dispatchable: [], rules: new Map(), policies: new Map(), }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts index a683cdb377723..12515672e27ac 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts @@ -23,22 +23,22 @@ export class EvaluateMatchersStep implements DispatcherStep { public readonly name = 'evaluate_matchers'; public async execute(state: Readonly): Promise { - const { active = [], rules = new Map(), policies = new Map() } = state; + const { dispatchable = [], rules = new Map(), policies = new Map() } = state; - const matched = evaluateMatchers(active, rules, policies); + const matched = evaluateMatchers(dispatchable, rules, policies); return { type: 'continue', data: { matched } }; } } export function evaluateMatchers( - activeEpisodes: readonly AlertEpisode[], + dispatchable: readonly AlertEpisode[], rules: ReadonlyMap, policies: ReadonlyMap ): MatchedPair[] { const matched: MatchedPair[] = []; - for (const episode of activeEpisodes) { + for (const episode of dispatchable) { const rule = rules.get(episode.rule_id); if (!rule) continue; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts index 2a0955e64bf30..8431ab151cb51 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts @@ -36,7 +36,7 @@ describe('FetchRulesStep', () => { const step = new FetchRulesStep(mockService); const state = createDispatcherPipelineState({ - active: [ + dispatchable: [ createAlertEpisode({ rule_id: 'r1' }), createAlertEpisode({ rule_id: 'r1', episode_id: 'e2' }), ], @@ -55,7 +55,7 @@ describe('FetchRulesStep', () => { const mockService = createMockRulesSoService(); const step = new FetchRulesStep(mockService); - const state = createDispatcherPipelineState({ active: [] }); + const state = createDispatcherPipelineState({ dispatchable: [] }); const result = await step.execute(state); expect(result.type).toBe('continue'); @@ -72,7 +72,7 @@ describe('FetchRulesStep', () => { const step = new FetchRulesStep(mockService); const state = createDispatcherPipelineState({ - active: [createAlertEpisode({ rule_id: 'r1' })], + dispatchable: [createAlertEpisode({ rule_id: 'r1' })], }); const result = await step.execute(state); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts index 16c9b7d2a05b1..6c4e3952d7ddb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts @@ -20,9 +20,9 @@ export class FetchRulesStep implements DispatcherStep { constructor(private readonly rulesSavedObjectService: RulesSavedObjectServiceContract) {} public async execute(state: Readonly): Promise { - const { active = [] } = state; + const { dispatchable = [] } = state; - const uniqueRuleIds = [...new Set(active.map((ep) => ep.rule_id))]; + const uniqueRuleIds = [...new Set(dispatchable.map((ep) => ep.rule_id))]; if (uniqueRuleIds.length === 0) { return { type: 'continue', data: { rules: new Map() } }; } 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 fa7447bc40e37..bd49f1b65b3e7 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 @@ -97,7 +97,7 @@ export interface DispatcherPipelineState { readonly input: DispatcherPipelineInput; readonly episodes?: AlertEpisode[]; readonly suppressions?: AlertEpisodeSuppression[]; - readonly active?: AlertEpisode[]; + readonly dispatchable?: AlertEpisode[]; readonly suppressed?: Array; readonly rules?: Map; readonly policies?: Map; From 4c52825a1b13668820752bc070a69fd0120118a6 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 11:30:49 -0500 Subject: [PATCH 40/54] Add tests for record actions step --- .../server/lib/dispatcher/dispatcher.ts | 16 +- .../steps/evaluate_matchers_step.ts | 1 - .../dispatcher/steps/fetch_policies_step.ts | 4 +- .../lib/dispatcher/steps/fetch_rules_step.ts | 10 +- .../steps/record_actions_step.test.ts | 339 ++++++++++++++++++ .../dispatcher/steps/record_actions_step.ts | 3 + .../server/lib/dispatcher/types.ts | 2 +- 7 files changed, 359 insertions(+), 16 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts 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 0dd17ec31a416..dbbf028e67bee 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 @@ -11,23 +11,23 @@ import { LoggerServiceToken, type LoggerServiceContract, } from '../services/logger_service/logger_service'; +import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { QueryServiceContract } from '../services/query_service/query_service'; import { QueryServiceInternalToken } from '../services/query_service/tokens'; +import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { StorageServiceInternalToken } from '../services/storage_service/tokens'; -import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; -import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import { DispatcherPipeline } from './execution_pipeline'; import { - FetchEpisodesStep, - FetchSuppressionsStep, ApplySuppressionStep, - FetchRulesStep, - FetchPoliciesStep, - EvaluateMatchersStep, - BuildGroupsStep, ApplyThrottlingStep, + BuildGroupsStep, DispatchStep, + EvaluateMatchersStep, + FetchEpisodesStep, + FetchPoliciesStep, + FetchRulesStep, + FetchSuppressionsStep, RecordActionsStep, } from './steps'; import type { DispatcherExecutionParams, DispatcherExecutionResult } from './types'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts index 12515672e27ac..9af476922cee4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts @@ -6,7 +6,6 @@ */ import { evaluateKql } from '@kbn/eval-kql'; - import type { AlertEpisode, MatchedPair, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts index d21c6d3f9fe39..41984f52fb821 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts @@ -27,7 +27,9 @@ export class FetchPoliciesStep implements DispatcherStep { return { type: 'continue', data: { policies: new Map() } }; } - const uniquePolicyIds = [...new Set(rules.values().flatMap((r) => r.notificationPolicyIds))]; + const uniquePolicyIds = Array.from( + new Set(rules.values().flatMap((r) => r.notificationPolicyIds)) + ); if (uniquePolicyIds.length === 0) { return { type: 'continue', data: { policies: new Map() } }; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts index 6c4e3952d7ddb..8834a6f4b33f5 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts @@ -5,14 +5,14 @@ * 2.0. */ +import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; import type { - Rule, - RuleId, - DispatcherStep, DispatcherPipelineState, + DispatcherStep, DispatcherStepOutput, + Rule, + RuleId, } from '../types'; -import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; export class FetchRulesStep implements DispatcherStep { public readonly name = 'fetch_rules'; @@ -22,7 +22,7 @@ export class FetchRulesStep implements DispatcherStep { public async execute(state: Readonly): Promise { const { dispatchable = [] } = state; - const uniqueRuleIds = [...new Set(dispatchable.map((ep) => ep.rule_id))]; + const uniqueRuleIds = Array.from(new Set(dispatchable.map((ep) => ep.rule_id))); if (uniqueRuleIds.length === 0) { return { type: 'continue', data: { rules: new Map() } }; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts new file mode 100644 index 0000000000000..cb11a3567f5f3 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts @@ -0,0 +1,339 @@ +/* + * 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 { RecordActionsStep } from './record_actions_step'; +import type { StorageServiceContract } from '../../services/storage_service/storage_service'; +import { ALERT_ACTIONS_DATA_STREAM } from '../../../resources/alert_actions'; +import { + createDispatcherPipelineState, + createAlertEpisode, + createNotificationGroup, +} from '../fixtures/test_utils'; + +const createMockStorageService = (): jest.Mocked => ({ + bulkIndexDocs: jest.fn().mockResolvedValue(undefined), +}); + +describe('RecordActionsStep', () => { + const mockDate = new Date('2026-01-22T08:00:00.000Z'); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(mockDate); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('halts when suppressed, throttled, and dispatch are all empty', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const state = createDispatcherPipelineState({ + suppressed: [], + throttled: [], + dispatch: [], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'halt', reason: 'no_actions' }); + expect(mockService.bulkIndexDocs).not.toHaveBeenCalled(); + }); + + it('halts when suppressed, throttled, and dispatch are undefined', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const state = createDispatcherPipelineState({}); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'halt', reason: 'no_actions' }); + expect(mockService.bulkIndexDocs).not.toHaveBeenCalled(); + }); + + it('records suppressed episodes with action_type suppress', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const episode = createAlertEpisode({ + rule_id: 'rule-1', + group_hash: 'hash-1', + last_event_timestamp: '2026-01-22T07:00:00.000Z', + }); + + const state = createDispatcherPipelineState({ + suppressed: [{ ...episode, reason: 'user acknowledged' }], + throttled: [], + dispatch: [], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); + expect(mockService.bulkIndexDocs).toHaveBeenCalledWith({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + { + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-1', + last_series_event_timestamp: '2026-01-22T07:00:00.000Z', + actor: 'system', + action_type: 'suppress', + rule_id: 'rule-1', + source: 'internal', + reason: 'user acknowledged', + }, + ], + }); + }); + + it('records throttled notification groups with throttle-specific reason', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const episode = createAlertEpisode({ + rule_id: 'rule-1', + group_hash: 'hash-1', + last_event_timestamp: '2026-01-22T07:00:00.000Z', + }); + + const group = createNotificationGroup({ + id: 'group-1', + policyId: 'policy-1', + episodes: [episode], + }); + + const state = createDispatcherPipelineState({ + suppressed: [], + throttled: [group], + dispatch: [], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); + expect(mockService.bulkIndexDocs).toHaveBeenCalledWith({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + { + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-1', + last_series_event_timestamp: '2026-01-22T07:00:00.000Z', + actor: 'system', + action_type: 'suppress', + rule_id: 'rule-1', + source: 'internal', + reason: 'suppressed by throttled policy policy-1', + }, + ], + }); + }); + + it('records dispatched episodes with fire and notified action types', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const episode = createAlertEpisode({ + rule_id: 'rule-1', + group_hash: 'hash-1', + last_event_timestamp: '2026-01-22T07:00:00.000Z', + }); + + const group = createNotificationGroup({ + id: 'group-1', + ruleId: 'rule-1', + policyId: 'policy-1', + episodes: [episode], + }); + + const state = createDispatcherPipelineState({ + suppressed: [], + throttled: [], + dispatch: [group], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); + expect(mockService.bulkIndexDocs).toHaveBeenCalledWith({ + index: ALERT_ACTIONS_DATA_STREAM, + docs: [ + { + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-1', + last_series_event_timestamp: '2026-01-22T07:00:00.000Z', + actor: 'system', + action_type: 'fire', + rule_id: 'rule-1', + source: 'internal', + reason: 'dispatched by policy policy-1', + }, + { + '@timestamp': mockDate.toISOString(), + actor: 'system', + action_type: 'notified', + rule_id: 'rule-1', + group_hash: 'irrelevant', + last_series_event_timestamp: mockDate.toISOString(), + notification_group_id: 'group-1', + source: 'internal', + reason: 'notified by policy policy-1 with throttle interval', + }, + ], + }); + }); + + it('handles combined suppressed, throttled, and dispatch arrays', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const suppressedEpisode = createAlertEpisode({ + rule_id: 'rule-suppressed', + group_hash: 'hash-suppressed', + episode_id: 'ep-suppressed', + last_event_timestamp: '2026-01-22T07:00:00.000Z', + }); + + const throttledEpisode = createAlertEpisode({ + rule_id: 'rule-throttled', + group_hash: 'hash-throttled', + episode_id: 'ep-throttled', + last_event_timestamp: '2026-01-22T07:10:00.000Z', + }); + + const dispatchEpisode = createAlertEpisode({ + rule_id: 'rule-dispatch', + group_hash: 'hash-dispatch', + episode_id: 'ep-dispatch', + last_event_timestamp: '2026-01-22T07:20:00.000Z', + }); + + const throttledGroup = createNotificationGroup({ + id: 'throttled-group', + policyId: 'throttle-policy', + episodes: [throttledEpisode], + }); + + const dispatchGroup = createNotificationGroup({ + id: 'dispatch-group', + ruleId: 'rule-dispatch', + policyId: 'dispatch-policy', + episodes: [dispatchEpisode], + }); + + const state = createDispatcherPipelineState({ + suppressed: [{ ...suppressedEpisode, reason: 'manually suppressed' }], + throttled: [throttledGroup], + dispatch: [dispatchGroup], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); + + const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; + expect(callArgs.index).toBe(ALERT_ACTIONS_DATA_STREAM); + expect(callArgs.docs).toHaveLength(4); + + expect(callArgs.docs[0]).toEqual({ + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-suppressed', + last_series_event_timestamp: '2026-01-22T07:00:00.000Z', + actor: 'system', + action_type: 'suppress', + rule_id: 'rule-suppressed', + source: 'internal', + reason: 'manually suppressed', + }); + + expect(callArgs.docs[1]).toEqual({ + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-throttled', + last_series_event_timestamp: '2026-01-22T07:10:00.000Z', + actor: 'system', + action_type: 'suppress', + rule_id: 'rule-throttled', + source: 'internal', + reason: 'suppressed by throttled policy throttle-policy', + }); + + expect(callArgs.docs[2]).toEqual({ + '@timestamp': mockDate.toISOString(), + group_hash: 'hash-dispatch', + last_series_event_timestamp: '2026-01-22T07:20:00.000Z', + actor: 'system', + action_type: 'fire', + rule_id: 'rule-dispatch', + source: 'internal', + reason: 'dispatched by policy dispatch-policy', + }); + + expect(callArgs.docs[3]).toEqual({ + '@timestamp': mockDate.toISOString(), + actor: 'system', + action_type: 'notified', + rule_id: 'rule-dispatch', + group_hash: 'irrelevant', + last_series_event_timestamp: mockDate.toISOString(), + notification_group_id: 'dispatch-group', + source: 'internal', + reason: 'notified by policy dispatch-policy with throttle interval', + }); + }); + + it('records multiple episodes within a single dispatch group', async () => { + const mockService = createMockStorageService(); + const step = new RecordActionsStep(mockService); + + const episode1 = createAlertEpisode({ + rule_id: 'rule-1', + group_hash: 'hash-1', + episode_id: 'ep-1', + last_event_timestamp: '2026-01-22T07:00:00.000Z', + }); + + const episode2 = createAlertEpisode({ + rule_id: 'rule-1', + group_hash: 'hash-2', + episode_id: 'ep-2', + last_event_timestamp: '2026-01-22T07:05:00.000Z', + }); + + const group = createNotificationGroup({ + id: 'group-1', + ruleId: 'rule-1', + policyId: 'policy-1', + episodes: [episode1, episode2], + }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); + + const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; + expect(callArgs.docs).toHaveLength(3); + expect(callArgs.docs[0].action_type).toBe('fire'); + expect(callArgs.docs[0].group_hash).toBe('hash-1'); + expect(callArgs.docs[1].action_type).toBe('fire'); + expect(callArgs.docs[1].group_hash).toBe('hash-2'); + expect(callArgs.docs[2].action_type).toBe('notified'); + expect(callArgs.docs[2].notification_group_id).toBe('group-1'); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts index d8dae815a4bbc..871cee1651f2d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts @@ -21,6 +21,9 @@ export class RecordActionsStep implements DispatcherStep { public async execute(state: Readonly): Promise { const { suppressed = [], throttled = [], dispatch = [] } = state; + if (suppressed.length === 0 && throttled.length === 0 && dispatch.length === 0) { + return { type: 'halt', reason: 'no_actions' }; + } const now = new Date(); 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 bd49f1b65b3e7..3dc2db794c786 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 @@ -107,7 +107,7 @@ export interface DispatcherPipelineState { readonly throttled?: NotificationGroup[]; } -export type DispatcherHaltReason = 'no_episodes'; +export type DispatcherHaltReason = 'no_episodes' | 'no_actions'; export type DispatcherStepOutput = | { type: 'continue'; data?: Partial> } From b0530d25fc4521d77bee2327eb52fe22eb23cb27 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 11:43:03 -0500 Subject: [PATCH 41/54] Remove inject from dispatcher service --- .../server/lib/dispatcher/dispatcher.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) 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 dbbf028e67bee..49eb170f28958 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 @@ -6,17 +6,11 @@ */ import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; -import { inject, injectable } from 'inversify'; -import { - LoggerServiceToken, - type LoggerServiceContract, -} from '../services/logger_service/logger_service'; +import { type LoggerServiceContract } from '../services/logger_service/logger_service'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { QueryServiceContract } from '../services/query_service/query_service'; -import { QueryServiceInternalToken } from '../services/query_service/tokens'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; -import { StorageServiceInternalToken } from '../services/storage_service/tokens'; import { DispatcherPipeline } from './execution_pipeline'; import { ApplySuppressionStep, @@ -36,14 +30,13 @@ export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; } -@injectable() export class DispatcherService implements DispatcherServiceContract { private readonly pipeline: DispatcherPipeline; constructor( - @inject(QueryServiceInternalToken) queryService: QueryServiceContract, - @inject(LoggerServiceToken) logger: LoggerServiceContract, - @inject(StorageServiceInternalToken) storageService: StorageServiceContract, + queryService: QueryServiceContract, + logger: LoggerServiceContract, + storageService: StorageServiceContract, workflowsManagement: WorkflowsManagementApi, rulesSavedObjectService: RulesSavedObjectServiceContract, notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract From 59b20b8853ae1e811a0e4d66902d03882c605931 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 13:04:21 -0500 Subject: [PATCH 42/54] Fix dispatcher step tests --- .../server/lib/dispatcher/dispatcher.test.ts | 5 +- .../fixtures/workflows_management_api.mock.ts | 14 ++++++ .../dispatcher/steps/dispatch_step.test.ts | 47 +++++++++--------- .../lib/dispatcher/steps/dispatch_step.ts | 49 ++++++++++++++----- .../lib/dispatcher/workflow_dispatcher.ts | 37 -------------- 5 files changed, 78 insertions(+), 74 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts 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 3b15082da68b2..ddad386e1b50e 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 @@ -20,6 +20,7 @@ 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 { createWorkflowsManagementApi } from './fixtures/workflows_management_api.mock'; import { createAlertEpisodeSuppressionsResponse, createDispatchableAlertEventsResponse, @@ -105,7 +106,7 @@ describe('DispatcherService', () => { queryService, loggerService, storageService, - undefined as any, + createWorkflowsManagementApi(), rulesSoService, npSoService ); @@ -334,7 +335,7 @@ describe('DispatcherService', () => { queryService, loggerService, storageService, - undefined as any, + createWorkflowsManagementApi(), rulesSoService, npSoService ); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts new file mode 100644 index 0000000000000..235b16dfe4555 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts @@ -0,0 +1,14 @@ +/* + * 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 type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; + +export const createWorkflowsManagementApi = (): jest.Mocked => + ({ + getWorkflow: jest.fn(), + runWorkflow: jest.fn(), + } as unknown as jest.Mocked); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts index c8525b65cc9d5..fe34e4b5d033c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts @@ -5,37 +5,31 @@ * 2.0. */ -import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; -import { DispatchStep } from './dispatch_step'; +import type { WorkflowDetailDto } from '@kbn/workflows'; import { createLoggerService } from '../../services/logger_service/logger_service.mock'; import { createDispatcherPipelineState, createNotificationGroup, createNotificationPolicy, } from '../fixtures/test_utils'; - -jest.mock('../workflow_dispatcher', () => ({ - dispatchWorkflow: jest.fn(), -})); - -const { dispatchWorkflow } = jest.requireMock('../workflow_dispatcher'); - -const createMockWorkflowsManagement = (): jest.Mocked => - ({ - getWorkflow: jest.fn(), - runWorkflow: jest.fn(), - } as any); +import { createWorkflowsManagementApi } from '../fixtures/workflows_management_api.mock'; +import { DispatchStep } from './dispatch_step'; describe('DispatchStep', () => { afterEach(() => jest.clearAllMocks()); it('dispatches workflows for groups with API keys', async () => { const { loggerService } = createLoggerService(); - const wfm = createMockWorkflowsManagement(); + const wfm = createWorkflowsManagementApi(); + wfm.getWorkflow.mockResolvedValue({ id: 'workflow-1' } as unknown as WorkflowDetailDto); const step = new DispatchStep(wfm, loggerService); const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); - const policy = createNotificationPolicy({ id: 'p1', apiKey: 'abc123' }); + const policy = createNotificationPolicy({ + id: 'p1', + apiKey: 'abc123', + workflowId: 'workflow-1', + }); const state = createDispatcherPipelineState({ dispatch: [group], @@ -45,13 +39,18 @@ describe('DispatchStep', () => { const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(dispatchWorkflow).toHaveBeenCalledTimes(1); - expect(dispatchWorkflow).toHaveBeenCalledWith(group, expect.any(Object), wfm); + expect(wfm.runWorkflow).toHaveBeenCalledTimes(1); + expect(wfm.runWorkflow).toHaveBeenCalledWith( + { id: 'workflow-1' } as unknown as WorkflowDetailDto, + 'default', + group, + expect.any(Object) + ); }); it('skips dispatch when policy has no API key', async () => { const { loggerService } = createLoggerService(); - const wfm = createMockWorkflowsManagement(); + const wfm = createWorkflowsManagementApi(); const step = new DispatchStep(wfm, loggerService); const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); @@ -65,12 +64,12 @@ describe('DispatchStep', () => { const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(dispatchWorkflow).not.toHaveBeenCalled(); + expect(wfm.runWorkflow).not.toHaveBeenCalled(); }); it('skips dispatch when policy is not found', async () => { const { loggerService } = createLoggerService(); - const wfm = createMockWorkflowsManagement(); + const wfm = createWorkflowsManagementApi(); const step = new DispatchStep(wfm, loggerService); const group = createNotificationGroup({ id: 'g1', policyId: 'missing' }); @@ -83,18 +82,18 @@ describe('DispatchStep', () => { const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(dispatchWorkflow).not.toHaveBeenCalled(); + expect(wfm.runWorkflow).not.toHaveBeenCalled(); }); it('continues with no-op when dispatch is empty', async () => { const { loggerService } = createLoggerService(); - const wfm = createMockWorkflowsManagement(); + const wfm = createWorkflowsManagementApi(); const step = new DispatchStep(wfm, loggerService); const state = createDispatcherPipelineState({ dispatch: [] }); const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(dispatchWorkflow).not.toHaveBeenCalled(); + expect(wfm.runWorkflow).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts index 18779d009a945..29cd37879f65b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts @@ -7,10 +7,15 @@ import type { FakeRawRequest, KibanaRequest } from '@kbn/core-http-server'; import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; +import type { WorkflowYaml } from '@kbn/workflows'; import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; -import type { DispatcherStep, DispatcherPipelineState, DispatcherStepOutput } from '../types'; import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; -import { dispatchWorkflow } from '../workflow_dispatcher'; +import type { + DispatcherPipelineState, + DispatcherStep, + DispatcherStepOutput, + NotificationGroup, +} from '../types'; export class DispatchStep implements DispatcherStep { public readonly name = 'dispatch'; @@ -32,18 +37,40 @@ export class DispatchStep implements DispatcherStep { }); continue; } - const fakeRequest = craftFakeRequest(policy.apiKey); - await dispatchWorkflow(group, fakeRequest, this.workflowsManagement); + const fakeRequest = this.craftFakeRequest(policy.apiKey); + await this.dispatchWorkflow(group, fakeRequest); } return { type: 'continue' }; } -} -function craftFakeRequest(apiKey: string): KibanaRequest { - const fakeRawRequest: FakeRawRequest = { - headers: { authorization: `ApiKey ${apiKey}` }, - path: '/', - }; - return kibanaRequestFactory(fakeRawRequest); + private craftFakeRequest(apiKey: string): KibanaRequest { + const fakeRawRequest: FakeRawRequest = { + headers: { authorization: `ApiKey ${apiKey}` }, + path: '/', + }; + return kibanaRequestFactory(fakeRawRequest); + } + + private async dispatchWorkflow(group: NotificationGroup, request: KibanaRequest): Promise { + const spaceId = 'default'; + + const workflow = await this.workflowsManagement.getWorkflow(group.workflowId, spaceId); + if (!workflow) { + return; + } + + void this.workflowsManagement.runWorkflow( + { + id: workflow.id, + name: workflow.name, + enabled: workflow.enabled, + definition: workflow.definition as WorkflowYaml, + yaml: workflow.yaml, + }, + spaceId, + group, + request + ); + } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts deleted file mode 100644 index 63d6e5fb75801..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/workflow_dispatcher.ts +++ /dev/null @@ -1,37 +0,0 @@ -/* - * 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 type { KibanaRequest } from '@kbn/core-http-server'; -import type { WorkflowYaml } from '@kbn/workflows'; -import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; -import type { NotificationGroup } from './types'; - -export async function dispatchWorkflow( - group: NotificationGroup, - request: KibanaRequest, - workflowsManagement: WorkflowsManagementApi -): Promise { - const spaceId = 'default'; - - const workflow = await workflowsManagement.getWorkflow(group.workflowId, spaceId); - if (!workflow) { - return; - } - - void workflowsManagement.runWorkflow( - { - id: workflow.id, - name: workflow.name, - enabled: workflow.enabled, - definition: workflow.definition as WorkflowYaml, - yaml: workflow.yaml, - }, - spaceId, - group, - request - ); -} From b3cfd8943cdac9d318d68a2592b90a4700bb34a9 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 13:21:41 -0500 Subject: [PATCH 43/54] remove comment --- .../shared/alerting_v2/server/resources/alert_actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a59c0129d5ccf..e89a8d7246c73 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 @@ -52,7 +52,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" | "suppress" | "notified" + action_type: z.string(), episode_id: z.string().optional(), rule_id: z.string(), notification_group_id: z.string().optional(), From f1c1af1f0fffa6adfd22cb87a103a52e0e6ed262 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 20 Feb 2026 14:02:36 -0500 Subject: [PATCH 44/54] remove as unknown type cast --- .../shared/alerting_v2/server/setup/bind_services.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 1932791d7f9de..7900816b03d1a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -7,7 +7,6 @@ import { PluginSetup, PluginStart } from '@kbn/core-di'; import { CoreStart, Request } from '@kbn/core-di-server'; -import type { SavedObjectsClientContract } from '@kbn/core/server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; import { DirectorService } from '../lib/director/director'; @@ -87,12 +86,11 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { .toDynamicValue(({ get }) => { const savedObjects = get(CoreStart('savedObjects')); const spaces = get(PluginStart('spaces')); - const internalClient = savedObjects.createInternalRepository([ - RULE_SAVED_OBJECT_TYPE, - ]) as unknown as SavedObjectsClientContract; + const internalClient = savedObjects.createInternalRepository([RULE_SAVED_OBJECT_TYPE]); return new RulesSavedObjectService(() => internalClient, spaces); }) .inSingletonScope(); + bind(NotificationPolicySavedObjectService).toSelf().inRequestScope(); bind(NotificationPolicySavedObjectServiceInternalToken) .toDynamicValue(({ get }) => { @@ -100,7 +98,7 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { const spaces = get(PluginStart('spaces')); const internalClient = savedObjects.createInternalRepository([ NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, - ]) as unknown as SavedObjectsClientContract; + ]); return new NotificationPolicySavedObjectService(() => internalClient, spaces); }) .inSingletonScope(); From 34b2e23d0f9a7327a914220707d9c38cf036c1bc Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 23 Feb 2026 07:50:47 -0500 Subject: [PATCH 45/54] Remove workflow and apikey in notificaiton policy for now --- .../plugins/shared/alerting_v2/kibana.jsonc | 3 +- .../server/lib/dispatcher/dispatcher.test.ts | 4 - .../server/lib/dispatcher/dispatcher.ts | 4 +- .../fixtures/workflows_management_api.mock.ts | 14 ---- .../integration_tests/dispatcher.test.ts | 1 - .../dispatcher/steps/dispatch_step.test.ts | 82 ++++++------------- .../lib/dispatcher/steps/dispatch_step.ts | 62 ++------------ .../steps/fetch_policies_step.test.ts | 2 - .../dispatcher/steps/fetch_policies_step.ts | 1 - .../server/lib/dispatcher/types.ts | 2 - .../notification_policy_client.test.ts | 31 +------ .../notification_policy_client.ts | 33 +------- .../notification_policy_mappings.ts | 1 - .../v1.ts | 1 - .../alerting_v2/server/setup/bind_services.ts | 12 +-- .../shared/alerting_v2/server/types.ts | 2 - 16 files changed, 39 insertions(+), 216 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc index 5fe1a938e721e..67b7e82e84c56 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc +++ b/x-pack/platform/plugins/shared/alerting_v2/kibana.jsonc @@ -14,8 +14,7 @@ "features", "spaces", "data", - "security", - "workflowsManagement" + "security" ], "optionalPlugins": ["management"], "extraPublicDirs": [] 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 ddad386e1b50e..6bd5d9b6efe8d 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 @@ -20,7 +20,6 @@ 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 { createWorkflowsManagementApi } from './fixtures/workflows_management_api.mock'; import { createAlertEpisodeSuppressionsResponse, createDispatchableAlertEventsResponse, @@ -73,7 +72,6 @@ const createMockNpSoService = ( name: `Policy ${id}`, description: `Description for ${id}`, workflow_id: 'workflow-test-id', - apiKey: null, createdBy: null, updatedBy: null, createdAt: '2026-01-01T00:00:00.000Z', @@ -106,7 +104,6 @@ describe('DispatcherService', () => { queryService, loggerService, storageService, - createWorkflowsManagementApi(), rulesSoService, npSoService ); @@ -335,7 +332,6 @@ describe('DispatcherService', () => { queryService, loggerService, storageService, - createWorkflowsManagementApi(), rulesSoService, npSoService ); 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 49eb170f28958..f2b69123eef1f 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 @@ -5,7 +5,6 @@ * 2.0. */ -import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; import { type LoggerServiceContract } from '../services/logger_service/logger_service'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { QueryServiceContract } from '../services/query_service/query_service'; @@ -37,7 +36,6 @@ export class DispatcherService implements DispatcherServiceContract { queryService: QueryServiceContract, logger: LoggerServiceContract, storageService: StorageServiceContract, - workflowsManagement: WorkflowsManagementApi, rulesSavedObjectService: RulesSavedObjectServiceContract, notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract ) { @@ -50,7 +48,7 @@ export class DispatcherService implements DispatcherServiceContract { new EvaluateMatchersStep(), new BuildGroupsStep(), new ApplyThrottlingStep(queryService, logger), - new DispatchStep(workflowsManagement, logger), + new DispatchStep(logger), new RecordActionsStep(storageService), ]); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts deleted file mode 100644 index 235b16dfe4555..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/workflows_management_api.mock.ts +++ /dev/null @@ -1,14 +0,0 @@ -/* - * 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 type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; - -export const createWorkflowsManagementApi = (): jest.Mocked => - ({ - getWorkflow: jest.fn(), - runWorkflow: jest.fn(), - } as unknown as jest.Mocked); 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 846a62c79f9e5..76e347b7aa397 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 @@ -365,7 +365,6 @@ describe('DispatcherService integration tests', () => { queryService, mockLoggerService, storageService, - undefined as any, mockRulesSoService, mockNpSoService ); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts index fe34e4b5d033c..52a6b494489ea 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts @@ -5,95 +5,59 @@ * 2.0. */ -import type { WorkflowDetailDto } from '@kbn/workflows'; import { createLoggerService } from '../../services/logger_service/logger_service.mock'; import { createDispatcherPipelineState, createNotificationGroup, createNotificationPolicy, } from '../fixtures/test_utils'; -import { createWorkflowsManagementApi } from '../fixtures/workflows_management_api.mock'; import { DispatchStep } from './dispatch_step'; describe('DispatchStep', () => { afterEach(() => jest.clearAllMocks()); - it('dispatches workflows for groups with API keys', async () => { - const { loggerService } = createLoggerService(); - const wfm = createWorkflowsManagementApi(); - wfm.getWorkflow.mockResolvedValue({ id: 'workflow-1' } as unknown as WorkflowDetailDto); - const step = new DispatchStep(wfm, loggerService); + it('logs debug message for each dispatch group', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService); - const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); - const policy = createNotificationPolicy({ - id: 'p1', - apiKey: 'abc123', - workflowId: 'workflow-1', - }); + const group1 = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + const group2 = createNotificationGroup({ id: 'g2', policyId: 'p2' }); + const policy1 = createNotificationPolicy({ id: 'p1', workflowId: 'workflow-1' }); + const policy2 = createNotificationPolicy({ id: 'p2', workflowId: 'workflow-2' }); const state = createDispatcherPipelineState({ - dispatch: [group], - policies: new Map([['p1', policy]]), + dispatch: [group1, group2], + policies: new Map([ + ['p1', policy1], + ['p2', policy2], + ]), }); const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(wfm.runWorkflow).toHaveBeenCalledTimes(1); - expect(wfm.runWorkflow).toHaveBeenCalledWith( - { id: 'workflow-1' } as unknown as WorkflowDetailDto, - 'default', - group, - expect.any(Object) - ); + expect(mockLogger.debug).toHaveBeenCalledTimes(2); }); - it('skips dispatch when policy has no API key', async () => { - const { loggerService } = createLoggerService(); - const wfm = createWorkflowsManagementApi(); - const step = new DispatchStep(wfm, loggerService); - - const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); - const policy = createNotificationPolicy({ id: 'p1' }); - - const state = createDispatcherPipelineState({ - dispatch: [group], - policies: new Map([['p1', policy]]), - }); - - const result = await step.execute(state); - - expect(result.type).toBe('continue'); - expect(wfm.runWorkflow).not.toHaveBeenCalled(); - }); - - it('skips dispatch when policy is not found', async () => { - const { loggerService } = createLoggerService(); - const wfm = createWorkflowsManagementApi(); - const step = new DispatchStep(wfm, loggerService); - - const group = createNotificationGroup({ id: 'g1', policyId: 'missing' }); - - const state = createDispatcherPipelineState({ - dispatch: [group], - policies: new Map(), - }); + it('continues with no-op when dispatch is empty', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService); + const state = createDispatcherPipelineState({ dispatch: [] }); const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(wfm.runWorkflow).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalled(); }); - it('continues with no-op when dispatch is empty', async () => { - const { loggerService } = createLoggerService(); - const wfm = createWorkflowsManagementApi(); - const step = new DispatchStep(wfm, loggerService); + it('continues when dispatch is undefined', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService); - const state = createDispatcherPipelineState({ dispatch: [] }); + const state = createDispatcherPipelineState({}); const result = await step.execute(state); expect(result.type).toBe('continue'); - expect(wfm.runWorkflow).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalled(); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts index 29cd37879f65b..195eb15529dc6 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts @@ -5,72 +5,24 @@ * 2.0. */ -import type { FakeRawRequest, KibanaRequest } from '@kbn/core-http-server'; -import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; -import type { WorkflowYaml } from '@kbn/workflows'; -import type { WorkflowsManagementApi } from '@kbn/workflows-management-plugin/server/workflows_management/workflows_management_api'; import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; -import type { - DispatcherPipelineState, - DispatcherStep, - DispatcherStepOutput, - NotificationGroup, -} from '../types'; +import type { DispatcherPipelineState, DispatcherStep, DispatcherStepOutput } from '../types'; export class DispatchStep implements DispatcherStep { public readonly name = 'dispatch'; - constructor( - private readonly workflowsManagement: WorkflowsManagementApi, - private readonly logger: LoggerServiceContract - ) {} + constructor(private readonly logger: LoggerServiceContract) {} public async execute(state: Readonly): Promise { - const { dispatch = [], policies = new Map() } = state; + const { dispatch = [] } = state; for (const group of dispatch) { - const policy = policies.get(group.policyId); - if (!policy?.apiKey) { - this.logger.debug({ - message: () => - `Skipping dispatch for group ${group.id}: notification policy ${group.policyId} has no API key`, - }); - continue; - } - const fakeRequest = this.craftFakeRequest(policy.apiKey); - await this.dispatchWorkflow(group, fakeRequest); + this.logger.debug({ + message: () => + `Dispatching notification group ${group.id} for policy ${group.policyId} with workflow ${group.workflowId}`, + }); } return { type: 'continue' }; } - - private craftFakeRequest(apiKey: string): KibanaRequest { - const fakeRawRequest: FakeRawRequest = { - headers: { authorization: `ApiKey ${apiKey}` }, - path: '/', - }; - return kibanaRequestFactory(fakeRawRequest); - } - - private async dispatchWorkflow(group: NotificationGroup, request: KibanaRequest): Promise { - const spaceId = 'default'; - - const workflow = await this.workflowsManagement.getWorkflow(group.workflowId, spaceId); - if (!workflow) { - return; - } - - void this.workflowsManagement.runWorkflow( - { - id: workflow.id, - name: workflow.name, - enabled: workflow.enabled, - definition: workflow.definition as WorkflowYaml, - yaml: workflow.yaml, - }, - spaceId, - group, - request - ); - } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts index 7a428ad30f5a5..99fd524b5c219 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts @@ -26,7 +26,6 @@ describe('FetchPoliciesStep', () => { attributes: { name: 'Policy 1', workflow_id: 'w1', - apiKey: 'key123', }, }, ] as any); @@ -45,7 +44,6 @@ describe('FetchPoliciesStep', () => { if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(1); expect(result.data?.policies?.get('p1')?.name).toBe('Policy 1'); - expect(result.data?.policies?.get('p1')?.apiKey).toBe('key123'); expect(mockService.bulkGetByIds).toHaveBeenCalledWith(['p1']); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts index 41984f52fb821..3e584532f6f49 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts @@ -44,7 +44,6 @@ export class FetchPoliciesStep implements DispatcherStep { id: doc.id, name: doc.attributes.name, workflowId: doc.attributes.workflow_id, - apiKey: doc.attributes.apiKey ?? undefined, matcher: undefined, groupBy: [], throttle: undefined, 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 3dc2db794c786..f70b07b336ae3 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 @@ -65,8 +65,6 @@ export interface NotificationPolicy { }; /** Target workflow to dispatch matched episodes to */ workflowId: WorkflowId; - /** Encoded API key of the policy author, used to craft a fakeRequest at dispatch time */ - apiKey?: string; } export interface MatchedPair { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts index 00db578e56444..b11fa8e3a3e87 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts @@ -5,12 +5,10 @@ * 2.0. */ -import { httpServerMock } from '@kbn/core-http-server-mocks'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { UserProfileServiceStart } from '@kbn/core-user-profile-server'; import type { SavedObjectsClientContract } from '@kbn/core/server'; -import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import { NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, type NotificationPolicySavedObjectAttributes, @@ -27,19 +25,6 @@ describe('NotificationPolicyClient', () => { let mockSavedObjectsClient: jest.Mocked; let userService: UserService; let userProfile: jest.Mocked; - const mockRequest = httpServerMock.createKibanaRequest(); - const mockSecurity = { - authc: { - apiKeys: { - grantAsInternalUser: jest.fn().mockResolvedValue({ - id: 'api-key-id', - name: 'test-api-key', - api_key: 'test-api-key-secret', - encoded: 'dGVzdC1lbmNvZGVk', - }), - }, - }, - } as unknown as SecurityPluginStart; beforeAll(() => { jest.useFakeTimers().setSystemTime(new Date('2025-01-01T00:00:00.000Z')); @@ -52,12 +37,7 @@ describe('NotificationPolicyClient', () => { createNotificationPolicySavedObjectService()); ({ userService, userProfile } = createUserService()); - client = new NotificationPolicyClient( - notificationPolicySavedObjectService, - userService, - mockRequest, - mockSecurity - ); + client = new NotificationPolicyClient(notificationPolicySavedObjectService, userService); userProfile.getCurrent.mockResolvedValue(createUserProfile('elastic_profile_uid')); @@ -107,7 +87,6 @@ describe('NotificationPolicyClient', () => { name: 'my-policy', description: 'my-policy description', workflow_id: 'my-workflow', - apiKey: 'YXBpLWtleS1pZDp0ZXN0LWFwaS1rZXktc2VjcmV0', createdBy: 'elastic_profile_uid', updatedBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', @@ -129,7 +108,6 @@ describe('NotificationPolicyClient', () => { updatedAt: '2025-01-01T00:00:00.000Z', }) ); - expect(res).not.toHaveProperty('apiKey'); }); it('creates a notification policy without custom id', async () => { @@ -199,7 +177,6 @@ describe('NotificationPolicyClient', () => { name: 'test-policy', description: 'test-policy description', workflow_id: 'test-workflow', - apiKey: 'existing-encoded-key', createdBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', updatedBy: 'elastic_profile_uid', @@ -231,7 +208,6 @@ describe('NotificationPolicyClient', () => { updatedBy: existingAttributes.updatedBy, updatedAt: existingAttributes.updatedAt, }); - expect(res).not.toHaveProperty('apiKey'); }); it('throws 404 when notification policy is not found', async () => { @@ -256,7 +232,6 @@ describe('NotificationPolicyClient', () => { name: 'original-policy', description: 'original-policy description', workflow_id: 'original-workflow', - apiKey: 'old-encoded-key', createdBy: 'creator_profile_uid', createdAt: '2024-12-01T00:00:00.000Z', updatedBy: 'updater_profile_uid', @@ -289,7 +264,6 @@ describe('NotificationPolicyClient', () => { name: 'updated-policy', description: 'original-policy description', workflow_id: 'updated-workflow', - apiKey: 'YXBpLWtleS1pZDp0ZXN0LWFwaS1rZXktc2VjcmV0', updatedBy: 'elastic_profile_uid', updatedAt: '2025-01-01T00:00:00.000Z', createdBy: 'creator_profile_uid', @@ -308,7 +282,6 @@ describe('NotificationPolicyClient', () => { updatedAt: '2025-01-01T00:00:00.000Z', }) ); - expect(res).not.toHaveProperty('apiKey'); }); it('throws 404 when notification policy is not found', async () => { @@ -334,7 +307,6 @@ describe('NotificationPolicyClient', () => { name: 'original-policy', description: 'original-policy description', workflow_id: 'original-workflow', - apiKey: 'old-encoded-key', createdBy: 'creator_profile_uid', createdAt: '2024-12-01T00:00:00.000Z', updatedBy: 'updater_profile_uid', @@ -372,7 +344,6 @@ describe('NotificationPolicyClient', () => { name: 'policy-to-delete', description: 'policy-to-delete description', workflow_id: 'workflow-to-delete', - apiKey: 'encoded-key-to-delete', createdBy: 'elastic_profile_uid', createdAt: '2025-01-01T00:00:00.000Z', updatedBy: 'elastic_profile_uid', diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index 259303fee5fe8..2025de64bd882 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -6,15 +6,9 @@ */ import Boom from '@hapi/boom'; -import { PluginStart } from '@kbn/core-di'; -import { Request } from '@kbn/core-di-server'; -import type { KibanaRequest } from '@kbn/core-http-server'; import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; -import type { SecurityPluginStart } from '@kbn/security-plugin/server'; import { inject, injectable } from 'inversify'; -import { omit } from 'lodash'; import { type NotificationPolicySavedObjectAttributes } from '../../saved_objects'; -import type { AlertingServerStartDependencies } from '../../types'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { NotificationPolicySavedObjectService } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { UserServiceContract } from '../services/user_service/user_service'; @@ -30,10 +24,7 @@ export class NotificationPolicyClient { constructor( @inject(NotificationPolicySavedObjectService) private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract, - @inject(UserService) private readonly userService: UserServiceContract, - @inject(Request) private readonly request: KibanaRequest, - @inject(PluginStart('security')) - private readonly security: SecurityPluginStart + @inject(UserService) private readonly userService: UserServiceContract ) {} public async createNotificationPolicy( @@ -41,13 +32,11 @@ export class NotificationPolicyClient { ): Promise { const userProfileUid = await this.getUserProfileUid(); const now = new Date().toISOString(); - const apiKey = await this.generateApiKey(params.data.name); const attributes: NotificationPolicySavedObjectAttributes = { name: params.data.name, description: params.data.description, workflow_id: params.data.workflow_id, - apiKey, createdBy: userProfileUid, createdAt: now, updatedBy: userProfileUid, @@ -60,7 +49,7 @@ export class NotificationPolicyClient { id: params.options?.id, }); - return { id, version, ...omit(attributes, 'apiKey') }; + return { id, version, ...attributes }; } catch (e) { if (SavedObjectsErrorHelpers.isConflictError(e)) { const conflictId = params.options?.id ?? 'unknown'; @@ -73,7 +62,7 @@ export class NotificationPolicyClient { public async getNotificationPolicy({ id }: { id: string }): Promise { try { const doc = await this.notificationPolicySavedObjectService.get(id); - return { id, version: doc.version, ...omit(doc.attributes, 'apiKey') }; + return { id, version: doc.version, ...doc.attributes }; } catch (e) { if (SavedObjectsErrorHelpers.isNotFoundError(e)) { throw Boom.notFound(`Notification policy with id "${id}" not found`); @@ -90,12 +79,9 @@ export class NotificationPolicyClient { const { attributes: existingAttrs } = await this.fetchRawNotificationPolicy(params.options.id); - const apiKey = await this.generateApiKey(params.data.name ?? existingAttrs.name); - const nextAttrs: NotificationPolicySavedObjectAttributes = { ...existingAttrs, ...params.data, - apiKey, updatedBy: userProfileUid, updatedAt: now, }; @@ -107,7 +93,7 @@ export class NotificationPolicyClient { version: params.options.version, }); - return { id: params.options.id, version: updated.version, ...omit(nextAttrs, 'apiKey') }; + return { id: params.options.id, version: updated.version, ...nextAttrs }; } catch (e) { if (SavedObjectsErrorHelpers.isConflictError(e)) { throw Boom.conflict( @@ -141,15 +127,4 @@ export class NotificationPolicyClient { private async getUserProfileUid(): Promise { return this.userService.getCurrentUserProfileUid(); } - - private async generateApiKey(policyName: string): Promise { - const result = await this.security.authc.apiKeys.grantAsInternalUser(this.request, { - name: `alerting_v2:notification_policy:${policyName}`, - role_descriptors: {}, - metadata: { managed: true }, - }); - - if (!result) return null; - return Buffer.from(`${result.id}:${result.api_key}`).toString('base64'); - } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts index 77da5228600e5..cb6dd4e9241e1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts @@ -16,7 +16,6 @@ export const notificationPolicyMappings: SavedObjectsTypeMappingDefinition = { name: { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } }, description: { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } }, workflow_id: { type: 'keyword' }, - apiKey: { type: 'binary' }, createdBy: { type: 'keyword' }, createdAt: { type: 'date' }, updatedBy: { type: 'keyword' }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts index f4154692d7825..e93e4874fcef1 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/notification_policy_saved_object_attributes/v1.ts @@ -14,7 +14,6 @@ export const notificationPolicySavedObjectAttributesSchema = schema.object({ name: schema.string(), description: schema.string(), workflow_id: schema.string(), - apiKey: schema.nullable(schema.string()), createdBy: schema.nullable(schema.string()), updatedBy: schema.nullable(schema.string()), createdAt: schema.string(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 7900816b03d1a..0570462132561 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { PluginSetup, PluginStart } from '@kbn/core-di'; +import { PluginStart } from '@kbn/core-di'; import { CoreStart, Request } from '@kbn/core-di-server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; @@ -46,7 +46,7 @@ import { } from '../lib/services/task_run_scope_service/create_task_runner'; import { UserService } from '../lib/services/user_service/user_service'; import { NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, RULE_SAVED_OBJECT_TYPE } from '../saved_objects'; -import type { AlertingServerSetupDependencies, AlertingServerStartDependencies } from '../types'; +import type { AlertingServerStartDependencies } from '../types'; export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(AlertActionsClient).toSelf().inRequestScope(); @@ -137,9 +137,6 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(DispatcherServiceScopedToken) .toDynamicValue(({ get }) => { - const workflowsManagement = get( - PluginSetup('workflowsManagement') - ); const queryService = get(QueryServiceScopedToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); @@ -150,7 +147,6 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { queryService, loggerService, storageService, - workflowsManagement.management, rulesSoService, npSoService ); @@ -159,9 +155,6 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(DispatcherServiceInternalToken) .toDynamicValue(({ get }) => { - const workflowsManagement = get( - PluginSetup('workflowsManagement') - ); const queryService = get(QueryServiceInternalToken); const loggerService = get(LoggerServiceToken); const storageService = get(StorageServiceInternalToken); @@ -171,7 +164,6 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { queryService, loggerService, storageService, - workflowsManagement.management, rulesSoService, npSoService ); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts index 04a8e426f881d..c007b565c7c5b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/types.ts @@ -14,7 +14,6 @@ import type { FeaturesPluginStart, FeaturesPluginSetup } from '@kbn/features-plu import type { SpacesPluginSetup, SpacesPluginStart } from '@kbn/spaces-plugin/server'; import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import type { SecurityPluginStart } from '@kbn/security-plugin/server'; -import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; export type AlertingServerSetup = void; export type AlertingServerStart = void; @@ -23,7 +22,6 @@ export interface AlertingServerSetupDependencies { taskManager: TaskManagerSetupContract; features: FeaturesPluginSetup; spaces: SpacesPluginSetup; - workflowsManagement: WorkflowsServerPluginSetup; } export interface AlertingServerStartDependencies { From f6e255a4269b09476029f55bb6db1ab56689ec03 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:34:35 +0000 Subject: [PATCH 46/54] Changes from node scripts/lint_ts_projects --fix --- x-pack/platform/plugins/shared/alerting_v2/tsconfig.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 7d0dc3ec275eb..49ae1c8252229 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -55,10 +55,7 @@ "@kbn/core-user-profile-server-mocks", "@kbn/core-user-profile-server", "@kbn/es-mappings", - "@kbn/workflows-management-plugin", "@kbn/std", - "@kbn/core-http-server-utils", - "@kbn/workflows", "@kbn/eval-kql" ], "exclude": ["target/**/*"] From a8f4287d44eb07faa8245c102f888efae4e517f3 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Mon, 23 Feb 2026 10:24:51 -0500 Subject: [PATCH 47/54] Update integration tests --- .../integration_tests/dispatcher.test.ts | 83 ++++++++++++++----- 1 file changed, 63 insertions(+), 20 deletions(-) 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 76e347b7aa397..c1162e097e88d 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 @@ -19,8 +19,15 @@ import { StorageService, type StorageServiceContract, } from '../../services/storage_service/storage_service'; +import type { SpacesPluginStart } from '@kbn/spaces-plugin/server'; +import { NotificationPolicySavedObjectService } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { RulesSavedObjectService } from '../../services/rules_saved_object_service/rules_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; +import type { + RuleSavedObjectAttributes, + NotificationPolicySavedObjectAttributes, +} from '../../../saved_objects'; import { DispatcherService, type DispatcherServiceContract } from '../dispatcher'; import { setupTestServers } from './setup_test_servers'; @@ -320,6 +327,8 @@ describe('DispatcherService integration tests', () => { let queryService: QueryServiceContract; let storageService: StorageServiceContract; let mockLoggerService: LoggerServiceContract; + let rulesSoService: RulesSavedObjectServiceContract; + let npSoService: NotificationPolicySavedObjectServiceContract; beforeAll(async () => { const servers = await setupTestServers(); @@ -328,6 +337,17 @@ describe('DispatcherService integration tests', () => { esClient = kibanaServer.coreStart.elasticsearch.client.asInternalUser; await new Promise((res) => setTimeout(res, 5000)); + + rulesSoService = new RulesSavedObjectService( + (opts) => kibanaServer.coreStart.savedObjects.getUnsafeInternalClient(opts), + undefined as unknown as SpacesPluginStart + ); + npSoService = new NotificationPolicySavedObjectService( + (opts) => kibanaServer.coreStart.savedObjects.getUnsafeInternalClient(opts), + undefined as unknown as SpacesPluginStart + ); + + await seedRulesAndPolicies(rulesSoService, npSoService); }); afterAll(async () => { @@ -346,27 +366,12 @@ describe('DispatcherService integration tests', () => { queryService = new QueryService(esClient, mockLoggerService); storageService = new StorageService(esClient, mockLoggerService); - const mockRulesSoService: RulesSavedObjectServiceContract = { - bulkGetByIds: jest.fn().mockResolvedValue([]), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - find: jest.fn(), - }; - const mockNpSoService: NotificationPolicySavedObjectServiceContract = { - bulkGetByIds: jest.fn().mockResolvedValue([]), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - }; dispatcherService = new DispatcherService( queryService, mockLoggerService, storageService, - mockRulesSoService, - mockNpSoService + rulesSoService, + npSoService ); }); @@ -401,7 +406,7 @@ describe('DispatcherService integration tests', () => { const actionsResponse = await esClient.search({ index: ALERT_ACTIONS_DATA_STREAM, - query: { match_all: {} }, + query: { term: { action_type: 'fire' } }, size: 100, }); @@ -626,10 +631,48 @@ async function seedAlertEvents(esClient: ElasticsearchClient, events: AlertEvent await esClient.bulk({ operations, - refresh: 'wait_for', + refresh: true, }); } +const NOTIFICATION_POLICY_ID = 'np-1'; + +const TEST_RULE_IDS = ['rule-1', 'rule-001', 'rule-002', 'rule-003', 'rule-004', 'rule-005']; + +async function seedRulesAndPolicies( + rulesSoService: RulesSavedObjectServiceContract, + npSoService: NotificationPolicySavedObjectServiceContract +): Promise { + const policyAttrs: NotificationPolicySavedObjectAttributes = { + name: 'Test Policy', + description: 'Test notification policy', + workflow_id: 'test-workflow', + createdBy: null, + updatedBy: null, + createdAt: '2026-01-20T00:00:00.000Z', + updatedAt: null, + }; + await npSoService.create({ attrs: policyAttrs, id: NOTIFICATION_POLICY_ID }); + + const ruleAttrs: RuleSavedObjectAttributes = { + kind: 'alert', + metadata: { name: 'Test Rule' }, + time_field: '@timestamp', + schedule: { every: '5m' }, + evaluation: { query: { base: 'FROM test' } }, + notification_policies: [{ ref: NOTIFICATION_POLICY_ID }], + enabled: true, + createdBy: null, + updatedBy: null, + updatedAt: '2026-01-20T00:00:00.000Z', + createdAt: '2026-01-20T00:00:00.000Z', + }; + + await Promise.all( + TEST_RULE_IDS.map((ruleId) => rulesSoService.create({ attrs: ruleAttrs, id: ruleId })) + ); +} + async function seedAlertActions( esClient: ElasticsearchClient, actions: AlertAction[] @@ -641,6 +684,6 @@ async function seedAlertActions( await esClient.bulk({ operations, - refresh: 'wait_for', + refresh: true, }); } From 43d8446f3018ec75f404d213ae34e4a18cbf1a40 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 13:45:28 -0500 Subject: [PATCH 48/54] Move to injectable() --- .../shared/alerting_v2/server/index.ts | 2 + .../server/lib/dispatcher/dispatcher.test.ts | 53 +++++++++++---- .../server/lib/dispatcher/dispatcher.ts | 44 ++----------- .../lib/dispatcher/execution_pipeline.ts | 12 +++- .../lib/dispatcher/fixtures/test_utils.ts | 4 +- .../integration_tests/dispatcher.test.ts | 38 ++++++++--- .../steps/apply_suppression_step.ts | 2 + .../dispatcher/steps/apply_throttling_step.ts | 12 +++- .../steps/build_groups_step.test.ts | 20 ++++-- .../lib/dispatcher/steps/build_groups_step.ts | 4 +- .../dispatcher/steps/dispatch_step.test.ts | 10 ++- .../lib/dispatcher/steps/dispatch_step.ts | 11 +++- .../steps/evaluate_matchers_step.ts | 2 + .../dispatcher/steps/fetch_episodes_step.ts | 7 +- .../dispatcher/steps/fetch_policies_step.ts | 12 ++-- .../lib/dispatcher/steps/fetch_rules_step.ts | 8 ++- .../steps/fetch_suppressions_step.ts | 7 +- .../dispatcher/steps/record_actions_step.ts | 7 +- .../server/lib/dispatcher/tokens.ts | 10 +-- .../server/lib/dispatcher/types.ts | 12 ++-- .../notification_policy_client.test.ts | 8 +-- .../server/routes/run_dispatch_route.ts | 65 ------------------- .../server/setup/bind_dispatcher_executor.ts | 41 ++++++++++++ .../alerting_v2/server/setup/bind_routes.ts | 2 - .../alerting_v2/server/setup/bind_services.ts | 37 +---------- 25 files changed, 227 insertions(+), 203 deletions(-) delete mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/index.ts b/x-pack/platform/plugins/shared/alerting_v2/server/index.ts index 59846e4d58065..163561a00c039 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/index.ts @@ -14,6 +14,7 @@ import { bindOnStart } from './setup/bind_on_start'; import { bindRoutes } from './setup/bind_routes'; import { bindServices } from './setup/bind_services'; import { bindRuleExecutionServices } from './setup/bind_rule_executor'; +import { bindDispatcherExecutionServices } from './setup/bind_dispatcher_executor'; import { bindTasks } from './setup/bind_tasks'; export const config: PluginConfigDescriptor = { @@ -31,6 +32,7 @@ export const module = new ContainerModule((options) => { bindRoutes(options); bindServices(options); bindRuleExecutionServices(options); + bindDispatcherExecutionServices(options); bindTasks(options); }); 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 6bd5d9b6efe8d..29dc91fe0c9bd 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 @@ -20,12 +20,25 @@ 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 { DispatcherPipeline } from './execution_pipeline'; import { createAlertEpisodeSuppressionsResponse, createDispatchableAlertEventsResponse, createLastNotifiedTimestampsResponse, } from './fixtures/dispatcher'; import { getDispatchableAlertEventsQuery } from './queries'; +import { + FetchEpisodesStep, + FetchSuppressionsStep, + ApplySuppressionStep, + FetchRulesStep, + FetchPoliciesStep, + EvaluateMatchersStep, + BuildGroupsStep, + ApplyThrottlingStep, + DispatchStep, + RecordActionsStep, +} from './steps'; import type { AlertEpisode, AlertEpisodeSuppression } from './types'; const createMockRuleSoAttributes = ( @@ -71,7 +84,7 @@ const createMockNpSoService = ( attributes: { name: `Policy ${id}`, description: `Description for ${id}`, - workflow_id: 'workflow-test-id', + destinations: [{ type: 'workflow', id: 'workflow-test-id' }], createdBy: null, updatedBy: null, createdAt: '2026-01-01T00:00:00.000Z', @@ -85,6 +98,28 @@ const createMockNpSoService = ( delete: jest.fn(), }); +function buildDispatcherService(deps: { + queryService: QueryServiceContract; + storageService: StorageServiceContract; + rulesSoService: RulesSavedObjectServiceContract; + npSoService: NotificationPolicySavedObjectServiceContract; +}): DispatcherService { + const { loggerService } = createLoggerService(); + const pipeline = new DispatcherPipeline(loggerService, [ + new FetchEpisodesStep(deps.queryService), + new FetchSuppressionsStep(deps.queryService), + new ApplySuppressionStep(), + new FetchRulesStep(deps.rulesSoService), + new FetchPoliciesStep(deps.npSoService), + new EvaluateMatchersStep(), + new BuildGroupsStep(), + new ApplyThrottlingStep(deps.queryService, loggerService), + new DispatchStep(loggerService), + new RecordActionsStep(deps.storageService), + ]); + return new DispatcherService(pipeline); +} + describe('DispatcherService', () => { let dispatcherService: DispatcherService; let queryService: QueryServiceContract; @@ -97,16 +132,14 @@ describe('DispatcherService', () => { beforeEach(() => { ({ queryService, mockEsClient: queryEsClient } = createQueryService()); ({ storageService, mockEsClient: storageEsClient } = createStorageService()); - const { loggerService } = createLoggerService(); rulesSoService = createMockRulesSoService(['rule-1', 'rule-2']); npSoService = createMockNpSoService(['policy_456']); - dispatcherService = new DispatcherService( + dispatcherService = buildDispatcherService({ queryService, - loggerService, storageService, rulesSoService, - npSoService - ); + npSoService, + }); }); afterEach(() => { @@ -327,14 +360,12 @@ describe('DispatcherService', () => { 'rule-005', ]); npSoService = createMockNpSoService(['policy_456']); - const { loggerService } = createLoggerService(); - dispatcherService = new DispatcherService( + dispatcherService = buildDispatcherService({ queryService, - loggerService, storageService, rulesSoService, - npSoService - ); + npSoService, + }); // Dataset: 5 rules, 9 episodes total // rule-001: single series, ack then unack → 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 f2b69123eef1f..16801ba4207d8 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 @@ -5,53 +5,17 @@ * 2.0. */ -import { type LoggerServiceContract } from '../services/logger_service/logger_service'; -import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; -import type { QueryServiceContract } from '../services/query_service/query_service'; -import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; -import type { StorageServiceContract } from '../services/storage_service/storage_service'; -import { DispatcherPipeline } from './execution_pipeline'; -import { - ApplySuppressionStep, - ApplyThrottlingStep, - BuildGroupsStep, - DispatchStep, - EvaluateMatchersStep, - FetchEpisodesStep, - FetchPoliciesStep, - FetchRulesStep, - FetchSuppressionsStep, - RecordActionsStep, -} from './steps'; +import { inject, injectable } from 'inversify'; +import { DispatcherPipeline, type DispatcherPipelineContract } from './execution_pipeline'; import type { DispatcherExecutionParams, DispatcherExecutionResult } from './types'; export interface DispatcherServiceContract { run(params: DispatcherExecutionParams): Promise; } +@injectable() export class DispatcherService implements DispatcherServiceContract { - private readonly pipeline: DispatcherPipeline; - - constructor( - queryService: QueryServiceContract, - logger: LoggerServiceContract, - storageService: StorageServiceContract, - rulesSavedObjectService: RulesSavedObjectServiceContract, - notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract - ) { - this.pipeline = new DispatcherPipeline(logger, [ - new FetchEpisodesStep(queryService), - new FetchSuppressionsStep(queryService), - new ApplySuppressionStep(), - new FetchRulesStep(rulesSavedObjectService), - new FetchPoliciesStep(notificationPolicySavedObjectService), - new EvaluateMatchersStep(), - new BuildGroupsStep(), - new ApplyThrottlingStep(queryService, logger), - new DispatchStep(logger), - new RecordActionsStep(storageService), - ]); - } + constructor(@inject(DispatcherPipeline) private readonly pipeline: DispatcherPipelineContract) {} public async run({ previousStartedAt = new Date(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts index d801d3b143047..363f295eca5b2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts @@ -5,13 +5,18 @@ * 2.0. */ -import type { LoggerServiceContract } from '../services/logger_service/logger_service'; +import { inject, injectable, multiInject } from 'inversify'; +import { + LoggerServiceToken, + type LoggerServiceContract, +} from '../services/logger_service/logger_service'; import type { DispatcherHaltReason, DispatcherPipelineInput, DispatcherPipelineState, DispatcherStep, } from './types'; +import { DispatcherExecutionStepsToken } from './tokens'; import { withDispatcherSpan } from './with_dispatcher_span'; export interface DispatcherPipelineResult { @@ -24,10 +29,11 @@ export interface DispatcherPipelineContract { execute(input: DispatcherPipelineInput): Promise; } +@injectable() export class DispatcherPipeline implements DispatcherPipelineContract { constructor( - private readonly logger: LoggerServiceContract, - private readonly steps: DispatcherStep[] + @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, + @multiInject(DispatcherExecutionStepsToken) private readonly steps: DispatcherStep[] ) {} public async execute(input: DispatcherPipelineInput): Promise { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts index a18b7759743bf..f9d40d437a0c8 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/fixtures/test_utils.ts @@ -79,7 +79,7 @@ export function createNotificationPolicy( return { id: 'policy-1', name: 'Test policy', - workflowId: 'workflow-1', + destinations: [{ type: 'workflow' as const, id: 'workflow-1' }], groupBy: [], ...overrides, }; @@ -100,7 +100,7 @@ export function createNotificationGroup( id: 'group-1', ruleId: 'rule-1', policyId: 'policy-1', - workflowId: 'workflow-1', + destinations: [{ type: 'workflow' as const, id: 'workflow-1' }], groupKey: {}, episodes: [createAlertEpisode()], ...overrides, 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 a6d586bd81430..66d8b8778346f 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 @@ -29,6 +29,19 @@ import type { NotificationPolicySavedObjectAttributes, } from '../../../saved_objects'; import { DispatcherService, type DispatcherServiceContract } from '../dispatcher'; +import { DispatcherPipeline } from '../execution_pipeline'; +import { + FetchEpisodesStep, + FetchSuppressionsStep, + ApplySuppressionStep, + FetchRulesStep, + FetchPoliciesStep, + EvaluateMatchersStep, + BuildGroupsStep, + ApplyThrottlingStep, + DispatchStep, + RecordActionsStep, +} from '../steps'; import { waitForDataStreamsReady } from './helpers/wait'; import { setupTestServers } from './setup_test_servers'; @@ -367,13 +380,20 @@ describe('DispatcherService integration tests', () => { queryService = new QueryService(esClient, mockLoggerService); storageService = new StorageService(esClient, mockLoggerService); - dispatcherService = new DispatcherService( - queryService, - mockLoggerService, - storageService, - rulesSoService, - npSoService - ); + + const pipeline = new DispatcherPipeline(mockLoggerService, [ + new FetchEpisodesStep(queryService), + new FetchSuppressionsStep(queryService), + new ApplySuppressionStep(), + new FetchRulesStep(rulesSoService), + new FetchPoliciesStep(npSoService), + new EvaluateMatchersStep(), + new BuildGroupsStep(), + new ApplyThrottlingStep(queryService, mockLoggerService), + new DispatchStep(mockLoggerService), + new RecordActionsStep(storageService), + ]); + dispatcherService = new DispatcherService(pipeline); }); describe('when there are no alert events', () => { @@ -647,11 +667,11 @@ async function seedRulesAndPolicies( const policyAttrs: NotificationPolicySavedObjectAttributes = { name: 'Test Policy', description: 'Test notification policy', - workflow_id: 'test-workflow', + destinations: [{ type: 'workflow' as const, id: 'test-workflow' }], createdBy: null, updatedBy: null, createdAt: '2026-01-20T00:00:00.000Z', - updatedAt: null, + updatedAt: '2026-01-20T00:00:00.000Z', }; await npSoService.create({ attrs: policyAttrs, id: NOTIFICATION_POLICY_ID }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts index 4af4f7ef5195e..3852e119c2dc8 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_suppression_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { injectable } from 'inversify'; import type { AlertEpisode, AlertEpisodeSuppression, @@ -13,6 +14,7 @@ import type { DispatcherStepOutput, } from '../types'; +@injectable() export class ApplySuppressionStep implements DispatcherStep { public readonly name = 'apply_suppression'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts index 20af5d886311f..42c2dd5e2b9bb 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { inject, injectable } from 'inversify'; import type { LastNotifiedRecord, NotificationGroup, @@ -14,18 +15,23 @@ import type { DispatcherPipelineState, DispatcherStepOutput, } from '../types'; -import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; +import { + LoggerServiceToken, + type LoggerServiceContract, +} from '../../services/logger_service/logger_service'; import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { QueryServiceInternalToken } from '../../services/query_service/tokens'; import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; import { getLastNotifiedTimestampsQuery } from '../queries'; import { parseDurationToMs } from '../../duration'; +@injectable() export class ApplyThrottlingStep implements DispatcherStep { public readonly name = 'apply_throttling'; constructor( - private readonly queryService: QueryServiceContract, - private readonly logger: LoggerServiceContract + @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract, + @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract ) {} public async execute(state: Readonly): Promise { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts index af4ecee76f12a..06b1c3c73402f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.test.ts @@ -21,7 +21,10 @@ describe('BuildGroupsStep', () => { matched: [ createMatchedPair({ episode: createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }), - policy: createNotificationPolicy({ id: 'p1', workflowId: 'w1' }), + policy: createNotificationPolicy({ + id: 'p1', + destinations: [{ type: 'workflow', id: 'w1' }], + }), }), ], }); @@ -49,7 +52,10 @@ describe('BuildGroupsStep', () => { describe('buildNotificationGroups', () => { it('creates separate groups for different episodes with no groupBy', () => { - const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const policy = createNotificationPolicy({ + id: 'p1', + destinations: [{ type: 'workflow', id: 'w1' }], + }); const matched = [ createMatchedPair({ episode: createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }), @@ -67,7 +73,10 @@ describe('buildNotificationGroups', () => { }); it('groups episodes from same rule+policy+groupKey into same group', () => { - const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const policy = createNotificationPolicy({ + id: 'p1', + destinations: [{ type: 'workflow', id: 'w1' }], + }); const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); const matched = [ createMatchedPair({ episode, policy }), @@ -81,7 +90,10 @@ describe('buildNotificationGroups', () => { }); it('assigns deterministic group IDs', () => { - const policy = createNotificationPolicy({ id: 'p1', workflowId: 'w1' }); + const policy = createNotificationPolicy({ + id: 'p1', + destinations: [{ type: 'workflow', id: 'w1' }], + }); const episode = createAlertEpisode({ rule_id: 'r1', group_hash: 'h1', episode_id: 'e1' }); const groups1 = buildNotificationGroups([createMatchedPair({ episode, policy })]); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts index f4b735a334bbb..7d0111f558d9a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/build_groups_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { injectable } from 'inversify'; import objectHash from 'object-hash'; import type { MatchedPair, @@ -14,6 +15,7 @@ import type { DispatcherStepOutput, } from '../types'; +@injectable() export class BuildGroupsStep implements DispatcherStep { public readonly name = 'build_groups'; @@ -51,7 +53,7 @@ export function buildNotificationGroups(matched: readonly MatchedPair[]): Notifi id: notificationGroupId, ruleId: episode.rule_id, policyId: policy.id, - workflowId: policy.workflowId, + destinations: policy.destinations, groupKey, episodes: [], }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts index 52a6b494489ea..de5d8baab322c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.test.ts @@ -22,8 +22,14 @@ describe('DispatchStep', () => { const group1 = createNotificationGroup({ id: 'g1', policyId: 'p1' }); const group2 = createNotificationGroup({ id: 'g2', policyId: 'p2' }); - const policy1 = createNotificationPolicy({ id: 'p1', workflowId: 'workflow-1' }); - const policy2 = createNotificationPolicy({ id: 'p2', workflowId: 'workflow-2' }); + const policy1 = createNotificationPolicy({ + id: 'p1', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + const policy2 = createNotificationPolicy({ + id: 'p2', + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); const state = createDispatcherPipelineState({ dispatch: [group1, group2], diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts index 195eb15529dc6..0ac8dd438932a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/dispatch_step.ts @@ -5,13 +5,18 @@ * 2.0. */ -import type { LoggerServiceContract } from '../../services/logger_service/logger_service'; +import { inject, injectable } from 'inversify'; +import { + LoggerServiceToken, + type LoggerServiceContract, +} from '../../services/logger_service/logger_service'; import type { DispatcherPipelineState, DispatcherStep, DispatcherStepOutput } from '../types'; +@injectable() export class DispatchStep implements DispatcherStep { public readonly name = 'dispatch'; - constructor(private readonly logger: LoggerServiceContract) {} + constructor(@inject(LoggerServiceToken) private readonly logger: LoggerServiceContract) {} public async execute(state: Readonly): Promise { const { dispatch = [] } = state; @@ -19,7 +24,7 @@ export class DispatchStep implements DispatcherStep { for (const group of dispatch) { this.logger.debug({ message: () => - `Dispatching notification group ${group.id} for policy ${group.policyId} with workflow ${group.workflowId}`, + `Dispatching notification group ${group.id} for policy ${group.policyId} with ${group.destinations.length} destination(s)`, }); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts index 9af476922cee4..5b03e234dd27c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/evaluate_matchers_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { injectable } from 'inversify'; import { evaluateKql } from '@kbn/eval-kql'; import type { AlertEpisode, @@ -18,6 +19,7 @@ import type { DispatcherStepOutput, } from '../types'; +@injectable() export class EvaluateMatchersStep implements DispatcherStep { public readonly name = 'evaluate_matchers'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts index 67232a29ec210..26e7249d00bab 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_episodes_step.ts @@ -6,6 +6,7 @@ */ import moment from 'moment'; +import { inject, injectable } from 'inversify'; import type { AlertEpisode, DispatcherStep, @@ -13,14 +14,18 @@ import type { DispatcherStepOutput, } from '../types'; import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { QueryServiceInternalToken } from '../../services/query_service/tokens'; import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; import { LOOKBACK_WINDOW_MINUTES } from '../constants'; import { getDispatchableAlertEventsQuery } from '../queries'; +@injectable() export class FetchEpisodesStep implements DispatcherStep { public readonly name = 'fetch_episodes'; - constructor(private readonly queryService: QueryServiceContract) {} + constructor( + @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract + ) {} public async execute(state: Readonly): Promise { const { previousStartedAt } = state.input; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts index 3e584532f6f49..aaf7f8c689d13 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { inject, injectable } from 'inversify'; import type { NotificationPolicy, NotificationPolicyId, @@ -13,11 +14,14 @@ import type { DispatcherStepOutput, } from '../types'; import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { NotificationPolicySavedObjectServiceInternalToken } from '../tokens'; +@injectable() export class FetchPoliciesStep implements DispatcherStep { public readonly name = 'fetch_policies'; constructor( + @inject(NotificationPolicySavedObjectServiceInternalToken) private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract ) {} @@ -43,10 +47,10 @@ export class FetchPoliciesStep implements DispatcherStep { policies.set(doc.id, { id: doc.id, name: doc.attributes.name, - workflowId: doc.attributes.workflow_id, - matcher: undefined, - groupBy: [], - throttle: undefined, + destinations: doc.attributes.destinations ?? [], + matcher: doc.attributes.matcher, + groupBy: doc.attributes.group_by ?? [], + throttle: doc.attributes.throttle, }); } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts index 8834a6f4b33f5..1e895453c6b13 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts @@ -5,7 +5,9 @@ * 2.0. */ +import { inject, injectable } from 'inversify'; import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; +import { RulesSavedObjectServiceInternalToken } from '../tokens'; import type { DispatcherPipelineState, DispatcherStep, @@ -14,10 +16,14 @@ import type { RuleId, } from '../types'; +@injectable() export class FetchRulesStep implements DispatcherStep { public readonly name = 'fetch_rules'; - constructor(private readonly rulesSavedObjectService: RulesSavedObjectServiceContract) {} + constructor( + @inject(RulesSavedObjectServiceInternalToken) + private readonly rulesSavedObjectService: RulesSavedObjectServiceContract + ) {} public async execute(state: Readonly): Promise { const { dispatchable = [] } = state; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts index 9837617ef3e26..6414f5a5ec973 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_suppressions_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { inject, injectable } from 'inversify'; import type { AlertEpisodeSuppression, DispatcherStep, @@ -12,13 +13,17 @@ import type { DispatcherStepOutput, } from '../types'; import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { QueryServiceInternalToken } from '../../services/query_service/tokens'; import { queryResponseToRecords } from '../../services/query_service/query_response_to_records'; import { getAlertEpisodeSuppressionsQuery } from '../queries'; +@injectable() export class FetchSuppressionsStep implements DispatcherStep { public readonly name = 'fetch_suppressions'; - constructor(private readonly queryService: QueryServiceContract) {} + constructor( + @inject(QueryServiceInternalToken) private readonly queryService: QueryServiceContract + ) {} public async execute(state: Readonly): Promise { const { episodes } = state; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts index 871cee1651f2d..0ce27c74707d3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { inject, injectable } from 'inversify'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../../resources/alert_actions'; import type { AlertEpisode, @@ -13,11 +14,15 @@ import type { DispatcherStepOutput, } from '../types'; import type { StorageServiceContract } from '../../services/storage_service/storage_service'; +import { StorageServiceInternalToken } from '../../services/storage_service/tokens'; +@injectable() export class RecordActionsStep implements DispatcherStep { public readonly name = 'record_actions'; - constructor(private readonly storageService: StorageServiceContract) {} + constructor( + @inject(StorageServiceInternalToken) private readonly storageService: StorageServiceContract + ) {} public async execute(state: Readonly): Promise { const { suppressed = [], throttled = [], dispatch = [] } = state; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts index 5d4a369b0db50..feafa3a2e62ee 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts @@ -7,15 +7,17 @@ import type { ServiceIdentifier } from 'inversify'; import type { DispatcherService } from './dispatcher'; +import type { DispatcherStep } from './types'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; /** - * DispatcherService scoped to the current request + * Token for multi-injecting the ordered dispatcher execution steps. + * Binding order defines execution order. */ -export const DispatcherServiceScopedToken = Symbol.for( - 'alerting_v2.DispatcherServiceScoped' -) as ServiceIdentifier; +export const DispatcherExecutionStepsToken = Symbol.for( + 'alerting_v2.DispatcherExecutionSteps' +) as ServiceIdentifier; /** * DispatcherService singleton 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 f70b07b336ae3..ac04f7903f54b 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 @@ -7,9 +7,13 @@ export type RuleId = string; export type NotificationPolicyId = string; -export type WorkflowId = string; export type NotificationGroupId = string; +export interface NotificationPolicyDestination { + type: 'workflow'; + id: string; +} + export interface AlertEpisode { last_event_timestamp: string; rule_id: RuleId; @@ -63,8 +67,8 @@ export interface NotificationPolicy { throttle?: { interval?: string; // e.g. '1h', '30m', '5m' }; - /** Target workflow to dispatch matched episodes to */ - workflowId: WorkflowId; + /** Target destinations to dispatch matched episodes to */ + destinations: NotificationPolicyDestination[]; } export interface MatchedPair { @@ -76,7 +80,7 @@ export interface NotificationGroup { id: NotificationGroupId; ruleId: RuleId; policyId: NotificationPolicyId; - workflowId: WorkflowId; + destinations: NotificationPolicyDestination[]; groupKey: Record; episodes: AlertEpisode[]; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts index 63efae2dd6a38..a81e673e35895 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.test.ts @@ -215,13 +215,7 @@ describe('NotificationPolicyClient', () => { expect(res).toEqual({ id: 'policy-id-get-1', version: 'WzEsMV0=', - name: existingAttributes.name, - description: existingAttributes.description, - workflow_id: existingAttributes.workflow_id, - createdBy: existingAttributes.createdBy, - createdAt: existingAttributes.createdAt, - updatedBy: existingAttributes.updatedBy, - updatedAt: existingAttributes.updatedAt, + ...existingAttributes, }); }); 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 deleted file mode 100644 index 7411a59bbfba1..0000000000000 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/run_dispatch_route.ts +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; -import { schema, type TypeOf } from '@kbn/config-schema'; -import type { RouteHandler } from '@kbn/core-di-server'; -import { Request, Response } from '@kbn/core-di-server'; -import type { KibanaRequest, KibanaResponseFactory, RouteSecurity } from '@kbn/core-http-server'; -import { inject, injectable } from 'inversify'; -import { type DispatcherServiceContract } from '../lib/dispatcher/dispatcher'; -import { DispatcherServiceScopedToken } from '../lib/dispatcher/tokens'; - -const runDispatchBodySchema = schema.object({ - previousStartedAt: schema.maybe(schema.string({ minLength: 1 })), -}); - -type RunDispatchBody = TypeOf; - -@injectable() -export class RunDispatchRoute implements RouteHandler { - static method = 'post' as const; - static path = '/internal/alerting/v2/dispatcher/_run'; - static security: RouteSecurity = { - authz: { - enabled: false, - reason: 'This is an internal testing route', - }, - }; - static options = { access: 'internal' } as const; - static validate = { - request: { - body: runDispatchBodySchema, - }, - } as const; - - constructor( - @inject(Request) - private readonly request: KibanaRequest, - @inject(Response) private readonly response: KibanaResponseFactory, - @inject(DispatcherServiceScopedToken) - private readonly dispatcherService: DispatcherServiceContract - ) {} - - async handle() { - try { - const previousStartedAt = this.request.body.previousStartedAt - ? new Date(this.request.body.previousStartedAt) - : undefined; - - const result = await this.dispatcherService.run({ previousStartedAt }); - - return this.response.ok({ body: { startedAt: result.startedAt.toISOString() } }); - } catch (e) { - const boom = Boom.isBoom(e) ? e : Boom.boomify(e); - return this.response.customError({ - statusCode: boom.output.statusCode, - body: boom.output.payload, - }); - } - } -} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts new file mode 100644 index 0000000000000..2887eda64a902 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts @@ -0,0 +1,41 @@ +/* + * 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 type { ContainerModuleLoadOptions } from 'inversify'; +import { DispatcherPipeline } from '../lib/dispatcher/execution_pipeline'; +import { DispatcherExecutionStepsToken } from '../lib/dispatcher/tokens'; +import { + FetchEpisodesStep, + FetchSuppressionsStep, + ApplySuppressionStep, + FetchRulesStep, + FetchPoliciesStep, + EvaluateMatchersStep, + BuildGroupsStep, + ApplyThrottlingStep, + DispatchStep, + RecordActionsStep, +} from '../lib/dispatcher/steps'; + +export const bindDispatcherExecutionServices = ({ bind }: ContainerModuleLoadOptions) => { + /** + * Dispatcher execution steps via multi-injection. + * Binding order defines execution order. + */ + bind(DispatcherExecutionStepsToken).to(FetchEpisodesStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(FetchSuppressionsStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(ApplySuppressionStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(FetchRulesStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(FetchPoliciesStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(EvaluateMatchersStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(BuildGroupsStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(ApplyThrottlingStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(DispatchStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(RecordActionsStep).inSingletonScope(); + + bind(DispatcherPipeline).toSelf().inSingletonScope(); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts index 2f5aac72cfe83..b7fa405258e9f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_routes.ts @@ -12,7 +12,6 @@ import { UpdateRuleRoute } from '../routes/update_rule_route'; import { GetRulesRoute } from '../routes/get_rules_route'; import { GetRuleRoute } from '../routes/get_rule_route'; import { DeleteRuleRoute } from '../routes/delete_rule_route'; -import { RunDispatchRoute } from '../routes/run_dispatch_route'; import { CreateAlertActionRoute } from '../routes/create_alert_action_route'; import { BulkCreateAlertActionRoute } from '../routes/bulk_create_alert_action_route'; import { CreateNotificationPolicyRoute } from '../routes/notification_policies/create_notification_policy_route'; @@ -26,7 +25,6 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(GetRulesRoute); bind(Route).toConstantValue(GetRuleRoute); bind(Route).toConstantValue(DeleteRuleRoute); - bind(Route).toConstantValue(RunDispatchRoute); bind(Route).toConstantValue(CreateAlertActionRoute); bind(Route).toConstantValue(BulkCreateAlertActionRoute); bind(Route).toConstantValue(CreateNotificationPolicyRoute); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 0570462132561..5b0d52a34f2d8 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -17,7 +17,6 @@ import { TransitionStrategyToken } from '../lib/director/strategies/types'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; import { DispatcherServiceInternalToken, - DispatcherServiceScopedToken, NotificationPolicySavedObjectServiceInternalToken, RulesSavedObjectServiceInternalToken, } from '../lib/dispatcher/tokens'; @@ -135,40 +134,8 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { }) .inSingletonScope(); - bind(DispatcherServiceScopedToken) - .toDynamicValue(({ get }) => { - const queryService = get(QueryServiceScopedToken); - const loggerService = get(LoggerServiceToken); - const storageService = get(StorageServiceInternalToken); - const rulesSoService = get(RulesSavedObjectServiceInternalToken); - const npSoService = get(NotificationPolicySavedObjectServiceInternalToken); - - return new DispatcherService( - queryService, - loggerService, - storageService, - rulesSoService, - npSoService - ); - }) - .inRequestScope(); - - bind(DispatcherServiceInternalToken) - .toDynamicValue(({ get }) => { - const queryService = get(QueryServiceInternalToken); - const loggerService = get(LoggerServiceToken); - const storageService = get(StorageServiceInternalToken); - const rulesSoService = get(RulesSavedObjectServiceInternalToken); - const npSoService = get(NotificationPolicySavedObjectServiceInternalToken); - return new DispatcherService( - queryService, - loggerService, - storageService, - rulesSoService, - npSoService - ); - }) - .inSingletonScope(); + bind(DispatcherService).toSelf().inSingletonScope(); + bind(DispatcherServiceInternalToken).toService(DispatcherService); bind(DirectorService).toSelf().inSingletonScope(); bind(TransitionStrategyFactory).toSelf().inSingletonScope(); From d951a95a3bea55b9e66651de7325e2a41c07f394 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 13:49:27 -0500 Subject: [PATCH 49/54] reuse test_util --- .../server/lib/dispatcher/dispatcher.test.ts | 23 ++++--------------- 1 file changed, 5 insertions(+), 18 deletions(-) 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 29dc91fe0c9bd..f3b885d3bb5c3 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 @@ -11,6 +11,7 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; import type { RuleSavedObjectAttributes } from '../../saved_objects'; +import { createRuleSoAttributes } from '../test_utils'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import type { QueryServiceContract } from '../services/query_service/query_service'; @@ -41,23 +42,6 @@ import { } from './steps'; import type { AlertEpisode, AlertEpisodeSuppression } from './types'; -const createMockRuleSoAttributes = ( - overrides: Partial = {} -): RuleSavedObjectAttributes => ({ - kind: 'alert', - metadata: { name: 'Test rule' }, - time_field: '@timestamp', - schedule: { every: '1m' }, - evaluation: { query: { base: 'FROM logs-*' } }, - notification_policies: [{ ref: 'policy_456' }], - enabled: true, - createdBy: null, - updatedBy: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - ...overrides, -}); - const createMockRulesSoService = ( ruleIds: string[], overrides?: Partial @@ -65,7 +49,10 @@ const createMockRulesSoService = ( bulkGetByIds: jest.fn().mockResolvedValue( ruleIds.map((id) => ({ id, - attributes: createMockRuleSoAttributes(overrides), + attributes: createRuleSoAttributes({ + notification_policies: [{ ref: 'policy_456' }], + ...overrides, + }), })) ), create: jest.fn(), From aae8266dee36b35bbb23747d444e4ee0f4d97e01 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 14:00:26 -0500 Subject: [PATCH 50/54] reuse mocks --- .../steps/fetch_policies_step.test.ts | 55 ++++++++++--------- .../dispatcher/steps/fetch_rules_step.test.ts | 50 ++++++++--------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts index 99fd524b5c219..5dc4f76f4f75b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts @@ -6,31 +6,35 @@ */ import { FetchPoliciesStep } from './fetch_policies_step'; -import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import type { NotificationPolicySavedObjectService } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { createNotificationPolicySavedObjectService } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service.mock'; import { createDispatcherPipelineState, createRule } from '../fixtures/test_utils'; -const createMockNpSoService = (): jest.Mocked => ({ - bulkGetByIds: jest.fn(), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), -}); - describe('FetchPoliciesStep', () => { + let npSoService: NotificationPolicySavedObjectService; + + beforeEach(() => { + ({ notificationPolicySavedObjectService: npSoService } = + createNotificationPolicySavedObjectService()); + }); + it('fetches unique policies from rules', async () => { - const mockService = createMockNpSoService(); - mockService.bulkGetByIds.mockResolvedValue([ + jest.spyOn(npSoService, 'bulkGetByIds').mockResolvedValue([ { id: 'p1', attributes: { name: 'Policy 1', - workflow_id: 'w1', + description: 'Test', + destinations: [{ type: 'workflow' as const, id: 'w1' }], + createdBy: null, + updatedBy: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', }, }, - ] as any); + ]); - const step = new FetchPoliciesStep(mockService); + const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ rules: new Map([ ['r1', createRule({ id: 'r1', notificationPolicyIds: ['p1'] })], @@ -44,12 +48,12 @@ describe('FetchPoliciesStep', () => { if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(1); expect(result.data?.policies?.get('p1')?.name).toBe('Policy 1'); - expect(mockService.bulkGetByIds).toHaveBeenCalledWith(['p1']); + expect(npSoService.bulkGetByIds).toHaveBeenCalledWith(['p1']); }); it('returns empty map when rules is empty', async () => { - const mockService = createMockNpSoService(); - const step = new FetchPoliciesStep(mockService); + jest.spyOn(npSoService, 'bulkGetByIds'); + const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ rules: new Map() }); const result = await step.execute(state); @@ -57,12 +61,12 @@ describe('FetchPoliciesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(0); - expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + expect(npSoService.bulkGetByIds).not.toHaveBeenCalled(); }); it('returns empty map when rules have no policy IDs', async () => { - const mockService = createMockNpSoService(); - const step = new FetchPoliciesStep(mockService); + jest.spyOn(npSoService, 'bulkGetByIds'); + const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ rules: new Map([['r1', createRule({ id: 'r1', notificationPolicyIds: [] })]]), @@ -73,16 +77,15 @@ describe('FetchPoliciesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(0); - expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + expect(npSoService.bulkGetByIds).not.toHaveBeenCalled(); }); it('skips documents with errors', async () => { - const mockService = createMockNpSoService(); - mockService.bulkGetByIds.mockResolvedValue([ - { id: 'p1', error: { statusCode: 404, message: 'Not found' } }, - ] as any); + jest + .spyOn(npSoService, 'bulkGetByIds') + .mockResolvedValue([{ id: 'p1', error: { statusCode: 404, message: 'Not found' } }] as any); - const step = new FetchPoliciesStep(mockService); + const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ rules: new Map([['r1', createRule({ id: 'r1', notificationPolicyIds: ['p1'] })]]), }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts index 8431ab151cb51..ee715efd59d65 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts @@ -6,35 +6,30 @@ */ import { FetchRulesStep } from './fetch_rules_step'; -import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; +import type { RulesSavedObjectService } from '../../services/rules_saved_object_service/rules_saved_object_service'; +import { createRulesSavedObjectService } from '../../services/rules_saved_object_service/rules_saved_object_service.mock'; +import { createRuleSoAttributes } from '../../test_utils'; import { createAlertEpisode, createDispatcherPipelineState } from '../fixtures/test_utils'; -const createMockRulesSoService = (): jest.Mocked => ({ - bulkGetByIds: jest.fn(), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - find: jest.fn(), -}); - describe('FetchRulesStep', () => { + let rulesSoService: RulesSavedObjectService; + + beforeEach(() => { + ({ rulesSavedObjectService: rulesSoService } = createRulesSavedObjectService()); + }); + it('fetches rules for unique rule IDs from active episodes', async () => { - const mockService = createMockRulesSoService(); - mockService.bulkGetByIds.mockResolvedValue([ + jest.spyOn(rulesSoService, 'bulkGetByIds').mockResolvedValue([ { id: 'r1', - attributes: { + attributes: createRuleSoAttributes({ metadata: { name: 'Rule 1' }, notification_policies: [{ ref: 'p1' }], - enabled: true, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', - }, + }), }, - ] as any); + ]); - const step = new FetchRulesStep(mockService); + const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ dispatchable: [ createAlertEpisode({ rule_id: 'r1' }), @@ -48,12 +43,12 @@ describe('FetchRulesStep', () => { if (result.type !== 'continue') return; expect(result.data?.rules?.size).toBe(1); expect(result.data?.rules?.get('r1')?.name).toBe('Rule 1'); - expect(mockService.bulkGetByIds).toHaveBeenCalledWith(['r1']); + expect(rulesSoService.bulkGetByIds).toHaveBeenCalledWith(['r1']); }); it('returns empty map when no active episodes', async () => { - const mockService = createMockRulesSoService(); - const step = new FetchRulesStep(mockService); + jest.spyOn(rulesSoService, 'bulkGetByIds'); + const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ dispatchable: [] }); const result = await step.execute(state); @@ -61,16 +56,15 @@ describe('FetchRulesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.rules?.size).toBe(0); - expect(mockService.bulkGetByIds).not.toHaveBeenCalled(); + expect(rulesSoService.bulkGetByIds).not.toHaveBeenCalled(); }); it('skips documents with errors', async () => { - const mockService = createMockRulesSoService(); - mockService.bulkGetByIds.mockResolvedValue([ - { id: 'r1', error: { statusCode: 404, message: 'Not found' } }, - ] as any); + jest + .spyOn(rulesSoService, 'bulkGetByIds') + .mockResolvedValue([{ id: 'r1', error: { statusCode: 404, message: 'Not found' } }] as any); - const step = new FetchRulesStep(mockService); + const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ dispatchable: [createAlertEpisode({ rule_id: 'r1' })], }); From dd22e98e367105a677cbfdb7db3e5824267b0009 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 14:10:05 -0500 Subject: [PATCH 51/54] move tokens into their service folder --- .../lib/dispatcher/execution_pipeline.ts | 2 +- .../dispatcher/steps/fetch_policies_step.ts | 2 +- .../lib/dispatcher/steps/fetch_rules_step.ts | 2 +- .../server/lib/dispatcher/steps/tokens.ts | 17 +++++++++++++ .../server/lib/dispatcher/tokens.ts | 25 ------------------- .../tokens.ts | 16 ++++++++++++ .../rules_saved_object_service/tokens.ts | 16 ++++++++++++ .../server/setup/bind_dispatcher_executor.ts | 2 +- .../alerting_v2/server/setup/bind_services.ts | 8 +++--- 9 files changed, 56 insertions(+), 34 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/tokens.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts index 363f295eca5b2..f3ccb6e62e017 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/execution_pipeline.ts @@ -16,7 +16,7 @@ import type { DispatcherPipelineState, DispatcherStep, } from './types'; -import { DispatcherExecutionStepsToken } from './tokens'; +import { DispatcherExecutionStepsToken } from './steps/tokens'; import { withDispatcherSpan } from './with_dispatcher_span'; export interface DispatcherPipelineResult { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts index aaf7f8c689d13..1e800148fcba0 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.ts @@ -14,7 +14,7 @@ import type { DispatcherStepOutput, } from '../types'; import type { NotificationPolicySavedObjectServiceContract } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; -import { NotificationPolicySavedObjectServiceInternalToken } from '../tokens'; +import { NotificationPolicySavedObjectServiceInternalToken } from '../../services/notification_policy_saved_object_service/tokens'; @injectable() export class FetchPoliciesStep implements DispatcherStep { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts index 1e895453c6b13..afcaf0fcfbd2d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.ts @@ -7,7 +7,7 @@ import { inject, injectable } from 'inversify'; import type { RulesSavedObjectServiceContract } from '../../services/rules_saved_object_service/rules_saved_object_service'; -import { RulesSavedObjectServiceInternalToken } from '../tokens'; +import { RulesSavedObjectServiceInternalToken } from '../../services/rules_saved_object_service/tokens'; import type { DispatcherPipelineState, DispatcherStep, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/tokens.ts new file mode 100644 index 0000000000000..325390d37ed7b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/tokens.ts @@ -0,0 +1,17 @@ +/* + * 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 type { ServiceIdentifier } from 'inversify'; +import type { DispatcherStep } from '../types'; + +/** + * Token for multi-injecting the ordered dispatcher execution steps. + * Binding order defines execution order. + */ +export const DispatcherExecutionStepsToken = Symbol.for( + 'alerting_v2.DispatcherExecutionSteps' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts index feafa3a2e62ee..612d8eb9e4a88 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/tokens.ts @@ -7,17 +7,6 @@ import type { ServiceIdentifier } from 'inversify'; import type { DispatcherService } from './dispatcher'; -import type { DispatcherStep } from './types'; -import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; -import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; - -/** - * Token for multi-injecting the ordered dispatcher execution steps. - * Binding order defines execution order. - */ -export const DispatcherExecutionStepsToken = Symbol.for( - 'alerting_v2.DispatcherExecutionSteps' -) as ServiceIdentifier; /** * DispatcherService singleton @@ -25,17 +14,3 @@ export const DispatcherExecutionStepsToken = Symbol.for( export const DispatcherServiceInternalToken = Symbol.for( 'alerting_v2.DispatcherServiceInternal' ) as ServiceIdentifier; - -/** - * RulesSavedObjectService singleton (internal user, no request scope) - */ -export const RulesSavedObjectServiceInternalToken = Symbol.for( - 'alerting_v2.RulesSavedObjectServiceInternal' -) as ServiceIdentifier; - -/** - * NotificationPolicySavedObjectService singleton (internal user, no request scope) - */ -export const NotificationPolicySavedObjectServiceInternalToken = Symbol.for( - 'alerting_v2.NotificationPolicySavedObjectServiceInternal' -) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts new file mode 100644 index 0000000000000..20d4bd9614f73 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts @@ -0,0 +1,16 @@ +/* + * 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 type { ServiceIdentifier } from 'inversify'; +import type { NotificationPolicySavedObjectServiceContract } from './notification_policy_saved_object_service'; + +/** + * NotificationPolicySavedObjectService singleton (internal user, no request scope) + */ +export const NotificationPolicySavedObjectServiceInternalToken = Symbol.for( + 'alerting_v2.NotificationPolicySavedObjectServiceInternal' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts new file mode 100644 index 0000000000000..b5fa4f1ce7e7e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts @@ -0,0 +1,16 @@ +/* + * 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 type { ServiceIdentifier } from 'inversify'; +import type { RulesSavedObjectServiceContract } from './rules_saved_object_service'; + +/** + * RulesSavedObjectService singleton (internal user, no request scope) + */ +export const RulesSavedObjectServiceInternalToken = Symbol.for( + 'alerting_v2.RulesSavedObjectServiceInternal' +) as ServiceIdentifier; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts index 2887eda64a902..405961d56cc42 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts @@ -7,7 +7,7 @@ import type { ContainerModuleLoadOptions } from 'inversify'; import { DispatcherPipeline } from '../lib/dispatcher/execution_pipeline'; -import { DispatcherExecutionStepsToken } from '../lib/dispatcher/tokens'; +import { DispatcherExecutionStepsToken } from '../lib/dispatcher/steps/tokens'; import { FetchEpisodesStep, FetchSuppressionsStep, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 5b0d52a34f2d8..9fe100e6ab167 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -15,11 +15,9 @@ import { CountTimeframeStrategy } from '../lib/director/strategies/count_timefra import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { TransitionStrategyToken } from '../lib/director/strategies/types'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; -import { - DispatcherServiceInternalToken, - NotificationPolicySavedObjectServiceInternalToken, - RulesSavedObjectServiceInternalToken, -} from '../lib/dispatcher/tokens'; +import { DispatcherServiceInternalToken } from '../lib/dispatcher/tokens'; +import { NotificationPolicySavedObjectServiceInternalToken } from '../lib/services/notification_policy_saved_object_service/tokens'; +import { RulesSavedObjectServiceInternalToken } from '../lib/services/rules_saved_object_service/tokens'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; import { RulesClient } from '../lib/rules_client'; import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; From 5702779d74c932c8b9dce718ce04b5e18ba6151f Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 14:15:18 -0500 Subject: [PATCH 52/54] use correct token names --- .../notification_policy_client.ts | 4 ++-- .../server/lib/rules_client/rules_client.ts | 4 ++-- .../tokens.ts | 7 +++++++ .../services/rules_saved_object_service/tokens.ts | 7 +++++++ .../alerting_v2/server/setup/bind_services.ts | 14 ++++++++++++-- 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index dd63b1d13a7f2..1e92c10fcb579 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -16,7 +16,7 @@ import { stringifyZodError } from '@kbn/zod-helpers'; import { inject, injectable } from 'inversify'; import { type NotificationPolicySavedObjectAttributes } from '../../saved_objects'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; -import { NotificationPolicySavedObjectService } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { NotificationPolicySavedObjectServiceScopedToken } from '../services/notification_policy_saved_object_service/tokens'; import type { UserServiceContract } from '../services/user_service/user_service'; import { UserService } from '../services/user_service/user_service'; import type { CreateNotificationPolicyParams, UpdateNotificationPolicyParams } from './types'; @@ -24,7 +24,7 @@ import type { CreateNotificationPolicyParams, UpdateNotificationPolicyParams } f @injectable() export class NotificationPolicyClient { constructor( - @inject(NotificationPolicySavedObjectService) + @inject(NotificationPolicySavedObjectServiceScopedToken) private readonly notificationPolicySavedObjectService: NotificationPolicySavedObjectServiceContract, @inject(UserService) private readonly userService: UserServiceContract ) {} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts index 5d841aeb8fd69..aa25663fd14ed 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts @@ -20,7 +20,7 @@ import { inject, injectable } from 'inversify'; import { type RuleSavedObjectAttributes } from '../../saved_objects'; import { ensureRuleExecutorTaskScheduled, getRuleExecutorTaskId } from '../rule_executor/schedule'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; -import { RulesSavedObjectService } from '../services/rules_saved_object_service/rules_saved_object_service'; +import { RulesSavedObjectServiceScopedToken } from '../services/rules_saved_object_service/tokens'; import type { UserServiceContract } from '../services/user_service/user_service'; import { UserService } from '../services/user_service/user_service'; import type { @@ -44,7 +44,7 @@ export class RulesClient { constructor( @inject(Request) private readonly request: KibanaRequest, @inject(CoreStart('http')) private readonly http: HttpServiceStart, - @inject(RulesSavedObjectService) + @inject(RulesSavedObjectServiceScopedToken) private readonly rulesSavedObjectService: RulesSavedObjectServiceContract, @inject(PluginStart('taskManager')) private readonly taskManager: TaskManagerStartContract, @inject(UserService) private readonly userService: UserServiceContract diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts index 20d4bd9614f73..834c20b521404 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/notification_policy_saved_object_service/tokens.ts @@ -8,6 +8,13 @@ import type { ServiceIdentifier } from 'inversify'; import type { NotificationPolicySavedObjectServiceContract } from './notification_policy_saved_object_service'; +/** + * NotificationPolicySavedObjectService scoped to the current request + */ +export const NotificationPolicySavedObjectServiceScopedToken = Symbol.for( + 'alerting_v2.NotificationPolicySavedObjectServiceScoped' +) as ServiceIdentifier; + /** * NotificationPolicySavedObjectService singleton (internal user, no request scope) */ diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts index b5fa4f1ce7e7e..4c984cc3c0458 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/rules_saved_object_service/tokens.ts @@ -8,6 +8,13 @@ import type { ServiceIdentifier } from 'inversify'; import type { RulesSavedObjectServiceContract } from './rules_saved_object_service'; +/** + * RulesSavedObjectService scoped to the current request + */ +export const RulesSavedObjectServiceScopedToken = Symbol.for( + 'alerting_v2.RulesSavedObjectServiceScoped' +) as ServiceIdentifier; + /** * RulesSavedObjectService singleton (internal user, no request scope) */ diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 9fe100e6ab167..0add2806323db 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -16,8 +16,14 @@ import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_r import { TransitionStrategyToken } from '../lib/director/strategies/types'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; import { DispatcherServiceInternalToken } from '../lib/dispatcher/tokens'; -import { NotificationPolicySavedObjectServiceInternalToken } from '../lib/services/notification_policy_saved_object_service/tokens'; -import { RulesSavedObjectServiceInternalToken } from '../lib/services/rules_saved_object_service/tokens'; +import { + NotificationPolicySavedObjectServiceInternalToken, + NotificationPolicySavedObjectServiceScopedToken, +} from '../lib/services/notification_policy_saved_object_service/tokens'; +import { + RulesSavedObjectServiceInternalToken, + RulesSavedObjectServiceScopedToken, +} from '../lib/services/rules_saved_object_service/tokens'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; import { RulesClient } from '../lib/rules_client'; import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; @@ -79,6 +85,7 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { ); bind(RulesSavedObjectService).toSelf().inRequestScope(); + bind(RulesSavedObjectServiceScopedToken).toService(RulesSavedObjectService); bind(RulesSavedObjectServiceInternalToken) .toDynamicValue(({ get }) => { const savedObjects = get(CoreStart('savedObjects')); @@ -89,6 +96,9 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { .inSingletonScope(); bind(NotificationPolicySavedObjectService).toSelf().inRequestScope(); + bind(NotificationPolicySavedObjectServiceScopedToken).toService( + NotificationPolicySavedObjectService + ); bind(NotificationPolicySavedObjectServiceInternalToken) .toDynamicValue(({ get }) => { const savedObjects = get(CoreStart('savedObjects')); From 92e1a9dd23a74f1e7866e1498d72f29c1b7261da Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Thu, 26 Feb 2026 14:33:08 -0500 Subject: [PATCH 53/54] Remove raw function --- .../notification_policy_client.ts | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts index 1e92c10fcb579..c4b6a6d7e29e4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/notification_policy_client.ts @@ -14,6 +14,7 @@ import { import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import { stringifyZodError } from '@kbn/zod-helpers'; import { inject, injectable } from 'inversify'; +import { omit } from 'lodash'; import { type NotificationPolicySavedObjectAttributes } from '../../saved_objects'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { NotificationPolicySavedObjectServiceScopedToken } from '../services/notification_policy_saved_object_service/tokens'; @@ -111,10 +112,12 @@ export class NotificationPolicyClient { const userProfileUid = await this.getUserProfileUid(); const now = new Date().toISOString(); - const { attributes: existingAttrs } = await this.fetchRawNotificationPolicy(params.options.id); + const existingPolicy = await this.getNotificationPolicy({ + id: params.options.id, + }); const nextAttrs: NotificationPolicySavedObjectAttributes = { - ...existingAttrs, + ...omit(existingPolicy, ['id', 'version']), ...parsed.data, updatedBy: userProfileUid, updatedAt: now, @@ -143,21 +146,6 @@ export class NotificationPolicyClient { await this.notificationPolicySavedObjectService.delete({ id }); } - private async fetchRawNotificationPolicy(id: string): Promise<{ - attributes: NotificationPolicySavedObjectAttributes; - version?: string; - }> { - try { - const doc = await this.notificationPolicySavedObjectService.get(id); - return { attributes: doc.attributes, version: doc.version }; - } catch (e) { - if (SavedObjectsErrorHelpers.isNotFoundError(e)) { - throw Boom.notFound(`Notification policy with id "${id}" not found`); - } - throw e; - } - } - private async getUserProfileUid(): Promise { return this.userService.getCurrentUserProfileUid(); } From f63d6f8cf6eb30bd4fd027e47e80a3ee00625a01 Mon Sep 17 00:00:00 2001 From: Kevin Delemme Date: Fri, 27 Feb 2026 11:51:36 -0500 Subject: [PATCH 54/54] Address comments --- .../server/lib/dispatcher/dispatcher.test.ts | 78 +++++++++++-------- .../integration_tests/dispatcher.test.ts | 4 +- .../dispatcher/steps/apply_throttling_step.ts | 8 +- .../steps/fetch_policies_step.test.ts | 60 ++++++++------ .../dispatcher/steps/fetch_rules_step.test.ts | 49 ++++++++---- .../server/lib/dispatcher/steps/index.ts | 2 +- .../steps/record_actions_step.test.ts | 18 ++--- .../dispatcher/steps/record_actions_step.ts | 2 +- .../server/setup/bind_dispatcher_executor.ts | 4 +- 9 files changed, 139 insertions(+), 86 deletions(-) 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 f3b885d3bb5c3..aafaec0322a58 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 @@ -7,16 +7,19 @@ 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 type { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; import moment from 'moment'; import { ALERT_ACTIONS_DATA_STREAM, type AlertAction } from '../../resources/alert_actions'; +import { RULE_SAVED_OBJECT_TYPE, NOTIFICATION_POLICY_SAVED_OBJECT_TYPE } from '../../saved_objects'; import type { RuleSavedObjectAttributes } from '../../saved_objects'; import { createRuleSoAttributes } from '../test_utils'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import type { NotificationPolicySavedObjectServiceContract } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; +import { createNotificationPolicySavedObjectService } from '../services/notification_policy_saved_object_service/notification_policy_saved_object_service.mock'; import type { QueryServiceContract } from '../services/query_service/query_service'; import { createQueryService } from '../services/query_service/query_service.mock'; import type { RulesSavedObjectServiceContract } from '../services/rules_saved_object_service/rules_saved_object_service'; +import { createRulesSavedObjectService } from '../services/rules_saved_object_service/rules_saved_object_service.mock'; import type { StorageServiceContract } from '../services/storage_service/storage_service'; import { createStorageService } from '../services/storage_service/storage_service.mock'; import { LOOKBACK_WINDOW_MINUTES } from './constants'; @@ -38,36 +41,33 @@ import { BuildGroupsStep, ApplyThrottlingStep, DispatchStep, - RecordActionsStep, + StoreActionsStep, } from './steps'; import type { AlertEpisode, AlertEpisodeSuppression } from './types'; -const createMockRulesSoService = ( +function mockRulesBulkGet( + mockSoClient: jest.Mocked, ruleIds: string[], overrides?: Partial -): RulesSavedObjectServiceContract => ({ - bulkGetByIds: jest.fn().mockResolvedValue( - ruleIds.map((id) => ({ +) { + mockSoClient.bulkGet.mockResolvedValue({ + saved_objects: ruleIds.map((id) => ({ id, + type: RULE_SAVED_OBJECT_TYPE, attributes: createRuleSoAttributes({ notification_policies: [{ ref: 'policy_456' }], ...overrides, }), - })) - ), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), - find: jest.fn(), -}); + references: [], + })), + }); +} -const createMockNpSoService = ( - policyIds: string[] -): NotificationPolicySavedObjectServiceContract => ({ - bulkGetByIds: jest.fn().mockResolvedValue( - policyIds.map((id) => ({ +function mockNpBulkGet(mockSoClient: jest.Mocked, policyIds: string[]) { + mockSoClient.bulkGet.mockResolvedValue({ + saved_objects: policyIds.map((id) => ({ id, + type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, attributes: { name: `Policy ${id}`, description: `Description for ${id}`, @@ -77,13 +77,10 @@ const createMockNpSoService = ( createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', }, - })) - ), - create: jest.fn(), - get: jest.fn(), - update: jest.fn(), - delete: jest.fn(), -}); + references: [], + })), + }); +} function buildDispatcherService(deps: { queryService: QueryServiceContract; @@ -102,7 +99,7 @@ function buildDispatcherService(deps: { new BuildGroupsStep(), new ApplyThrottlingStep(deps.queryService, loggerService), new DispatchStep(loggerService), - new RecordActionsStep(deps.storageService), + new StoreActionsStep(deps.storageService), ]); return new DispatcherService(pipeline); } @@ -115,12 +112,23 @@ describe('DispatcherService', () => { let storageEsClient: jest.Mocked; let rulesSoService: RulesSavedObjectServiceContract; let npSoService: NotificationPolicySavedObjectServiceContract; + let rulesMockSoClient: jest.Mocked; + let npMockSoClient: jest.Mocked; beforeEach(() => { ({ queryService, mockEsClient: queryEsClient } = createQueryService()); ({ storageService, mockEsClient: storageEsClient } = createStorageService()); - rulesSoService = createMockRulesSoService(['rule-1', 'rule-2']); - npSoService = createMockNpSoService(['policy_456']); + + const rulesMock = createRulesSavedObjectService(); + rulesSoService = rulesMock.rulesSavedObjectService; + rulesMockSoClient = rulesMock.mockSavedObjectsClient; + mockRulesBulkGet(rulesMockSoClient, ['rule-1', 'rule-2']); + + const npMock = createNotificationPolicySavedObjectService(); + npSoService = npMock.notificationPolicySavedObjectService; + npMockSoClient = npMock.mockSavedObjectsClient; + mockNpBulkGet(npMockSoClient, ['policy_456']); + dispatcherService = buildDispatcherService({ queryService, storageService, @@ -339,14 +347,22 @@ describe('DispatcherService', () => { }); it('dispatches correct fire/suppress actions across 5 rules with ack, unack, snooze, and deactivate suppressions', async () => { - rulesSoService = createMockRulesSoService([ + const rulesMock = createRulesSavedObjectService(); + rulesSoService = rulesMock.rulesSavedObjectService; + rulesMockSoClient = rulesMock.mockSavedObjectsClient; + mockRulesBulkGet(rulesMockSoClient, [ 'rule-001', 'rule-002', 'rule-003', 'rule-004', 'rule-005', ]); - npSoService = createMockNpSoService(['policy_456']); + + const npMock = createNotificationPolicySavedObjectService(); + npSoService = npMock.notificationPolicySavedObjectService; + npMockSoClient = npMock.mockSavedObjectsClient; + mockNpBulkGet(npMockSoClient, ['policy_456']); + dispatcherService = buildDispatcherService({ queryService, storageService, 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 66d8b8778346f..0e1ad1f757310 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 @@ -40,7 +40,7 @@ import { BuildGroupsStep, ApplyThrottlingStep, DispatchStep, - RecordActionsStep, + StoreActionsStep, } from '../steps'; import { waitForDataStreamsReady } from './helpers/wait'; import { setupTestServers } from './setup_test_servers'; @@ -391,7 +391,7 @@ describe('DispatcherService integration tests', () => { new BuildGroupsStep(), new ApplyThrottlingStep(queryService, mockLoggerService), new DispatchStep(mockLoggerService), - new RecordActionsStep(storageService), + new StoreActionsStep(storageService), ]); dispatcherService = new DispatcherService(pipeline); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts index 42c2dd5e2b9bb..bf221835e3169 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/apply_throttling_step.ts @@ -101,6 +101,10 @@ export function applyThrottling( } function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { - const intervalMillis = parseDurationToMs(interval); - return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); + try { + const intervalMillis = parseDurationToMs(interval); + return lastNotifiedAt.getTime() + intervalMillis > now.getTime(); + } catch { + return false; + } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts index 5dc4f76f4f75b..81168add89d5d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_policies_step.test.ts @@ -5,34 +5,41 @@ * 2.0. */ +import type { SavedObjectsClientContract } from '@kbn/core/server'; import { FetchPoliciesStep } from './fetch_policies_step'; import type { NotificationPolicySavedObjectService } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service'; import { createNotificationPolicySavedObjectService } from '../../services/notification_policy_saved_object_service/notification_policy_saved_object_service.mock'; +import { NOTIFICATION_POLICY_SAVED_OBJECT_TYPE } from '../../../saved_objects'; import { createDispatcherPipelineState, createRule } from '../fixtures/test_utils'; describe('FetchPoliciesStep', () => { let npSoService: NotificationPolicySavedObjectService; + let mockSavedObjectsClient: jest.Mocked; beforeEach(() => { - ({ notificationPolicySavedObjectService: npSoService } = + ({ notificationPolicySavedObjectService: npSoService, mockSavedObjectsClient } = createNotificationPolicySavedObjectService()); }); it('fetches unique policies from rules', async () => { - jest.spyOn(npSoService, 'bulkGetByIds').mockResolvedValue([ - { - id: 'p1', - attributes: { - name: 'Policy 1', - description: 'Test', - destinations: [{ type: 'workflow' as const, id: 'w1' }], - createdBy: null, - updatedBy: null, - createdAt: '2026-01-01T00:00:00.000Z', - updatedAt: '2026-01-01T00:00:00.000Z', + mockSavedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'p1', + type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, + attributes: { + name: 'Policy 1', + description: 'Test', + destinations: [{ type: 'workflow' as const, id: 'w1' }], + createdBy: null, + updatedBy: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + references: [], }, - }, - ]); + ], + }); const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ @@ -48,11 +55,13 @@ describe('FetchPoliciesStep', () => { if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(1); expect(result.data?.policies?.get('p1')?.name).toBe('Policy 1'); - expect(npSoService.bulkGetByIds).toHaveBeenCalledWith(['p1']); + expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, id: 'p1' }], + undefined + ); }); it('returns empty map when rules is empty', async () => { - jest.spyOn(npSoService, 'bulkGetByIds'); const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ rules: new Map() }); @@ -61,11 +70,10 @@ describe('FetchPoliciesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(0); - expect(npSoService.bulkGetByIds).not.toHaveBeenCalled(); + expect(mockSavedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); it('returns empty map when rules have no policy IDs', async () => { - jest.spyOn(npSoService, 'bulkGetByIds'); const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ @@ -77,13 +85,21 @@ describe('FetchPoliciesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.policies?.size).toBe(0); - expect(npSoService.bulkGetByIds).not.toHaveBeenCalled(); + expect(mockSavedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); it('skips documents with errors', async () => { - jest - .spyOn(npSoService, 'bulkGetByIds') - .mockResolvedValue([{ id: 'p1', error: { statusCode: 404, message: 'Not found' } }] as any); + mockSavedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'p1', + type: NOTIFICATION_POLICY_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + error: { statusCode: 404, message: 'Not found', error: 'Not Found' }, + }, + ], + } as any); const step = new FetchPoliciesStep(npSoService); const state = createDispatcherPipelineState({ diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts index ee715efd59d65..24045525ccf3d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/fetch_rules_step.test.ts @@ -5,29 +5,37 @@ * 2.0. */ +import type { SavedObjectsClientContract } from '@kbn/core/server'; import { FetchRulesStep } from './fetch_rules_step'; import type { RulesSavedObjectService } from '../../services/rules_saved_object_service/rules_saved_object_service'; import { createRulesSavedObjectService } from '../../services/rules_saved_object_service/rules_saved_object_service.mock'; import { createRuleSoAttributes } from '../../test_utils'; +import { RULE_SAVED_OBJECT_TYPE } from '../../../saved_objects'; import { createAlertEpisode, createDispatcherPipelineState } from '../fixtures/test_utils'; describe('FetchRulesStep', () => { let rulesSoService: RulesSavedObjectService; + let mockSavedObjectsClient: jest.Mocked; beforeEach(() => { - ({ rulesSavedObjectService: rulesSoService } = createRulesSavedObjectService()); + ({ rulesSavedObjectService: rulesSoService, mockSavedObjectsClient } = + createRulesSavedObjectService()); }); it('fetches rules for unique rule IDs from active episodes', async () => { - jest.spyOn(rulesSoService, 'bulkGetByIds').mockResolvedValue([ - { - id: 'r1', - attributes: createRuleSoAttributes({ - metadata: { name: 'Rule 1' }, - notification_policies: [{ ref: 'p1' }], - }), - }, - ]); + mockSavedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'r1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: createRuleSoAttributes({ + metadata: { name: 'Rule 1' }, + notification_policies: [{ ref: 'p1' }], + }), + references: [], + }, + ], + }); const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ @@ -43,11 +51,12 @@ describe('FetchRulesStep', () => { if (result.type !== 'continue') return; expect(result.data?.rules?.size).toBe(1); expect(result.data?.rules?.get('r1')?.name).toBe('Rule 1'); - expect(rulesSoService.bulkGetByIds).toHaveBeenCalledWith(['r1']); + expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith([ + { type: RULE_SAVED_OBJECT_TYPE, id: 'r1' }, + ]); }); it('returns empty map when no active episodes', async () => { - jest.spyOn(rulesSoService, 'bulkGetByIds'); const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ dispatchable: [] }); @@ -56,13 +65,21 @@ describe('FetchRulesStep', () => { expect(result.type).toBe('continue'); if (result.type !== 'continue') return; expect(result.data?.rules?.size).toBe(0); - expect(rulesSoService.bulkGetByIds).not.toHaveBeenCalled(); + expect(mockSavedObjectsClient.bulkGet).not.toHaveBeenCalled(); }); it('skips documents with errors', async () => { - jest - .spyOn(rulesSoService, 'bulkGetByIds') - .mockResolvedValue([{ id: 'r1', error: { statusCode: 404, message: 'Not found' } }] as any); + mockSavedObjectsClient.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'r1', + type: RULE_SAVED_OBJECT_TYPE, + attributes: {}, + references: [], + error: { statusCode: 404, message: 'Not found', error: 'Not Found' }, + }, + ], + } as any); const step = new FetchRulesStep(rulesSoService); const state = createDispatcherPipelineState({ diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts index c5b824d077dd7..8cdc17f92531d 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/index.ts @@ -14,4 +14,4 @@ export { EvaluateMatchersStep } from './evaluate_matchers_step'; export { BuildGroupsStep } from './build_groups_step'; export { ApplyThrottlingStep } from './apply_throttling_step'; export { DispatchStep } from './dispatch_step'; -export { RecordActionsStep } from './record_actions_step'; +export { StoreActionsStep } from './record_actions_step'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts index cb11a3567f5f3..9b50c8680b613 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { RecordActionsStep } from './record_actions_step'; +import { StoreActionsStep } from './record_actions_step'; import type { StorageServiceContract } from '../../services/storage_service/storage_service'; import { ALERT_ACTIONS_DATA_STREAM } from '../../../resources/alert_actions'; import { @@ -18,7 +18,7 @@ const createMockStorageService = (): jest.Mocked => ({ bulkIndexDocs: jest.fn().mockResolvedValue(undefined), }); -describe('RecordActionsStep', () => { +describe('StoreActionsStep', () => { const mockDate = new Date('2026-01-22T08:00:00.000Z'); beforeEach(() => { @@ -33,7 +33,7 @@ describe('RecordActionsStep', () => { it('halts when suppressed, throttled, and dispatch are all empty', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const state = createDispatcherPipelineState({ suppressed: [], @@ -49,7 +49,7 @@ describe('RecordActionsStep', () => { it('halts when suppressed, throttled, and dispatch are undefined', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const state = createDispatcherPipelineState({}); @@ -61,7 +61,7 @@ describe('RecordActionsStep', () => { it('records suppressed episodes with action_type suppress', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const episode = createAlertEpisode({ rule_id: 'rule-1', @@ -98,7 +98,7 @@ describe('RecordActionsStep', () => { it('records throttled notification groups with throttle-specific reason', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const episode = createAlertEpisode({ rule_id: 'rule-1', @@ -141,7 +141,7 @@ describe('RecordActionsStep', () => { it('records dispatched episodes with fire and notified action types', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const episode = createAlertEpisode({ rule_id: 'rule-1', @@ -196,7 +196,7 @@ describe('RecordActionsStep', () => { it('handles combined suppressed, throttled, and dispatch arrays', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const suppressedEpisode = createAlertEpisode({ rule_id: 'rule-suppressed', @@ -295,7 +295,7 @@ describe('RecordActionsStep', () => { it('records multiple episodes within a single dispatch group', async () => { const mockService = createMockStorageService(); - const step = new RecordActionsStep(mockService); + const step = new StoreActionsStep(mockService); const episode1 = createAlertEpisode({ rule_id: 'rule-1', diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts index 0ce27c74707d3..0c6d7d9198faa 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/record_actions_step.ts @@ -17,7 +17,7 @@ import type { StorageServiceContract } from '../../services/storage_service/stor import { StorageServiceInternalToken } from '../../services/storage_service/tokens'; @injectable() -export class RecordActionsStep implements DispatcherStep { +export class StoreActionsStep implements DispatcherStep { public readonly name = 'record_actions'; constructor( diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts index 405961d56cc42..e0752cbdd50a9 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_dispatcher_executor.ts @@ -18,7 +18,7 @@ import { BuildGroupsStep, ApplyThrottlingStep, DispatchStep, - RecordActionsStep, + StoreActionsStep, } from '../lib/dispatcher/steps'; export const bindDispatcherExecutionServices = ({ bind }: ContainerModuleLoadOptions) => { @@ -35,7 +35,7 @@ export const bindDispatcherExecutionServices = ({ bind }: ContainerModuleLoadOpt bind(DispatcherExecutionStepsToken).to(BuildGroupsStep).inSingletonScope(); bind(DispatcherExecutionStepsToken).to(ApplyThrottlingStep).inSingletonScope(); bind(DispatcherExecutionStepsToken).to(DispatchStep).inSingletonScope(); - bind(DispatcherExecutionStepsToken).to(RecordActionsStep).inSingletonScope(); + bind(DispatcherExecutionStepsToken).to(StoreActionsStep).inSingletonScope(); bind(DispatcherPipeline).toSelf().inSingletonScope(); };