diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index cac8c62205631..df8e7c258f26a 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -109,8 +109,11 @@ "destinations.type", "enabled", "groupBy", + "groupingMode", "name", "snoozedUntil", + "throttle", + "throttle.strategy", "updatedAt", "updatedBy", "updatedByUsername" diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 9750b21ab4d53..002433d850390 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -367,6 +367,17 @@ "groupBy": { "type": "keyword" }, + "groupingMode": { + "type": "keyword" + }, + "throttle": { + "type": "object", + "properties": { + "strategy": { + "type": "keyword" + } + } + }, "name": { "fields": { "keyword": { diff --git a/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts index f2e03ec5c12a1..433b724099450 100644 --- a/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts +++ b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.test.ts @@ -35,6 +35,22 @@ describe('evaluateKql', () => { expect(evaluateKql(kql, { user: { info: { name: 'Jane Doe' } } })).toBe(false); }); + it('should correctly evaluate a quoted value with dots in nested field path', () => { + const kql = 'data.host.name: "admin-console.prod.001"'; + expect( + evaluateKql(kql, { + episode_status: 'recovering', + data: { count: 2, host: { name: 'admin-console.prod.001' } }, + }) + ).toBe(true); + expect( + evaluateKql(kql, { + episode_status: 'recovering', + data: { count: 2, host: { name: 'other-host' } }, + }) + ).toBe(false); + }); + it('should correctly evaluate a simple "is" KQL expression with boolean', () => { const kql = 'isActive: true'; expect(evaluateKql(kql, { isActive: true })).toBe(true); @@ -257,6 +273,29 @@ describe('evaluateKql', () => { expect(evaluateKql(kql, { timestamp: resolved })).toBe(true); expect(evaluateKql(kql, { timestamp: '2025-01-01T00:00:00.000Z' })).toBe(false); }); + + it('should match "now+1h" against the resolved date', () => { + const kql = 'timestamp: now+1h'; + const resolved = dateMath.parse('now+1h')!.toISOString(); + expect(evaluateKql(kql, { timestamp: resolved })).toBe(true); + expect(evaluateKql(kql, { timestamp: '2020-01-01T00:00:00.000Z' })).toBe(false); + }); + + it('should match "now/d" with rounding', () => { + const kql = 'timestamp: now/d'; + const resolved = dateMath.parse('now/d')!.toISOString(); + expect(evaluateKql(kql, { timestamp: resolved })).toBe(true); + expect(evaluateKql(kql, { timestamp: '2020-01-01T00:00:00.000Z' })).toBe(false); + }); + + it('should match datemath against any element in an array field', () => { + const kql = 'timestamps: now-1d'; + const resolved = dateMath.parse('now-1d')!.toISOString(); + expect(evaluateKql(kql, { timestamps: ['2020-01-01T00:00:00.000Z', resolved] })).toBe(true); + expect( + evaluateKql(kql, { timestamps: ['2020-01-01T00:00:00.000Z', '2021-06-01T00:00:00.000Z'] }) + ).toBe(false); + }); }); describe('range expressions with datemath', () => { @@ -293,6 +332,34 @@ describe('evaluateKql', () => { true ); }); + + it('should evaluate > with datemath on the right side', () => { + const kql = 'timestamp > now-7d'; + // now-7d is 2025-01-08T12:00:00Z + expect(evaluateKql(kql, { timestamp: '2025-01-09T00:00:00.000Z' })).toBe(true); + expect(evaluateKql(kql, { timestamp: dateMath.parse('now-7d')!.toISOString() })).toBe( + false + ); + }); + + it('should evaluate <= with datemath on the right side', () => { + const kql = 'timestamp <= now'; + const nowIso = dateMath.parse('now')!.toISOString(); + expect(evaluateKql(kql, { timestamp: nowIso })).toBe(true); + expect(evaluateKql(kql, { timestamp: '2025-01-14T00:00:00.000Z' })).toBe(true); + expect(evaluateKql(kql, { timestamp: '2025-01-16T00:00:00.000Z' })).toBe(false); + }); + + it('should match range with datemath against any element in an array field', () => { + const kql = 'timestamps >= now-7d'; + // now-7d is 2025-01-08T12:00:00Z + expect( + evaluateKql(kql, { timestamps: ['2025-01-01T00:00:00.000Z', '2025-01-10T00:00:00.000Z'] }) + ).toBe(true); + expect( + evaluateKql(kql, { timestamps: ['2025-01-01T00:00:00.000Z', '2025-01-02T00:00:00.000Z'] }) + ).toBe(false); + }); }); describe('fallback for non-datemath strings', () => { diff --git a/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts index c979f3f28a347..f41362224951d 100644 --- a/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts +++ b/src/platform/packages/shared/kbn-eval-kql/src/eval_kql.ts @@ -174,6 +174,10 @@ function readContextPath( return { pathExists: true, value: result }; } +function isDateMathExpression(value: string): boolean { + return value.startsWith('now') || value.includes('||'); +} + function convertLiteralToValue( node: KqlLiteralNode, expectedType: 'string' | 'number' | 'boolean' @@ -181,10 +185,11 @@ function convertLiteralToValue( switch (expectedType) { case 'string': { const strValue = String(node.value); - const parsed = dateMath.parse(strValue); - if (parsed?.isValid()) { - // it's a date math expression, return the resolved ISO string - return parsed.toISOString(); + if (isDateMathExpression(strValue)) { + const parsed = dateMath.parse(strValue); + if (parsed?.isValid()) { + return parsed.toISOString(); + } } return strValue; diff --git a/src/platform/plugins/shared/kql/public/autocomplete/providers/value_suggestion_provider.ts b/src/platform/plugins/shared/kql/public/autocomplete/providers/value_suggestion_provider.ts index 24d524cbca4c7..75b8808c932e6 100644 --- a/src/platform/plugins/shared/kql/public/autocomplete/providers/value_suggestion_provider.ts +++ b/src/platform/plugins/shared/kql/public/autocomplete/providers/value_suggestion_provider.ts @@ -27,7 +27,7 @@ interface ValueSuggestionsGetFnArgs { boolFilter?: any[]; signal?: AbortSignal; method?: ValueSuggestionsMethod; - querySuggestionKey?: 'rules' | 'cases' | 'alerts' | 'endpoints'; + querySuggestionKey?: 'rules' | 'cases' | 'alerts' | 'endpoints' | 'notification_policies'; } const getAutocompleteTimefilter = ({ timefilter }: TimefilterSetup, indexPattern: DataView) => { diff --git a/src/platform/plugins/shared/kql/public/components/query_string_input/query_string_input.tsx b/src/platform/plugins/shared/kql/public/components/query_string_input/query_string_input.tsx index f15d7e6026e71..596541c5f8ebc 100644 --- a/src/platform/plugins/shared/kql/public/components/query_string_input/query_string_input.tsx +++ b/src/platform/plugins/shared/kql/public/components/query_string_input/query_string_input.tsx @@ -151,6 +151,11 @@ export interface QueryStringInputProps { * Add additional filters used for suggestions */ filtersForSuggestions?: Filter[]; + + /** + * Debounce delay in ms for fetching suggestions. Defaults to 100. + */ + suggestionsDebounceMs?: number; } interface State { @@ -333,7 +338,7 @@ export class QueryStringInput extends PureComponent { if (this.props.onSubmit) { diff --git a/src/platform/plugins/shared/kql/public/components/typeahead/suggestions_component.tsx b/src/platform/plugins/shared/kql/public/components/typeahead/suggestions_component.tsx index c777a021ed371..58a3c45dce1ca 100644 --- a/src/platform/plugins/shared/kql/public/components/typeahead/suggestions_component.tsx +++ b/src/platform/plugins/shared/kql/public/components/typeahead/suggestions_component.tsx @@ -39,7 +39,7 @@ interface SuggestionsComponentProps { } export interface SuggestionsAbstraction { - type: 'alerts' | 'rules' | 'cases' | 'endpoints'; + type: 'alerts' | 'rules' | 'cases' | 'endpoints' | 'notification_policies'; fields: Record< string, { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts index cd70a3aff7918..9bf6b44f15788 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-constants/index.ts @@ -9,3 +9,6 @@ export const ALERTING_V2_RULE_API_PATH = '/api/alerting/v2/rules' as const; export const ALERTING_V2_ALERT_API_PATH = '/api/alerting/v2/alerts' as const; export const ALERTING_V2_NOTIFICATION_POLICY_API_PATH = '/api/alerting/v2/notification_policies' as const; +// KQL private API path for data fields suggestions +export const INTERNAL_ALERTING_V2_SUGGESTIONS_API_PATH = + '/internal/notification_policies/suggestions/values' as const; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/matcher_context.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/matcher_context.ts index 69ebea53f21bc..48b42cb77a1f7 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/matcher_context.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/matcher_context.ts @@ -21,11 +21,12 @@ export interface MatcherContext { episode_id: string; episode_status: 'inactive' | 'pending' | 'active' | 'recovering'; rule: MatcherContextRule; + data?: Record; } export interface MatcherContextFieldDescriptor { path: string; - type: 'string' | 'boolean' | 'string[]'; + type: 'string' | 'boolean' | 'string[]' | 'object'; } export const MATCHER_CONTEXT_FIELDS: MatcherContextFieldDescriptor[] = [ @@ -40,4 +41,5 @@ export const MATCHER_CONTEXT_FIELDS: MatcherContextFieldDescriptor[] = [ { path: 'rule.enabled', type: 'boolean' }, { path: 'rule.createdAt', type: 'string' }, { path: 'rule.updatedAt', type: 'string' }, + { path: 'data', type: 'object' }, ]; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.test.ts index f433a8a98b140..78ae06b8b5d05 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.test.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.test.ts @@ -5,7 +5,351 @@ * 2.0. */ -import { bulkActionNotificationPoliciesBodySchema } from './notification_policy_data_schema'; +import { + bulkActionNotificationPoliciesBodySchema, + createNotificationPolicyDataSchema, + updateNotificationPolicyDataSchema, +} from './notification_policy_data_schema'; + +const DESTINATIONS = [{ type: 'workflow' as const, id: 'wf-1' }]; + +describe('createNotificationPolicyDataSchema', () => { + const base = { name: 'Test', description: 'Desc', destinations: DESTINATIONS }; + + describe('valid payloads', () => { + it('accepts minimal payload (defaults to per_episode, no throttle)', () => { + const result = createNotificationPolicyDataSchema.parse(base); + + expect(result.groupingMode).toBeUndefined(); + expect(result.throttle).toBeUndefined(); + }); + + it('accepts per_episode + on_status_change', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, + }); + + expect(result.groupingMode).toBe('per_episode'); + expect(result.throttle?.strategy).toBe('on_status_change'); + }); + + it('accepts per_episode + per_status_interval with interval', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_episode', + throttle: { strategy: 'per_status_interval', interval: '5m' }, + }); + + expect(result.throttle).toEqual({ strategy: 'per_status_interval', interval: '5m' }); + }); + + it('accepts per_episode + every_time', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_episode', + throttle: { strategy: 'every_time' }, + }); + + expect(result.throttle?.strategy).toBe('every_time'); + }); + + it('accepts per_field + time_interval with interval', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval', interval: '10m' }, + }); + + expect(result.groupingMode).toBe('per_field'); + expect(result.throttle).toEqual({ strategy: 'time_interval', interval: '10m' }); + }); + + it('accepts per_field + every_time', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'every_time' }, + }); + + expect(result.throttle?.strategy).toBe('every_time'); + }); + + it('accepts all + time_interval with interval', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '1h' }, + }); + + expect(result.groupingMode).toBe('all'); + }); + + it('accepts all + every_time', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'all', + throttle: { strategy: 'every_time' }, + }); + + expect(result.throttle?.strategy).toBe('every_time'); + }); + + it('accepts empty throttle object (no strategy)', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + throttle: {}, + }); + + expect(result.throttle).toEqual({}); + }); + + it('accepts no groupingMode with per_episode-compatible strategy', () => { + const result = createNotificationPolicyDataSchema.parse({ + ...base, + throttle: { strategy: 'on_status_change' }, + }); + + expect(result.groupingMode).toBeUndefined(); + expect(result.throttle?.strategy).toBe('on_status_change'); + }); + }); + + describe('invalid payloads', () => { + it('rejects per_episode + time_interval', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_episode', + throttle: { strategy: 'time_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects per_field + on_status_change', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_field', + throttle: { strategy: 'on_status_change' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects per_field + per_status_interval', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_field', + throttle: { strategy: 'per_status_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects all + on_status_change', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'all', + throttle: { strategy: 'on_status_change' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects all + per_status_interval', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'all', + throttle: { strategy: 'per_status_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects per_status_interval without interval', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'per_episode', + throttle: { strategy: 'per_status_interval' }, + }) + ).toThrow('requires an interval'); + }); + + it('rejects time_interval without interval', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + groupingMode: 'all', + throttle: { strategy: 'time_interval' }, + }) + ).toThrow('requires an interval'); + }); + + it('rejects omitted groupingMode with time_interval (defaults to per_episode)', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + throttle: { strategy: 'time_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects empty destinations', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + ...base, + destinations: [], + }) + ).toThrow(); + }); + + it('rejects missing name', () => { + expect(() => + createNotificationPolicyDataSchema.parse({ + description: 'Desc', + destinations: DESTINATIONS, + }) + ).toThrow(); + }); + }); +}); + +describe('updateNotificationPolicyDataSchema', () => { + describe('valid payloads', () => { + it('accepts an empty partial update', () => { + const result = updateNotificationPolicyDataSchema.parse({}); + + expect(result).toEqual({}); + }); + + it('accepts updating only name', () => { + const result = updateNotificationPolicyDataSchema.parse({ name: 'New name' }); + + expect(result.name).toBe('New name'); + }); + + it('accepts compatible groupingMode and throttle together', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + expect(result.groupingMode).toBe('all'); + expect(result.throttle).toEqual({ strategy: 'time_interval', interval: '5m' }); + }); + + it('accepts throttle without groupingMode (skips validation)', () => { + const result = updateNotificationPolicyDataSchema.parse({ + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + expect(result.throttle).toEqual({ strategy: 'time_interval', interval: '5m' }); + }); + + it('accepts groupingMode without throttle (skips validation)', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: 'per_field', + }); + + expect(result.groupingMode).toBe('per_field'); + }); + + it('accepts setting throttle to null (clear throttle)', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: 'per_episode', + throttle: null, + }); + + expect(result.throttle).toBeNull(); + }); + + it('accepts setting groupingMode to null with throttle absent (skips validation)', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: null, + }); + + expect(result.groupingMode).toBeNull(); + }); + + it('accepts setting both groupingMode and throttle to null', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: null, + throttle: null, + }); + + expect(result.groupingMode).toBeNull(); + expect(result.throttle).toBeNull(); + }); + + it('accepts setting matcher to null', () => { + const result = updateNotificationPolicyDataSchema.parse({ + matcher: null, + }); + + expect(result.matcher).toBeNull(); + }); + + it('accepts setting groupBy to null', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupBy: null, + }); + + expect(result.groupBy).toBeNull(); + }); + + it('accepts groupingMode null with per_episode-compatible strategy (defaults to per_episode)', () => { + const result = updateNotificationPolicyDataSchema.parse({ + groupingMode: null, + throttle: { strategy: 'on_status_change' }, + }); + + expect(result.groupingMode).toBeNull(); + expect(result.throttle?.strategy).toBe('on_status_change'); + }); + }); + + describe('invalid payloads', () => { + it('rejects incompatible groupingMode and throttle strategy', () => { + expect(() => + updateNotificationPolicyDataSchema.parse({ + groupingMode: 'per_episode', + throttle: { strategy: 'time_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects groupingMode null with aggregate-only strategy (null defaults to per_episode)', () => { + expect(() => + updateNotificationPolicyDataSchema.parse({ + groupingMode: null, + throttle: { strategy: 'time_interval', interval: '5m' }, + }) + ).toThrow('not valid for grouping mode'); + }); + + it('rejects strategy requiring interval when interval is missing', () => { + expect(() => + updateNotificationPolicyDataSchema.parse({ + groupingMode: 'all', + throttle: { strategy: 'time_interval' }, + }) + ).toThrow('requires an interval'); + }); + + it('rejects per_field + on_status_change', () => { + expect(() => + updateNotificationPolicyDataSchema.parse({ + groupingMode: 'per_field', + throttle: { strategy: 'on_status_change' }, + }) + ).toThrow('not valid for grouping mode'); + }); + }); +}); describe('bulkActionNotificationPoliciesBodySchema', () => { it('accepts a delete action', () => { diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.ts index a58c84ed75944..f6dda86d55a23 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_data_schema.ts @@ -17,10 +17,68 @@ export const notificationPolicyDestinationSchema = z.discriminatedUnion('type', workflowNotificationPolicyDestinationSchema, ]); +export const groupingModeSchema = z.enum(['per_episode', 'all', 'per_field']); + +export type GroupingMode = z.infer; + +export const throttleStrategySchema = z.enum([ + 'on_status_change', + 'per_status_interval', + 'time_interval', + 'every_time', +]); + +export type ThrottleStrategy = z.infer; + +const throttleSchema = z.object({ + strategy: throttleStrategySchema.optional(), + interval: durationSchema.optional(), +}); + +const PER_EPISODE_STRATEGIES = new Set([ + 'on_status_change', + 'per_status_interval', + 'every_time', +]); +const AGGREGATE_STRATEGIES = new Set(['time_interval', 'every_time']); +const STRATEGIES_REQUIRING_INTERVAL = new Set(['per_status_interval', 'time_interval']); + +const validateGroupingModeAndStrategy = (payload: { + value: { + groupingMode?: string | null; + throttle?: { strategy?: string; interval?: string } | null; + }; + issues: z.core.$ZodRawIssue[]; +}) => { + const { value: data, issues } = payload; + const mode = data.groupingMode ?? 'per_episode'; + const strategy = data.throttle?.strategy; + if (!strategy) return; + + const allowed = mode === 'per_episode' ? PER_EPISODE_STRATEGIES : AGGREGATE_STRATEGIES; + if (!allowed.has(strategy)) { + issues.push({ + code: 'custom', + message: `Strategy "${strategy}" is not valid for grouping mode "${mode}"`, + path: ['throttle', 'strategy'], + input: data, + }); + } + + if (STRATEGIES_REQUIRING_INTERVAL.has(strategy) && !data.throttle?.interval) { + issues.push({ + code: 'custom', + message: `Strategy "${strategy}" requires an interval to be defined`, + path: ['throttle', 'interval'], + input: data, + }); + } +}; + export type NotificationPolicyDestination = z.infer; export const snoozeNotificationPolicyBodySchema = z.object({ - snoozedUntil: z.string().datetime(), + snoozedUntil: z.iso.datetime(), }); export type SnoozeNotificationPolicyBody = z.infer; @@ -38,7 +96,7 @@ const bulkDisableActionSchema = z.object({ const bulkSnoozeActionSchema = z.object({ id: z.string(), action: z.literal('snooze'), - snoozedUntil: z.string().datetime(), + snoozedUntil: z.iso.datetime(), }); const bulkUnsnoozeActionSchema = z.object({ @@ -75,30 +133,40 @@ export type BulkActionNotificationPoliciesBody = z.infer< typeof bulkActionNotificationPoliciesBodySchema >; -export const createNotificationPolicyDataSchema = z.object({ - name: z.string(), - description: z.string(), - destinations: z - .array(notificationPolicyDestinationSchema) - .min(1, 'At least one destination must be provided'), - matcher: z.string().optional(), - groupBy: z.array(z.string()).optional(), - throttle: z.object({ interval: durationSchema }).optional(), -}); +export const createNotificationPolicyDataSchema = z + .object({ + name: z.string(), + description: z.string(), + destinations: z + .array(notificationPolicyDestinationSchema) + .min(1, 'At least one destination must be provided'), + matcher: z.string().optional(), + groupBy: z.array(z.string()).optional(), + groupingMode: groupingModeSchema.optional(), + throttle: throttleSchema.optional(), + }) + .check(validateGroupingModeAndStrategy); export type CreateNotificationPolicyData = z.infer; -export const updateNotificationPolicyDataSchema = z.object({ - name: z.string().optional(), - description: z.string().optional(), - destinations: z - .array(notificationPolicyDestinationSchema) - .min(1, 'At least one destination must be provided') - .optional(), - matcher: z.string().optional().nullable(), - groupBy: z.array(z.string()).optional().nullable(), - throttle: z.object({ interval: durationSchema }).optional().nullable(), -}); +export const updateNotificationPolicyDataSchema = z + .object({ + name: z.string().optional(), + description: z.string().optional(), + destinations: z + .array(notificationPolicyDestinationSchema) + .min(1, 'At least one destination must be provided') + .optional(), + matcher: z.string().optional().nullable(), + groupBy: z.array(z.string()).optional().nullable(), + groupingMode: groupingModeSchema.optional().nullable(), + throttle: throttleSchema.optional().nullable(), + }) + .check((payload) => { + if (payload.value.throttle === null || payload.value.throttle === undefined) return; + if (payload.value.groupingMode === undefined) return; + validateGroupingModeAndStrategy(payload); + }); export type UpdateNotificationPolicyData = z.infer; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_response.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_response.ts index 1b6cb54d34d46..74795a79c0aa3 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_response.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/notification_policy_response.ts @@ -5,7 +5,11 @@ * 2.0. */ -import type { NotificationPolicyDestination } from './notification_policy_data_schema'; +import type { + GroupingMode, + NotificationPolicyDestination, + ThrottleStrategy, +} from './notification_policy_data_schema'; export interface NotificationPolicyResponse { id: string; @@ -16,7 +20,8 @@ export interface NotificationPolicyResponse { destinations: NotificationPolicyDestination[]; matcher: string | null; groupBy: string[] | null; - throttle: { interval: string } | null; + groupingMode: GroupingMode | null; + throttle: { strategy?: ThrottleStrategy; interval?: string } | null; snoozedUntil: string | null; auth: { owner: string; diff --git a/x-pack/platform/plugins/shared/alerting_v2/moon.yml b/x-pack/platform/plugins/shared/alerting_v2/moon.yml index fc29d96a0b0ad..f064ddac00cbf 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/moon.yml +++ b/x-pack/platform/plugins/shared/alerting_v2/moon.yml @@ -80,6 +80,7 @@ dependsOn: - '@kbn/core-security-server-mocks' - '@kbn/kql' - '@kbn/scout' + - '@kbn/object-utils' - '@kbn/alerting-v2-constants' tags: - plugin diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/dispatch_section.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/dispatch_section.tsx new file mode 100644 index 0000000000000..2aa5bb4f0813c --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/dispatch_section.tsx @@ -0,0 +1,199 @@ +/* + * 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 { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiSelect } from '@elastic/eui'; +import type { GroupingMode, ThrottleStrategy } from '@kbn/alerting-v2-schemas'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useMemo } from 'react'; +import { Controller, useFormContext, useWatch } from 'react-hook-form'; +import { useFetchDataFields } from '../../../../hooks/use_fetch_data_fields'; +import { + AGGREGATE_STRATEGY_OPTIONS, + DEFAULT_STRATEGY_FOR_MODE, + DEFAULT_THROTTLE_INTERVAL, + GROUPING_MODE_HELP_TEXT, + GROUPING_MODE_OPTIONS, + PER_EPISODE_STRATEGY_OPTIONS, + STRATEGY_HELP_TEXT, + THROTTLE_INTERVAL_PATTERN, +} from '../constants'; +import { needsInterval } from '../form_utils'; +import type { NotificationPolicyFormState } from '../types'; +import { DurationInput } from './duration_input/duration_input'; + +export const DispatchSection: React.FC = () => { + const { control, setValue, getValues } = useFormContext(); + const groupingMode = useWatch({ control, name: 'groupingMode' }); + const throttleStrategy = useWatch({ control, name: 'throttleStrategy' }); + const { data: dataFieldNames } = useFetchDataFields(); + + useEffect(() => { + if (needsInterval(getValues('throttleStrategy')) && !getValues('throttleInterval')) { + setValue('throttleInterval', DEFAULT_THROTTLE_INTERVAL); + } + }, [getValues, setValue]); + + const groupByOptions = useMemo( + () => (dataFieldNames ?? []).map((name) => ({ label: name })), + [dataFieldNames] + ); + + const showInterval = needsInterval(throttleStrategy); + + const strategyOptions = + groupingMode === 'per_episode' ? PER_EPISODE_STRATEGY_OPTIONS : AGGREGATE_STRATEGY_OPTIONS; + + return ( + <> + ( + + { + const mode = id as GroupingMode; + field.onChange(mode); + setValue('throttleStrategy', DEFAULT_STRATEGY_FOR_MODE[mode]); + setValue( + 'throttleInterval', + needsInterval(DEFAULT_STRATEGY_FOR_MODE[mode]) ? DEFAULT_THROTTLE_INTERVAL : '' + ); + }} + isFullWidth + type="single" + data-test-subj="groupingModeToggle" + /> + + )} + /> + + {groupingMode === 'per_field' && ( + { + if (!val || val.length === 0) { + return i18n.translate('xpack.alertingV2.notificationPolicy.form.groupBy.required', { + defaultMessage: 'At least one group-by field is required.', + }); + } + return true; + }, + }} + render={({ field, fieldState: { error } }) => ( + + ({ label: g }))} + options={groupByOptions} + onCreateOption={(val) => { + field.onChange([...field.value, val]); + }} + onChange={(options) => { + field.onChange(options.map((o) => o.label)); + }} + /> + + )} + /> + )} + + ( + + { + field.onChange(e); + const strategy = e.target.value as ThrottleStrategy; + if (needsInterval(strategy) && !getValues('throttleInterval')) { + setValue('throttleInterval', DEFAULT_THROTTLE_INTERVAL); + } + }} + data-test-subj="strategySelect" + /> + + )} + /> + + {showInterval && ( + { + if (!val || !THROTTLE_INTERVAL_PATTERN.test(val)) { + return i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.throttleInterval.required', + { defaultMessage: 'Repeat interval is required.' } + ); + } + return true; + }, + }} + render={({ field, fieldState: { error } }) => ( + + + + )} + /> + )} + + ); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.stories.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.stories.tsx new file mode 100644 index 0000000000000..494ff49e73d60 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.stories.tsx @@ -0,0 +1,53 @@ +/* + * 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 React, { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { DurationInput } from './duration_input'; + +interface DurationInputStoryProps { + initialValue: string; + isInvalid?: boolean; +} + +const DurationInputStory = ({ initialValue, isInvalid }: DurationInputStoryProps) => { + const [value, setValue] = useState(initialValue); + + return ; +}; + +const meta: Meta = { + title: 'Alerting V2/Notification Policy/Form/Duration Input', + component: DurationInputStory, + parameters: { + layout: 'padded', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Empty: Story = { + args: { initialValue: '' }, +}; + +export const FiveMinutes: Story = { + args: { initialValue: '5m' }, +}; + +export const OneHour: Story = { + args: { initialValue: '1h' }, +}; + +export const ThirtySeconds: Story = { + args: { initialValue: '30s' }, +}; + +export const Invalid: Story = { + args: { initialValue: '5m', isInvalid: true }, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.test.tsx new file mode 100644 index 0000000000000..042c59087037e --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.test.tsx @@ -0,0 +1,109 @@ +/* + * 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 React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DurationInput } from './duration_input'; + +describe('DurationInput', () => { + it('renders with parsed value and unit from string prop', () => { + render(); + + const numberInput = screen.getByTestId('durationValueInput'); + const unitSelect = screen.getByTestId('durationUnitSelect'); + + expect(numberInput).toHaveValue(5); + expect(unitSelect).toHaveValue('m'); + }); + + it('renders empty number with default unit when value is empty', () => { + render(); + + const numberInput = screen.getByTestId('durationValueInput'); + const unitSelect = screen.getByTestId('durationUnitSelect'); + + expect(numberInput).toHaveValue(null); + expect(unitSelect).toHaveValue('m'); + }); + + it('calls onChange with combined string when number changes', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + const numberInput = screen.getByTestId('durationValueInput'); + await user.clear(numberInput); + await user.type(numberInput, '10'); + + expect(onChange).toHaveBeenCalledWith('10m'); + }); + + it('calls onChange with combined string when unit changes', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + await user.selectOptions(screen.getByTestId('durationUnitSelect'), 'h'); + + expect(onChange).toHaveBeenCalledWith('5h'); + }); + + it('calls onChange with empty string when number is cleared', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + const numberInput = screen.getByTestId('durationValueInput'); + await user.clear(numberInput); + + expect(onChange).toHaveBeenCalledWith(''); + }); + + it('passes isInvalid to EuiFieldNumber', () => { + render(); + + const numberInput = screen.getByTestId('durationValueInput'); + expect(numberInput).toHaveAttribute('aria-invalid', 'true'); + }); + + it('parses 1d correctly', () => { + render(); + + expect(screen.getByTestId('durationValueInput')).toHaveValue(1); + expect(screen.getByTestId('durationUnitSelect')).toHaveValue('d'); + }); + + it('parses 30s correctly', () => { + render(); + + expect(screen.getByTestId('durationValueInput')).toHaveValue(30); + expect(screen.getByTestId('durationUnitSelect')).toHaveValue('s'); + }); + + it('syncs internal state when value prop changes externally', () => { + const { rerender } = render(); + + expect(screen.getByTestId('durationValueInput')).toHaveValue(5); + expect(screen.getByTestId('durationUnitSelect')).toHaveValue('m'); + + rerender(); + + expect(screen.getByTestId('durationValueInput')).toHaveValue(null); + expect(screen.getByTestId('durationUnitSelect')).toHaveValue('m'); + }); + + it('syncs when value prop changes to a different duration', () => { + const { rerender } = render(); + + rerender(); + + expect(screen.getByTestId('durationValueInput')).toHaveValue(10); + expect(screen.getByTestId('durationUnitSelect')).toHaveValue('h'); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.tsx new file mode 100644 index 0000000000000..13bd15e5ee886 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/duration_input/duration_input.tsx @@ -0,0 +1,128 @@ +/* + * 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 { EuiFieldNumber, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useState } from 'react'; + +type DurationUnit = 's' | 'm' | 'h' | 'd'; + +const DURATION_UNIT_OPTIONS: Array<{ value: DurationUnit; text: string }> = [ + { + value: 's', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.durationInput.seconds', { + defaultMessage: 'second(s)', + }), + }, + { + value: 'm', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.durationInput.minutes', { + defaultMessage: 'minute(s)', + }), + }, + { + value: 'h', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.durationInput.hours', { + defaultMessage: 'hour(s)', + }), + }, + { + value: 'd', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.durationInput.days', { + defaultMessage: 'day(s)', + }), + }, +]; + +const VALID_UNITS: ReadonlySet = new Set(['s', 'm', 'h', 'd']); + +const isDurationUnit = (c: string): c is DurationUnit => VALID_UNITS.has(c as DurationUnit); + +const parseDuration = (raw: string): { value: number | ''; unit: DurationUnit } => { + if (!raw) return { value: '', unit: 'm' }; + + const lastChar = raw.charAt(raw.length - 1); + const unit = isDurationUnit(lastChar) ? lastChar : 'm'; + const parsed = parseInt(raw, 10); + + return { value: Number.isNaN(parsed) ? '' : parsed, unit }; +}; + +interface DurationInputProps { + value: string; + onChange: (value: string) => void; + isInvalid?: boolean; + 'data-test-subj'?: string; +} + +export function DurationInput({ + value, + onChange, + isInvalid, + 'data-test-subj': dataTestSubj, +}: DurationInputProps) { + const [durationValue, setDurationValue] = useState(() => parseDuration(value).value); + const [durationUnit, setDurationUnit] = useState(() => parseDuration(value).unit); + + useEffect(() => { + const parsed = parseDuration(value); + setDurationValue(parsed.value); + setDurationUnit(parsed.unit); + }, [value]); + + const handleValueChange = (e: React.ChangeEvent) => { + const raw = e.target.value; + if (raw === '') { + setDurationValue(''); + onChange(''); + return; + } + + const num = parseInt(raw, 10); + if (!Number.isNaN(num)) { + setDurationValue(num); + if (num > 0) { + onChange(`${num}${durationUnit}`); + } + } + }; + + const handleUnitChange = (e: React.ChangeEvent) => { + const newUnit = e.target.value as DurationUnit; + setDurationUnit(newUnit); + if (typeof durationValue === 'number' && durationValue > 0) { + onChange(`${durationValue}${newUnit}`); + } + }; + + return ( + + } + min={1} + value={durationValue} + onChange={handleValueChange} + fullWidth + isInvalid={isInvalid} + data-test-subj={dataTestSubj ?? 'durationValueInput'} + /> + ); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/matcher_input/matcher_kql_input.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/matcher_input/matcher_kql_input.tsx index 1fbc77155c976..4eee01df6dbb4 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/matcher_input/matcher_kql_input.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/matcher_input/matcher_kql_input.tsx @@ -8,7 +8,7 @@ import React, { useCallback, useMemo } from 'react'; import type { DataView } from '@kbn/data-views-plugin/common'; import type { Query } from '@kbn/es-query'; -import type { KqlPluginStart } from '@kbn/kql/public'; +import type { KqlPluginStart, SuggestionsAbstraction } from '@kbn/kql/public'; import { useService } from '@kbn/core-di-browser'; import { PluginStart } from '@kbn/core-di'; import { MATCHER_CONTEXT_FIELDS } from '@kbn/alerting-v2-schemas'; @@ -19,21 +19,13 @@ interface MatcherInputProps { fullWidth?: boolean; placeholder?: string; 'data-test-subj'?: string; + dataFieldNames?: string[]; } -const syntheticDataView = [ - { - title: '', - fieldFormatMap: {}, - fields: MATCHER_CONTEXT_FIELDS.map((f) => ({ - name: f.path, - type: f.type === 'boolean' ? 'boolean' : 'string', - esTypes: [f.type === 'boolean' ? 'boolean' : 'keyword'], - searchable: true, - aggregatable: true, - })), - }, -] as unknown as DataView[]; +const matcherSuggestionsAbstraction: SuggestionsAbstraction = { + type: 'notification_policies', + fields: {}, +}; export const MatcherInput = ({ value, @@ -41,9 +33,36 @@ export const MatcherInput = ({ fullWidth, placeholder, 'data-test-subj': dataTestSubj, + dataFieldNames, }: MatcherInputProps) => { const { QueryStringInput } = useService(PluginStart('kql')) as KqlPluginStart; + const syntheticDataView = useMemo(() => { + const baseFields = MATCHER_CONTEXT_FIELDS.map((f) => ({ + name: f.path, + type: f.type === 'boolean' ? 'boolean' : 'string', + esTypes: [f.type === 'boolean' ? 'boolean' : 'keyword'], + searchable: true, + aggregatable: true, + })); + + const dataFields = (dataFieldNames ?? []).map((name) => ({ + name, + type: 'string', + esTypes: ['keyword'], + searchable: true, + aggregatable: true, + })); + + return [ + { + title: '', + fieldFormatMap: {}, + fields: [...baseFields, ...dataFields], + }, + ] as unknown as DataView[]; + }, [dataFieldNames]); + const query: Query = useMemo(() => ({ query: value, language: 'kuery' }), [value]); const handleChange = useCallback( @@ -65,6 +84,8 @@ export const MatcherInput = ({ dataTestSubj={dataTestSubj} size="s" className={fullWidth ? 'euiFieldText--fullWidth' : undefined} + suggestionsAbstraction={matcherSuggestionsAbstraction} + suggestionsDebounceMs={300} /> ); }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/workflow_selector.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/workflow_selector.tsx index bad7ba2bb6fc1..67e3e5ba2b0ce 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/workflow_selector.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/components/workflow_selector.tsx @@ -61,6 +61,7 @@ export const WorkflowSelector = () => { label={i18n.translate('xpack.alertingV2.notificationPolicy.form.destination', { defaultMessage: 'Destinations', })} + fullWidth isInvalid={!!error} error={error?.message} > diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts index 30ba97ff35bc8..9cc0b2ed5a50b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/constants.ts @@ -5,31 +5,118 @@ * 2.0. */ +import type { GroupingMode, ThrottleStrategy } from '@kbn/alerting-v2-schemas'; import { i18n } from '@kbn/i18n'; import type { NotificationPolicyFormState } from './types'; -export const FREQUENCY_OPTIONS = [ +export const GROUPING_MODE_OPTIONS: Array<{ id: GroupingMode; label: string }> = [ { - value: 'immediate', - text: i18n.translate('xpack.alertingV2.notificationPolicy.form.frequency.immediate', { - defaultMessage: 'Immediate', + id: 'per_episode', + label: i18n.translate('xpack.alertingV2.notificationPolicy.form.dispatch.mode.perEpisode', { + defaultMessage: 'Per episode', }), }, { - value: 'throttle', - text: i18n.translate('xpack.alertingV2.notificationPolicy.form.frequency.throttle', { - defaultMessage: 'Throttle', + id: 'per_field', + label: i18n.translate('xpack.alertingV2.notificationPolicy.form.dispatch.mode.perGroup', { + defaultMessage: 'Per group', + }), + }, + { + id: 'all', + label: i18n.translate('xpack.alertingV2.notificationPolicy.form.dispatch.mode.digest', { + defaultMessage: 'Digest', }), }, ]; +export const GROUPING_MODE_HELP_TEXT: Record = { + per_episode: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.mode.perEpisode.help', + { defaultMessage: 'One dispatch for each matched episode.' } + ), + per_field: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.mode.perGroup.help', + { defaultMessage: 'Episodes grouped by shared field values. One dispatch per group.' } + ), + all: i18n.translate('xpack.alertingV2.notificationPolicy.form.dispatch.mode.digest.help', { + defaultMessage: + 'All matched episodes bundled into a single dispatch. Good for periodic summaries.', + }), +}; + +export const PER_EPISODE_STRATEGY_OPTIONS: Array<{ value: ThrottleStrategy; text: string }> = [ + { + value: 'on_status_change', + text: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.onStatusChange', + { defaultMessage: 'On status change' } + ), + }, + { + value: 'per_status_interval', + text: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.perStatusInterval', + { defaultMessage: 'On status change + repeat on interval' } + ), + }, + { + value: 'every_time', + text: i18n.translate('xpack.alertingV2.notificationPolicy.form.dispatch.strategy.everyTime', { + defaultMessage: 'Every evaluation (no throttle)', + }), + }, +]; + +export const AGGREGATE_STRATEGY_OPTIONS: Array<{ value: ThrottleStrategy; text: string }> = [ + { + value: 'time_interval', + text: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.timeInterval', + { defaultMessage: 'At most once every...' } + ), + }, + { + value: 'every_time', + text: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.everyTimeAggregate', + { defaultMessage: 'Every evaluation (no throttle)' } + ), + }, +]; + +export const DEFAULT_STRATEGY_FOR_MODE: Record = { + per_episode: 'on_status_change', + per_field: 'time_interval', + all: 'time_interval', +}; + +export const STRATEGY_HELP_TEXT: Partial> = { + on_status_change: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.onStatusChange.help', + { defaultMessage: 'When the episode status changes.' } + ), + per_status_interval: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.perStatusInterval.help', + { defaultMessage: 'On status change and on a repeat interval while status is unchanged.' } + ), + every_time: i18n.translate( + 'xpack.alertingV2.notificationPolicy.form.dispatch.strategy.everyTime.help', + { defaultMessage: 'No minimum time between dispatches for the same episode.' } + ), +}; + export const THROTTLE_INTERVAL_PATTERN = /^[1-9][0-9]*[dhms]$/; +export const DEFAULT_THROTTLE_INTERVAL = '5m'; + export const DEFAULT_FORM_STATE: NotificationPolicyFormState = { name: '', description: '', matcher: '', + groupingMode: 'per_episode', groupBy: [], - frequency: { type: 'immediate' }, + throttleStrategy: 'on_status_change', + throttleInterval: '', destinations: [], }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.test.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.test.ts index eb1318f4760e9..c8a4d582b0b92 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.test.ts @@ -5,57 +5,149 @@ * 2.0. */ -import { toCreatePayload, toUpdatePayload } from './form_utils'; +import type { NotificationPolicyResponse } from '@kbn/alerting-v2-schemas'; +import { toCreatePayload, toFormState, toUpdatePayload } from './form_utils'; describe('notification policy form utils', () => { const state = { name: 'Policy', description: 'Description', matcher: '', + groupingMode: 'per_episode' as const, groupBy: [], - frequency: { type: 'immediate' as const }, + throttleStrategy: 'on_status_change' as const, + throttleInterval: '', destinations: [{ type: 'workflow' as const, id: 'workflow-1' }], }; - it('omits empty nullable fields from create payloads', () => { - expect(toCreatePayload(state)).toEqual({ - name: 'Policy', - description: 'Description', - destinations: [{ type: 'workflow', id: 'workflow-1' }], + describe('toCreatePayload', () => { + it('includes groupingMode and throttle strategy, omits empty nullable fields', () => { + expect(toCreatePayload(state)).toEqual({ + name: 'Policy', + description: 'Description', + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + }); + + it('includes groupBy only for per_field mode', () => { + const payload = toCreatePayload({ + ...state, + groupingMode: 'per_field', + groupBy: ['host.name'], + throttleStrategy: 'time_interval', + throttleInterval: '5m', + }); + + expect(payload).toEqual({ + name: 'Policy', + description: 'Description', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval', interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + }); + + it('omits throttle interval for strategies that do not require it', () => { + const payload = toCreatePayload({ + ...state, + throttleStrategy: 'every_time', + }); + + expect(payload.throttle).toEqual({ strategy: 'every_time' }); }); }); - it('sends explicit null values for cleared nullable fields in update payloads', () => { - expect(toUpdatePayload(state, 'WzEsMV0=')).toEqual({ - version: 'WzEsMV0=', - name: 'Policy', - description: 'Description', - matcher: null, - groupBy: null, - throttle: null, - destinations: [{ type: 'workflow', id: 'workflow-1' }], + describe('toUpdatePayload', () => { + it('sends explicit null values for cleared nullable fields', () => { + expect(toUpdatePayload(state, 'WzEsMV0=')).toEqual({ + version: 'WzEsMV0=', + name: 'Policy', + description: 'Description', + groupingMode: 'per_episode', + matcher: null, + groupBy: null, + throttle: { strategy: 'on_status_change' }, + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + }); + + it('preserves concrete nullable values', () => { + expect( + toUpdatePayload( + { + ...state, + matcher: 'event.severity: critical', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttleStrategy: 'time_interval', + throttleInterval: '5m', + }, + 'WzEsMV0=' + ) + ).toEqual({ + version: 'WzEsMV0=', + name: 'Policy', + description: 'Description', + groupingMode: 'per_field', + matcher: 'event.severity: critical', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval', interval: '5m' }, + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); }); }); - it('preserves concrete nullable values in update payloads', () => { - expect( - toUpdatePayload( - { - ...state, - matcher: 'event.severity: critical', - groupBy: ['host.name'], - frequency: { type: 'throttle', interval: '5m' }, - }, - 'WzEsMV0=' - ) - ).toEqual({ + describe('toFormState', () => { + const baseResponse: NotificationPolicyResponse = { + id: 'policy-1', version: 'WzEsMV0=', - name: 'Policy', - description: 'Description', - matcher: 'event.severity: critical', + name: 'Test Policy', + description: 'A test policy', + enabled: true, + matcher: 'data.severity : "critical"', groupBy: ['host.name'], - throttle: { interval: '5m' }, - destinations: [{ type: 'workflow', id: 'workflow-1' }], + groupingMode: 'per_field', + throttle: { strategy: 'time_interval', interval: '5m' }, + snoozedUntil: null, + destinations: [{ type: 'workflow', id: 'workflow-2' }], + createdBy: 'elastic', + createdByUsername: 'elastic', + createdAt: '2026-03-01T10:00:00.000Z', + updatedBy: 'elastic', + updatedByUsername: 'elastic', + updatedAt: '2026-03-01T10:00:00.000Z', + auth: { owner: 'elastic', createdByUser: true }, + }; + + it('maps server response to form state', () => { + expect(toFormState(baseResponse)).toEqual({ + name: 'Test Policy', + description: 'A test policy', + matcher: 'data.severity : "critical"', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttleStrategy: 'time_interval', + throttleInterval: '5m', + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); + }); + + it('applies defaults when groupingMode and throttle are null', () => { + expect( + toFormState({ ...baseResponse, groupingMode: null, throttle: null, groupBy: null }) + ).toEqual({ + name: 'Test Policy', + description: 'A test policy', + matcher: 'data.severity : "critical"', + groupingMode: 'per_episode', + groupBy: [], + throttleStrategy: 'on_status_change', + throttleInterval: '', + destinations: [{ type: 'workflow', id: 'workflow-2' }], + }); }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts index e31f301d856a7..297dffd3f0201 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/form_utils.ts @@ -8,19 +8,31 @@ import type { CreateNotificationPolicyData, NotificationPolicyResponse, + ThrottleStrategy, UpdateNotificationPolicyBody, } from '@kbn/alerting-v2-schemas'; +import { DEFAULT_STRATEGY_FOR_MODE } from './constants'; import type { NotificationPolicyFormState } from './types'; +export const needsInterval = (strategy: ThrottleStrategy): boolean => + strategy === 'per_status_interval' || strategy === 'time_interval'; + +const buildThrottle = (state: NotificationPolicyFormState) => ({ + strategy: state.throttleStrategy, + ...(needsInterval(state.throttleStrategy) ? { interval: state.throttleInterval } : {}), +}); + export const toFormState = (response: NotificationPolicyResponse): NotificationPolicyFormState => { + const groupingMode = response.groupingMode ?? 'per_episode'; + return { name: response.name, description: response.description, matcher: response.matcher ?? '', + groupingMode, groupBy: response.groupBy ?? [], - frequency: response.throttle - ? { type: 'throttle', interval: response.throttle.interval } - : { type: 'immediate' }, + throttleStrategy: response.throttle?.strategy ?? DEFAULT_STRATEGY_FOR_MODE[groupingMode], + throttleInterval: response.throttle?.interval ?? '', destinations: response.destinations.map((d) => ({ type: d.type, id: d.id })), }; }; @@ -31,11 +43,12 @@ export const toCreatePayload = ( return { name: state.name, description: state.description, + groupingMode: state.groupingMode, ...(state.matcher ? { matcher: state.matcher } : {}), - ...(state.groupBy.length > 0 ? { groupBy: state.groupBy } : {}), - ...(state.frequency.type === 'throttle' - ? { throttle: { interval: state.frequency.interval } } + ...(state.groupingMode === 'per_field' && state.groupBy.length > 0 + ? { groupBy: state.groupBy } : {}), + throttle: buildThrottle(state), destinations: state.destinations.map((d) => ({ type: d.type, id: d.id })), }; }; @@ -48,9 +61,10 @@ export const toUpdatePayload = ( version, name: state.name, description: state.description, + groupingMode: state.groupingMode, matcher: state.matcher || null, - groupBy: state.groupBy.length > 0 ? state.groupBy : null, - throttle: state.frequency.type === 'throttle' ? { interval: state.frequency.interval } : null, + groupBy: state.groupingMode === 'per_field' && state.groupBy.length > 0 ? state.groupBy : null, + throttle: buildThrottle(state), destinations: state.destinations.map((d) => ({ type: d.type, id: d.id })), }; }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx index 2331c5c9aeec5..5cadb384c9c18 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.stories.tsx @@ -46,7 +46,6 @@ export const CreateMode: Story = { defaultValues: { ...DEFAULT_FORM_STATE, groupBy: [...DEFAULT_FORM_STATE.groupBy], - frequency: { ...DEFAULT_FORM_STATE.frequency }, destinations: DEFAULT_FORM_STATE.destinations.map((destination) => ({ ...destination })), }, }, @@ -58,9 +57,41 @@ export const EditMode: Story = { name: 'Critical production alerts', description: 'Routes critical production alerts to escalation workflows', matcher: 'data.severity : "critical" and data.env : "prod"', + groupingMode: 'per_field', groupBy: ['host.name', 'service.name'], - frequency: { type: 'throttle', interval: '5m' }, + throttleStrategy: 'time_interval', + throttleInterval: '5m', destinations: [{ type: 'workflow', id: 'workflow-2' }], }, }, }; + +export const PerEpisodeWithInterval: Story = { + args: { + defaultValues: { + name: 'Status change with reminders', + description: 'Notifies on status change and repeats every hour', + matcher: '', + groupingMode: 'per_episode', + groupBy: [], + throttleStrategy: 'per_status_interval', + throttleInterval: '1h', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }, + }, +}; + +export const DigestMode: Story = { + args: { + defaultValues: { + name: 'Digest summary', + description: 'Bundles all episodes into a single digest', + matcher: '', + groupingMode: 'all', + groupBy: [], + throttleStrategy: 'time_interval', + throttleInterval: '15m', + destinations: [{ type: 'workflow', id: 'workflow-3' }], + }, + }, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx index e8ec4ec5bd059..3d7681fd619fc 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.test.tsx @@ -29,6 +29,10 @@ jest.mock('./components/matcher_input', () => ({ ), })); +jest.mock('../../../hooks/use_fetch_data_fields', () => ({ + useFetchDataFields: () => ({ data: undefined, isLoading: false }), +})); + jest.mock('../../../hooks/use_fetch_workflows', () => ({ useFetchWorkflows: () => ({ data: { results: [], total: 0, page: 1, size: 100 }, @@ -57,9 +61,10 @@ const renderForm = (defaultValues: NotificationPolicyFormState = DEFAULT_FORM_ST const TEST_SUBJ = { nameInput: 'nameInput', - descriptionInput: 'descriptionInput', - frequencySelect: 'frequencySelect', + groupingModeToggle: 'groupingModeToggle', + strategySelect: 'strategySelect', throttleIntervalInput: 'throttleIntervalInput', + groupByInput: 'groupByInput', } as const; describe('NotificationPolicyForm', () => { @@ -72,30 +77,126 @@ describe('NotificationPolicyForm', () => { expect(await screen.findByText('Name is required.')).toBeInTheDocument(); }); - it('shows throttle interval input only when throttle frequency is selected', async () => { + it('renders grouping mode toggle with Per Episode selected by default', () => { + renderForm(); + + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + expect(toggle).toBeInTheDocument(); + const perEpisodeButton = toggle.querySelector('button[aria-pressed="true"]'); + expect(perEpisodeButton).toBeInTheDocument(); + expect(screen.getByTestId(TEST_SUBJ.strategySelect)).toHaveValue('on_status_change'); + }); + + it('shows strategy select for per_episode mode', () => { + renderForm(); + + const strategySelect = screen.getByTestId(TEST_SUBJ.strategySelect); + expect(strategySelect).toBeInTheDocument(); + expect(strategySelect).toHaveValue('on_status_change'); + }); + + it('shows interval input when per_status_interval strategy is selected', async () => { const user = userEvent.setup(); renderForm(); expect(screen.queryByTestId(TEST_SUBJ.throttleIntervalInput)).not.toBeInTheDocument(); - await user.selectOptions(screen.getByTestId(TEST_SUBJ.frequencySelect), 'throttle'); + await user.selectOptions(screen.getByTestId(TEST_SUBJ.strategySelect), 'per_status_interval'); expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toBeInTheDocument(); }); - it('validates throttle interval format when in throttle mode', async () => { + it('shows group by and strategy when Per Group mode is selected', async () => { const user = userEvent.setup(); renderForm(); - await user.selectOptions(screen.getByTestId(TEST_SUBJ.frequencySelect), 'throttle'); + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + const buttons = toggle.querySelectorAll('button'); + await user.click(buttons[1]); // Per Group is the second button - const intervalInput = screen.getByTestId(TEST_SUBJ.throttleIntervalInput); - await user.clear(intervalInput); - await user.type(intervalInput, '10x'); - await user.tab(); + expect(screen.getByTestId(TEST_SUBJ.groupByInput)).toBeInTheDocument(); + expect(screen.getByTestId(TEST_SUBJ.strategySelect)).toBeInTheDocument(); + }); + + it('shows strategy select with time_interval when Digest mode is selected', async () => { + const user = userEvent.setup(); + renderForm(); + + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + const buttons = toggle.querySelectorAll('button'); + await user.click(buttons[2]); // Digest is the third button + + const strategySelect = screen.getByTestId(TEST_SUBJ.strategySelect); + expect(strategySelect).toBeInTheDocument(); + expect(strategySelect).toHaveValue('time_interval'); + }); + + it('shows interval input when time_interval is the default strategy in digest mode', async () => { + const user = userEvent.setup(); + renderForm(); + + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + const buttons = toggle.querySelectorAll('button'); + await user.click(buttons[2]); // Digest is the third button + + expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toBeInTheDocument(); + }); + + it('pre-fills interval with 5m when switching to digest mode', async () => { + const user = userEvent.setup(); + renderForm(); + + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + const buttons = toggle.querySelectorAll('button'); + await user.click(buttons[2]); // Digest + + expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toHaveValue(5); + }); + + it('pre-fills interval with 5m when selecting per_status_interval strategy', async () => { + const user = userEvent.setup(); + renderForm(); + + await user.selectOptions(screen.getByTestId(TEST_SUBJ.strategySelect), 'per_status_interval'); + + expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toHaveValue(5); + }); + + it('preserves groupBy fields when switching away from per_field and back', async () => { + const user = userEvent.setup(); + renderForm({ + ...DEFAULT_FORM_STATE, + groupingMode: 'per_field', + groupBy: ['host.name', 'service.name'], + throttleStrategy: 'time_interval', + throttleInterval: '5m', + }); + + const toggle = screen.getByTestId(TEST_SUBJ.groupingModeToggle); + const buttons = toggle.querySelectorAll('button'); + + // Switch to Per Episode + await user.click(buttons[0]); + expect(screen.queryByTestId(TEST_SUBJ.groupByInput)).not.toBeInTheDocument(); + + // Switch back to Per Group + await user.click(buttons[1]); + const groupByInput = screen.getByTestId(TEST_SUBJ.groupByInput); + expect(groupByInput).toBeInTheDocument(); + + // The previously selected groupBy values should still be present as pills + expect(screen.getByTitle('host.name')).toBeInTheDocument(); + expect(screen.getByTitle('service.name')).toBeInTheDocument(); + }); + + it('pre-fills interval with 5m on mount when strategy needs interval and interval is empty', () => { + renderForm({ + ...DEFAULT_FORM_STATE, + groupingMode: 'all', + throttleStrategy: 'time_interval', + throttleInterval: '', + }); - expect( - await screen.findByText('Invalid throttle interval. Must be in the format of 1h, 5m, 30s') - ).toBeInTheDocument(); + expect(screen.getByTestId(TEST_SUBJ.throttleIntervalInput)).toHaveValue(5); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx index eb5e2e63dfaee..24d5d0dc3ab55 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/notification_policy_form.tsx @@ -6,10 +6,8 @@ */ import { - EuiComboBox, EuiFieldText, EuiFormRow, - EuiSelect, EuiSpacer, EuiSplitPanel, EuiText, @@ -19,15 +17,16 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; -import { Controller, useFormContext, useWatch } from 'react-hook-form'; -import { FREQUENCY_OPTIONS, THROTTLE_INTERVAL_PATTERN } from './constants'; +import { Controller, useFormContext } from 'react-hook-form'; import { MatcherInput } from './components/matcher_input'; +import { DispatchSection } from './components/dispatch_section'; import { WorkflowSelector } from './components/workflow_selector'; import type { NotificationPolicyFormState } from './types'; +import { useFetchDataFields } from '../../../hooks/use_fetch_data_fields'; export const NotificationPolicyForm = () => { const { control } = useFormContext(); - const frequency = useWatch({ control, name: 'frequency' }); + const { data: dataFieldNames } = useFetchDataFields(); return ( <> @@ -122,7 +121,7 @@ export const NotificationPolicyForm = () => { @@ -142,9 +141,10 @@ export const NotificationPolicyForm = () => { onChange={field.onChange} fullWidth data-test-subj="matcherInput" + dataFieldNames={dataFieldNames} placeholder={i18n.translate( 'xpack.alertingV2.notificationPolicy.form.matcher.placeholder', - { defaultMessage: 'e.g. episode_status : "active" and rule.name : "my-rule"' } + { defaultMessage: 'e.g. data.host.name : "my-host.com" and rule.id : "uuid"' } )} /> @@ -160,48 +160,20 @@ export const NotificationPolicyForm = () => {

- ( - - ({ label: g }))} - noSuggestions - onCreateOption={(val) => { - field.onChange([...field.value, val]); - }} - onChange={(options) => { - field.onChange(options.map((o) => o.label)); - }} - /> - - )} - /> + @@ -212,103 +184,18 @@ export const NotificationPolicyForm = () => {

- - ( - - - - )} - /> - {frequency.type === 'throttle' && ( - ( - - - - )} - /> - )} - - - - - - - - -

- -

-
-
diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts index 5ab6430639ab7..beeef161e5b28 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/types.ts @@ -5,27 +5,19 @@ * 2.0. */ -export interface ThrottleFrequency { - type: 'throttle'; - interval: string; // e.g. '1h', '30m', '5m' -} - -export interface ImmediateFrequency { - type: 'immediate'; -} - -export type NotificationPolicyFrequency = ThrottleFrequency | ImmediateFrequency; - -export interface NotificationPolicyDestination { - type: 'workflow'; - id: string; -} +import type { + GroupingMode, + NotificationPolicyDestination, + ThrottleStrategy, +} from '@kbn/alerting-v2-schemas'; export interface NotificationPolicyFormState { name: string; description: string; matcher: string; + groupingMode: GroupingMode; groupBy: string[]; - frequency: NotificationPolicyFrequency; + throttleStrategy: ThrottleStrategy; + throttleInterval: string; destinations: NotificationPolicyDestination[]; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts index 2413603be2b90..03d5ddcc8a8be 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.test.ts @@ -18,7 +18,8 @@ const EXISTING_POLICY: NotificationPolicyResponse = { enabled: true, matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + groupingMode: 'per_field', + throttle: { strategy: 'time_interval', interval: '5m' }, snoozedUntil: null, destinations: [{ type: 'workflow', id: 'workflow-2' }], createdBy: 'elastic', @@ -79,6 +80,8 @@ describe('useNotificationPolicyForm', () => { expect(onSubmitCreate).toHaveBeenCalledWith({ name: 'My policy', description: 'A description', + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, destinations: [], }); }); @@ -106,7 +109,8 @@ describe('useNotificationPolicyForm', () => { const payload = onSubmitCreate.mock.calls[0][0]; expect(payload).not.toHaveProperty('matcher'); expect(payload).not.toHaveProperty('groupBy'); - expect(payload).not.toHaveProperty('throttle'); + expect(payload.groupingMode).toBe('per_episode'); + expect(payload.throttle).toEqual({ strategy: 'on_status_change' }); }); }); @@ -136,15 +140,18 @@ describe('useNotificationPolicyForm', () => { name: 'Critical production alerts', description: 'Routes critical alerts', matcher: 'data.severity : "critical"', + groupingMode: 'per_field', groupBy: ['host.name', 'service.name'], - frequency: { type: 'throttle', interval: '5m' }, + throttleStrategy: 'time_interval', + throttleInterval: '5m', destinations: [{ type: 'workflow', id: 'workflow-2' }], }); }); - it('maps immediate frequency when no throttle is present', () => { + it('maps default strategy when no throttle is present', () => { const policyWithoutThrottle: NotificationPolicyResponse = { ...EXISTING_POLICY, + groupingMode: null, throttle: null, }; const { result } = renderHook(() => @@ -155,7 +162,8 @@ describe('useNotificationPolicyForm', () => { }) ); - expect(result.current.methods.getValues().frequency).toEqual({ type: 'immediate' }); + expect(result.current.methods.getValues().groupingMode).toBe('per_episode'); + expect(result.current.methods.getValues().throttleStrategy).toBe('on_status_change'); }); it('calls onSubmitUpdate with id, payload, and version on submit', async () => { @@ -177,9 +185,10 @@ describe('useNotificationPolicyForm', () => { version: 'WzEsMV0=', name: 'Critical production alerts', description: 'Routes critical alerts', + groupingMode: 'per_field', matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + throttle: { strategy: 'time_interval', interval: '5m' }, destinations: [{ type: 'workflow', id: 'workflow-2' }], }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts index 701a6a112d70f..706f0530120f3 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form/use_notification_policy_form.ts @@ -14,7 +14,7 @@ import { useCallback, useMemo } from 'react'; import { useForm, useWatch } from 'react-hook-form'; import { THROTTLE_INTERVAL_PATTERN } from './constants'; import { DEFAULT_FORM_STATE } from './constants'; -import { toCreatePayload, toFormState, toUpdatePayload } from './form_utils'; +import { needsInterval, toCreatePayload, toFormState, toUpdatePayload } from './form_utils'; import type { NotificationPolicyFormState } from './types'; interface UseNotificationPolicyFormParams { @@ -40,19 +40,27 @@ export const useNotificationPolicyForm = ({ defaultValues, }); - const [name, destinations, frequency] = useWatch({ + const [name, destinations, groupingMode, groupBy, throttleStrategy, throttleInterval] = useWatch({ control: methods.control, - name: ['name', 'destinations', 'frequency'], + name: [ + 'name', + 'destinations', + 'groupingMode', + 'groupBy', + 'throttleStrategy', + 'throttleInterval', + ], }); const isSubmitEnabled = useMemo(() => { const hasName = name.trim().length > 0; const hasDestinations = destinations.length > 0; - const hasValidThrottleInterval = - frequency.type !== 'throttle' || THROTTLE_INTERVAL_PATTERN.test(frequency.interval); + const hasValidGroupBy = groupingMode === 'per_field' ? groupBy.length > 0 : true; + const hasValidInterval = + !needsInterval(throttleStrategy) || THROTTLE_INTERVAL_PATTERN.test(throttleInterval); - return hasName && hasDestinations && hasValidThrottleInterval; - }, [destinations.length, frequency, name]); + return hasName && hasDestinations && hasValidGroupBy && hasValidInterval; + }, [destinations.length, groupBy.length, groupingMode, name, throttleStrategy, throttleInterval]); const onSubmitValid = useCallback( (values: NotificationPolicyFormState) => { diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx index ec1fecdeb33a5..2f746a679ce3a 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/form_flyout/notification_policy_form_flyout.test.tsx @@ -26,6 +26,10 @@ jest.mock('../form/components/matcher_input', () => ({ ), })); +jest.mock('../../../hooks/use_fetch_data_fields', () => ({ + useFetchDataFields: () => ({ data: undefined, isLoading: false }), +})); + jest.mock('../../../hooks/use_fetch_workflows', () => ({ useFetchWorkflows: () => ({ data: { @@ -118,6 +122,8 @@ describe('NotificationPolicyFormFlyout', () => { expect(onSave).toHaveBeenCalledWith({ name: 'Policy from test', description: 'Description from test', + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, destinations: [{ type: 'workflow', id: 'wf-1' }], }); }); @@ -133,7 +139,8 @@ describe('NotificationPolicyFormFlyout', () => { enabled: true, matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + groupingMode: 'per_field', + throttle: { strategy: 'time_interval', interval: '5m' }, snoozedUntil: null, destinations: [{ type: 'workflow', id: 'workflow-2' }], createdBy: 'elastic', @@ -167,9 +174,10 @@ describe('NotificationPolicyFormFlyout', () => { version: 'WzEsMV0=', name: 'Critical production alerts', description: 'Routes critical alerts', + groupingMode: 'per_field', matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + throttle: { strategy: 'time_interval', interval: '5m' }, destinations: [{ type: 'workflow', id: 'workflow-2' }], }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_snooze_popover.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_snooze_popover.test.tsx index 82ff6c18e995d..831832cf5b56f 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_snooze_popover.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/components/notification_policy/notification_policy_snooze_popover.test.tsx @@ -22,6 +22,7 @@ const createPolicy = ( enabled: true, matcher: null, groupBy: null, + groupingMode: null, throttle: null, snoozedUntil: null, destinations: [], diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts index ed53f84a947ef..f96deb5cb5c86 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/query_key_factory.ts @@ -22,6 +22,11 @@ export const workflowKeys = { search: (params: { query: string }) => [...workflowKeys.searches(), params] as const, }; +export const matcherSuggestionKeys = { + all: ['matcherSuggestions'] as const, + dataFields: () => [...matcherSuggestionKeys.all, 'dataFields'] as const, +}; + export const notificationPolicyKeys = { all: ['notificationPolicy'] as const, detail: (id: string) => [...notificationPolicyKeys.all, 'detail', id] as const, diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_data_fields.ts b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_data_fields.ts new file mode 100644 index 0000000000000..64396d0724aff --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/public/hooks/use_fetch_data_fields.ts @@ -0,0 +1,22 @@ +/* + * 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 { useService } from '@kbn/core-di-browser'; +import { useQuery } from '@kbn/react-query'; +import { NotificationPoliciesApi } from '../services/notification_policies_api'; +import { matcherSuggestionKeys } from './query_key_factory'; + +export const useFetchDataFields = () => { + const notificationPoliciesApi = useService(NotificationPoliciesApi); + + return useQuery({ + queryKey: matcherSuggestionKeys.dataFields(), + queryFn: () => notificationPoliciesApi.fetchDataFields(), + refetchOnWindowFocus: false, + staleTime: 30 * 60 * 1000, + }); +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.test.tsx index f7bec19777a60..a1bddc274eb99 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/list_notification_policies_page/list_notification_policies_page.test.tsx @@ -144,7 +144,8 @@ const createPolicy = ( destinations: [{ type: 'workflow', id: 'workflow-1' }], matcher: null, groupBy: null, - throttle: null, + groupingMode: null, + throttle: { strategy: undefined, interval: undefined }, snoozedUntil: null, auth: { owner: 'elastic', diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx index 8afd600a263a2..c788cb56f7263 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx +++ b/x-pack/platform/plugins/shared/alerting_v2/public/pages/notification_policy_form_page/notification_policy_form_page.test.tsx @@ -65,6 +65,10 @@ jest.mock('../../hooks/use_fetch_notification_policy', () => ({ useFetchNotificationPolicy: (...args: unknown[]) => mockUseFetchNotificationPolicy(...args), })); +jest.mock('../../hooks/use_fetch_data_fields', () => ({ + useFetchDataFields: () => ({ data: undefined, isLoading: false }), +})); + jest.mock('../../hooks/use_fetch_workflows', () => ({ useFetchWorkflows: () => ({ data: { @@ -101,7 +105,8 @@ const EXISTING_POLICY: NotificationPolicyResponse = { enabled: true, matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + groupingMode: 'per_field', + throttle: { strategy: 'time_interval', interval: '5m' }, snoozedUntil: null, destinations: [{ type: 'workflow', id: 'workflow-2' }], createdBy: 'elastic', @@ -173,6 +178,8 @@ describe('NotificationPolicyFormPage', () => { { name: 'Policy from test', description: 'Description from test', + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, destinations: [{ type: 'workflow', id: 'workflow-1' }], }, expect.objectContaining({ onSuccess: expect.any(Function) }) @@ -266,9 +273,10 @@ describe('NotificationPolicyFormPage', () => { version: 'WzEsMV0=', name: 'Critical production alerts', description: 'Routes critical alerts', + groupingMode: 'per_field', matcher: 'data.severity : "critical"', groupBy: ['host.name', 'service.name'], - throttle: { interval: '5m' }, + throttle: { strategy: 'time_interval', interval: '5m' }, destinations: [{ type: 'workflow', id: 'workflow-2' }], }, }, diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts b/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts index 18a36aea6530f..6737ccdbed357 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/services/notification_policies_api.ts @@ -114,4 +114,10 @@ export class NotificationPoliciesApi { { body: JSON.stringify(body) } ); } + + public async fetchDataFields() { + return this.http.get( + `${ALERTING_V2_NOTIFICATION_POLICY_API_PATH}/suggestions/data_fields` + ); + } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/public/services/workflows_api.ts b/x-pack/platform/plugins/shared/alerting_v2/public/services/workflows_api.ts index f26cb06633cfa..36b6ce1d17918 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/public/services/workflows_api.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/public/services/workflows_api.ts @@ -6,21 +6,26 @@ */ import { CoreStart } from '@kbn/core-di-browser'; -import type { HttpStart } from '@kbn/core/public'; +import type { HttpFetchQuery, HttpStart } from '@kbn/core/public'; import type { WorkflowDetailDto, WorkflowListDto, WorkflowsSearchParams } from '@kbn/workflows'; import { inject, injectable } from 'inversify'; +const API_VERSION = '2023-10-31'; + @injectable() export class WorkflowsApi { constructor(@inject(CoreStart('http')) private readonly http: HttpStart) {} public async getWorkflow(id: string): Promise { - return this.http.get(`/api/workflows/${id}`); + return this.http.get(`/api/workflows/workflow/${encodeURIComponent(id)}`, { + version: API_VERSION, + }); } public async searchWorkflows(params: WorkflowsSearchParams): Promise { - return this.http.post('/api/workflows/search', { - body: JSON.stringify(params), + return this.http.get('/api/workflows', { + query: params as unknown as HttpFetchQuery, + version: API_VERSION, }); } } 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 56bb85dc9a57d..4d6536a8f50af 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 @@ -249,7 +249,7 @@ describe('DispatcherService', () => { 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).toHaveLength(0); + expect(notifiedActions).toHaveLength(alertEpisodes.length); expect(docs).toEqual( expect.arrayContaining([ 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 fe3f5b9d3f7c4..10be880a658f8 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 @@ -9,7 +9,7 @@ import type { EsqlQueryResponse } from '@elastic/elasticsearch/lib/api/types'; import type { AlertEpisode, AlertEpisodeSuppression, LastNotifiedRecord } from '../types'; export const createDispatchableAlertEventsResponse = ( - alertEpisodes: AlertEpisode[] + alertEpisodes: Array ): EsqlQueryResponse => { return { columns: [ @@ -18,6 +18,7 @@ export const createDispatchableAlertEventsResponse = ( { name: 'group_hash', type: 'keyword' }, { name: 'episode_id', type: 'keyword' }, { name: 'episode_status', type: 'keyword' }, + { name: 'data_json', type: 'keyword' }, ], values: alertEpisodes.map((alertEpisode) => [ alertEpisode.last_event_timestamp, @@ -25,6 +26,7 @@ export const createDispatchableAlertEventsResponse = ( alertEpisode.group_hash, alertEpisode.episode_id, alertEpisode.episode_status, + alertEpisode.data_json ?? null, ]), }; }; 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 fbe4be722e05f..be891df1bf3d4 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 @@ -102,7 +102,6 @@ export function createNotificationGroup( return { id: 'group-1', spaceId: 'default', - ruleId: 'rule-1', policyId: 'policy-1', destinations: [{ type: 'workflow' as const, id: 'workflow-1' }], groupKey: {}, 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 1f560bfd470b6..8d648e271a7d3 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 @@ -345,6 +345,90 @@ const SUPPRESSION_USER_ACTIONS: AlertAction[] = [ }, ]; +/** + * Matcher test: 3 episodes for rule-matcher, 2 critical and 1 warning. + * A policy with matcher 'data.severity: "critical"' should only match the 2 critical episodes. + */ +const MATCHER_ALERT_EVENTS: AlertEvent[] = [ + { + '@timestamp': '2026-02-01T10:00:00.000Z', + type: 'alert', + rule: { id: 'rule-matcher', version: 1 }, + group_hash: 'rule-matcher-series-1', + episode: { id: 'rule-matcher-s1-ep1', status: 'active' }, + data: { severity: 'critical' }, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-02-01T10:05:00.000Z', + type: 'alert', + rule: { id: 'rule-matcher', version: 1 }, + group_hash: 'rule-matcher-series-1', + episode: { id: 'rule-matcher-s1-ep2', status: 'active' }, + data: { severity: 'critical' }, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-02-01T10:10:00.000Z', + type: 'alert', + rule: { id: 'rule-matcher', version: 1 }, + group_hash: 'rule-matcher-series-1', + episode: { id: 'rule-matcher-s1-ep3', status: 'active' }, + data: { severity: 'warning' }, + status: 'breached', + source: 'internal', + }, +]; + +/** + * GroupBy test: 4 episodes for rule-groupby across 4 series, grouped into 2 hosts. + * A policy with groupBy: ['host.name'] should produce 2 notification groups. + */ +const GROUPBY_ALERT_EVENTS: AlertEvent[] = [ + { + '@timestamp': '2026-02-01T11:00:00.000Z', + type: 'alert', + rule: { id: 'rule-groupby', version: 1 }, + group_hash: 'rule-groupby-series-1', + episode: { id: 'rule-groupby-s1-ep1', status: 'active' }, + data: { 'host.name': 'server-1' }, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-02-01T11:05:00.000Z', + type: 'alert', + rule: { id: 'rule-groupby', version: 1 }, + group_hash: 'rule-groupby-series-2', + episode: { id: 'rule-groupby-s2-ep1', status: 'active' }, + data: { 'host.name': 'server-1' }, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-02-01T11:10:00.000Z', + type: 'alert', + rule: { id: 'rule-groupby', version: 1 }, + group_hash: 'rule-groupby-series-3', + episode: { id: 'rule-groupby-s3-ep1', status: 'active' }, + data: { 'host.name': 'server-2' }, + status: 'breached', + source: 'internal', + }, + { + '@timestamp': '2026-02-01T11:15:00.000Z', + type: 'alert', + rule: { id: 'rule-groupby', version: 1 }, + group_hash: 'rule-groupby-series-4', + episode: { id: 'rule-groupby-s4-ep1', status: 'active' }, + data: { 'host.name': 'server-2' }, + status: 'breached', + source: 'internal', + }, +]; + const createMockWorkflowsManagement = (): WorkflowsServerPluginSetup['management'] => ({ getWorkflow: jest.fn().mockResolvedValue(null), @@ -411,7 +495,9 @@ describe('DispatcherService integration tests', () => { page: 1, perPage: 1000, }); - return allPolicies.map((doc) => ({ id: doc.id, attributes: doc.attributes })); + return allPolicies + .filter((doc) => doc.attributes.enabled) + .map((doc) => ({ id: doc.id, attributes: doc.attributes })); }); const pipeline = new DispatcherPipeline(mockLoggerService, [ @@ -429,6 +515,9 @@ describe('DispatcherService integration tests', () => { dispatcherService = new DispatcherService(pipeline); await setNotificationPolicyThrottle(npSoService, null); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_ID, true); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_MATCHER_ID, false); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_GROUPBY_ID, false); }); describe('when there are no alert events', () => { @@ -468,7 +557,7 @@ describe('DispatcherService integration tests', () => { const fireEvents = actionsResponse.hits.hits.map((hit) => hit._source as Record); - expect(fireEvents).toHaveLength(5); + expect(fireEvents).toHaveLength(3); fireEvents.forEach((event) => { expect(event).toMatchObject({ '@timestamp': expect.any(String), @@ -481,10 +570,8 @@ describe('DispatcherService integration tests', () => { }); const timestamps = fireEvents.map((event) => event.last_series_event_timestamp).sort(); expect(timestamps).toEqual([ - '2026-01-22T07:10:00.000Z', // Episode 1 - '2026-01-22T07:15:00.000Z', // Episode 1 - '2026-01-22T07:20:00.000Z', // Episode 2 - '2026-01-22T07:25:00.000Z', // Episode 2 + '2026-01-22T07:15:00.000Z', // Episode 1 (collapsed: latest status inactive) + '2026-01-22T07:25:00.000Z', // Episode 2 (collapsed: latest status inactive) '2026-01-22T07:50:00.000Z', // Episode 3 ]); @@ -494,13 +581,16 @@ describe('DispatcherService integration tests', () => { size: 100, }); - expect(notifiedActionsResponse.hits.hits).toHaveLength(0); + expect(notifiedActionsResponse.hits.hits).toHaveLength(3); }); }); describe('when the notification policy has a throttle interval', () => { it('should persist notified actions for dispatched notification groups', async () => { - await setNotificationPolicyThrottle(npSoService, { interval: '1h' }); + await setNotificationPolicyThrottle(npSoService, { + strategy: 'per_status_interval', + interval: '1h', + }); await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); const result = await dispatcherService.run({ @@ -528,11 +618,11 @@ describe('DispatcherService integration tests', () => { actor: 'system', action_type: 'notified', rule_id: 'rule-1', - group_hash: 'irrelevant', source: 'internal', - reason: 'notified by policy np-1 with throttle interval', + reason: 'notified by policy np-1', }); expect(action.notification_group_id).toEqual(expect.any(String)); + expect(action.group_hash).not.toBe('irrelevant'); }); }); }); @@ -619,11 +709,11 @@ describe('DispatcherService integration tests', () => { (hit) => hit._source as Record ); - expect(dispatchedActions).toHaveLength(10); + expect(dispatchedActions).toHaveLength(9); const fireActions = dispatchedActions.filter((a) => a.action_type === 'fire'); const suppressActions = dispatchedActions.filter((a) => a.action_type === 'suppress'); - expect(fireActions).toHaveLength(6); + expect(fireActions).toHaveLength(5); expect(suppressActions).toHaveLength(4); // rule-001: fire (ack then unack cancels suppression) @@ -651,6 +741,8 @@ describe('DispatcherService integration tests', () => { ); // rule-003: all fire (no user actions) + // series-1: 1 episode (active) → 1 fire + // series-2: ep1 collapsed to inactive (16:05), ep2 collapsed to active (16:15) → 2 fires expect(dispatchedActions).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -659,12 +751,6 @@ describe('DispatcherService integration tests', () => { 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', @@ -713,6 +799,351 @@ describe('DispatcherService integration tests', () => { ); }); }); + + describe('when the notification policy has a matcher', () => { + beforeEach(async () => { + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_ID, false); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_MATCHER_ID, true); + }); + + it('should only dispatch episodes matching the KQL expression', async () => { + await seedAlertEvents(esClient, MATCHER_ALERT_EVENTS); + + const result = await dispatcherService.run({ + previousStartedAt: new Date('2026-02-01T09: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', 'unmatched'] } }], + }, + }, + size: 100, + }); + + const actions = actionsResponse.hits.hits.map( + (hit) => hit._source as Record + ); + + const fireActions = actions.filter((a) => a.action_type === 'fire'); + const unmatchedActions = actions.filter((a) => a.action_type === 'unmatched'); + + expect(fireActions).toHaveLength(2); + fireActions.forEach((action) => { + expect(action).toMatchObject({ + rule_id: 'rule-matcher', + action_type: 'fire', + actor: 'system', + source: 'internal', + }); + }); + + expect(unmatchedActions).toHaveLength(1); + expect(unmatchedActions[0]).toMatchObject({ + rule_id: 'rule-matcher', + action_type: 'unmatched', + }); + }); + }); + + describe('when the notification policy has groupBy fields', () => { + beforeEach(async () => { + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_ID, false); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_GROUPBY_ID, true); + await setNotificationPolicyThrottle(npSoService, null, NOTIFICATION_POLICY_GROUPBY_ID); + }); + + it('should group episodes by the specified data fields', async () => { + await setNotificationPolicyThrottle( + npSoService, + { strategy: 'time_interval', interval: '1h' }, + NOTIFICATION_POLICY_GROUPBY_ID + ); + await seedAlertEvents(esClient, GROUPBY_ALERT_EVENTS); + + const result = await dispatcherService.run({ + previousStartedAt: new Date('2026-02-01T10:00:00.000Z'), + }); + + expect(result.startedAt).toBeDefined(); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const fireResponse = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + + const fireActions = fireResponse.hits.hits.map( + (hit) => hit._source as Record + ); + + expect(fireActions).toHaveLength(4); + fireActions.forEach((action) => { + expect(action).toMatchObject({ + rule_id: 'rule-groupby', + action_type: 'fire', + actor: 'system', + source: 'internal', + }); + }); + + const notifiedResponse = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + + const notifiedActions = notifiedResponse.hits.hits.map( + (hit) => hit._source as Record + ); + + expect(notifiedActions).toHaveLength(2); + const groupIds = notifiedActions.map((a) => a.notification_group_id); + expect(new Set(groupIds).size).toBe(2); + notifiedActions.forEach((action) => { + expect(action).toMatchObject({ + action_type: 'notified', + rule_id: 'rule-groupby', + actor: 'system', + source: 'internal', + }); + }); + }); + }); + + describe('throttle strategies', () => { + it('per_episode + on_status_change: throttles on second dispatch when status unchanged', async () => { + await setNotificationPolicyThrottle(npSoService, { strategy: 'on_status_change' }); + await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const firstRunFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(firstRunFires.hits.hits).toHaveLength(3); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const totalFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(totalFires.hits.hits).toHaveLength(3); + }); + + it('per_episode + per_status_interval: throttles within interval when status unchanged', async () => { + await setNotificationPolicyThrottle(npSoService, { + strategy: 'per_status_interval', + interval: '1h', + }); + await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const firstRunFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(firstRunFires.hits.hits).toHaveLength(3); + + const notifiedActions = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + const notifiedSources = notifiedActions.hits.hits.map( + (hit) => hit._source as Record + ); + notifiedSources.forEach((action) => { + expect(action.notification_group_id).toEqual(expect.any(String)); + expect(action.episode_status).toEqual(expect.any(String)); + }); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const totalFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(totalFires.hits.hits).toHaveLength(3); + }); + + it('per_episode + every_time: dispatches new event even when status unchanged', async () => { + await setNotificationPolicyThrottle(npSoService, { strategy: 'every_time' }); + await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const firstRunFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(firstRunFires.hits.hits).toHaveLength(3); + + await seedAlertEvents(esClient, [ + { + '@timestamp': '2026-01-22T07:55:00.000Z', + type: 'alert', + rule: { id: 'rule-1', version: 1 }, + group_hash: 'rule-1-series-1', + episode: { + id: 'rule-1-series-1-episode-3', + status: 'active', + }, + data: {}, + status: 'breached', + source: 'internal', + }, + ]); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const totalFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(totalFires.hits.hits).toHaveLength(4); + }); + + it('all + time_interval: digest mode groups all episodes and throttles on second dispatch', async () => { + await updateNotificationPolicy(npSoService, NOTIFICATION_POLICY_ID, { + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '1h' }, + }); + await seedAlertEvents(esClient, ALERT_EVENTS_TEST_DATA); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const fireActions = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(fireActions.hits.hits).toHaveLength(3); + + const notifiedActions = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + expect(notifiedActions.hits.hits).toHaveLength(1); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-01-22T07:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const totalFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(totalFires.hits.hits).toHaveLength(3); + + const totalNotified = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + expect(totalNotified.hits.hits).toHaveLength(1); + }); + + it('per_field + time_interval: throttles groups on second dispatch within interval', async () => { + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_ID, false); + await setNotificationPolicyEnabled(npSoService, NOTIFICATION_POLICY_GROUPBY_ID, true); + await setNotificationPolicyThrottle( + npSoService, + { strategy: 'time_interval', interval: '1h' }, + NOTIFICATION_POLICY_GROUPBY_ID + ); + await seedAlertEvents(esClient, GROUPBY_ALERT_EVENTS); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-02-01T10:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const firstRunFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(firstRunFires.hits.hits).toHaveLength(4); + + const firstRunNotified = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + expect(firstRunNotified.hits.hits).toHaveLength(2); + + await dispatcherService.run({ + previousStartedAt: new Date('2026-02-01T10:00:00.000Z'), + }); + + await esClient.indices.refresh({ index: ALERT_ACTIONS_DATA_STREAM }); + + const totalFires = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'fire' } }, + size: 100, + }); + expect(totalFires.hits.hits).toHaveLength(4); + + const totalNotified = await esClient.search({ + index: ALERT_ACTIONS_DATA_STREAM, + query: { term: { action_type: 'notified' } }, + size: 100, + }); + expect(totalNotified.hits.hits).toHaveLength(2); + }); + }); }); async function cleanupDataStreams(esClient: ElasticsearchClient): Promise { @@ -741,8 +1172,19 @@ async function seedAlertEvents(esClient: ElasticsearchClient, events: AlertEvent } const NOTIFICATION_POLICY_ID = 'np-1'; - -const TEST_RULE_IDS = ['rule-1', 'rule-001', 'rule-002', 'rule-003', 'rule-004', 'rule-005']; +const NOTIFICATION_POLICY_MATCHER_ID = 'np-matcher'; +const NOTIFICATION_POLICY_GROUPBY_ID = 'np-groupby'; + +const TEST_RULE_IDS = [ + 'rule-1', + 'rule-001', + 'rule-002', + 'rule-003', + 'rule-004', + 'rule-005', + 'rule-matcher', + 'rule-groupby', +]; async function seedRulesAndPolicies( rulesSoService: RulesSavedObjectServiceContract, @@ -767,6 +1209,25 @@ async function seedRulesAndPolicies( }; await npSoService.create({ attrs: policyAttrs, id: NOTIFICATION_POLICY_ID }); + const matcherPolicyAttrs: NotificationPolicySavedObjectAttributes = { + ...policyAttrs, + name: 'Matcher Policy', + description: 'Only matches critical severity', + enabled: false, + matcher: 'data.severity: "critical"', + }; + await npSoService.create({ attrs: matcherPolicyAttrs, id: NOTIFICATION_POLICY_MATCHER_ID }); + + const groupByPolicyAttrs: NotificationPolicySavedObjectAttributes = { + ...policyAttrs, + name: 'GroupBy Policy', + description: 'Groups by host.name', + enabled: false, + groupBy: ['data.host.name'], + groupingMode: 'per_field', + }; + await npSoService.create({ attrs: groupByPolicyAttrs, id: NOTIFICATION_POLICY_GROUPBY_ID }); + const ruleAttrs: RuleSavedObjectAttributes = { kind: 'alert', metadata: { name: 'Test Rule' }, @@ -787,17 +1248,46 @@ async function seedRulesAndPolicies( async function setNotificationPolicyThrottle( npSoService: NotificationPolicySavedObjectServiceContract, - throttle: NotificationPolicySavedObjectAttributes['throttle'] + throttle: NotificationPolicySavedObjectAttributes['throttle'], + policyId: string = NOTIFICATION_POLICY_ID ): Promise { - const policy = await npSoService.get(NOTIFICATION_POLICY_ID); + const policy = await npSoService.get(policyId); await npSoService.update({ - id: NOTIFICATION_POLICY_ID, + id: policyId, version: policy.version, attrs: { throttle }, }); } +async function setNotificationPolicyEnabled( + npSoService: NotificationPolicySavedObjectServiceContract, + policyId: string, + enabled: boolean +): Promise { + const policy = await npSoService.get(policyId); + + await npSoService.update({ + id: policyId, + version: policy.version, + attrs: { enabled }, + }); +} + +async function updateNotificationPolicy( + npSoService: NotificationPolicySavedObjectServiceContract, + policyId: string, + attrs: Partial +): Promise { + const policy = await npSoService.get(policyId); + + await npSoService.update({ + id: policyId, + version: policy.version, + attrs, + }); +} + async function seedAlertActions( esClient: ElasticsearchClient, actions: AlertAction[] 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 237cf66fe1ea9..f50ac68dbcef9 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 @@ -49,18 +49,39 @@ describe('getDispatchableAlertEventsQuery', () => { ); }); - it('aggregates by rule_id, group_hash, episode_id, episode_status', () => { + it('aggregates by rule_id, group_hash, episode_id with episode_status as LAST aggregation', () => { const req = getDispatchableAlertEventsQuery(); - expect(req.query).toContain('BY rule_id, group_hash, episode_id, episode_status'); + expect(req.query).toContain('BY rule_id, group_hash, episode_id'); + expect(req.query).not.toContain('BY rule_id, group_hash, episode_id, episode_status'); + expect(req.query).toContain('last_episode_status = LAST(episode_status, @timestamp)'); }); - it('keeps the expected output columns', () => { + it('fetches _source metadata alongside _index', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('METADATA _index, _source'); + }); + + it('extracts data_json from _source using JSON_EXTRACT for rule-events rows', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('JSON_EXTRACT(_source, "$.data")'); + }); + + it('aggregates data_json using LAST by timestamp', () => { + const req = getDispatchableAlertEventsQuery(); + + expect(req.query).toContain('data_json = LAST(data_json, @timestamp)'); + }); + + it('keeps the expected output columns and renames episode_status', () => { const req = getDispatchableAlertEventsQuery(); expect(req.query).toContain( - 'KEEP last_event_timestamp, rule_id, group_hash, episode_id, episode_status' + 'KEEP last_event_timestamp, rule_id, group_hash, episode_id, last_episode_status, data_json' ); + expect(req.query).toContain('RENAME last_episode_status AS episode_status'); }); it('sorts by timestamp ascending with a limit', () => { @@ -206,7 +227,13 @@ describe('getLastNotifiedTimestampsQuery', () => { it('keeps the expected output columns', () => { const req = getLastNotifiedTimestampsQuery(['group-1']); - expect(req.query).toContain('KEEP notification_group_id, last_notified'); + expect(req.query).toContain('KEEP notification_group_id, last_notified, episode_status'); + }); + + it('aggregates episode_status using LAST by timestamp', () => { + const req = getLastNotifiedTimestampsQuery(['group-1']); + + expect(req.query).toContain('episode_status = LAST(episode_status, @timestamp)'); }); it('groups by notification_group_id', () => { 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 18cdf9cb61447..66ad35b27d83a 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 @@ -20,20 +20,24 @@ import type { AlertEpisode, NotificationGroupId } from './types'; export const getDispatchableAlertEventsQuery = (): EsqlRequest => { const alertEventType: AlertEventType = 'alert'; - return esql`FROM ${ALERT_EVENTS_DATA_STREAM},${ALERT_ACTIONS_DATA_STREAM} METADATA _index + return esql`FROM ${ALERT_EVENTS_DATA_STREAM},${ALERT_ACTIONS_DATA_STREAM} METADATA _index, _source | WHERE (_index LIKE ${ALERT_ACTIONS_BACKING_INDEX}) OR (_index LIKE ${ALERT_EVENTS_BACKING_INDEX} and type == ${alertEventType}) - | EVAL + | EVAL rule_id = COALESCE(rule.id, rule_id), episode_id = COALESCE(episode.id, episode_id), - episode_status = episode.status + episode_status = episode.status, + data_json = CASE(_index LIKE ${ALERT_EVENTS_BACKING_INDEX}, JSON_EXTRACT(_source, "$.data"), NULL) | 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" OR action_type == "suppress" OR action_type == "unmatched") BY rule_id, group_hash | WHERE (last_fired IS NULL OR last_fired < @timestamp) or (_index LIKE ${ALERT_ACTIONS_BACKING_INDEX}) | STATS - last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ${ALERT_EVENTS_BACKING_INDEX} - BY rule_id, group_hash, episode_id, episode_status + last_event_timestamp = MAX(@timestamp) WHERE _index LIKE ${ALERT_EVENTS_BACKING_INDEX}, + last_episode_status = LAST(episode_status, @timestamp) WHERE _index LIKE ${ALERT_EVENTS_BACKING_INDEX}, + data_json = LAST(data_json, @timestamp) WHERE _index LIKE ${ALERT_EVENTS_BACKING_INDEX} + BY rule_id, group_hash, episode_id | WHERE last_event_timestamp IS NOT NULL - | KEEP last_event_timestamp, rule_id, group_hash, episode_id, episode_status + | KEEP last_event_timestamp, rule_id, group_hash, episode_id, last_episode_status, data_json + | RENAME last_episode_status AS episode_status | SORT last_event_timestamp asc | LIMIT 10000`.toRequest(); }; @@ -85,9 +89,9 @@ export const getLastNotifiedTimestampsQuery = ( 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} + return esql`FROM ${ALERT_ACTIONS_DATA_STREAM} | WHERE ${whereClause} - | STATS last_notified = MAX(@timestamp) BY notification_group_id - | KEEP notification_group_id, last_notified + | STATS last_notified = MAX(@timestamp), episode_status = LAST(episode_status, @timestamp) BY notification_group_id + | KEEP notification_group_id, last_notified, episode_status `.toRequest(); }; 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 index 6b281f58ffe5c..94b19f1cb987a 100644 --- 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 @@ -6,91 +6,458 @@ */ import { applyThrottling } from './apply_throttling_step'; -import { createNotificationGroup, createNotificationPolicy } from '../fixtures/test_utils'; +import { + createAlertEpisode, + createNotificationGroup, + createNotificationPolicy, +} from '../fixtures/test_utils'; +import type { NotificationGroupId, LastNotifiedInfo } from '../types'; + +const NOW = new Date('2026-01-22T10:00:00.000Z'); + +const info = (lastNotified: string, episodeStatus?: string): LastNotifiedInfo => ({ + lastNotified: new Date(lastNotified), + episodeStatus, +}); 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); + describe('per_episode + on_status_change', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches when status changed', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'recovering' })], + }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T09:59:00.000Z', 'active')], + ]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('throttles when status unchanged', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'active' })], + }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T09:59:00.000Z', 'active')], + ]), + NOW + ); + + expect(dispatch).toHaveLength(0); + expect(throttled).toHaveLength(1); + }); }); - it('throttles group when last notified within interval', () => { - const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); - const policy = createNotificationPolicy({ id: 'p1', throttle: { interval: '1h' } }); + describe('per_episode + per_status_interval', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_episode', + throttle: { strategy: 'per_status_interval', interval: '1h' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches when status changed', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'recovering' })], + }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T09:59:00.000Z', 'active')], + ]), + NOW + ); - 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(1); + expect(throttled).toHaveLength(0); + }); - expect(dispatch).toHaveLength(0); - expect(throttled).toHaveLength(1); + it('dispatches when status unchanged and interval expired', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'active' })], + }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T08:00:00.000Z', 'active')], + ]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('throttles when status unchanged and within interval', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'active' })], + }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T09:30:00.000Z', 'active')], + ]), + NOW + ); + + 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' } }); + describe('per_episode + every_time', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_episode', + throttle: { strategy: 'every_time' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches even with recent notification', () => { + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'active' })], + }); - 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') - ); + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([ + ['g1', info('2026-01-22T09:59:59.000Z', 'active')], + ]), + NOW + ); - expect(dispatch).toHaveLength(1); - expect(throttled).toHaveLength(0); + 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' }); + describe('per_field + time_interval', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches when interval expired', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:50:00.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('throttles when within interval', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:58:00.000Z')]]), + NOW + ); - 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(0); + expect(throttled).toHaveLength(1); + }); - expect(dispatch).toHaveLength(1); - expect(throttled).toHaveLength(0); + it('dispatches when no interval configured', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval' }, + }); + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map([['g1', info('2026-01-22T09:59:59.000Z')]]), + NOW + ); + + 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'); + describe('per_field + every_time', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'every_time' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches even with recent notification', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:59:59.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); }); - it('returns empty arrays when no groups', () => { - const { dispatch, throttled } = applyThrottling([], new Map(), new Map(), new Date()); + describe('all + time_interval', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches when interval expired', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:50:00.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('throttles when within interval', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:58:00.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(0); + expect(throttled).toHaveLength(1); + }); + + it('dispatches when no interval configured', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'all', + throttle: { strategy: 'time_interval' }, + }); + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', policy]]), + new Map([['g1', info('2026-01-22T09:59:59.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + }); + + describe('all + every_time', () => { + const basePolicy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'all', + throttle: { strategy: 'every_time' }, + }); + + it('dispatches when no previous notification exists', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map(), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + + it('dispatches even with recent notification', () => { + const group = createNotificationGroup({ id: 'g1', policyId: 'p1' }); + + const { dispatch, throttled } = applyThrottling( + [group], + new Map([['p1', basePolicy]]), + new Map([['g1', info('2026-01-22T09:59:59.000Z')]]), + NOW + ); + + expect(dispatch).toHaveLength(1); + expect(throttled).toHaveLength(0); + }); + }); + + describe('mixed groups', () => { + it('handles mixed dispatch and throttle across groups', () => { + const g1 = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'active' })], + }); + const g2 = createNotificationGroup({ + id: 'g2', + policyId: 'p1', + episodes: [createAlertEpisode({ episode_status: 'recovering' })], + }); + const policy = createNotificationPolicy({ + id: 'p1', + throttle: { strategy: 'on_status_change' }, + }); + + const { dispatch, throttled } = applyThrottling( + [g1, g2], + new Map([['p1', policy]]), + new Map([ + ['g1', info('2026-01-22T09:30:00.000Z', 'active')], + ['g2', info('2026-01-22T09:30:00.000Z', 'active')], + ]), + NOW + ); + + 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(), NOW); - expect(dispatch).toHaveLength(0); - expect(throttled).toHaveLength(0); + 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 index bf221835e3169..fe23c3e2ae1e8 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 @@ -6,24 +6,24 @@ */ import { inject, injectable } from 'inversify'; -import type { - LastNotifiedRecord, - NotificationGroup, - NotificationGroupId, - NotificationPolicy, - DispatcherStep, - DispatcherPipelineState, - DispatcherStepOutput, -} from '../types'; +import { parseDurationToMs } from '../../duration'; 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'; +import type { + DispatcherPipelineState, + DispatcherStep, + DispatcherStepOutput, + LastNotifiedInfo, + LastNotifiedRecord, + NotificationGroup, + NotificationGroupId, + NotificationPolicy, +} from '../types'; @injectable() export class ApplyThrottlingStep implements DispatcherStep { @@ -60,14 +60,19 @@ export class ApplyThrottlingStep implements DispatcherStep { private async fetchLastNotifiedTimestamps( notificationGroupIds: NotificationGroupId[] - ): Promise> { - const result = await this.queryService.executeQuery({ + ): Promise> { + const records = await this.queryService.executeQueryRows({ query: getLastNotifiedTimestampsQuery(notificationGroupIds).query, }); - const records = queryResponseToRecords(result); - return new Map( - records.map((record) => [record.notification_group_id, new Date(record.last_notified)]) + return new Map( + records.map((record) => [ + record.notification_group_id, + { + lastNotified: new Date(record.last_notified), + episodeStatus: record.episode_status, + }, + ]) ); } } @@ -75,7 +80,7 @@ export class ApplyThrottlingStep implements DispatcherStep { export function applyThrottling( groups: readonly NotificationGroup[], policies: ReadonlyMap, - lastNotifiedMap: ReadonlyMap, + lastNotifiedMap: ReadonlyMap, now: Date ): { dispatch: NotificationGroup[]; throttled: NotificationGroup[] } { const dispatch: NotificationGroup[] = []; @@ -83,23 +88,54 @@ export function applyThrottling( 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); - } + const bucket = shouldDispatch(group, policy, lastNotifiedMap.get(group.id), now) + ? dispatch + : throttled; + bucket.push(group); } return { dispatch, throttled }; } +function shouldDispatch( + group: NotificationGroup, + policy: NotificationPolicy, + lastRecord: LastNotifiedInfo | undefined, + now: Date +): boolean { + if (!lastRecord) return true; + + const groupingMode = policy.groupingMode ?? 'per_episode'; + const strategy = + policy.throttle?.strategy ?? + (groupingMode === 'per_episode' ? 'on_status_change' : 'time_interval'); + + if (strategy === 'every_time') return true; + + // Aggregate modes (per_field, all): throttle by interval only + if (groupingMode !== 'per_episode') { + return ( + !policy.throttle?.interval || + !isWithinInterval(lastRecord.lastNotified, policy.throttle.interval, now) + ); + } + + // per_episode: always dispatch on status change + const statusChanged = lastRecord.episodeStatus !== group.episodes[0]?.episode_status; + if (statusChanged) return true; + + // per_status_interval: also dispatch when interval has elapsed + if (strategy === 'per_status_interval') { + return ( + !!policy.throttle?.interval && + !isWithinInterval(lastRecord.lastNotified, policy.throttle.interval, now) + ); + } + + // on_status_change with no change → throttle + return false; +} + function isWithinInterval(lastNotifiedAt: Date, interval: string, now: Date): boolean { try { const intervalMillis = parseDurationToMs(interval); 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 06b1c3c73402f..9eac66b317f8b 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 @@ -34,7 +34,6 @@ describe('BuildGroupsStep', () => { 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); }); @@ -102,12 +101,218 @@ describe('buildNotificationGroups', () => { 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(); + it('groups episodes by a single data field', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupBy: ['data.host.name'], + groupingMode: 'per_field' as const, + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e1', + data: { host: { name: 'server-1' } }, + }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e2', + data: { host: { name: 'server-1' } }, + }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(1); + expect(groups[0].episodes).toHaveLength(2); + expect(groups[0].groupKey).toEqual({ 'data.host.name': 'server-1' }); + }); + + it('creates separate groups for different field values', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupBy: ['data.host.name'], + groupingMode: 'per_field' as const, + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e1', + data: { host: { name: 'server-1' } }, + }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e2', + data: { host: { name: 'server-2' } }, + }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(2); + expect(groups[0].groupKey).toEqual({ 'data.host.name': 'server-1' }); + expect(groups[1].groupKey).toEqual({ 'data.host.name': 'server-2' }); + }); + + it('groups episodes by multiple data fields', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupBy: ['data.host.name', 'data.env'], + groupingMode: 'per_field' as const, + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e1', + data: { host: { name: 'server-1' }, env: 'prod' }, + }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e2', + data: { host: { name: 'server-1' }, env: 'prod' }, + }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e3', + data: { host: { name: 'server-1' }, env: 'staging' }, + }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(2); + const prodGroup = groups.find((g) => g.groupKey['data.env'] === 'prod')!; + const stagingGroup = groups.find((g) => g.groupKey['data.env'] === 'staging')!; + expect(prodGroup.episodes).toHaveLength(2); + expect(stagingGroup.episodes).toHaveLength(1); + }); + + it('defaults missing data fields to null', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupBy: ['data.host.name', 'data.env'], + groupingMode: 'per_field' as const, + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e1', + data: { host: { name: 'server-1' } }, + }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ + rule_id: 'r1', + episode_id: 'e2', + data: {}, + }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + const groupWithHost = groups.find((g) => g.groupKey['data.host.name'] === 'server-1')!; + expect(groupWithHost.groupKey).toEqual({ 'data.host.name': 'server-1', 'data.env': null }); + + const groupWithoutHost = groups.find((g) => g.groupKey['data.host.name'] === null)!; + expect(groupWithoutHost.groupKey).toEqual({ 'data.host.name': null, 'data.env': null }); + }); + + it('creates one group per rule for all mode', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'all', + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e1' }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e2' }), + policy, + }), + ]; + + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(1); + expect(groups[0].episodes).toHaveLength(2); + expect(groups[0].groupKey).toEqual({}); + }); + + it('merges episodes from different rules into one group in all mode', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'all', + destinations: [{ type: 'workflow', id: 'w1' }], + }); + const matched = [ + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e1' }), + policy, + }), + createMatchedPair({ + episode: createAlertEpisode({ rule_id: 'r2', episode_id: 'e2' }), + policy, + }), + ]; - expect(() => buildNotificationGroups([createMatchedPair({ episode, policy })])).toThrow( - 'Grouping by fields is not supported yet' - ); + const groups = buildNotificationGroups(matched); + + expect(groups).toHaveLength(1); + expect(groups[0].episodes).toHaveLength(2); + expect(groups[0].episodes[0].rule_id).toBe('r1'); + expect(groups[0].episodes[1].rule_id).toBe('r2'); + }); + + it('creates one group per episode for explicit per_episode mode', () => { + const policy = createNotificationPolicy({ + id: 'p1', + groupingMode: 'per_episode', + destinations: [{ type: 'workflow', id: '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); }); }); 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 0a0fa9bd3d70d..17693105ff1c4 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 @@ -6,13 +6,14 @@ */ import { injectable } from 'inversify'; +import { get } from 'lodash'; import objectHash from 'object-hash'; import type { - MatchedPair, - NotificationGroup, - DispatcherStep, DispatcherPipelineState, + DispatcherStep, DispatcherStepOutput, + MatchedPair, + NotificationGroup, } from '../types'; @injectable() @@ -32,18 +33,25 @@ export function buildNotificationGroups(matched: readonly MatchedPair[]): Notifi 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'); + let groupKey: Record; + switch (policy.groupingMode ?? 'per_episode') { + case 'per_episode': + groupKey = { + groupHash: episode.group_hash, + episodeId: episode.episode_id, + }; + break; + case 'all': + groupKey = {}; + break; + case 'per_field': + groupKey = Object.fromEntries( + policy.groupBy.map((field) => [field, get(episode, field, null)]) + ); + break; } const notificationGroupId = objectHash({ - ruleId: episode.rule_id, policyId: policy.id, groupKey, }); @@ -52,7 +60,6 @@ export function buildNotificationGroups(matched: readonly MatchedPair[]): Notifi groupMap.set(notificationGroupId, { id: notificationGroupId, spaceId: policy.spaceId, - ruleId: episode.rule_id, policyId: policy.id, destinations: policy.destinations, groupKey, 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 ccb63f6462adf..78414c81bf723 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 @@ -78,7 +78,6 @@ describe('DispatchStep', () => { 'default', expect.objectContaining({ id: 'g1', - ruleId: group.ruleId, policyId: 'p1', groupKey: group.groupKey, episodes: group.episodes, @@ -192,4 +191,161 @@ describe('DispatchStep', () => { expect(result.type).toBe('continue'); expect(mockLogger.debug).not.toHaveBeenCalled(); }); + + it('continues dispatching remaining groups when one group fails', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService, mockWfm); + + mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto()); + mockWfm.scheduleWorkflow + .mockResolvedValueOnce('exec-1') + .mockRejectedValueOnce(new Error('network timeout')) + .mockResolvedValueOnce('exec-3'); + + const policy = createNotificationPolicy({ + id: 'p1', + apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==', + }); + + const groups = Array.from({ length: 3 }, (_, i) => + createNotificationGroup({ + id: `g${i}`, + policyId: 'p1', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }) + ); + + const state = createDispatcherPipelineState({ + dispatch: groups, + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(mockWfm.getWorkflow).toHaveBeenCalledTimes(3); + expect(mockWfm.scheduleWorkflow).toHaveBeenCalledTimes(3); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith('network timeout', expect.anything()); + }); + + it('logs error when scheduleWorkflow throws', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService, mockWfm); + + mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto()); + mockWfm.scheduleWorkflow.mockRejectedValue(new Error('service unavailable')); + + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }); + const policy = createNotificationPolicy({ + id: 'p1', + apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==', + }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + 'service unavailable', + expect.objectContaining({ + error: expect.objectContaining({ message: 'service unavailable' }), + }) + ); + }); + + it('continues dispatching remaining destinations when one destination fails', async () => { + const { loggerService, mockLogger } = createLoggerService(); + const step = new DispatchStep(loggerService, mockWfm); + + mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto()); + mockWfm.scheduleWorkflow + .mockRejectedValueOnce(new Error('workflow-1 failed')) + .mockResolvedValueOnce('exec-2'); + + const group = createNotificationGroup({ + id: 'g1', + policyId: 'p1', + destinations: [ + { type: 'workflow', id: 'workflow-1' }, + { type: 'workflow', id: 'workflow-2' }, + ], + }); + const policy = createNotificationPolicy({ + id: 'p1', + apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==', + }); + + const state = createDispatcherPipelineState({ + dispatch: [group], + policies: new Map([['p1', policy]]), + }); + + const result = await step.execute(state); + + expect(result.type).toBe('continue'); + expect(mockWfm.scheduleWorkflow).toHaveBeenCalledTimes(2); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + }); + + it('dispatches multiple groups concurrently with a max concurrency of 3', async () => { + jest.useFakeTimers(); + const { loggerService } = createLoggerService(); + const step = new DispatchStep(loggerService, mockWfm); + + let inFlight = 0; + let maxInFlight = 0; + + mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto()); + mockWfm.scheduleWorkflow.mockImplementation( + () => + new Promise((resolve) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + setTimeout(() => { + inFlight--; + resolve('exec-id'); + }, 10); + }) + ); + + const policy = createNotificationPolicy({ + id: 'p1', + apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==', + }); + + const groups = Array.from({ length: 5 }, (_, i) => + createNotificationGroup({ + id: `g${i}`, + policyId: 'p1', + destinations: [{ type: 'workflow', id: 'workflow-1' }], + }) + ); + + const state = createDispatcherPipelineState({ + dispatch: groups, + policies: new Map([['p1', policy]]), + }); + + const executePromise = step.execute(state); + + await jest.advanceTimersByTimeAsync(100); + + const result = await executePromise; + + expect(result.type).toBe('continue'); + expect(mockWfm.scheduleWorkflow).toHaveBeenCalledTimes(5); + expect(maxInFlight).toBeLessThanOrEqual(3); + + jest.useRealTimers(); + }); }); 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 c0f85fb813cb3..9e7c73addab46 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 @@ -11,6 +11,7 @@ import type { KibanaRequest } from '@kbn/core/server'; import type { WorkflowExecutionEngineModel } from '@kbn/workflows'; import type { WorkflowsServerPluginSetup } from '@kbn/workflows-management-plugin/server'; import { inject, injectable } from 'inversify'; +import pLimit from 'p-limit'; import { LoggerServiceToken, type LoggerServiceContract, @@ -20,11 +21,14 @@ import type { DispatcherStep, DispatcherStepOutput, NotificationGroup, + NotificationPolicyId, + NotificationPolicy, NotificationPolicyWorkflowPayload, } from '../types'; import { WorkflowsManagementApiToken } from './dispatch_step_tokens'; const NOTIFICATION_POLICY_TRIGGER = 'notification_policy'; +const MAX_CONCURRENT_DISPATCHES = 3; @injectable() export class DispatchStep implements DispatcherStep { @@ -39,7 +43,20 @@ export class DispatchStep implements DispatcherStep { public async execute(state: Readonly): Promise { const { dispatch = [], policies } = state; - for (const group of dispatch) { + const limiter = pLimit(MAX_CONCURRENT_DISPATCHES); + + await Promise.allSettled( + dispatch.map((group) => limiter(() => this.dispatchGroup(group, policies))) + ); + + return { type: 'continue' }; + } + + private async dispatchGroup( + group: NotificationGroup, + policies?: Map + ): Promise { + try { const policy = policies?.get(group.policyId); const apiKey = policy?.apiKey; @@ -48,7 +65,7 @@ export class DispatchStep implements DispatcherStep { message: () => `No API key found for policy ${group.policyId}, skipping dispatch of group ${group.id}`, }); - continue; + return; } const fakeRequest = this.craftFakeRequest(apiKey); @@ -58,11 +75,31 @@ export class DispatchStep implements DispatcherStep { continue; } - await this.dispatchWorkflow(group, destination.id, fakeRequest); + try { + await this.dispatchWorkflow(group, destination.id, fakeRequest); + } catch (err) { + this.logger.error({ + error: + err instanceof Error + ? err + : new Error( + `Failed to dispatch group ${group.id} to workflow ${destination.id}: ${String( + err + )}` + ), + }); + } } + } catch (err) { + this.logger.error({ + error: + err instanceof Error + ? err + : new Error( + `Failed to dispatch group ${group.id} for policy ${group.policyId}: ${String(err)}` + ), + }); } - - return { type: 'continue' }; } private craftFakeRequest(apiKey: string): KibanaRequest { @@ -110,7 +147,6 @@ export class DispatchStep implements DispatcherStep { const payload: NotificationPolicyWorkflowPayload = { id: group.id, - ruleId: group.ruleId, policyId: group.policyId, groupKey: group.groupKey, episodes: group.episodes, 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 68f95b3734e44..274c499209139 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 @@ -275,4 +275,103 @@ describe('evaluateMatchers', () => { expect(matched).toHaveLength(0); }); }); + + describe('data field KQL matching', () => { + it('matches on data.severity', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + data: { severity: 'critical' }, + }); + const rule = createRule({ id: 'r1' }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'data.severity: "critical"', + }); + + const matched = evaluateMatchers( + [episode], + new Map([['r1', rule]]), + new Map([['p1', policy]]) + ); + + expect(matched).toHaveLength(1); + }); + + it('does not match when data field value differs', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + data: { env: 'staging' }, + }); + const rule = createRule({ id: 'r1' }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'data.env: "production"', + }); + + const matched = evaluateMatchers( + [episode], + new Map([['r1', rule]]), + new Map([['p1', policy]]) + ); + + expect(matched).toHaveLength(0); + }); + + it('does not match when episode has no data', () => { + const episode = createAlertEpisode({ rule_id: 'r1' }); + const rule = createRule({ id: 'r1' }); + 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); + }); + + it('matches combined data and rule conditions', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + data: { severity: 'critical' }, + }); + const rule = createRule({ id: 'r1', name: 'CPU Alert' }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'data.severity: "critical" and rule.name: "CPU Alert"', + }); + + const matched = evaluateMatchers( + [episode], + new Map([['r1', rule]]), + new Map([['p1', policy]]) + ); + + expect(matched).toHaveLength(1); + }); + + it('matches on nested data fields (unflattened dot-separated keys)', () => { + const episode = createAlertEpisode({ + rule_id: 'r1', + data: { host: { name: 'my-host.com' } }, + }); + const rule = createRule({ id: 'r1' }); + const policy = createNotificationPolicy({ + id: 'p1', + matcher: 'data.host.name: "my-host.com"', + }); + + const matched = evaluateMatchers( + [episode], + new Map([['r1', rule]]), + new Map([['p1', policy]]) + ); + + expect(matched).toHaveLength(1); + }); + }); }); 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 abbd74ee0168a..a110a0d608dce 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 @@ -40,12 +40,7 @@ export function evaluateMatchers( ): MatchedPair[] { const matched: MatchedPair[] = []; - const policiesBySpace = new Map(); - for (const policy of policies.values()) { - const list = policiesBySpace.get(policy.spaceId) ?? []; - list.push(policy); - policiesBySpace.set(policy.spaceId, list); - } + const policiesBySpace = Map.groupBy(policies.values(), (policy) => policy.spaceId); for (const episode of dispatchable) { const rule = rules.get(episode.rule_id); @@ -80,6 +75,7 @@ function createMatcherContext(episode: AlertEpisode, rule: Rule): MatcherContext group_hash: episode.group_hash, episode_id: episode.episode_id, episode_status: episode.episode_status, + ...(episode.data ? { data: episode.data } : {}), rule: { id: rule.id, name: rule.name, 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 index 49767d0dc63af..3c4a5c3511634 100644 --- 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 @@ -5,7 +5,7 @@ * 2.0. */ -import { FetchEpisodesStep } from './fetch_episodes_step'; +import { FetchEpisodesStep, parseDataJson, parseAlertEpisodes } 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'; @@ -31,6 +31,46 @@ describe('FetchEpisodesStep', () => { expect(result.data?.episodes?.[0].rule_id).toBe('r1'); }); + it('parses data_json into data on episodes', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchEpisodesStep(queryService); + + const episodes = [ + { + ...createAlertEpisode({ rule_id: 'r1' }), + data_json: JSON.stringify({ severity: 'critical', host: 'server-01' }), + }, + ]; + + 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?.[0].data).toEqual({ + severity: 'critical', + host: 'server-01', + }); + }); + + it('omits data when data_json is null', async () => { + const { queryService, mockEsClient } = createQueryService(); + const step = new FetchEpisodesStep(queryService); + + const episodes = [{ ...createAlertEpisode({ rule_id: 'r1' }), data_json: null }]; + + 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?.[0].data).toBeUndefined(); + }); + it('halts with no_episodes when none are found', async () => { const { queryService, mockEsClient } = createQueryService(); const step = new FetchEpisodesStep(queryService); @@ -53,3 +93,90 @@ describe('FetchEpisodesStep', () => { await expect(step.execute(state)).rejects.toThrow('ES error'); }); }); + +describe('parseDataJson', () => { + it('parses valid JSON object', () => { + expect(parseDataJson('{"severity":"critical","count":5}')).toEqual({ + severity: 'critical', + count: 5, + }); + }); + + it('returns empty object for malformed JSON', () => { + expect(parseDataJson('{not valid')).toEqual({}); + }); + + it('returns empty object for JSON array', () => { + expect(parseDataJson('[1,2,3]')).toEqual({}); + }); + + it('returns empty object for JSON null', () => { + expect(parseDataJson('null')).toEqual({}); + }); + + it('filters out non-primitive values', () => { + expect(parseDataJson('{"a":"ok","b":{"nested":true},"c":[1]}')).toEqual({ a: 'ok' }); + }); + + it('keeps string, number, and boolean values', () => { + expect(parseDataJson('{"s":"str","n":42,"b":true}')).toEqual({ s: 'str', n: 42, b: true }); + }); + + it('unflattens dot-separated keys into nested objects', () => { + expect(parseDataJson('{"host.name":"my-host.com","host.ip":"10.0.0.1"}')).toEqual({ + host: { name: 'my-host.com', ip: '10.0.0.1' }, + }); + }); + + it('handles mixed flat and dot-separated keys', () => { + expect(parseDataJson('{"severity":"critical","host.name":"srv-01"}')).toEqual({ + severity: 'critical', + host: { name: 'srv-01' }, + }); + }); + + it('handles deeply nested dot-separated keys', () => { + expect(parseDataJson('{"a.b.c":"deep"}')).toEqual({ + a: { b: { c: 'deep' } }, + }); + }); +}); + +describe('parseAlertEpisodes', () => { + it('converts data_json to data and removes data_json', () => { + const raw = [ + { + last_event_timestamp: '2026-01-22T07:10:00.000Z', + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + episode_status: 'active' as const, + data_json: '{"host":"server-01"}', + }, + ]; + + const result = parseAlertEpisodes(raw); + + expect(result).toHaveLength(1); + expect(result[0].data).toEqual({ host: 'server-01' }); + expect(result[0]).not.toHaveProperty('data_json'); + }); + + it('omits data when data_json is null', () => { + const raw = [ + { + last_event_timestamp: '2026-01-22T07:10:00.000Z', + rule_id: 'r1', + group_hash: 'h1', + episode_id: 'e1', + episode_status: 'active' as const, + data_json: null, + }, + ]; + + const result = parseAlertEpisodes(raw); + + expect(result).toHaveLength(1); + expect(result[0].data).toBeUndefined(); + }); +}); 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 26e7249d00bab..67bf8cfacf639 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,19 +6,29 @@ */ import moment from 'moment'; +import { set } from '@kbn/safer-lodash-set'; import { inject, injectable } from 'inversify'; import type { AlertEpisode, + AlertEpisodeData, DispatcherStep, DispatcherPipelineState, 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'; +interface RawAlertEpisode { + last_event_timestamp: string; + rule_id: string; + group_hash: string; + episode_id: string; + episode_status: 'inactive' | 'pending' | 'active' | 'recovering'; + data_json: string | null; +} + @injectable() export class FetchEpisodesStep implements DispatcherStep { public readonly name = 'fetch_episodes'; @@ -34,7 +44,7 @@ export class FetchEpisodesStep implements DispatcherStep { .subtract(LOOKBACK_WINDOW_MINUTES, 'minutes') .toISOString(); - const result = await this.queryService.executeQuery({ + const result = await this.queryService.executeQueryRows({ query: getDispatchableAlertEventsQuery().query, filter: { range: { @@ -45,7 +55,7 @@ export class FetchEpisodesStep implements DispatcherStep { }, }); - const episodes = queryResponseToRecords(result); + const episodes = parseAlertEpisodes(result); if (episodes.length === 0) { return { type: 'halt', reason: 'no_episodes' }; @@ -54,3 +64,26 @@ export class FetchEpisodesStep implements DispatcherStep { return { type: 'continue', data: { episodes } }; } } + +export function parseAlertEpisodes(raw: RawAlertEpisode[]): AlertEpisode[] { + return raw.map(({ data_json, ...rest }) => ({ + ...rest, + ...(data_json ? { data: parseDataJson(data_json) } : {}), + })); +} + +export function parseDataJson(json: string): AlertEpisodeData { + try { + const parsed = JSON.parse(json); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}; + const result: AlertEpisodeData = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + set(result, key.split('.'), value); + } + } + return result; + } catch { + return {}; + } +} 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 517e2ee80c00f..52c2b6e593b66 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 @@ -46,6 +46,7 @@ export class FetchPoliciesStep implements DispatcherStep { destinations: doc.attributes.destinations ?? [], matcher: doc.attributes.matcher ?? undefined, groupBy: doc.attributes.groupBy ?? [], + groupingMode: doc.attributes.groupingMode ?? undefined, throttle: doc.attributes.throttle ?? undefined, snoozedUntil: doc.attributes.snoozedUntil ?? null, apiKey: doc.attributes.auth.apiKey, 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 6414f5a5ec973..08f3293112e8a 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 @@ -6,16 +6,15 @@ */ import { inject, injectable } from 'inversify'; +import type { QueryServiceContract } from '../../services/query_service/query_service'; +import { QueryServiceInternalToken } from '../../services/query_service/tokens'; +import { getAlertEpisodeSuppressionsQuery } from '../queries'; import type { AlertEpisodeSuppression, - DispatcherStep, DispatcherPipelineState, + DispatcherStep, 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 { @@ -31,11 +30,10 @@ export class FetchSuppressionsStep implements DispatcherStep { return { type: 'continue', data: { suppressions: [] } }; } - const result = await this.queryService.executeQuery({ + const suppressions = await this.queryService.executeQueryRows({ 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/store_actions_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.test.ts index 8bcbf69b484f5..c3d637227e875 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.test.ts @@ -160,7 +160,7 @@ describe('StoreActionsStep', () => { }); }); - it('records dispatched episodes with fire action type when policy has no throttle interval', async () => { + it('records dispatched episodes with fire and notified actions', async () => { const mockService = createMockStorageService(); const step = new StoreActionsStep(mockService); @@ -172,7 +172,6 @@ describe('StoreActionsStep', () => { const group = createNotificationGroup({ id: 'group-1', - ruleId: 'rule-1', policyId: 'policy-1', episodes: [episode], }); @@ -187,36 +186,45 @@ describe('StoreActionsStep', () => { 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', - }, - ], + const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; + expect(callArgs.docs).toHaveLength(2); + expect(callArgs.docs[0]).toEqual({ + '@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', + }); + expect(callArgs.docs[1]).toEqual({ + '@timestamp': mockDate.toISOString(), + actor: 'system', + action_type: 'notified', + rule_id: 'rule-1', + group_hash: 'hash-1', + last_series_event_timestamp: mockDate.toISOString(), + notification_group_id: 'group-1', + source: 'internal', + reason: 'notified by policy policy-1', + episode_status: 'active', }); }); - it('records notified action when dispatch policy has a throttle interval', async () => { + it('includes episode_status on notified record for per_episode mode', async () => { const mockService = createMockStorageService(); const step = new StoreActionsStep(mockService); const episode = createAlertEpisode({ rule_id: 'rule-1', group_hash: 'hash-1', + episode_status: 'recovering', last_event_timestamp: '2026-01-22T07:00:00.000Z', }); const group = createNotificationGroup({ id: 'group-1', - ruleId: 'rule-1', policyId: 'policy-1', episodes: [episode], }); @@ -232,32 +240,60 @@ describe('StoreActionsStep', () => { 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', - }, - ], + const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; + const notifiedDoc = callArgs.docs.find( + (d: Record) => d.action_type === 'notified' + ); + expect(notifiedDoc).toEqual( + expect.objectContaining({ + action_type: 'notified', + group_hash: 'hash-1', + notification_group_id: 'group-1', + episode_status: 'recovering', + reason: 'notified by policy policy-1', + }) + ); + }); + + it('omits episode_status on notified record for all mode', async () => { + const mockService = createMockStorageService(); + const step = new StoreActionsStep(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({ + dispatch: [group], + policies: new Map([ + [ + 'policy-1', + createNotificationPolicy({ + id: 'policy-1', + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '5m' }, + }), + ], + ]), + }); + + const result = await step.execute(state); + + expect(result).toEqual({ type: 'continue' }); + const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; + const notifiedDoc = callArgs.docs.find( + (d: Record) => d.action_type === 'notified' + ); + expect(notifiedDoc).toBeDefined(); + expect(notifiedDoc?.episode_status).toBeUndefined(); }); it('handles combined suppressed, throttled, and dispatch arrays', async () => { @@ -293,7 +329,6 @@ describe('StoreActionsStep', () => { const dispatchGroup = createNotificationGroup({ id: 'dispatch-group', - ruleId: 'rule-dispatch', policyId: 'dispatch-policy', episodes: [dispatchEpisode], }); @@ -352,17 +387,16 @@ describe('StoreActionsStep', () => { 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', - }); + expect(callArgs.docs[3]).toEqual( + expect.objectContaining({ + action_type: 'notified', + rule_id: 'rule-dispatch', + group_hash: 'hash-dispatch', + notification_group_id: 'dispatch-group', + episode_status: 'active', + reason: 'notified by policy dispatch-policy', + }) + ); }); it('records unmatched episodes with action_type unmatched', async () => { @@ -465,7 +499,6 @@ describe('StoreActionsStep', () => { const dispatchGroup = createNotificationGroup({ id: 'dispatch-group', - ruleId: 'rule-dispatch', policyId: 'dispatch-policy', episodes: [dispatchedEpisode], }); @@ -532,7 +565,6 @@ describe('StoreActionsStep', () => { const group = createNotificationGroup({ id: 'group-1', - ruleId: 'rule-1', policyId: 'policy-1', episodes: [episode1, episode2], }); @@ -547,10 +579,12 @@ describe('StoreActionsStep', () => { expect(mockService.bulkIndexDocs).toHaveBeenCalledTimes(1); const callArgs = mockService.bulkIndexDocs.mock.calls[0][0]; - expect(callArgs.docs).toHaveLength(2); + 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].group_hash).toBe('hash-1'); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.ts index 4f869b904beb1..9b2b9268b5b09 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/steps/store_actions_step.ts @@ -70,19 +70,24 @@ export class StoreActionsStep implements DispatcherStep { }) ) ), - ...dispatch - .filter((group) => policies?.get(group.policyId)?.throttle?.interval) - .map((group) => ({ + ...dispatch.map((group) => { + const groupingMode = policies?.get(group.policyId)?.groupingMode ?? 'per_episode'; + const action: AlertAction = { '@timestamp': now.toISOString(), actor: 'system', action_type: 'notified', - rule_id: group.ruleId, - group_hash: 'irrelevant', + rule_id: group.episodes[0]?.rule_id ?? 'unknown', + group_hash: group.episodes[0]?.group_hash ?? 'unknown', last_series_event_timestamp: now.toISOString(), notification_group_id: group.id, source: 'internal', - reason: `notified by policy ${group.policyId} with throttle interval`, - })), + reason: `notified by policy ${group.policyId}`, + }; + if (groupingMode === 'per_episode') { + action.episode_status = group.episodes[0]?.episode_status; + } + return action; + }), ...unmatched.map((episode) => toAction({ episode, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.test.ts index b3b0cb988058b..e7a592346b138 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/task_runner.test.ts @@ -7,10 +7,12 @@ import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server/task'; import type { DispatcherServiceContract } from './dispatcher'; +import type { DispatcherEnabledProvider } from './tokens'; import { DispatcherTaskRunner } from './task_runner'; describe('DispatcherTaskRunner', () => { let dispatcherService: jest.Mocked; + let dispatcherEnabledProvider: jest.MockedFunction; let runner: DispatcherTaskRunner; let abortController: AbortController; @@ -27,7 +29,8 @@ describe('DispatcherTaskRunner', () => { beforeEach(() => { dispatcherService = { run: jest.fn() }; - runner = new DispatcherTaskRunner(dispatcherService); + dispatcherEnabledProvider = jest.fn().mockResolvedValue(true); + runner = new DispatcherTaskRunner(dispatcherService, dispatcherEnabledProvider); abortController = new AbortController(); }); @@ -61,5 +64,33 @@ describe('DispatcherTaskRunner', () => { }, }); }); + + it('skips dispatcher when setting is disabled', async () => { + dispatcherEnabledProvider.mockResolvedValue(false); + + const result = await runner.run({ taskInstance, abortController }); + + expect(dispatcherService.run).not.toHaveBeenCalled(); + expect(result).toEqual({ state: taskInstance.state }); + }); + + it('preserves previousStartedAt when disabled', async () => { + dispatcherEnabledProvider.mockResolvedValue(false); + + const result = await runner.run({ taskInstance, abortController }); + + expect(result.state).toEqual({ previousStartedAt: '2026-01-22T07:30:00.000Z' }); + }); + + it('defaults to enabled when uiSettings read fails', async () => { + dispatcherEnabledProvider.mockRejectedValue(new Error('SO unavailable')); + dispatcherService.run.mockResolvedValue({ + startedAt: new Date('2026-01-22T07:45:00.000Z'), + }); + + await runner.run({ taskInstance, abortController }); + + expect(dispatcherService.run).toHaveBeenCalled(); + }); }); }); 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 ab55405a72dd7..108b1ecde314b 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 @@ -8,7 +8,11 @@ import type { RunContext, RunResult } from '@kbn/task-manager-plugin/server/task'; import { inject, injectable } from 'inversify'; import { type DispatcherServiceContract } from './dispatcher'; -import { DispatcherServiceInternalToken } from './tokens'; +import { + DispatcherEnabledProviderToken, + DispatcherServiceInternalToken, + type DispatcherEnabledProvider, +} from './tokens'; import type { DispatcherExecutionParams, DispatcherExecutionResult, @@ -21,10 +25,18 @@ type TaskRunParams = Pick; export class DispatcherTaskRunner { constructor( @inject(DispatcherServiceInternalToken) - private readonly dispatcherService: DispatcherServiceContract + private readonly dispatcherService: DispatcherServiceContract, + @inject(DispatcherEnabledProviderToken) + private readonly dispatcherEnabledProvider: DispatcherEnabledProvider ) {} public async run({ taskInstance, abortController }: TaskRunParams): Promise { + const isEnabled = await this.safeIsEnabled(); + + if (!isEnabled) { + return { state: taskInstance.state }; + } + const params = this.createDispatcherParams(taskInstance, abortController); const result = await this.dispatcherService.run(params); @@ -32,6 +44,14 @@ export class DispatcherTaskRunner { return this.buildRunResult(result); } + private async safeIsEnabled(): Promise { + try { + return await this.dispatcherEnabledProvider(); + } catch { + return true; + } + } + private createDispatcherParams( taskInstance: TaskRunParams['taskInstance'], abortController: AbortController 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 612d8eb9e4a88..1dba2c93d78a4 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 @@ -14,3 +14,13 @@ import type { DispatcherService } from './dispatcher'; export const DispatcherServiceInternalToken = Symbol.for( 'alerting_v2.DispatcherServiceInternal' ) as ServiceIdentifier; + +/** + * Async function that reads the `observability:alerting:dispatcherEnabled` uiSetting + * and returns whether the dispatcher task should execute its pipeline. + */ +export type DispatcherEnabledProvider = () => Promise; + +export const DispatcherEnabledProviderToken = Symbol.for( + 'alerting_v2.DispatcherEnabledProvider' +) as ServiceIdentifier; 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 5d1e206293fa3..7ce125c50b578 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 NotificationGroupId = string; +export type AlertEpisodeData = Record; export interface NotificationPolicyDestination { type: 'workflow'; @@ -20,6 +21,7 @@ export interface AlertEpisode { group_hash: string; episode_id: string; episode_status: 'inactive' | 'pending' | 'active' | 'recovering'; + data?: AlertEpisodeData; } export interface AlertEpisodeSuppression { @@ -66,8 +68,11 @@ export interface NotificationPolicy { 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 */ + /** How episodes are grouped into notification payloads */ + groupingMode?: 'per_episode' | 'all' | 'per_field'; + /** Throttle configuration controlling notification frequency */ throttle?: { + strategy?: 'on_status_change' | 'per_status_interval' | 'time_interval' | 'every_time'; interval?: string; // e.g. '1h', '30m', '5m' }; snoozedUntil?: string | null; @@ -86,7 +91,6 @@ export interface MatchedPair { export interface NotificationGroup { id: NotificationGroupId; spaceId: string; - ruleId: RuleId; policyId: NotificationPolicyId; destinations: NotificationPolicyDestination[]; groupKey: Record; @@ -95,7 +99,6 @@ export interface NotificationGroup { export interface NotificationPolicyWorkflowPayload { id: NotificationGroupId; - ruleId: RuleId; policyId: NotificationPolicyId; groupKey: Record; episodes: AlertEpisode[]; @@ -104,6 +107,12 @@ export interface NotificationPolicyWorkflowPayload { export interface LastNotifiedRecord { notification_group_id: NotificationGroupId; last_notified: string; + episode_status?: string; +} + +export interface LastNotifiedInfo { + lastNotified: Date; + episodeStatus?: string; } export interface DispatcherPipelineInput { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/ui_settings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/ui_settings.ts new file mode 100644 index 0000000000000..f591b65cfcea1 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/ui_settings.ts @@ -0,0 +1,29 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; +import type { UiSettingsParams } from '@kbn/core/types'; + +export const DISPATCHER_ENABLED_SETTING_ID = 'observability:alerting:dispatcherEnabled'; + +export const dispatcherUiSettings: Record> = { + [DISPATCHER_ENABLED_SETTING_ID]: { + category: ['observability'], + name: i18n.translate('xpack.alertingVTwo.dispatcherEnabledSettingName', { + defaultMessage: 'Alerting v2 dispatcher', + }), + scope: 'global', + value: true, + description: i18n.translate('xpack.alertingVTwo.dispatcherEnabledSettingDescription', { + defaultMessage: + 'Controls whether the alerting v2 dispatcher task processes alert episodes. When disabled, the task still runs on schedule but performs no work.', + }), + schema: schema.boolean(), + requiresPageReload: false, + }, +}; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/utils.ts index 9c6ef862d066d..249e5a24a4d58 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/utils.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/notification_policy_client/utils.ts @@ -71,6 +71,7 @@ export const buildCreateNotificationPolicyAttributes = ({ destinations: data.destinations, matcher: data.matcher ?? null, groupBy: data.groupBy ?? null, + groupingMode: data.groupingMode ?? null, throttle: data.throttle ?? null, snoozedUntil: null, auth, @@ -105,6 +106,7 @@ export const buildUpdateNotificationPolicyAttributes = ({ destinations: update.destinations ?? existing.destinations, matcher: resolveNextNullableField(update.matcher, existing.matcher), groupBy: resolveNextNullableField(update.groupBy, existing.groupBy), + groupingMode: resolveNextNullableField(update.groupingMode, existing.groupingMode), throttle: resolveNextNullableField(update.throttle, existing.throttle), snoozedUntil: normalizeNullableField(existing.snoozedUntil), auth, @@ -135,6 +137,7 @@ export const transformNotificationPolicySoAttributesToApiResponse = ({ destinations: attributes.destinations, matcher: normalizeNullableField(attributes.matcher), groupBy: normalizeNullableField(attributes.groupBy), + groupingMode: normalizeNullableField(attributes.groupingMode), throttle: normalizeNullableField(attributes.throttle), snoozedUntil: normalizeNullableField(attributes.snoozedUntil), auth: toAuthResponse(attributes.auth), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/matcher_suggestions_service/matcher_suggestions_service.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/matcher_suggestions_service/matcher_suggestions_service.ts new file mode 100644 index 0000000000000..9860a91232871 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/services/matcher_suggestions_service/matcher_suggestions_service.ts @@ -0,0 +1,259 @@ +/* + * 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 { ElasticsearchClient, SavedObjectsClientContract } from '@kbn/core/server'; +import { inject, injectable } from 'inversify'; +import { flattenObject } from '@kbn/object-utils'; +import { EsServiceScopedToken } from '../es_service/tokens'; +import { RuleSavedObjectsClientToken } from '../rules_saved_object_service/tokens'; +import { + ALERT_EVENTS_DATA_STREAM, + alertEpisodeStatus, +} from '../../../resources/datastreams/alert_events'; +import { RULE_SAVED_OBJECT_TYPE, type RuleSavedObjectAttributes } from '../../../saved_objects'; + +const MAX_SUGGESTIONS = 10; +const MAX_DATA_FIELDS = 100; +const DATA_FIELD_SAMPLE_SIZE = 1000; +const ALERT_EVENTS_LOOKBACK = 'now-24h'; + +const EPISODE_STATUS_VALUES = Object.values(alertEpisodeStatus); + +enum MatcherField { + EpisodeStatus = 'episode_status', + RuleName = 'rule.name', + RuleDescription = 'rule.description', + RuleLabels = 'rule.labels', + RuleId = 'rule.id', + EpisodeId = 'episode_id', + GroupHash = 'group_hash', +} + +interface RuleSoFieldConfig { + searchField: string; + accessor: (attrs: RuleSavedObjectAttributes) => string | undefined; +} + +const RULE_SO_FIELD_CONFIG: Partial> = { + [MatcherField.RuleName]: { + searchField: 'metadata.name', + accessor: (a) => a.metadata.name, + }, + [MatcherField.RuleDescription]: { + searchField: 'metadata.description', + accessor: (a) => a.metadata.description, + }, +}; + +const MATCHER_FIELD_TO_ES_FIELD: Partial> = { + [MatcherField.EpisodeId]: 'episode.id', + [MatcherField.GroupHash]: 'group_hash', +}; + +const getEscapedQuery = (q: string = '') => + q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`); + +const isIndexNotFoundException = (e: unknown): boolean => { + const err = e as Record | undefined; + return ( + err?.meta?.body?.error?.type === 'index_not_found_exception' || + err?.body?.error?.type === 'index_not_found_exception' + ); +}; + +@injectable() +export class MatcherSuggestionsService { + constructor( + @inject(RuleSavedObjectsClientToken) + private readonly ruleSoClient: SavedObjectsClientContract, + @inject(EsServiceScopedToken) + private readonly esClient: ElasticsearchClient + ) {} + + async getSuggestions(field: string, query: string): Promise { + const soFieldConfig = RULE_SO_FIELD_CONFIG[field as MatcherField]; + if (soFieldConfig) { + return this.getRuleSoFieldSuggestions( + query, + soFieldConfig.searchField, + soFieldConfig.accessor + ); + } + + const esField = MATCHER_FIELD_TO_ES_FIELD[field as MatcherField]; + if (esField) { + return this.getAlertEventFieldSuggestions(esField, query); + } + + switch (field) { + case MatcherField.EpisodeStatus: + return this.getStaticSuggestions(EPISODE_STATUS_VALUES, query); + + case MatcherField.RuleLabels: + return this.getRuleLabelsSuggestions(query); + + case MatcherField.RuleId: + return this.getRuleIdSuggestions(query); + + default: + if (field.startsWith('data.')) { + return this.getAlertEventFieldSuggestions(field, query); + } + return []; + } + } + + async getDataFieldNames(): Promise { + try { + const result = await this.esClient.search({ + index: ALERT_EVENTS_DATA_STREAM, + size: DATA_FIELD_SAMPLE_SIZE, + timeout: '10s', + _source: ['data'], + query: { + bool: { + filter: [ + { term: { type: 'alert' } }, + { range: { '@timestamp': { gte: ALERT_EVENTS_LOOKBACK } } }, + { exists: { field: 'data' } }, + { terms: { 'episode.status': ['pending', 'active', 'recovering'] } }, + ], + }, + }, + sort: [{ '@timestamp': 'desc' }], + }); + + const fieldNames = new Set(); + for (const hit of result.hits.hits) { + const source = hit._source as { data?: Record } | undefined; + if (source?.data && typeof source.data === 'object') { + for (const key of Object.keys(flattenObject(source.data, 'data'))) { + fieldNames.add(key); + } + } + } + + return Array.from(fieldNames).sort().slice(0, MAX_DATA_FIELDS); + } catch (e) { + if (isIndexNotFoundException(e)) { + return []; + } + throw e; + } + } + + private getStaticSuggestions(values: string[], query: string): string[] { + const lowerQuery = query.toLowerCase(); + return values + .filter((v) => !lowerQuery || v.toLowerCase().startsWith(lowerQuery)) + .slice(0, MAX_SUGGESTIONS); + } + + private async getRuleSoFieldSuggestions( + query: string, + searchField: string, + accessor: (attrs: RuleSavedObjectAttributes) => string | undefined + ): Promise { + const result = await this.ruleSoClient.find({ + type: RULE_SAVED_OBJECT_TYPE, + page: 1, + perPage: MAX_SUGGESTIONS, + ...(query ? { search: `${getEscapedQuery(query)}*`, searchFields: [searchField] } : {}), + sortField: 'updatedAt', + sortOrder: 'desc', + }); + + return result.saved_objects + .map((so) => accessor(so.attributes)) + .filter((v): v is string => typeof v === 'string' && v.length > 0); + } + + private async getRuleLabelsSuggestions(query: string): Promise { + const result = await this.ruleSoClient.find({ + type: RULE_SAVED_OBJECT_TYPE, + page: 1, + perPage: 100, + fields: ['metadata.labels'], + sortField: 'updatedAt', + sortOrder: 'desc', + }); + + const allLabels = new Set(); + for (const so of result.saved_objects) { + const labels = so.attributes.metadata?.labels; + if (Array.isArray(labels)) { + for (const label of labels) { + allLabels.add(label); + } + } + } + + const lowerQuery = query.toLowerCase(); + return Array.from(allLabels) + .filter((label) => !lowerQuery || label.toLowerCase().startsWith(lowerQuery)) + .sort() + .slice(0, MAX_SUGGESTIONS); + } + + private async getRuleIdSuggestions(query: string): Promise { + const result = await this.ruleSoClient.find({ + type: RULE_SAVED_OBJECT_TYPE, + page: 1, + perPage: 100, + sortField: 'updatedAt', + sortOrder: 'desc', + }); + + const lowerQuery = query.toLowerCase(); + return result.saved_objects + .map((so) => so.id) + .filter((id) => !lowerQuery || id.toLowerCase().startsWith(lowerQuery)) + .slice(0, MAX_SUGGESTIONS); + } + + private async getAlertEventFieldSuggestions( + esFieldName: string, + query: string + ): Promise { + try { + const result = await this.esClient.search({ + index: ALERT_EVENTS_DATA_STREAM, + size: 0, + timeout: '10s', + terminate_after: 100000, + query: { + bool: { + filter: [ + { term: { type: 'alert' } }, + { range: { '@timestamp': { gte: ALERT_EVENTS_LOOKBACK } } }, + ], + }, + }, + aggs: { + suggestions: { + terms: { + size: MAX_SUGGESTIONS, + field: esFieldName, + include: `${getEscapedQuery(query)}.*`, + execution_hint: 'map' as const, + }, + }, + }, + }); + + const aggs = result.aggregations as + | { suggestions?: { buckets?: Array<{ key: string }> } } + | undefined; + return (aggs?.suggestions?.buckets ?? []).map((bucket) => bucket.key); + } catch (e) { + if (isIndexNotFoundException(e)) { + return []; + } + throw e; + } + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/datastreams/alert_actions.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/datastreams/alert_actions.ts index 79a2d7801b765..21ec79f65dda7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/datastreams/alert_actions.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/datastreams/alert_actions.ts @@ -39,6 +39,7 @@ const mappings: MappingsDefinition = { action_type: { type: 'keyword' }, group_hash: { type: 'keyword' }, episode_id: { type: 'keyword' }, + episode_status: { type: 'keyword' }, rule_id: { type: 'keyword' }, tags: { type: 'keyword' }, notification_group_id: { type: 'keyword' }, @@ -55,6 +56,7 @@ export const alertActionSchema = z.object({ actor: z.string().nullable(), action_type: z.string(), episode_id: z.string().optional(), + episode_status: z.string().optional(), rule_id: z.string(), notification_group_id: z.string().optional(), source: z.string().optional(), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts index 09ee569a3f519..efe396ae183a9 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/constants.ts @@ -9,4 +9,5 @@ export { ALERTING_V2_RULE_API_PATH, ALERTING_V2_ALERT_API_PATH, ALERTING_V2_NOTIFICATION_POLICY_API_PATH, + INTERNAL_ALERTING_V2_SUGGESTIONS_API_PATH, } from '@kbn/alerting-v2-constants'; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/routes/suggestions/matcher_data_fields_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/suggestions/matcher_data_fields_route.ts new file mode 100644 index 0000000000000..a28d16be5d2ce --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/suggestions/matcher_data_fields_route.ts @@ -0,0 +1,47 @@ +/* + * 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 { Response } from '@kbn/core-di-server'; +import type { RouteSecurity } from '@kbn/core-http-server'; +import type { KibanaResponseFactory } from '@kbn/core/server'; +import { inject, injectable } from 'inversify'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { MatcherSuggestionsService } from '../../lib/services/matcher_suggestions_service/matcher_suggestions_service'; +import { ALERTING_V2_NOTIFICATION_POLICY_API_PATH } from '../constants'; + +@injectable() +export class MatcherDataFieldsRoute { + static method = 'get' as const; + static path = `${ALERTING_V2_NOTIFICATION_POLICY_API_PATH}/suggestions/data_fields`; + static security: RouteSecurity = { + authz: { + requiredPrivileges: [ALERTING_V2_API_PRIVILEGES.notificationPolicies.read], + }, + }; + static options = { access: 'internal' } as const; + static validate = false as const; + + constructor( + @inject(Response) private readonly response: KibanaResponseFactory, + @inject(MatcherSuggestionsService) + private readonly suggestionsService: MatcherSuggestionsService + ) {} + + async handle() { + try { + const fields = await this.suggestionsService.getDataFieldNames(); + return this.response.ok({ body: fields }); + } 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/routes/suggestions/matcher_value_suggestions_route.ts b/x-pack/platform/plugins/shared/alerting_v2/server/routes/suggestions/matcher_value_suggestions_route.ts new file mode 100644 index 0000000000000..64a5e6b74f1f0 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/routes/suggestions/matcher_value_suggestions_route.ts @@ -0,0 +1,69 @@ +/* + * 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 } from '@kbn/config-schema'; +import type { KibanaRequest, KibanaResponseFactory } from '@kbn/core/server'; +import type { RouteSecurity } from '@kbn/core-http-server'; +import type { TypeOf } from '@kbn/config-schema'; +import { inject, injectable } from 'inversify'; +import { Request, Response } from '@kbn/core-di-server'; +import { ALERTING_V2_API_PRIVILEGES } from '../../lib/security/privileges'; +import { INTERNAL_ALERTING_V2_SUGGESTIONS_API_PATH } from '../constants'; +import { MatcherSuggestionsService } from '../../lib/services/matcher_suggestions_service/matcher_suggestions_service'; + +const suggestionsBodySchema = schema.object({ + field: schema.string(), + query: schema.string(), + filters: schema.maybe(schema.any()), + fieldMeta: schema.maybe(schema.any()), +}); + +type SuggestionsBody = TypeOf; + +@injectable() +export class MatcherValueSuggestionsRoute { + static method = 'post' as const; + static path = INTERNAL_ALERTING_V2_SUGGESTIONS_API_PATH; + static security: RouteSecurity = { + authz: { + requiredPrivileges: [ + ALERTING_V2_API_PRIVILEGES.notificationPolicies.read, + ALERTING_V2_API_PRIVILEGES.rules.read, + ], + }, + }; + static options = { access: 'internal' } as const; + static validate = { + request: { + body: suggestionsBodySchema, + }, + } as const; + + constructor( + @inject(Request) + private readonly request: KibanaRequest, + @inject(Response) private readonly response: KibanaResponseFactory, + @inject(MatcherSuggestionsService) + private readonly suggestionsService: MatcherSuggestionsService + ) {} + + async handle() { + const { field, query } = this.request.body; + + try { + const values = await this.suggestionsService.getSuggestions(field, query); + return this.response.ok({ body: values }); + } 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/saved_objects/notification_policy_mappings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/notification_policy_mappings.ts index 1a5242762f904..68021a0105017 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 @@ -17,6 +17,7 @@ export const notificationPolicyMappings: SavedObjectsTypeMappingDefinition = { description: { type: 'text', fields: { keyword: { type: 'keyword', ignore_above: 256 } } }, enabled: { type: 'boolean' }, groupBy: { type: 'keyword' }, + groupingMode: { type: 'keyword' }, destinations: { type: 'object', properties: { @@ -24,6 +25,12 @@ export const notificationPolicyMappings: SavedObjectsTypeMappingDefinition = { id: { type: 'keyword' }, }, }, + throttle: { + type: 'object', + properties: { + strategy: { type: 'keyword' }, + }, + }, snoozedUntil: { type: 'date' }, auth: { type: 'object', 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 26f196ac79ec2..ae82ed8bb6b18 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 @@ -21,10 +21,27 @@ export const notificationPolicySavedObjectAttributesSchema = schema.object({ groupBy: schema.maybe( schema.nullable(schema.arrayOf(schema.string(), { minSize: 1, maxSize: 10 })) ), + groupingMode: schema.maybe( + schema.nullable( + schema.oneOf([ + schema.literal('per_episode'), + schema.literal('all'), + schema.literal('per_field'), + ]) + ) + ), throttle: schema.maybe( schema.nullable( schema.object({ - interval: schema.string(), + strategy: schema.maybe( + schema.oneOf([ + schema.literal('on_status_change'), + schema.literal('per_status_interval'), + schema.literal('time_interval'), + schema.literal('every_time'), + ]) + ), + interval: schema.maybe(schema.string()), }) ) ), 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 6dc0169fa7854..ef39cdbde27ce 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 @@ -12,6 +12,7 @@ import type { AlertingServerSetupDependencies } from '../types'; import { registerFeaturePrivileges } from '../lib/security/privileges'; import { TaskDefinition } from '../lib/services/task_run_scope_service/create_task_runner'; import { registerSavedObjects } from '../saved_objects'; +import { dispatcherUiSettings } from '../lib/dispatcher/ui_settings'; export function bindOnSetup({ bind }: ContainerModuleLoadOptions) { bind(OnSetup).toConstantValue((container) => { @@ -33,6 +34,8 @@ export function bindOnSetup({ bind }: ContainerModuleLoadOptions) { alertingVTwo: {}, })); + container.get(CoreSetup('uiSettings')).registerGlobal(dispatcherUiSettings); + // Trigger task registration via onActivation callbacks container.getAll(TaskDefinition); }); 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 74ff69efd97a8..af979a7f2e20f 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 @@ -30,6 +30,8 @@ import { UnsnoozeNotificationPolicyRoute } from '../routes/notification_policies import { UpdateNotificationPolicyRoute } from '../routes/notification_policies/update_notification_policy_route'; import { UpdateNotificationPolicyApiKeyRoute } from '../routes/notification_policies/update_notification_policy_api_key_route'; import { DeleteNotificationPolicyRoute } from '../routes/notification_policies/delete_notification_policy_route'; +import { MatcherValueSuggestionsRoute } from '../routes/suggestions/matcher_value_suggestions_route'; +import { MatcherDataFieldsRoute } from '../routes/suggestions/matcher_data_fields_route'; export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(CreateRuleRoute); @@ -55,4 +57,6 @@ export function bindRoutes({ bind }: ContainerModuleLoadOptions) { bind(Route).toConstantValue(SnoozeNotificationPolicyRoute); bind(Route).toConstantValue(UnsnoozeNotificationPolicyRoute); bind(Route).toConstantValue(BulkActionNotificationPoliciesRoute); + bind(Route).toConstantValue(MatcherValueSuggestionsRoute); + bind(Route).toConstantValue(MatcherDataFieldsRoute); } 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 414bdcf944752..08ea8748e5bda 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,7 +15,11 @@ 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 } from '../lib/dispatcher/tokens'; +import { + DispatcherEnabledProviderToken, + DispatcherServiceInternalToken, +} from '../lib/dispatcher/tokens'; +import { DISPATCHER_ENABLED_SETTING_ID } from '../lib/dispatcher/ui_settings'; import { NotificationPolicyClient } from '../lib/notification_policy_client'; import { NotificationPolicyNamespaceToken } from '../lib/notification_policy_client/tokens'; import { RulesClient } from '../lib/rules_client'; @@ -62,6 +66,7 @@ import { EncryptedSavedObjectsClientToken, WorkflowsManagementApiToken, } from '../lib/dispatcher/steps/dispatch_step_tokens'; +import { MatcherSuggestionsService } from '../lib/services/matcher_suggestions_service/matcher_suggestions_service'; import type { AlertingServerSetupDependencies, AlertingServerStartDependencies } from '../types'; export function bindServices({ bind }: ContainerModuleLoadOptions) { @@ -220,9 +225,23 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { }) .inSingletonScope(); + bind(MatcherSuggestionsService).toSelf().inRequestScope(); + bind(DispatcherService).toSelf().inSingletonScope(); bind(DispatcherServiceInternalToken).toService(DispatcherService); + bind(DispatcherEnabledProviderToken) + .toDynamicValue(({ get }) => { + const savedObjects = get(CoreStart('savedObjects')); + const uiSettings = get(CoreStart('uiSettings')); + return async () => { + const soClient = savedObjects.createInternalRepository(); + const client = uiSettings.globalAsScopedToClient(soClient); + return client.get(DISPATCHER_ENABLED_SETTING_ID); + }; + }) + .inSingletonScope(); + bind(DirectorService).toSelf().inSingletonScope(); bind(TransitionStrategyFactory).toSelf().inSingletonScope(); diff --git a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json index 925bf1e295726..94c9b913616be 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json +++ b/x-pack/platform/plugins/shared/alerting_v2/tsconfig.json @@ -75,7 +75,8 @@ "@kbn/core-security-server-mocks", "@kbn/kql", "@kbn/scout", - "@kbn/alerting-v2-constants", + "@kbn/object-utils", + "@kbn/alerting-v2-constants" ], "exclude": ["target/**/*"] } diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/create_notification_policy.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/create_notification_policy.ts index 1884358edebf0..6ed617a57b484 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/create_notification_policy.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/create_notification_policy.ts @@ -52,6 +52,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(response.body.destinations).to.eql([{ type: 'workflow', id: 'my-workflow-id' }]); expect(response.body.matcher).to.be("env == 'production' && region == 'us-east-1'"); expect(response.body.groupBy).to.eql(['service.name', 'environment']); + expect(response.body.groupingMode).to.be(null); expect(response.body.throttle).to.eql({ interval: '1m' }); expect(response.body.createdAt).to.be.a('string'); expect(response.body.updatedAt).to.be.a('string'); @@ -191,9 +192,98 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(response.body.snoozedUntil).to.be(null); expect(response.body.matcher).to.be(null); expect(response.body.groupBy).to.be(null); + expect(response.body.groupingMode).to.be(null); expect(response.body.throttle).to.be(null); }); + it('should create a policy with groupingMode and throttle strategy', async () => { + const response = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'per-field-policy', + description: 'Groups by host with time interval', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupBy: ['host.name'], + groupingMode: 'per_field', + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + expect(response.status).to.be(200); + expect(response.body.groupingMode).to.be('per_field'); + expect(response.body.groupBy).to.eql(['host.name']); + expect(response.body.throttle).to.eql({ strategy: 'time_interval', interval: '5m' }); + }); + + it('should create a policy with per_episode grouping and on_status_change strategy', async () => { + const response = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'per-episode-policy', + description: 'Notifies on status change', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, + }); + + expect(response.status).to.be(200); + expect(response.body.groupingMode).to.be('per_episode'); + expect(response.body.throttle).to.eql({ strategy: 'on_status_change' }); + }); + + it('should create a policy with all grouping mode and every_time strategy', async () => { + const response = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'all-mode-policy', + description: 'Digest of all episodes every time', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'all', + throttle: { strategy: 'every_time' }, + }); + + expect(response.status).to.be(200); + expect(response.body.groupingMode).to.be('all'); + expect(response.body.throttle).to.eql({ strategy: 'every_time' }); + }); + + it('should return 400 for invalid groupingMode/strategy combination', async () => { + const response = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'invalid-combo', + description: 'on_status_change is not valid for all mode', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'all', + throttle: { strategy: 'on_status_change' }, + }); + + expect(response.status).to.be(400); + }); + + it('should return 400 when time_interval strategy is missing interval', async () => { + const response = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'missing-interval', + description: 'time_interval requires interval', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'all', + throttle: { strategy: 'time_interval' }, + }); + + expect(response.status).to.be(400); + }); + it('should return 400 when destinations is an empty array', async () => { const response = await supertestWithoutAuth .post(NOTIFICATION_POLICY_API_PATH) diff --git a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/update_notification_policy.ts b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/update_notification_policy.ts index 888464d21a49d..22b6a1eab6c20 100644 --- a/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/update_notification_policy.ts +++ b/x-pack/platform/test/api_integration_deployment_agnostic/apis/alerting_v2/notification_policy/update_notification_policy.ts @@ -222,6 +222,77 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) { expect(secondUpdate.status).to.be(409); }); + it('should update groupingMode and throttle strategy', async () => { + const createResponse = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'mode-update-policy', + description: 'will update grouping mode', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'per_episode', + throttle: { strategy: 'on_status_change' }, + }); + + expect(createResponse.status).to.be(200); + expect(createResponse.body.groupingMode).to.be('per_episode'); + + const createdPolicyId = createResponse.body.id as string; + const currentVersion = createResponse.body.version as string; + + const response = await supertestWithoutAuth + .put(`${NOTIFICATION_POLICY_API_PATH}/${createdPolicyId}`) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + groupingMode: 'all', + throttle: { strategy: 'time_interval', interval: '10m' }, + version: currentVersion, + }); + + expect(response.status).to.be(200); + expect(response.body.groupingMode).to.be('all'); + expect(response.body.throttle).to.eql({ strategy: 'time_interval', interval: '10m' }); + expect(response.body.name).to.be('mode-update-policy'); + }); + + it('should clear groupingMode when set to null', async () => { + const createResponse = await supertestWithoutAuth + .post(NOTIFICATION_POLICY_API_PATH) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + name: 'clear-mode-policy', + description: 'will clear grouping mode', + destinations: [{ type: 'workflow', id: 'wf-1' }], + groupingMode: 'per_field', + groupBy: ['host.name'], + throttle: { strategy: 'time_interval', interval: '5m' }, + }); + + expect(createResponse.status).to.be(200); + + const createdPolicyId = createResponse.body.id as string; + const currentVersion = createResponse.body.version as string; + + const response = await supertestWithoutAuth + .put(`${NOTIFICATION_POLICY_API_PATH}/${createdPolicyId}`) + .set(roleAuthc.apiKeyHeader) + .set(samlAuth.getInternalRequestHeader()) + .send({ + groupingMode: null, + groupBy: null, + throttle: null, + version: currentVersion, + }); + + expect(response.status).to.be(200); + expect(response.body.groupingMode).to.be(null); + expect(response.body.groupBy).to.be(null); + expect(response.body.throttle).to.be(null); + }); + it('should clear nullable fields when set to null', async () => { const createResponse = await supertestWithoutAuth .post(NOTIFICATION_POLICY_API_PATH)