diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index 5f83cfe8f7438..88d3d918f7cc7 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -62,7 +62,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ad_hoc_run_params": "9c372f2a8f8b468e9b699a6df633c7f14fab7f13216c9ec160813e75bae56098", "alert": "119624b6025ea6794d2c33e2b41c2e4730d10446430b285691f7638ee6787af5", "alerting_notification_policy": "81fc65ed6652cd1196f83b4222f227e8c7a3f646a6e044f63a2d82f12dacbfb0", - "alerting_rule": "fad2aefdadcdc8740c6a98bb7478bc4813f28c8a4bb7531489eae08c92bc9f4b", + "alerting_rule": "9e26ddcbca3edb3803dcfe3d6bf908341441fb32c67005cab71ae0f7b9841387", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", @@ -303,7 +303,7 @@ describe('checking migration metadata changes on all registered SO types', () => "alerting_rule|global: e78adb1490c02adb4c705491c87e08332c0f668e", "alerting_rule|mappings: 05a17ab7488d3b86ec25c724f21a19cd7ebb9778", "alerting_rule|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "alerting_rule|10.1.0: 5f12aa504f5362fc34201184b3f95e1746c48201be73f37993418a943d409a67", + "alerting_rule|10.1.0: 8b54e0f9a45fff3fbb39dd35dde15784674293f7dfb199bc10218f3658266207", "======================================================================================", "alerting_rule_template|global: a8ee387a4bc794ff6450017a92742b39b79e0446", "alerting_rule_template|mappings: eccf889027b5ea2d292c1bf0f9280348deaec0ef", diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts new file mode 100644 index 0000000000000..de164de037e2a --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts @@ -0,0 +1,468 @@ +/* + * 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 { createRuleDataSchema, updateRuleDataSchema } from './rule_data_schema'; + +const validCreateData = { + kind: 'alert', + metadata: { name: 'test rule' }, + schedule: { every: '5m' }, + evaluation: { query: { base: 'FROM logs-* | LIMIT 1', condition: 'count > 0' } }, +}; + +describe('createRuleDataSchema', () => { + describe('valid payloads', () => { + it('accepts a minimal valid payload and applies defaults', () => { + const result = createRuleDataSchema.parse(validCreateData); + + expect(result).toEqual({ + kind: 'alert', + metadata: { name: 'test rule' }, + time_field: '@timestamp', + schedule: { every: '5m' }, + evaluation: { query: { base: 'FROM logs-* | LIMIT 1', condition: 'count > 0' } }, + }); + }); + + it('accepts a full payload with all optional fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + metadata: { name: 'test rule', owner: 'team-a', labels: ['label-1', 'label-2'] }, + time_field: 'event.created', + schedule: { every: '5m', lookback: '10m' }, + grouping: { fields: ['host.name'] }, + recovery_policy: { type: 'no_breach' }, + no_data: { behavior: 'recover', timeframe: '15m' }, + state_transition: { + pending_operator: 'AND', + pending_count: 3, + pending_timeframe: '10m', + recovering_operator: 'OR', + recovering_count: 5, + recovering_timeframe: '15m', + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + metadata: { name: 'test rule', owner: 'team-a', labels: ['label-1', 'label-2'] }, + time_field: 'event.created', + schedule: { every: '5m', lookback: '10m' }, + grouping: { fields: ['host.name'] }, + state_transition: { + pending_operator: 'AND', + pending_count: 3, + pending_timeframe: '10m', + recovering_operator: 'OR', + recovering_count: 5, + recovering_timeframe: '15m', + }, + }) + ); + }); + + it('accepts kind "signal" without state_transition', () => { + const result = createRuleDataSchema.parse({ ...validCreateData, kind: 'signal' }); + expect(result.kind).toBe('signal'); + }); + + it('strips unknown properties', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + unknownProp: 'should be removed', + }); + + expect(result).not.toHaveProperty('unknownProp'); + }); + }); + + describe('metadata.name', () => { + it('rejects an empty name', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + metadata: { name: '' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a name exceeding 256 characters', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + metadata: { name: 'a'.repeat(257) }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('kind', () => { + it('rejects an invalid kind', () => { + const result = createRuleDataSchema.safeParse({ ...validCreateData, kind: 'unknown' }); + expect(result.success).toBe(false); + }); + }); + + describe('metadata.labels', () => { + it('rejects labels exceeding 100 items', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + metadata: { + name: 'test rule', + labels: Array.from({ length: 101 }, (_, i) => `label-${i}`), + }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects a label exceeding 64 characters', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + metadata: { name: 'test rule', labels: ['a'.repeat(65)] }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('schedule', () => { + it('rejects an invalid duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + schedule: { every: 'bad' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside schedule', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + schedule: { every: '1m', extra: true }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('evaluation.query.base', () => { + it('rejects an empty query', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + evaluation: { query: { base: '', condition: 'count > 0' } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid ES|QL query', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + evaluation: { query: { base: 'FROM |', condition: 'count > 0' } }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('schedule.lookback', () => { + it('rejects an invalid duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + schedule: { every: '5m', lookback: 'invalid' }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('grouping.fields', () => { + it('rejects more than 16 keys', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + grouping: { fields: Array.from({ length: 17 }, (_, i) => `field-${i}`) }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('state_transition', () => { + it('accepts an empty state_transition object', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + state_transition: {}, + }); + + expect(result.state_transition).toEqual({}); + }); + + it('accepts state_transition with only pending fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + state_transition: { + pending_operator: 'AND', + pending_count: 2, + pending_timeframe: '10m', + }, + }); + + expect(result.state_transition).toEqual({ + pending_operator: 'AND', + pending_count: 2, + pending_timeframe: '10m', + }); + }); + + it('accepts state_transition with only recovering fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + state_transition: { + recovering_operator: 'OR', + recovering_count: 5, + recovering_timeframe: '15m', + }, + }); + + expect(result.state_transition).toEqual({ + recovering_operator: 'OR', + recovering_count: 5, + recovering_timeframe: '15m', + }); + }); + + it('accepts pending_count of 0', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + state_transition: { pending_count: 0 }, + }); + + expect(result.state_transition?.pending_count).toBe(0); + }); + + it('accepts recovering_count of 0', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + state_transition: { recovering_count: 0 }, + }); + + expect(result.state_transition?.recovering_count).toBe(0); + }); + + it('rejects a negative pending_count', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { pending_count: -1 }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects a non-integer pending_count', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { pending_count: 1.5 }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects a negative recovering_count', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { recovering_count: -1 }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects a non-integer recovering_count', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { recovering_count: 2.5 }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an invalid pending_operator', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { pending_operator: 'XOR' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an invalid recovering_operator', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { recovering_operator: 'NOT' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an invalid pending_timeframe duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { pending_timeframe: 'bad' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an invalid recovering_timeframe duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { recovering_timeframe: 'bad' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside state_transition (strict)', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + state_transition: { unknownKey: true }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects state_transition when kind is "signal"', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + kind: 'signal', + state_transition: { pending_count: 1 }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe('required fields', () => { + it.each(['kind', 'metadata', 'schedule', 'evaluation'] as const)( + 'rejects when required field "%s" is missing', + (field) => { + const { [field]: _, ...data } = validCreateData; + const result = createRuleDataSchema.safeParse(data); + expect(result.success).toBe(false); + } + ); + }); +}); + +describe('updateRuleDataSchema', () => { + it('accepts an empty payload', () => { + const result = updateRuleDataSchema.parse({}); + expect(result).toEqual({}); + }); + + it('accepts partial updates', () => { + const result = updateRuleDataSchema.parse({ metadata: { name: 'updated name' } }); + expect(result).toEqual({ metadata: { name: 'updated name' } }); + }); + + it('accepts a state_transition object', () => { + const result = updateRuleDataSchema.parse({ + state_transition: { pending_count: 3, recovering_count: 5 }, + }); + + expect(result.state_transition).toEqual({ pending_count: 3, recovering_count: 5 }); + }); + + it('accepts state_transition set to null (removal)', () => { + const result = updateRuleDataSchema.parse({ state_transition: null }); + expect(result.state_transition).toBeNull(); + }); + + it('strips unknown properties', () => { + const result = updateRuleDataSchema.parse({ unknownProp: 'removed' }); + expect(result).not.toHaveProperty('unknownProp'); + }); + + describe('field constraints', () => { + it('rejects an empty name', () => { + const result = updateRuleDataSchema.safeParse({ metadata: { name: '' } }); + expect(result.success).toBe(false); + }); + + it('rejects a name exceeding 256 characters', () => { + const result = updateRuleDataSchema.safeParse({ + metadata: { name: 'a'.repeat(257) }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid schedule duration', () => { + const result = updateRuleDataSchema.safeParse({ schedule: { every: 'bad' } }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid lookback duration', () => { + const result = updateRuleDataSchema.safeParse({ schedule: { lookback: 'bad' } }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid ES|QL query', () => { + const result = updateRuleDataSchema.safeParse({ + evaluation: { query: { base: 'FROM |' } }, + }); + expect(result.success).toBe(false); + }); + + it('rejects more than 100 labels', () => { + const result = updateRuleDataSchema.safeParse({ + metadata: { labels: Array.from({ length: 101 }, (_, i) => `label-${i}`) }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects more than 16 grouping fields', () => { + const result = updateRuleDataSchema.safeParse({ + grouping: { fields: Array.from({ length: 17 }, (_, i) => `field-${i}`) }, + }); + + expect(result.success).toBe(false); + }); + }); + + describe('state_transition constraints', () => { + it('rejects an invalid pending_operator', () => { + const result = updateRuleDataSchema.safeParse({ + state_transition: { pending_operator: 'XOR' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects a non-integer pending_count', () => { + const result = updateRuleDataSchema.safeParse({ + state_transition: { pending_count: 1.5 }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects an invalid pending_timeframe duration', () => { + const result = updateRuleDataSchema.safeParse({ + state_transition: { pending_timeframe: 'bad' }, + }); + + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside state_transition (strict)', () => { + const result = updateRuleDataSchema.safeParse({ + state_transition: { unknownKey: true }, + }); + + expect(result.success).toBe(false); + }); + }); +}); diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts index d3dd989efa94a..2e2dadaf792bb 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.ts @@ -122,7 +122,9 @@ const stateTransitionSchema = z .describe('Time window for recovering evaluation.'), }) .strict() - .describe('Episode state transition thresholds (alert-only).'); + .describe('Episode state transition thresholds (alert-only).') + .optional() + .nullable(); /** Grouping (optional) */ @@ -172,12 +174,25 @@ export const createRuleDataSchema = z schedule: scheduleSchema, evaluation: evaluationSchema, recovery_policy: recoveryPolicySchema.optional(), - state_transition: stateTransitionSchema.optional(), + state_transition: stateTransitionSchema, grouping: groupingSchema.optional(), no_data: noDataSchema.optional(), notification_policies: z.array(notificationPolicyRefSchema).optional(), }) - .strip(); + .strip() + /** + * + * The `.refine` method adds a custom validation to the schema. + * In this case, it enforces that the `state_transition` property is only allowed when `kind` is "alert". + * The predicate `data.kind === 'alert' || data.state_transition == null` means: + * - If the rule kind is "alert", `state_transition` may be present (or absent). + * - For any other `kind`, `state_transition` must be `null` or `undefined`. + * If validation fails, the specified error message will be associated with the `state_transition` field. + */ + .refine((data) => data.kind === 'alert' || data.state_transition == null, { + message: 'state_transition is only allowed when kind is "alert".', + path: ['state_transition'], + }); export type CreateRuleData = z.infer; @@ -187,7 +202,7 @@ export const updateRuleDataSchema = z .object({ metadata: metadataSchema.partial().optional(), time_field: z.string().min(1).max(128).optional(), - schedule: scheduleSchema.partial().optional(), + schedule: scheduleSchema.partial().optional().nullable(), evaluation: z .object({ query: z @@ -201,7 +216,7 @@ export const updateRuleDataSchema = z .strict() .optional(), recovery_policy: recoveryPolicySchema.optional().nullable(), - state_transition: stateTransitionSchema.optional().nullable(), + state_transition: stateTransitionSchema, grouping: groupingSchema.optional().nullable(), no_data: noDataSchema.optional().nullable(), notification_policies: z.array(notificationPolicyRefSchema).optional().nullable(), diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_response.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_response.ts index 6040e5902b25b..2a7378d47f2d0 100644 --- a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_response.ts +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_response.ts @@ -46,7 +46,7 @@ export interface RuleResponse { recovering_operator?: 'AND' | 'OR'; recovering_count?: number; recovering_timeframe?: string; - }; + } | null; grouping?: { fields: string[]; }; diff --git a/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.test.ts b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.test.ts new file mode 100644 index 0000000000000..e83e3f50d2aee --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.test.ts @@ -0,0 +1,32 @@ +/* + * 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 { validateDuration, validateEsqlQuery } from './validation'; + +describe('validateDuration', () => { + it.each(['500ms', '30s', '5m', '1h', '7d', '2w'])('accepts valid duration "%s"', (value) => { + expect(validateDuration(value)).toBeUndefined(); + }); + + it.each(['', 'abc', '5x', '1.5m', 'm5', '5 m', '5M', '5H', '-1m', '5min', null, undefined, NaN])( + 'rejects invalid duration "%s"', + (value) => { + // @ts-expect-error - testing invalid values + expect(validateDuration(value)).not.toBeUndefined(); + } + ); +}); + +describe('validateEsqlQuery', () => { + it('accepts valid ES|QL query', () => { + expect(validateEsqlQuery('FROM logs-* | LIMIT 1')).toBeUndefined(); + }); + + it('rejects invalid ES|QL query', () => { + expect(validateEsqlQuery('FROM |')).toMatch(/Invalid ES\|QL query/); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/jest.integration.config.js b/x-pack/platform/plugins/shared/alerting_v2/jest.integration.config.js index 1c85c10d36f10..3441a10a4e6d6 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/jest.integration.config.js +++ b/x-pack/platform/plugins/shared/alerting_v2/jest.integration.config.js @@ -8,5 +8,7 @@ module.exports = { preset: '@kbn/test/jest_integration', rootDir: '../../../../..', - roots: ['/x-pack/platform/plugins/shared/alerting_v2'], + roots: [ + '/x-pack/platform/plugins/shared/alerting_v2/server/lib/dispatcher/integration_tests', + ], }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/README.md b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/README.md index f1e9cf2a669c9..0e561a2f8c08c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/README.md +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/README.md @@ -41,27 +41,35 @@ The `DirectorService` is the main orchestrator that: 4. Resolves episode IDs (creates new ones for new lifecycles) 5. Returns enriched alert events with episode information -### TransitionStrategyResolver +### TransitionStrategyFactory -The `TransitionStrategyResolver` is responsible for: +The `TransitionStrategyFactory` is responsible for: -- Registering available transition strategies -- Resolving which strategy to use for state transitions -- Providing a default strategy when none is specified +- Resolving which strategy to use for each rule at runtime +- Delegating selection to each strategy through `canHandle(rule)` +- Falling back to the default strategy when no specialized strategy matches +- Auto-discovering strategies through DI multi-injection (no constructor growth as strategies increase) ### ITransitionStrategy The `ITransitionStrategy` interface defines the contract for all transition strategies: ```typescript -interface TransitionContext { - currentAlertEpisodeStatus?: AlertEpisodeStatus | null; - alertEventStatus: AlertEventStatus; +interface StateTransitionContext { + rule: RuleResponse; + alertEvent: AlertEvent; + previousEpisode?: LatestAlertEventState; } interface ITransitionStrategy { name: string; - getNextState(ctx: TransitionContext): AlertEpisodeStatus; + canHandle(rule: RuleResponse): boolean; + getNextState(ctx: StateTransitionContext): StateTransitionResult; +} + +interface StateTransitionResult { + status: AlertEpisodeStatus; + statusCount?: number; } ``` @@ -145,128 +153,113 @@ The `BasicTransitionStrategy` implements a state machine with the following tran ## Creating a Custom Strategy -To create a custom transition strategy, implement the `ITransitionStrategy` interface. +To create a custom transition strategy, implement `ITransitionStrategy`. ### Step 1: Create the Strategy Class ```typescript import { injectable } from 'inversify'; -import { alertEpisodeStatus, type AlertEpisodeStatus } from '../../../resources/alert_events'; -import type { ITransitionStrategy, TransitionContext } from './types'; +import { alertEpisodeStatus } from '../../../resources/alert_events'; +import type { RuleResponse } from '../../rules_client/types'; +import type { + ITransitionStrategy, + StateTransitionContext, + StateTransitionResult, +} from './types'; @injectable() export class CustomTransitionStrategy implements ITransitionStrategy { readonly name = 'custom'; - getNextState({ - currentAlertEpisodeStatus, - alertEventStatus, - }: TransitionContext): AlertEpisodeStatus { - // Implement your custom transition logic here - - return alertEpisodeStatus.pending; - + canHandle(rule: RuleResponse): boolean { + // Return true when this strategy should be used for the given rule. + return rule.kind === 'alert'; + } + + getNextState(ctx: StateTransitionContext): StateTransitionResult { + // Implement your custom transition logic here. + return { status: alertEpisodeStatus.pending, statusCount: 1 }; } } ``` ### Step 2: Register the Strategy -Register your custom strategy with the `TransitionStrategyResolver`: +Register your custom strategy with DI token-based multi-injection: ```typescript -import { inject, injectable } from 'inversify'; -import type { ITransitionStrategy } from './types'; import { BasicTransitionStrategy } from './basic_strategy'; +import { TransitionStrategyFactory } from './strategy_resolver'; +import { TransitionStrategyToken } from './types'; import { CustomTransitionStrategy } from './custom_strategy'; -@injectable() -export class TransitionStrategyResolver { - private strategies = new Map(); - private defaultStrategy: ITransitionStrategy; - - constructor( - @inject(BasicTransitionStrategy) basic: BasicTransitionStrategy, - @inject(CustomTransitionStrategy) custom: CustomTransitionStrategy - ) { - this.register(basic); - this.register(custom); - this.defaultStrategy = basic; - } - - register(strategy: ITransitionStrategy) { - this.strategies.set(strategy.name, strategy); - } - - resolve(strategyName?: string): ITransitionStrategy { - if (strategyName) { - const strategy = this.strategies.get(strategyName); - if (strategy) { - return strategy; - } - } - return this.defaultStrategy; - } -} +bind(TransitionStrategyFactory).toSelf().inSingletonScope(); +bind(TransitionStrategyToken).to(CustomTransitionStrategy).inSingletonScope(); +bind(TransitionStrategyToken).to(BasicTransitionStrategy).inSingletonScope(); ``` -### Step 3: Bind the Strategy in the DI Container +**Important:** binding order matters. -Add the binding in your module's DI container configuration: - -```typescript -container.bind(CustomTransitionStrategy).toSelf().inSingletonScope(); -``` +- Specialized strategies first +- Fallback strategy (`BasicTransitionStrategy`) last ## Testing Strategies -When testing custom strategies, you can use the following pattern: +When testing strategies, use the shared helpers in `director/test_utils.ts`: + +- `buildLatestAlertEvent(...)` +- `buildStrategyStateTransitionContext(...)` + +### Example: strategy unit test setup ```typescript -import { BasicTransitionStrategy } from './basic_strategy'; +import { CountTimeframeStrategy } from './count_timeframe_strategy'; import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; +import { + buildLatestAlertEvent, + buildStrategyStateTransitionContext, +} from '../test_utils'; -describe('BasicTransitionStrategy', () => { - let strategy: BasicTransitionStrategy; +describe('CountTimeframeStrategy', () => { + let strategy: CountTimeframeStrategy; beforeEach(() => { - strategy = new BasicTransitionStrategy(); + strategy = new CountTimeframeStrategy(); }); - it('should transition from inactive to pending on breach', () => { + it('transitions pending to active when threshold is met', () => { const result = strategy.getNextState({ - currentAlertEpisodeStatus: alertEpisodeStatus.inactive, - alertEventStatus: alertEventStatus.breached, + ...buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + stateTransition: { pendingCount: 2 }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }), + }), }); - expect(result).toBe(alertEpisodeStatus.pending); + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); +}); +``` - it('should transition from pending to active on consecutive breach', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: alertEpisodeStatus.pending, - alertEventStatus: alertEventStatus.breached, - }); +## Notes on Count/Timeframe Strategies - expect(result).toBe(alertEpisodeStatus.active); - }); +`CountTimeframeStrategy` extends `BasicTransitionStrategy` and adds threshold-based +gating for: - it('should transition from active to recovering on recovery', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: alertEpisodeStatus.active, - alertEventStatus: alertEventStatus.recovered, - }); +- `pending -> active` +- `recovering -> inactive` - expect(result).toBe(alertEpisodeStatus.recovering); - }); +Threshold evaluation supports: - it('should handle null current status', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: null, - alertEventStatus: alertEventStatus.breached, - }); +- count only +- timeframe only +- count + timeframe with `AND` or `OR` - expect(result).toBe(alertEpisodeStatus.pending); - }); -}); -``` \ No newline at end of file +For timeframe evaluation, elapsed time is computed from: + +- current alert event `@timestamp` +- previous episode `last_episode_timestamp` \ No newline at end of file diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.mock.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.mock.ts index 070c1b2911959..9165d79cf3154 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.mock.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.mock.ts @@ -9,8 +9,7 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import { createQueryService } from '../services/query_service/query_service.mock'; -import { BasicTransitionStrategy } from './strategies/basic_strategy'; -import { TransitionStrategyFactory } from './strategies/strategy_resolver'; +import { createTransitionStrategyFactory } from './strategies/strategy_resolver.mock'; import { DirectorService } from './director'; export function createDirectorService(): { @@ -20,8 +19,7 @@ export function createDirectorService(): { const { queryService, mockEsClient } = createQueryService(); const { loggerService } = createLoggerService(); - const basicStrategy = new BasicTransitionStrategy(); - const strategyFactory = new TransitionStrategyFactory(basicStrategy); + const strategyFactory = createTransitionStrategyFactory(); const directorService = new DirectorService(strategyFactory, queryService, loggerService); return { diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.test.ts index 273ca1a78b90b..138cb7b4c8a25 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.test.ts @@ -8,12 +8,12 @@ import type { ElasticsearchClient } from '@kbn/core/server'; import type { DeeplyMockedApi } from '@kbn/core-elasticsearch-client-server-mocks'; import { DirectorService } from './director'; -import { TransitionStrategyFactory } from './strategies/strategy_resolver'; -import { BasicTransitionStrategy } from './strategies/basic_strategy'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import { createQueryService } from '../services/query_service/query_service.mock'; -import { alertEpisodeStatus } from '../../resources/alert_events'; +import { createTransitionStrategyFactory } from './strategies/strategy_resolver.mock'; +import { alertEpisodeStatus, alertEventType } from '../../resources/alert_events'; import { createAlertEvent, createEsqlResponse } from '../rule_executor/test_utils'; +import { createRuleResponse } from '../test_utils'; import type { LatestAlertEventState } from './queries'; jest.mock('uuid', () => ({ @@ -26,9 +26,18 @@ function createLatestAlertEventStateResponse(records: Array [r.last_status, r.last_episode_id, r.last_episode_status, r.group_hash]) + records.map((r) => [ + r.last_status, + r.last_episode_id, + r.last_episode_status, + r.last_episode_status_count, + r.last_episode_timestamp ?? null, + r.group_hash, + ]) ); } @@ -37,8 +46,7 @@ describe('DirectorService', () => { let mockEsClient: DeeplyMockedApi; beforeEach(() => { - const basicStrategy = new BasicTransitionStrategy(); - const strategyFactory = new TransitionStrategyFactory(basicStrategy); + const strategyFactory = createTransitionStrategyFactory(); const { queryService, mockEsClient: esClient } = createQueryService(); const { loggerService } = createLoggerService(); @@ -50,10 +58,12 @@ describe('DirectorService', () => { jest.clearAllMocks(); }); + const rule = createRuleResponse(); + describe('run', () => { it('returns empty array when no alert events provided', async () => { const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [], }); @@ -71,7 +81,7 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue(createLatestAlertEventStateResponse([])); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -93,16 +103,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'episode-1', last_episode_status: null, + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -124,16 +136,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'existing-episode-1', last_episode_status: 'inactive', + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -153,16 +167,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'existing-episode', last_episode_status: 'pending', + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -182,16 +198,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'existing-episode', last_episode_status: 'active', + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -211,16 +229,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'recovered', last_episode_id: 'existing-episode', last_episode_status: 'recovering', + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -239,22 +259,26 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'episode-1', last_episode_status: 'active', + last_episode_status_count: null, group_hash: 'hash-1', }, { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'episode-2', last_episode_status: 'active', + last_episode_status_count: null, group_hash: 'hash-2', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents, }); @@ -281,16 +305,18 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'recovered', last_episode_id: 'old-episode', last_episode_status: 'inactive', + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -308,32 +334,123 @@ describe('DirectorService', () => { mockEsClient.esql.query.mockResolvedValue( createLatestAlertEventStateResponse([ { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', last_status: 'breached', last_episode_id: 'existing-episode', last_episode_status: alertEpisodeStatus.active, + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); expect(result[0].episode?.id).toBe('existing-episode'); }); + it('sets type to alert on returned events', async () => { + const alertEvent = createAlertEvent({ + group_hash: 'hash-1', + status: 'breached', + type: 'signal', + episode: undefined, + }); + + mockEsClient.esql.query.mockResolvedValue(createLatestAlertEventStateResponse([])); + + const result = await directorService.run({ + rule, + alertEvents: [alertEvent], + }); + + expect(result[0].type).toBe(alertEventType.alert); + }); + it('propagates query service errors', async () => { const alertEvent = createAlertEvent(); mockEsClient.esql.query.mockRejectedValue(new Error('Query failed')); await expect( directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }) ).rejects.toThrow('Query failed'); }); + + it('includes status_count in episode when strategy returns one', async () => { + const ruleWithTransition = createRuleResponse({ + state_transition: { pending_count: 3 }, + }); + + const alertEvent = createAlertEvent({ + group_hash: 'hash-1', + status: 'breached', + episode: undefined, + }); + + mockEsClient.esql.query.mockResolvedValue( + createLatestAlertEventStateResponse([ + { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', + last_status: 'breached', + last_episode_id: 'episode-1', + last_episode_status: 'pending', + last_episode_status_count: 1, + group_hash: 'hash-1', + }, + ]) + ); + + const result = await directorService.run({ + rule: ruleWithTransition, + alertEvents: [alertEvent], + }); + + expect(result[0].episode).toEqual({ + id: 'episode-1', + status: alertEpisodeStatus.pending, + status_count: 2, + }); + }); + + it('transitions to active when count threshold is met', async () => { + const ruleWithTransition = createRuleResponse({ + state_transition: { pending_count: 3 }, + }); + + const alertEvent = createAlertEvent({ + group_hash: 'hash-1', + status: 'breached', + episode: undefined, + }); + + mockEsClient.esql.query.mockResolvedValue( + createLatestAlertEventStateResponse([ + { + last_episode_timestamp: '2026-01-01T00:00:00.000Z', + last_status: 'breached', + last_episode_id: 'episode-1', + last_episode_status: 'pending', + last_episode_status_count: 2, + group_hash: 'hash-1', + }, + ]) + ); + + const result = await directorService.run({ + rule: ruleWithTransition, + alertEvents: [alertEvent], + }); + + expect(result[0].episode).toEqual({ + id: 'episode-1', + status: alertEpisodeStatus.active, + }); + }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.ts index d3fc50380b855..c4b317fe47195 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.ts @@ -13,17 +13,19 @@ import type { QueryServiceContract } from '../services/query_service/query_servi import { QueryServiceInternalToken } from '../services/query_service/tokens'; import { getLatestAlertEventStateQuery, type LatestAlertEventState } from './queries'; import type { AlertEpisodeStatus } from '../../resources/alert_events'; -import { alertEpisodeStatus, type AlertEvent } from '../../resources/alert_events'; +import { alertEpisodeStatus, alertEventType, type AlertEvent } from '../../resources/alert_events'; +import type { RuleResponse } from '../rules_client/types'; import { queryResponseToRecords } from '../services/query_service/query_response_to_records'; import { TransitionStrategyFactory } from './strategies/strategy_resolver'; -import type { ITransitionStrategy } from './strategies/types'; +import type { ITransitionStrategy, StateTransitionResult } from './strategies/types'; interface RunDirectorParams { - ruleId: string; + rule: RuleResponse; alertEvents: AlertEvent[]; } interface CalculateNextStateParams { + rule: RuleResponse; currentAlertEvent: AlertEvent; previousAlertEvent?: LatestAlertEventState; strategy: ITransitionStrategy; @@ -43,17 +45,18 @@ export class DirectorService { @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract ) {} - async run({ ruleId, alertEvents }: RunDirectorParams): Promise { + async run({ rule, alertEvents }: RunDirectorParams): Promise { if (alertEvents.length === 0) { return []; } - const strategy = this.strategyFactory.getStrategy(); + const strategy = this.strategyFactory.getStrategy(rule); const groupHashes = Array.from(new Set(alertEvents.map((event) => event.group_hash))); - const alertStateByGroupHash = await this.fetchLatestAlertStateByGroupHash(ruleId, groupHashes); + const alertStateByGroupHash = await this.fetchLatestAlertStateByGroupHash(rule.id, groupHashes); const alertsWithNextEpisode = alertEvents.map((currentAlertEvent) => this.getAlertEventWithNextEpisode({ + rule, currentAlertEvent, previousAlertEvent: alertStateByGroupHash.get(currentAlertEvent.group_hash), strategy, @@ -82,35 +85,39 @@ export class DirectorService { } private getAlertEventWithNextEpisode({ + rule, currentAlertEvent, previousAlertEvent, strategy, }: CalculateNextStateParams): AlertEvent { const currentStatus = previousAlertEvent?.last_episode_status; - const nextStatus = strategy.getNextState({ - currentAlertEpisodeStatus: currentStatus, - alertEventStatus: currentAlertEvent.status, + const result: StateTransitionResult = strategy.getNextState({ + rule, + alertEvent: currentAlertEvent, + previousEpisode: previousAlertEvent, }); const episodeId = this.resolveEpisodeId({ previousAlertEvent, - nextStatus, + nextStatus: result.status, }); - if (currentStatus !== nextStatus) { + if (currentStatus !== result.status) { this.logger.debug({ message: `State Transition [${currentAlertEvent.group_hash}]: ${ currentStatus ?? 'unknown' - } -> ${nextStatus} (Episode: ${episodeId})`, + } -> ${result.status} (Episode: ${episodeId})`, }); } return { ...currentAlertEvent, + type: alertEventType.alert, episode: { id: episodeId, - status: nextStatus, + status: result.status, + ...(result.statusCount != null ? { status_count: result.statusCount } : {}), }, }; } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/queries.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/queries.ts index af04c5038c297..156caf4295c0c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/queries.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/queries.ts @@ -18,6 +18,8 @@ export interface LatestAlertEventState { last_status: AlertEventStatus; last_episode_id: string | null; last_episode_status: AlertEpisodeStatus | null; + last_episode_status_count: number | null; + last_episode_timestamp: string | null; group_hash: string; } @@ -32,10 +34,19 @@ export const getLatestAlertEventStateQuery = ({ query = query.pipe`STATS last_status = LAST(status, @timestamp), last_episode_id = LAST(episode.id, @timestamp), - last_episode_status = LAST(episode.status, @timestamp) + last_episode_status = LAST(episode.status, @timestamp), + last_episode_status_count = LAST(episode.status_count, @timestamp), + last_episode_timestamp = MAX(@timestamp) BY group_hash`; - query = query.keep('last_status', 'last_episode_id', 'last_episode_status', 'group_hash'); + query = query.keep( + 'last_status', + 'last_episode_id', + 'last_episode_status', + 'last_episode_status_count', + 'last_episode_timestamp', + 'group_hash' + ); return query; }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.test.ts index 491c94eac3d01..4960410829934 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.test.ts @@ -6,7 +6,10 @@ */ import { BasicTransitionStrategy } from './basic_strategy'; +import type { AlertEpisodeStatus, AlertEventStatus } from '../../../resources/alert_events'; import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; +import { createRuleResponse } from '../../test_utils'; +import { buildLatestAlertEvent, buildStrategyStateTransitionContext } from '../test_utils'; describe('BasicTransitionStrategy', () => { let strategy: BasicTransitionStrategy; @@ -15,160 +18,144 @@ describe('BasicTransitionStrategy', () => { strategy = new BasicTransitionStrategy(); }); + const getNextState = (...args: Parameters) => + strategy.getNextState(buildStrategyStateTransitionContext(...args)); + + const expectTransition = ({ + from, + on, + to, + }: { + from?: AlertEpisodeStatus | null; + on: AlertEventStatus; + to: AlertEpisodeStatus; + }) => { + const result = getNextState({ + eventStatus: on, + ...(from !== undefined + ? { + previousEpisode: buildLatestAlertEvent({ + episodeStatus: from, + eventStatus: on, + }), + } + : {}), + }); + + expect(result).toEqual({ status: to }); + }; + it('has name "basic"', () => { expect(strategy.name).toBe('basic'); }); - describe('no event status', () => { - it('returns pending when there is no current alert episode status', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: null, - alertEventStatus: alertEventStatus.breached, - }); - - expect(result).toBe(alertEpisodeStatus.pending); - }); - - it('returns pending when the current state is unknown', () => { - const result = strategy.getNextState({ - // @ts-expect-error - unknown state testing - currentAlertEpisodeStatus: 'unknown_state', - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.pending); + describe('canHandle', () => { + it('returns true for any rule (acts as fallback)', () => { + expect(strategy.canHandle(createRuleResponse())).toBe(true); + expect( + strategy.canHandle(createRuleResponse({ state_transition: { pending_count: 3 } })) + ).toBe(true); }); }); - describe('state transitions from inactive', () => { - const currentState = alertEpisodeStatus.inactive; - - it('transitions to pending on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, + describe('no previous episode', () => { + it('returns pending when there is no previous episode', () => { + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, }); - expect(result).toBe(alertEpisodeStatus.pending); }); - it('stays inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, + it('returns pending when previous episode status is null', () => { + expectTransition({ + from: null, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, }); - expect(result).toBe(alertEpisodeStatus.inactive); }); - it('stays inactive on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + it('returns pending when the current state is unknown', () => { + const result = getNextState({ + eventStatus: alertEventStatus.breached, + previousEpisode: { + ...buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + }), + // @ts-expect-error - unknown state testing + last_episode_status: 'unknown_state', + }, + }); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); }); - describe('state transitions from pending', () => { - const currentState = alertEpisodeStatus.pending; - - it('transitions to active on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.active); - }); - - it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + describe('state transitions from inactive', () => { + it.each<[string, AlertEventStatus, AlertEpisodeStatus]>([ + ['pending', alertEventStatus.breached, alertEpisodeStatus.pending], + ['inactive', alertEventStatus.recovered, alertEpisodeStatus.inactive], + ['inactive', alertEventStatus.no_data, alertEpisodeStatus.inactive], + ])('transitions to %s on %s event', (_label, on, to) => { + expectTransition({ from: alertEpisodeStatus.inactive, on, to }); }); + }); - it('transitions to pending on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.pending); + describe('state transitions from pending', () => { + it.each<[string, AlertEventStatus, AlertEpisodeStatus]>([ + ['active', alertEventStatus.breached, alertEpisodeStatus.active], + ['inactive', alertEventStatus.recovered, alertEpisodeStatus.inactive], + ['pending', alertEventStatus.no_data, alertEpisodeStatus.pending], + ])('transitions to %s on %s event', (_label, on, to) => { + expectTransition({ from: alertEpisodeStatus.pending, on, to }); }); }); describe('state transitions from active', () => { - const currentState = alertEpisodeStatus.active; - - it('stays active on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.active); - }); - - it('transitions to recovering on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.recovering); - }); - - it('transitions to inactive on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.active); + it.each<[string, AlertEventStatus, AlertEpisodeStatus]>([ + ['active', alertEventStatus.breached, alertEpisodeStatus.active], + ['recovering', alertEventStatus.recovered, alertEpisodeStatus.recovering], + ['active', alertEventStatus.no_data, alertEpisodeStatus.active], + ])('transitions to %s on %s event', (_label, on, to) => { + expectTransition({ from: alertEpisodeStatus.active, on, to }); }); }); describe('state transitions from recovering', () => { - const currentState = alertEpisodeStatus.recovering; - - it('transitions to active on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.active); - }); - - it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.inactive); - }); - - it('transitions to inactive on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.recovering); + it.each<[string, AlertEventStatus, AlertEpisodeStatus]>([ + ['active', alertEventStatus.breached, alertEpisodeStatus.active], + ['inactive', alertEventStatus.recovered, alertEpisodeStatus.inactive], + ['recovering', alertEventStatus.no_data, alertEpisodeStatus.recovering], + ])('transitions to %s on %s event', (_label, on, to) => { + expectTransition({ from: alertEpisodeStatus.recovering, on, to }); }); }); describe('defensive fallbacks', () => { - it('returns inactive for unknown current state', () => { - const result = strategy.getNextState({ - // @ts-expect-error - unknown state testing - currentAlertEpisodeStatus: 'unknown_state', - alertEventStatus: alertEventStatus.breached, + it('returns pending for unknown current state', () => { + const result = getNextState({ + eventStatus: alertEventStatus.breached, + previousEpisode: { + ...buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + }), + // @ts-expect-error - unknown state testing + last_episode_status: 'unknown_state', + }, }); - - expect(result).toBe(alertEpisodeStatus.pending); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('returns current state for unknown event status', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: alertEpisodeStatus.active, - // @ts-expect-error - unknown state testing - alertEventStatus: 'unknown_event', - }); - expect(result).toBe(alertEpisodeStatus.active); + const result = getNextState({ + // @ts-expect-error - unknown event status testing + eventStatus: 'unknown_event', + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.breached, + }), + }); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.ts index 60e5ce9dda17c..b6445e56dbcc6 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.ts @@ -8,13 +8,14 @@ import { injectable } from 'inversify'; import type { AlertEventStatus } from '../../../resources/alert_events'; import { alertEpisodeStatus, type AlertEpisodeStatus } from '../../../resources/alert_events'; -import type { ITransitionStrategy, TransitionContext } from './types'; +import type { RuleResponse } from '../../rules_client/types'; +import type { ITransitionStrategy, StateTransitionContext, StateTransitionResult } from './types'; @injectable() export class BasicTransitionStrategy implements ITransitionStrategy { - readonly name = 'basic'; + readonly name: string = 'basic'; - private readonly stateMachine: Record< + protected readonly stateMachine: Record< AlertEpisodeStatus, Record > = { @@ -40,22 +41,25 @@ export class BasicTransitionStrategy implements ITransitionStrategy { }, }; - getNextState({ - currentAlertEpisodeStatus, - alertEventStatus, - }: TransitionContext): AlertEpisodeStatus { + canHandle(_rule: RuleResponse): boolean { + return true; + } + + getNextState({ alertEvent, previousEpisode }: StateTransitionContext): StateTransitionResult { + const currentAlertEpisodeStatus = previousEpisode?.last_episode_status; + if (!currentAlertEpisodeStatus) { - return alertEpisodeStatus.pending; + return { status: alertEpisodeStatus.pending }; } const stateRules = this.stateMachine[currentAlertEpisodeStatus]; if (!stateRules) { - return alertEpisodeStatus.pending; + return { status: alertEpisodeStatus.pending }; } - const nextState = stateRules[alertEventStatus]; + const nextState = stateRules[alertEvent.status]; - return nextState ?? currentAlertEpisodeStatus ?? alertEpisodeStatus.pending; + return { status: nextState ?? currentAlertEpisodeStatus ?? alertEpisodeStatus.pending }; } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts new file mode 100644 index 0000000000000..29470f22f5ff6 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts @@ -0,0 +1,474 @@ +/* + * 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 { CountTimeframeStrategy } from './count_timeframe_strategy'; +import type { AlertEpisodeStatus, AlertEventStatus } from '../../../resources/alert_events'; +import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; +import type { RuleResponse } from '@kbn/alerting-v2-schemas'; +import { createRuleResponse } from '../../test_utils'; +import { buildLatestAlertEvent, buildStrategyStateTransitionContext } from '../test_utils'; + +describe('CountTimeframeStrategy', () => { + let strategy: CountTimeframeStrategy; + + beforeEach(() => { + strategy = new CountTimeframeStrategy(); + }); + + const getNextState = (...args: Parameters) => + strategy.getNextState(buildStrategyStateTransitionContext(...args)); + + const expectTransition = ({ + from, + on, + to, + stateTransition, + statusCount, + expectedStatusCount, + eventTimestamp, + previousTimestamp, + }: { + from?: AlertEpisodeStatus; + on: AlertEventStatus; + to: AlertEpisodeStatus; + stateTransition?: RuleResponse['state_transition']; + statusCount?: number | null; + expectedStatusCount?: number; + eventTimestamp?: string; + previousTimestamp?: string; + }) => { + const result = getNextState({ + eventStatus: on, + stateTransition, + eventTimestamp, + ...(from != null + ? { + previousEpisode: buildLatestAlertEvent({ + episodeStatus: from, + eventStatus: on, + statusCount, + previousTimestamp, + }), + } + : {}), + }); + + expect(result).toEqual({ + status: to, + ...(expectedStatusCount != null ? { statusCount: expectedStatusCount } : {}), + }); + }; + + it('has name "count_timeframe"', () => { + expect(strategy.name).toBe('count_timeframe'); + }); + + describe('canHandle', () => { + it('returns true when rule has stateTransition', () => { + expect( + strategy.canHandle(createRuleResponse({ state_transition: { pending_count: 3 } })) + ).toBe(true); + }); + + it('returns false when stateTransition is an empty object', () => { + expect(strategy.canHandle(createRuleResponse({ state_transition: {} }))).toBe(false); + }); + + it('returns false when stateTransition is undefined', () => { + expect(strategy.canHandle(createRuleResponse({ state_transition: undefined }))).toBe(false); + }); + + it('returns false when stateTransition is null', () => { + expect(strategy.canHandle(createRuleResponse({ state_transition: null }))).toBe(false); + }); + }); + + describe('without stateTransition config (falls back to basic)', () => { + it.each<[string, AlertEpisodeStatus, AlertEventStatus, AlertEpisodeStatus]>([ + [ + 'pending', + alertEpisodeStatus.inactive, + alertEventStatus.breached, + alertEpisodeStatus.pending, + ], + ['active', alertEpisodeStatus.pending, alertEventStatus.breached, alertEpisodeStatus.active], + [ + 'recovering', + alertEpisodeStatus.active, + alertEventStatus.recovered, + alertEpisodeStatus.recovering, + ], + [ + 'inactive', + alertEpisodeStatus.recovering, + alertEventStatus.recovered, + alertEpisodeStatus.inactive, + ], + ])('transitions to %s', (_label, from, on, to) => { + expectTransition({ from, on, to }); + }); + }); + + describe('pendingCount of 0 (skip pending)', () => { + const stateTransition: RuleResponse['state_transition'] = { pending_count: 0 }; + + it('transitions directly to active from inactive on breach', () => { + expectTransition({ + from: alertEpisodeStatus.inactive, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + }); + }); + + it('transitions directly to active when no previous episode', () => { + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + }); + }); + }); + + describe('pendingCount threshold', () => { + const stateTransition: RuleResponse['state_transition'] = { pending_count: 3 }; + + it('enters pending with statusCount 1 from inactive', () => { + expectTransition({ + from: alertEpisodeStatus.inactive, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + expectedStatusCount: 1, + }); + }); + + it('enters pending with statusCount 1 when no previous episode', () => { + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + expectedStatusCount: 1, + }); + }); + + it('stays in pending when count threshold not met', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + statusCount: 1, + expectedStatusCount: 2, + }); + }); + + it('transitions to active when count threshold is met', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 2, + }); + }); + + it('transitions to active when count exceeds threshold', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 5, + }); + }); + + it('still transitions pending to inactive on recovered event', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 3, + }); + }); + }); + + describe('pendingTimeframe threshold', () => { + it('transitions to active when timeframe is met', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition: { pending_timeframe: '2m' }, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('stays pending when timeframe is not met', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition: { pending_timeframe: '5m' }, + statusCount: 2, + expectedStatusCount: 3, + eventTimestamp: '2025-01-01T00:03:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('uses OR to combine count and timeframe', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition: { + pending_count: 5, + pending_timeframe: '2m', + pending_operator: 'OR', + }, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('uses AND to combine count and timeframe', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition: { + pending_count: 5, + pending_timeframe: '2m', + pending_operator: 'AND', + }, + statusCount: 1, + expectedStatusCount: 2, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + }); + + describe('recoveringCount of 0 (skip recovering)', () => { + const stateTransition: RuleResponse['state_transition'] = { recovering_count: 0 }; + + it('transitions directly to inactive from active on recovered', () => { + expectTransition({ + from: alertEpisodeStatus.active, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + }); + }); + }); + + describe('recoveringCount threshold', () => { + const stateTransition: RuleResponse['state_transition'] = { recovering_count: 3 }; + + it('enters recovering with statusCount 1 from active', () => { + expectTransition({ + from: alertEpisodeStatus.active, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition, + expectedStatusCount: 1, + }); + }); + + it('stays recovering when count threshold not met', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition, + statusCount: 1, + expectedStatusCount: 2, + }); + }); + + it('transitions to inactive when count threshold is met', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 2, + }); + }); + + it('still transitions recovering to active on breached event', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 1, + }); + }); + }); + + describe('recoveringTimeframe threshold', () => { + it('transitions to inactive when timeframe is met', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition: { recovering_timeframe: '2m' }, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('stays recovering when timeframe is not met', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition: { recovering_timeframe: '5m' }, + statusCount: 2, + expectedStatusCount: 3, + eventTimestamp: '2025-01-01T00:03:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('uses OR to combine count and timeframe', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition: { + recovering_count: 5, + recovering_timeframe: '2m', + recovering_operator: 'OR', + }, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + + it('uses AND to combine count and timeframe', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition: { + recovering_count: 5, + recovering_timeframe: '2m', + recovering_operator: 'AND', + }, + statusCount: 1, + expectedStatusCount: 2, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); + }); + }); + + describe('combined pending and recovering thresholds', () => { + const stateTransition: RuleResponse['state_transition'] = { + pending_count: 2, + recovering_count: 2, + }; + + it('applies pending threshold independently of recovering', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 1, + }); + }); + + it('applies recovering threshold independently of pending', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 1, + }); + }); + }); + + describe('no previous episode or status count', () => { + it('treats status count as 0 when the previous episode is not present', () => { + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition: { pending_count: 3 }, + expectedStatusCount: 1, + }); + }); + + it('treats status count as 1 when the previous episode is present but the status count no', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition: { pending_count: 3 }, + statusCount: null, + expectedStatusCount: 2, + }); + }); + }); + + describe('malformed duration fallback', () => { + it('ignores an invalid pending_timeframe and evaluates count only', () => { + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition: { pending_count: 2, pending_timeframe: 'bad' }, + statusCount: 1, + }); + }); + + it('ignores an invalid recovering_timeframe and evaluates count only', () => { + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition: { recovering_count: 2, recovering_timeframe: 'bad' }, + statusCount: 1, + }); + }); + }); + + describe('unaffected transitions (same as basic)', () => { + const stateTransition: RuleResponse['state_transition'] = { + pending_count: 5, + recovering_count: 5, + }; + + it.each<[string, AlertEpisodeStatus, AlertEventStatus, AlertEpisodeStatus]>([ + ['active', alertEpisodeStatus.active, alertEventStatus.breached, alertEpisodeStatus.active], + [ + 'inactive', + alertEpisodeStatus.inactive, + alertEventStatus.recovered, + alertEpisodeStatus.inactive, + ], + [ + 'inactive', + alertEpisodeStatus.inactive, + alertEventStatus.no_data, + alertEpisodeStatus.inactive, + ], + ])('stays %s', (_label, from, on, to) => { + expectTransition({ from, on, to, stateTransition }); + }); + }); +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.ts new file mode 100644 index 0000000000000..049081766dff2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.ts @@ -0,0 +1,286 @@ +/* + * 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 { injectable } from 'inversify'; +import type { AlertEpisodeStatus } from '../../../resources/alert_events'; +import { alertEpisodeStatus } from '../../../resources/alert_events'; +import type { RuleResponse } from '../../rules_client/types'; +import { parseDurationToMs } from '../../duration'; +import { BasicTransitionStrategy } from './basic_strategy'; +import type { StateTransitionContext, StateTransitionResult } from './types'; +import type { LatestAlertEventState } from '../queries'; + +const DEFAULT_STATUS_COUNT = 1; + +type Operator = NonNullable['pending_operator']>; +const DEFAULT_OPERATOR: Operator = 'OR'; + +interface ThresholdConfig { + operator: Operator; + count?: number; + timeframeMs?: number; +} + +/** + * Evaluates whether a count-based threshold is met. + * + * - If no count is configured, the threshold is always considered met. + * - Otherwise, the current count must be >= the configured count. + */ +const isCountThresholdMet = (currentCount: number, threshold?: number): boolean => { + if (threshold == null) { + return true; + } + + return currentCount >= threshold; +}; + +/** + * Evaluates whether a timeframe-based threshold is met. + * + * - If no timeframe is configured, the threshold is always considered met. + * - Otherwise, the elapsed time must be >= the configured timeframe. + */ +const isTimeframeThresholdMet = (elapsedMs: number, thresholdMs?: number): boolean => { + if (thresholdMs == null) { + return true; + } + + return elapsedMs >= thresholdMs; +}; + +/** + * Evaluates whether a combined (count + timeframe) threshold is met, + * taking the operator into account. + * + * - AND: both count and timeframe must be met. + * - OR: either count or timeframe is sufficient. + * + * When only one dimension is configured, the operator is irrelevant; + * the single dimension decides. + */ +const isThresholdMet = ( + currentCount: number, + elapsedMs: number, + config: ThresholdConfig +): boolean => { + const countMet = isCountThresholdMet(currentCount, config.count); + const timeframeMet = isTimeframeThresholdMet(elapsedMs, config.timeframeMs); + + const hasCount = config.count != null; + const hasTimeframe = config.timeframeMs != null; + + if (hasCount && hasTimeframe) { + return config.operator === 'AND' ? countMet && timeframeMet : countMet || timeframeMet; + } + + if (hasCount) { + return countMet; + } + + if (hasTimeframe) { + return timeframeMet; + } + + // No thresholds configured — always met (behave like basic). + return true; +}; + +/** + * A transition strategy that extends the basic state machine with + * configurable count (and future timeframe) thresholds for the + * `pending → active` and `recovering → inactive` transitions. + * + * - pending count of 0 means skip pending entirely (inactive → active). + * - recovering count of 0 means skip recovering entirely (active → inactive). + * - When no threshold is configured for a phase, the strategy behaves + * identically to the basic strategy for that phase. + */ +@injectable() +export class CountTimeframeStrategy extends BasicTransitionStrategy { + override readonly name = 'count_timeframe'; + + override canHandle(rule: RuleResponse): boolean { + return rule.state_transition != null && Object.keys(rule.state_transition).length > 0; + } + + override getNextState(ctx: StateTransitionContext): StateTransitionResult { + const { rule, previousEpisode, alertEvent } = ctx; + const stateTransition = rule.state_transition; + const currentEpisodeStatus = previousEpisode?.last_episode_status; + const currentStatusCount = this.getCurrentStatusCount(previousEpisode); + const currentEpisodeTimestamp = previousEpisode?.last_episode_timestamp; + const alertEventTimestamp = alertEvent['@timestamp']; + + const elapsedMs = this.getElapsedMs(alertEventTimestamp, currentEpisodeTimestamp); + + // Delegate to the inherited basic state machine to get the "natural" next state. + const basicResult = super.getNextState(ctx); + + if (!stateTransition) { + return basicResult; + } + + // --- Handle pending count of 0: skip pending, go directly to active --- + if (this.shouldSkipPending(stateTransition, basicResult.status)) { + return { status: alertEpisodeStatus.active }; + } + + // --- Handle recovering count of 0: skip recovering, go directly to inactive --- + if (this.shouldSkipRecovering(stateTransition, basicResult.status)) { + return { status: alertEpisodeStatus.inactive }; + } + + // --- Pending → Active threshold --- + if (this.isPendingToActiveTransition(currentEpisodeStatus, basicResult.status)) { + return this.getNextStateTransition({ + currentStatusCount, + elapsedMs, + operator: stateTransition.pending_operator ?? DEFAULT_OPERATOR, + count: stateTransition.pending_count, + timeframeMs: this.safeParseDurationToMs(stateTransition.pending_timeframe), + successStatus: alertEpisodeStatus.active, + stayStatus: alertEpisodeStatus.pending, + }); + } + + // --- Recovering → Inactive threshold --- + if (this.isRecoveringToInactiveTransition(currentEpisodeStatus, basicResult.status)) { + return this.getNextStateTransition({ + currentStatusCount, + elapsedMs, + operator: stateTransition.recovering_operator ?? DEFAULT_OPERATOR, + count: stateTransition.recovering_count, + timeframeMs: this.safeParseDurationToMs(stateTransition.recovering_timeframe), + successStatus: alertEpisodeStatus.inactive, + stayStatus: alertEpisodeStatus.recovering, + }); + } + + // --- Changing to pending for the first time --- + if ( + this.isChangingStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.pending) + ) { + return { status: alertEpisodeStatus.pending, statusCount: DEFAULT_STATUS_COUNT }; + } + + // --- Changing to recovering for the first time --- + if ( + this.isChangingStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.recovering) + ) { + return { status: alertEpisodeStatus.recovering, statusCount: DEFAULT_STATUS_COUNT }; + } + + return basicResult; + } + + private getCurrentStatusCount(previousEpisode?: LatestAlertEventState): number { + if (!previousEpisode) { + return 0; + } + + return previousEpisode.last_episode_status_count ?? DEFAULT_STATUS_COUNT; + } + + private shouldSkipPending( + stateTransition: NonNullable, + nextStatus: AlertEpisodeStatus + ): boolean { + return stateTransition.pending_count === 0 && nextStatus === alertEpisodeStatus.pending; + } + + private shouldSkipRecovering( + stateTransition: NonNullable, + nextStatus: AlertEpisodeStatus + ): boolean { + return stateTransition.recovering_count === 0 && nextStatus === alertEpisodeStatus.recovering; + } + + private isPendingToActiveTransition( + currentStatus: AlertEpisodeStatus | undefined | null, + nextStatus: AlertEpisodeStatus + ): boolean { + return currentStatus === alertEpisodeStatus.pending && nextStatus === alertEpisodeStatus.active; + } + + private isRecoveringToInactiveTransition( + currentStatus: AlertEpisodeStatus | undefined | null, + nextStatus: AlertEpisodeStatus + ): boolean { + return ( + currentStatus === alertEpisodeStatus.recovering && nextStatus === alertEpisodeStatus.inactive + ); + } + + private isChangingStatus( + currentStatus: AlertEpisodeStatus | undefined | null, + nextStatus: AlertEpisodeStatus, + targetStatus: AlertEpisodeStatus + ): boolean { + return nextStatus === targetStatus && currentStatus !== targetStatus; + } + + private getNextStateTransition({ + currentStatusCount, + elapsedMs, + operator, + count, + timeframeMs, + successStatus, + stayStatus, + }: { + currentStatusCount: number; + elapsedMs: number; + operator: Operator; + count?: number; + timeframeMs?: number; + successStatus: AlertEpisodeStatus; + stayStatus: AlertEpisodeStatus; + }): StateTransitionResult { + const nextCount = currentStatusCount + 1; + const config: ThresholdConfig = { operator, count, timeframeMs }; + + if (isThresholdMet(nextCount, elapsedMs, config)) { + return { status: successStatus }; + } + + return { status: stayStatus, statusCount: nextCount }; + } + + /** + * Safely parses a duration string to milliseconds. + * Returns `undefined` when the value is malformed so that + * the timeframe dimension is simply ignored instead of + * blowing up the entire state transition evaluation. + */ + private safeParseDurationToMs(value?: string): number | undefined { + if (!value) { + return undefined; + } + + try { + return parseDurationToMs(value); + } catch { + return undefined; + } + } + + private getElapsedMs(currentTimestamp?: string, previousTimestamp?: string | null): number { + if (!currentTimestamp || !previousTimestamp) { + return 0; + } + + const currentMs = Date.parse(currentTimestamp); + const previousMs = Date.parse(previousTimestamp); + + if (Number.isNaN(currentMs) || Number.isNaN(previousMs)) { + return 0; + } + + return Math.max(0, currentMs - previousMs); + } +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.mock.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.mock.ts new file mode 100644 index 0000000000000..da6fd0fbb577b --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { BasicTransitionStrategy } from './basic_strategy'; +import { CountTimeframeStrategy } from './count_timeframe_strategy'; +import { TransitionStrategyFactory } from './strategy_resolver'; + +export function createTransitionStrategyFactory(): TransitionStrategyFactory { + const countTimeframeStrategy = new CountTimeframeStrategy(); + const basicStrategy = new BasicTransitionStrategy(); + return new TransitionStrategyFactory([countTimeframeStrategy, basicStrategy]); +} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.test.ts index d9a3b8e387c23..439fc42379619 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.test.ts @@ -6,26 +6,47 @@ */ import { TransitionStrategyFactory } from './strategy_resolver'; -import { BasicTransitionStrategy } from './basic_strategy'; +import { createTransitionStrategyFactory } from './strategy_resolver.mock'; +import { createRuleResponse } from '../../test_utils'; describe('TransitionStrategyFactory', () => { - let strategyFactory: TransitionStrategyFactory; - let basicStrategy: BasicTransitionStrategy; + let factory: TransitionStrategyFactory; beforeEach(() => { - basicStrategy = new BasicTransitionStrategy(); - strategyFactory = new TransitionStrategyFactory(basicStrategy); + factory = createTransitionStrategyFactory(); }); - describe('constructor', () => { - it('registers the basic strategy by default', () => { - const resolved = strategyFactory.getStrategy(); + describe('getStrategy', () => { + it('returns the basic (fallback) strategy when rule has no stateTransition', () => { + const rule = createRuleResponse({ state_transition: undefined }); + const resolved = factory.getStrategy(rule); expect(resolved.name).toBe('basic'); }); - it('sets basic strategy as the default', () => { - const resolved = strategyFactory.getStrategy(); - expect(resolved).toBe(basicStrategy); + it('returns the basic (fallback) strategy when stateTransition is null', () => { + const rule = createRuleResponse({ state_transition: null }); + const resolved = factory.getStrategy(rule); + expect(resolved.name).toBe('basic'); + }); + + it('returns the count_timeframe strategy when rule has stateTransition', () => { + const rule = createRuleResponse({ state_transition: { pending_count: 3 } }); + const resolved = factory.getStrategy(rule); + expect(resolved.name).toBe('count_timeframe'); + }); + + it('returns the basic (fallback) strategy when stateTransition is an empty object', () => { + const rule = createRuleResponse({ state_transition: {} }); + const resolved = factory.getStrategy(rule); + expect(resolved.name).toBe('basic'); + }); + }); + + describe('error handling', () => { + it('throws when no strategies are registered', () => { + expect(() => new TransitionStrategyFactory([])).toThrow( + 'At least one transition strategy must be registered.' + ); }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.ts index b6c8819d5597f..e2b3ea20b58bd 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.ts @@ -5,33 +5,49 @@ * 2.0. */ -import { inject, injectable } from 'inversify'; +import { injectable, multiInject } from 'inversify'; +import type { RuleResponse } from '../../rules_client/types'; import type { ITransitionStrategy } from './types'; -import { BasicTransitionStrategy } from './basic_strategy'; - -export interface RegisterStrategyOptions { - default?: boolean; -} +import { TransitionStrategyToken } from './types'; +/** + * Resolves which transition strategy to use for a given rule. + * + * Strategies are auto-discovered via multi-injection on `TransitionStrategyToken`. + * Each strategy declares its applicability through `canHandle(rule)`. + * + * Resolution order: + * 1. Iterate strategies (most specific first, excluding the fallback). + * 2. If a strategy's `canHandle` returns true, use it. + * 3. Otherwise, fall back to the last strategy whose `canHandle` always returns true. + * + * To add a new strategy, simply: + * 1. Implement `ITransitionStrategy` (including `canHandle`). + * 2. Bind it to `TransitionStrategyToken` in `bind_services.ts`. + */ @injectable() export class TransitionStrategyFactory { - private strategies = new Map(); - private defaultStrategy: ITransitionStrategy; + private readonly strategies: ITransitionStrategy[]; + private readonly fallback: ITransitionStrategy; - constructor(@inject(BasicTransitionStrategy) basic: BasicTransitionStrategy) { - this.register(basic); - this.defaultStrategy = basic; - } + constructor(@multiInject(TransitionStrategyToken) strategies: ITransitionStrategy[]) { + if (strategies.length === 0) { + throw new Error('At least one transition strategy must be registered.'); + } - register(strategy: ITransitionStrategy, options: RegisterStrategyOptions = {}) { - this.strategies.set(strategy.name, strategy); + // The last registered strategy that handles everything acts as the fallback. + // Specialized strategies are checked first. + this.fallback = strategies[strategies.length - 1]!; + this.strategies = strategies.slice(0, -1); + } - if (Boolean(options.default)) { - this.defaultStrategy = strategy; + getStrategy(rule: RuleResponse): ITransitionStrategy { + for (const strategy of this.strategies) { + if (strategy.canHandle(rule)) { + return strategy; + } } - } - getStrategy(): ITransitionStrategy { - return this.defaultStrategy; + return this.fallback; } } diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/types.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/types.ts index 4f32c8571fa0a..d9ded9c3892d6 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/types.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/types.ts @@ -5,14 +5,37 @@ * 2.0. */ -import type { AlertEventStatus, AlertEpisodeStatus } from '../../../resources/alert_events'; +import type { AlertEpisodeStatus, AlertEvent } from '../../../resources/alert_events'; +import type { RuleResponse } from '../../rules_client/types'; +import type { LatestAlertEventState } from '../queries'; -export interface TransitionContext { - currentAlertEpisodeStatus?: AlertEpisodeStatus | null; - alertEventStatus: AlertEventStatus; +export interface StateTransitionContext { + rule: RuleResponse; + alertEvent: AlertEvent; + previousEpisode?: LatestAlertEventState; +} + +export interface StateTransitionResult { + status: AlertEpisodeStatus; + statusCount?: number; } export interface ITransitionStrategy { + /** Unique identifier for this strategy, used for logging and debugging. */ name: string; - getNextState(ctx: TransitionContext): AlertEpisodeStatus; + + /** + * Determines whether this strategy is applicable for the given rule. + * The {@link TransitionStrategyFactory} iterates registered strategies + * and selects the first one whose `canHandle` returns `true`. + */ + canHandle(rule: RuleResponse): boolean; + + /** + * Computes the next episode status (and optional status count) for an + * alert event, given the rule configuration and the previous episode state. + */ + getNextState(ctx: StateTransitionContext): StateTransitionResult; } + +export const TransitionStrategyToken = Symbol.for('TransitionStrategy'); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/test_utils.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/test_utils.ts new file mode 100644 index 0000000000000..cd1559501ff39 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/test_utils.ts @@ -0,0 +1,59 @@ +/* + * 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 { RuleResponse } from '@kbn/alerting-v2-schemas'; +import type { AlertEpisodeStatus, AlertEventStatus } from '../../resources/alert_events'; +import { createAlertEvent } from '../rule_executor/test_utils'; +import { createRuleResponse } from '../test_utils'; +import type { StateTransitionContext } from './strategies/types'; +import type { LatestAlertEventState } from './queries'; + +const DEFAULT_TIMESTAMP = '2025-01-01T00:00:00.000Z'; +const DEFAULT_EPISODE_ID = 'episode-1'; +const DEFAULT_GROUP_HASH = 'hash-1'; + +export const buildLatestAlertEvent = ({ + episodeStatus, + eventStatus, + statusCount, + previousTimestamp, + episodeId = DEFAULT_EPISODE_ID, + groupHash = DEFAULT_GROUP_HASH, +}: { + episodeStatus: AlertEpisodeStatus | null; + eventStatus: AlertEventStatus; + statusCount?: number | null; + previousTimestamp?: string | null; + episodeId?: string; + groupHash?: string; +}): LatestAlertEventState => ({ + last_status: eventStatus, + last_episode_id: episodeId, + last_episode_status: episodeStatus, + last_episode_status_count: statusCount ?? null, + last_episode_timestamp: previousTimestamp ?? DEFAULT_TIMESTAMP, + group_hash: groupHash, +}); + +export const buildStrategyStateTransitionContext = ({ + eventStatus, + stateTransition, + eventTimestamp, + previousEpisode, +}: { + eventStatus: AlertEventStatus; + stateTransition?: RuleResponse['state_transition']; + eventTimestamp?: string; + previousEpisode?: LatestAlertEventState; +}): StateTransitionContext => ({ + rule: createRuleResponse({ state_transition: stateTransition }), + alertEvent: createAlertEvent({ + status: eventStatus, + '@timestamp': eventTimestamp ?? DEFAULT_TIMESTAMP, + }), + ...(previousEpisode ? { previousEpisode } : {}), +}); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts index 67e4577299a6e..fb6c70fa17d04 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/duration.ts @@ -16,8 +16,10 @@ export function parseDurationToMs(value: string): number { if (!match) { throw new Error(`Invalid duration "${value}"`); } + const amount = Number(match[1]); const unit = match[2]; + switch (unit) { case 'ms': return amount; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/execution_pipeline.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/execution_pipeline.ts index d1a02bc9499c8..a5470e8916329 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/execution_pipeline.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/execution_pipeline.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { inject, injectable } from 'inversify'; +import { inject, injectable, multiInject } from 'inversify'; import type { RuleExecutionInput, RuleExecutionStep, @@ -34,7 +34,7 @@ export interface RuleExecutionPipelineContract { export class RuleExecutionPipeline implements RuleExecutionPipelineContract { constructor( @inject(LoggerServiceToken) private readonly logger: LoggerServiceContract, - @inject(RuleExecutionStepsToken) private readonly steps: RuleExecutionStep[], + @multiInject(RuleExecutionStepsToken) private readonly steps: RuleExecutionStep[], @inject(RuleExecutionMiddlewaresToken) private readonly middlewares: RuleExecutionMiddleware[] ) {} diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.test.ts index f7c20550097fe..18d7484a8d247 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.test.ts @@ -18,6 +18,8 @@ describe('DirectorStep', () => { { name: 'group_hash', type: 'keyword' }, { name: 'last_episode_id', type: 'keyword' }, { name: 'last_episode_status', type: 'keyword' }, + { name: 'last_episode_status_count', type: 'long' }, + { name: 'last_episode_timestamp', type: 'date' }, ], values: [], }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.ts index 953394070b02f..d0ef703c14349 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/steps/director_step.ts @@ -55,7 +55,7 @@ export class DirectorStep implements RuleExecutionStep { } try { - const alertsWithNextEpisode = await this.director.run({ ruleId: input.ruleId, alertEvents }); + const alertsWithNextEpisode = await this.director.run({ rule, alertEvents }); this.logger.debug({ message: `[${this.name}] Director completed for rule ${input.ruleId}`, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/tokens.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/tokens.ts index f0f8967f8e5aa..d6301498ceb03 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/tokens.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rule_executor/tokens.ts @@ -10,11 +10,12 @@ import type { RuleExecutionStep } from './types'; import type { RuleExecutionMiddleware } from './middleware'; /** - * Token for injecting the ordered steps array. + * Token for multi-injecting the ordered execution steps. + * Binding order defines execution order. */ export const RuleExecutionStepsToken = Symbol.for( 'alerting_v2.RuleExecutionSteps' -) as ServiceIdentifier; +) as ServiceIdentifier; /** * DI token for the array of step middleware. diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.test.ts index 7250301657145..765f58a2ddd4b 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.test.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.test.ts @@ -12,6 +12,7 @@ import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server'; import type { CreateRuleParams, UpdateRuleData } from './types'; import type { UserService } from '../services/user_service/user_service'; +import type { RuleSavedObjectAttributes } from '../../saved_objects'; import { RULE_SAVED_OBJECT_TYPE } from '../../saved_objects'; import { RulesClient } from './rules_client'; import { createRulesSavedObjectService } from '../services/rules_saved_object_service/rules_saved_object_service.mock'; @@ -218,9 +219,7 @@ describe('RulesClient', () => { SavedObjectsErrorHelpers.createGenericNotFoundError(RULE_SAVED_OBJECT_TYPE, 'rule-id-1') ); - await expect( - client.updateRule({ id: 'rule-id-1', data: {} as UpdateRuleData }) - ).rejects.toMatchObject({ + await expect(client.updateRule({ id: 'rule-id-1', data: {} })).rejects.toMatchObject({ output: { statusCode: 404 }, }); }); @@ -274,10 +273,85 @@ describe('RulesClient', () => { SavedObjectsErrorHelpers.createConflictError(RULE_SAVED_OBJECT_TYPE, 'rule-id-4') ); + await expect(client.updateRule({ id: 'rule-id-4', data: {} })).rejects.toMatchObject({ + output: { statusCode: 409 }, + }); + }); + + it('throws 400 when setting stateTransition on a signal rule', async () => { + const client = createClient(); + + const existingAttributes: RuleSavedObjectAttributes = { + ...baseSoAttrs, + kind: 'signal', + }; + + mockSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'rule-id-signal', + attributes: existingAttributes, + version: 'WzEsMV0=', + type: RULE_SAVED_OBJECT_TYPE, + references: [], + }); + await expect( - client.updateRule({ id: 'rule-id-4', data: {} as UpdateRuleData }) + client.updateRule({ + id: 'rule-id-signal', + data: { state_transition: { pending_count: 3 } }, + }) ).rejects.toMatchObject({ - output: { statusCode: 409 }, + output: { statusCode: 400 }, + message: 'stateTransition is only allowed for rules of kind "alert".', + }); + + expect(mockSavedObjectsClient.update).not.toHaveBeenCalled(); + }); + + it('allows setting stateTransition on an alert rule', async () => { + const client = createClient(); + + const existingAttributes: RuleSavedObjectAttributes = { + ...baseSoAttrs, + kind: 'alert', + }; + + mockSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'rule-id-alert', + attributes: existingAttributes, + version: 'WzEsMV0=', + type: RULE_SAVED_OBJECT_TYPE, + references: [], + }); + + await expect( + client.updateRule({ + id: 'rule-id-alert', + data: { + state_transition: { pending_count: 3, recovering_count: 5 }, + }, + }) + ).resolves.not.toThrow(); + }); + + it('allows setting stateTransition to null on a signal rule (removing it)', async () => { + const client = createClient(); + + const existingAttributes: RuleSavedObjectAttributes = { + ...baseSoAttrs, + kind: 'signal', + }; + + mockSavedObjectsClient.get.mockResolvedValueOnce({ + id: 'rule-id-signal-null', + attributes: existingAttributes, + version: 'WzEsMV0=', + type: RULE_SAVED_OBJECT_TYPE, + references: [], + }); + + await client.updateRule({ + id: 'rule-id-signal-null', + data: { stateTransition: null } as unknown as UpdateRuleData, }); }); }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts index 964c6887947e0..f46609de84ae2 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/rules_client/rules_client.ts @@ -139,6 +139,10 @@ export class RulesClient { throw e; } + if (existingAttrs.kind !== 'alert' && parsed.data.state_transition != null) { + throw Boom.badRequest('stateTransition is only allowed for rules of kind "alert".'); + } + const nextAttrs = buildUpdateRuleAttributes(existingAttrs, parsed.data, { updatedBy: userProfileUid, updatedAt: nowIso, diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_events.ts b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_events.ts index 5df7cf5a00363..7dec999f49d59 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_events.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/resources/alert_events.ts @@ -52,6 +52,7 @@ const mappings: MappingsDefinition = { properties: { id: { type: 'keyword' }, status: { type: 'keyword' }, // inactive | pending | active | recovering + status_count: { type: 'long' }, // only set for pending and recovering }, }, }, @@ -60,6 +61,7 @@ const mappings: MappingsDefinition = { const alertEventStatusSchema = z.enum(['breached', 'recovered', 'no_data']); const alertEventTypeSchema = z.enum(['signal', 'alert']); const alertEpisodeStatusSchema = z.enum(['inactive', 'pending', 'active', 'recovering']); +const alertEpisodeStatusCountSchema = z.number().int().optional(); export const alertEventStatus = alertEventStatusSchema.enum; export const alertEventType = alertEventTypeSchema.enum; @@ -81,6 +83,7 @@ export const alertEventSchema = z.object({ .object({ id: z.string(), status: alertEpisodeStatusSchema, + status_count: alertEpisodeStatusCountSchema, }) .optional(), }); diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/rule_saved_object_attributes/v1.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/rule_saved_object_attributes/v1.ts index 2e51411ad682a..600ef5f6e80f9 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/rule_saved_object_attributes/v1.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/schemas/rule_saved_object_attributes/v1.ts @@ -24,21 +24,17 @@ export const ruleSavedObjectAttributesSchema = schema.object({ owner: schema.maybe(schema.string()), labels: schema.maybe(schema.arrayOf(schema.string())), }), - time_field: schema.string(), - schedule: schema.object({ every: schema.string(), lookback: schema.maybe(schema.string()), }), - evaluation: schema.object({ query: schema.object({ base: schema.string(), condition: schema.string(), }), }), - recovery_policy: schema.maybe( schema.object({ type: schema.oneOf([schema.literal('query'), schema.literal('no_breach')]), @@ -50,26 +46,25 @@ export const ruleSavedObjectAttributesSchema = schema.object({ ), }) ), - state_transition: schema.maybe( - schema.object({ - pending_operator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), - pending_count: schema.maybe(schema.number()), - pending_timeframe: schema.maybe(schema.string()), - recovering_operator: schema.maybe( - schema.oneOf([schema.literal('AND'), schema.literal('OR')]) - ), - recovering_count: schema.maybe(schema.number()), - recovering_timeframe: schema.maybe(schema.string()), - }) + schema.nullable( + schema.object({ + pending_operator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), + pending_count: schema.maybe(schema.number()), + pending_timeframe: schema.maybe(schema.string()), + recovering_operator: schema.maybe( + schema.oneOf([schema.literal('AND'), schema.literal('OR')]) + ), + recovering_count: schema.maybe(schema.number()), + recovering_timeframe: schema.maybe(schema.string()), + }) + ) ), - grouping: schema.maybe( schema.object({ fields: schema.arrayOf(schema.string()), }) ), - no_data: schema.maybe( schema.object({ behavior: schema.maybe( @@ -90,7 +85,6 @@ export const ruleSavedObjectAttributesSchema = schema.object({ }) ) ), - // Server-managed fields enabled: schema.boolean(), createdBy: schema.nullable(schema.string()), diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_rule_executor.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_rule_executor.ts index 3745a96483b29..d7d80e89d8dbc 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_rule_executor.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_rule_executor.ts @@ -39,33 +39,16 @@ export const bindRuleExecutionServices = ({ bind }: ContainerModuleLoadOptions) .inSingletonScope(); /** - * Rule executor steps + * Rule execution steps via multi-injection. + * Binding order defines execution order. */ - bind(WaitForResourcesStep).toSelf().inSingletonScope(); - bind(FetchRuleStep).toSelf().inRequestScope(); - bind(ValidateRuleStep).toSelf().inSingletonScope(); - bind(ExecuteRuleQueryStep).toSelf().inRequestScope(); - bind(CreateAlertEventsStep).toSelf().inSingletonScope(); - bind(DirectorStep).toSelf().inSingletonScope(); - bind(StoreAlertEventsStep).toSelf().inSingletonScope(); - - /** - * Bind steps array (order defines execution order) - * Steps can be wrapped with decorators for per-step behavior - * For example: new AuditLoggingDecorator(get(ValidateRuleStep), auditService) - */ - - bind(RuleExecutionStepsToken) - .toDynamicValue(({ get }) => [ - get(WaitForResourcesStep), - get(FetchRuleStep), - get(ValidateRuleStep), - get(ExecuteRuleQueryStep), - get(CreateAlertEventsStep), - get(DirectorStep), - get(StoreAlertEventsStep), - ]) - .inRequestScope(); + bind(RuleExecutionStepsToken).to(WaitForResourcesStep).inSingletonScope(); + bind(RuleExecutionStepsToken).to(FetchRuleStep).inRequestScope(); + bind(RuleExecutionStepsToken).to(ValidateRuleStep).inSingletonScope(); + bind(RuleExecutionStepsToken).to(ExecuteRuleQueryStep).inRequestScope(); + bind(RuleExecutionStepsToken).to(CreateAlertEventsStep).inSingletonScope(); + bind(RuleExecutionStepsToken).to(DirectorStep).inSingletonScope(); + bind(RuleExecutionStepsToken).to(StoreAlertEventsStep).inSingletonScope(); bind(RuleExecutionPipeline).toSelf().inRequestScope(); }; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index a0dd733a48cdc..236fc387a62b6 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 @@ -28,10 +28,12 @@ import { import { RetryServiceToken } from '../lib/services/retry_service/tokens'; import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; import { DirectorService } from '../lib/director/director'; -import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; +import { CountTimeframeStrategy } from '../lib/director/strategies/count_timeframe_strategy'; import { ResourceManager } from '../lib/services/resource_service/resource_manager'; import { UserService } from '../lib/services/user_service/user_service'; +import { TransitionStrategyToken } from '../lib/director/strategies/types'; +import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { createTaskRunnerFactory, TaskRunnerFactoryToken, @@ -109,5 +111,9 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(DirectorService).toSelf().inSingletonScope(); bind(TransitionStrategyFactory).toSelf().inSingletonScope(); - bind(BasicTransitionStrategy).toSelf().inSingletonScope(); + + // Strategies are registered via TransitionStrategyToken for multi-injection. + // Order matters: specialized strategies first, fallback (BasicTransitionStrategy) last. + bind(TransitionStrategyToken).to(CountTimeframeStrategy).inSingletonScope(); + bind(TransitionStrategyToken).to(BasicTransitionStrategy).inSingletonScope(); }