From 19135f6d88e861da0522693475841ab0e03eaf42 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Sat, 7 Feb 2026 16:41:22 +0200 Subject: [PATCH 01/19] Add state transitions to mappings --- .../src/rule_data_schema.test.ts | 426 ++++++++++++++++++ .../src/rule_data_schema.ts | 48 +- .../src/validation.test.ts | 38 ++ .../lib/rules_client/rules_client.test.ts | 108 ++++- .../server/lib/rules_client/rules_client.ts | 5 + .../server/saved_objects/rule_mappings.ts | 12 +- .../rule_saved_object_attributes/v1.ts | 16 +- 7 files changed, 641 insertions(+), 12 deletions(-) create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts create mode 100644 x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.test.ts 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..350be2c57780c --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/rule_data_schema.test.ts @@ -0,0 +1,426 @@ +/* + * 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 = { + name: 'test rule', + kind: 'alert' as const, + schedule: { custom: '5m' }, + query: 'FROM logs-* | LIMIT 1', + lookbackWindow: '5m', +}; + +describe('createRuleDataSchema', () => { + describe('valid payloads', () => { + it('accepts a minimal valid payload and applies defaults', () => { + const result = createRuleDataSchema.parse(validCreateData); + + expect(result).toEqual({ + name: 'test rule', + kind: 'alert', + tags: [], + schedule: { custom: '5m' }, + enabled: true, + query: 'FROM logs-* | LIMIT 1', + timeField: '@timestamp', + lookbackWindow: '5m', + groupingKey: [], + }); + }); + + it('accepts a full payload with all optional fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + tags: ['tag-1', 'tag-2'], + enabled: false, + timeField: 'event.created', + groupingKey: ['host.name'], + stateTransition: { + pendingOperator: 'AND', + pendingCount: 3, + pendingTimeframe: '10m', + recoveringOperator: 'OR', + recoveringCount: 5, + recoveringTimeframe: '15m', + }, + }); + + expect(result).toEqual( + expect.objectContaining({ + tags: ['tag-1', 'tag-2'], + enabled: false, + timeField: 'event.created', + groupingKey: ['host.name'], + stateTransition: { + pendingOperator: 'AND', + pendingCount: 3, + pendingTimeframe: '10m', + recoveringOperator: 'OR', + recoveringCount: 5, + recoveringTimeframe: '15m', + }, + }) + ); + }); + + it('accepts kind "signal" without stateTransition', () => { + 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('name', () => { + it('rejects an empty name', () => { + const result = createRuleDataSchema.safeParse({ ...validCreateData, name: '' }); + expect(result.success).toBe(false); + }); + + it('rejects a name exceeding 64 characters', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + name: 'a'.repeat(65), + }); + 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('tags', () => { + it('rejects tags exceeding 100 items', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + tags: Array.from({ length: 101 }, (_, i) => `tag-${i}`), + }); + expect(result.success).toBe(false); + }); + + it('rejects a tag exceeding 64 characters', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + tags: ['a'.repeat(65)], + }); + expect(result.success).toBe(false); + }); + }); + + describe('schedule', () => { + it('rejects an invalid duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + schedule: { custom: 'bad' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside schedule (strict)', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + schedule: { custom: '1m', extra: true }, + }); + expect(result.success).toBe(false); + }); + }); + + describe('query', () => { + it('rejects an empty query', () => { + const result = createRuleDataSchema.safeParse({ ...validCreateData, query: '' }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid ES|QL query', () => { + const result = createRuleDataSchema.safeParse({ ...validCreateData, query: 'FROM |' }); + expect(result.success).toBe(false); + }); + }); + + describe('lookbackWindow', () => { + it('rejects an invalid duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + lookbackWindow: 'invalid', + }); + expect(result.success).toBe(false); + }); + }); + + describe('groupingKey', () => { + it('rejects more than 16 keys', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + groupingKey: Array.from({ length: 17 }, (_, i) => `field-${i}`), + }); + expect(result.success).toBe(false); + }); + }); + + describe('stateTransition', () => { + it('accepts an empty stateTransition object', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + stateTransition: {}, + }); + expect(result.stateTransition).toEqual({}); + }); + + it('accepts stateTransition with only pending fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + stateTransition: { pendingOperator: 'AND', pendingCount: 2, pendingTimeframe: '10m' }, + }); + expect(result.stateTransition).toEqual({ + pendingOperator: 'AND', + pendingCount: 2, + pendingTimeframe: '10m', + }); + }); + + it('accepts stateTransition with only recovering fields', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + stateTransition: { + recoveringOperator: 'OR', + recoveringCount: 5, + recoveringTimeframe: '15m', + }, + }); + expect(result.stateTransition).toEqual({ + recoveringOperator: 'OR', + recoveringCount: 5, + recoveringTimeframe: '15m', + }); + }); + + it('accepts pendingCount of 0', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + stateTransition: { pendingCount: 0 }, + }); + expect(result.stateTransition?.pendingCount).toBe(0); + }); + + it('accepts recoveringCount of 0', () => { + const result = createRuleDataSchema.parse({ + ...validCreateData, + stateTransition: { recoveringCount: 0 }, + }); + expect(result.stateTransition?.recoveringCount).toBe(0); + }); + + it('rejects a negative pendingCount', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { pendingCount: -1 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a non-integer pendingCount', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { pendingCount: 1.5 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a negative recoveringCount', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { recoveringCount: -1 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a non-integer recoveringCount', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { recoveringCount: 2.5 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid pendingOperator', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { pendingOperator: 'XOR' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid recoveringOperator', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { recoveringOperator: 'NOT' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid pendingTimeframe duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { pendingTimeframe: 'bad' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid recoveringTimeframe duration', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { recoveringTimeframe: 'bad' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside stateTransition (strict)', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + stateTransition: { unknownKey: true }, + }); + expect(result.success).toBe(false); + }); + + it('rejects stateTransition when kind is "signal"', () => { + const result = createRuleDataSchema.safeParse({ + ...validCreateData, + kind: 'signal', + stateTransition: { pendingCount: 1 }, + }); + expect(result.success).toBe(false); + + if (!result.success) { + const issue = result.error.issues.find((i) => i.path.includes('stateTransition')); + expect(issue?.message).toBe('stateTransition is only allowed when kind is "alert".'); + } + }); + }); + + describe('required fields', () => { + it.each(['name', 'kind', 'schedule', 'query', 'lookbackWindow'] 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({ name: 'updated name' }); + expect(result).toEqual({ name: 'updated name' }); + }); + + it('accepts a stateTransition object', () => { + const result = updateRuleDataSchema.parse({ + stateTransition: { pendingCount: 3, recoveringCount: 5 }, + }); + expect(result.stateTransition).toEqual({ pendingCount: 3, recoveringCount: 5 }); + }); + + it('accepts stateTransition set to null (removal)', () => { + const result = updateRuleDataSchema.parse({ stateTransition: null }); + expect(result.stateTransition).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({ name: '' }); + expect(result.success).toBe(false); + }); + + it('rejects a name exceeding 64 characters', () => { + const result = updateRuleDataSchema.safeParse({ name: 'a'.repeat(65) }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid schedule duration', () => { + const result = updateRuleDataSchema.safeParse({ schedule: { custom: 'bad' } }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid lookbackWindow duration', () => { + const result = updateRuleDataSchema.safeParse({ lookbackWindow: 'bad' }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid ES|QL query', () => { + const result = updateRuleDataSchema.safeParse({ query: 'FROM |' }); + expect(result.success).toBe(false); + }); + + it('rejects more than 100 tags', () => { + const result = updateRuleDataSchema.safeParse({ + tags: Array.from({ length: 101 }, (_, i) => `tag-${i}`), + }); + expect(result.success).toBe(false); + }); + + it('rejects more than 16 groupingKey entries', () => { + const result = updateRuleDataSchema.safeParse({ + groupingKey: Array.from({ length: 17 }, (_, i) => `field-${i}`), + }); + expect(result.success).toBe(false); + }); + }); + + describe('stateTransition constraints', () => { + it('rejects an invalid pendingOperator', () => { + const result = updateRuleDataSchema.safeParse({ + stateTransition: { pendingOperator: 'XOR' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a non-integer pendingCount', () => { + const result = updateRuleDataSchema.safeParse({ + stateTransition: { pendingCount: 1.5 }, + }); + expect(result.success).toBe(false); + }); + + it('rejects an invalid pendingTimeframe duration', () => { + const result = updateRuleDataSchema.safeParse({ + stateTransition: { pendingTimeframe: 'bad' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown keys inside stateTransition (strict)', () => { + const result = updateRuleDataSchema.safeParse({ + stateTransition: { 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 1ecfa1b9aa817..755419940e434 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 @@ -37,6 +37,46 @@ export const ruleKindSchema = z.enum(['alert', 'signal']).describe('The kind of export type RuleKind = z.infer; +const stateTransitionOperatorSchema = z + .enum(['AND', 'OR']) + .describe('How to combine count and timeframe thresholds.'); + +const stateTransitionSchema = z + .object({ + pendingOperator: stateTransitionOperatorSchema + .optional() + .describe('How to combine count and timeframe for pending.'), + pendingCount: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Number of consecutive breaches before transitioning to active. Zero moves directly to active.' + ), + pendingTimeframe: durationSchema.optional().describe('Time window for pending evaluation.'), + recoveringOperator: stateTransitionOperatorSchema + .optional() + .describe('How to combine count and timeframe for recovering.'), + recoveringCount: z + .number() + .int() + .min(0) + .optional() + .describe( + 'Number of consecutive recoveries before transitioning to inactive. Zero moves directly to inactive.' + ), + recoveringTimeframe: durationSchema + .optional() + .describe('Time window for recovering evaluation.'), + }) + .strict() + .nullable() + .optional() + .describe('Controls episode state transition thresholds for alert rules. Set to null to remove.'); + +export type StateTransition = z.infer; + export const createRuleDataSchema = z .object({ name: z.string().min(1).max(64).describe('Human-readable rule name.'), @@ -61,8 +101,13 @@ export const createRuleDataSchema = z .max(16) .default([]) .describe('Fields to group alert events by.'), + stateTransition: stateTransitionSchema, }) - .strip(); + .strip() + .refine((data) => !(data.kind !== 'alert' && data.stateTransition != null), { + message: 'stateTransition is only allowed when kind is "alert".', + path: ['stateTransition'], + }); export type CreateRuleData = z.infer; @@ -87,6 +132,7 @@ export const updateRuleDataSchema = z .max(16) .optional() .describe('Fields to group alert events by.'), + stateTransition: stateTransitionSchema, }) .strip(); 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..de98b5e4d0361 --- /dev/null +++ b/x-pack/platform/packages/shared/response-ops/alerting-v2-schemas/src/validation.test.ts @@ -0,0 +1,38 @@ +/* + * 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)).toMatch(/Invalid duration/); + } + ); +}); + +describe('validateEsqlQuery', () => { + it.each(['FROM logs-* | LIMIT 1', 'FROM index | WHERE field > 0 | LIMIT 10'])( + 'accepts valid ES|QL query "%s"', + (query) => { + expect(validateEsqlQuery(query)).toBeUndefined(); + } + ); + + it.each(['FROM |', '| LIMIT 1', 'NOT A QUERY |||'])( + 'rejects invalid ES|QL query "%s"', + (query) => { + expect(validateEsqlQuery(query)).toMatch(/Invalid ES\|QL query/); + } + ); +}); 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 66774062ea134..c5d5aaf27dca9 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 @@ -234,9 +234,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 }, }); }); @@ -262,7 +260,7 @@ describe('RulesClient', () => { await client.updateRule({ id: 'rule-id-1', - data: { enabled: false } as UpdateRuleData, + data: { enabled: false }, }); expect(getRuleExecutorTaskIdMock).toHaveBeenCalledWith({ @@ -299,7 +297,7 @@ describe('RulesClient', () => { await client.updateRule({ id: 'rule-id-2', - data: { enabled: true } as UpdateRuleData, + data: { enabled: true }, }); expect(ensureRuleExecutorTaskScheduledMock).toHaveBeenCalled(); @@ -334,11 +332,107 @@ 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 = { + ...baseCreateData, + kind: 'signal', + enabled: true, + createdBy: 'elastic', + createdAt: '2025-01-01T00:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + 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: { stateTransition: { pendingCount: 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 = { + ...baseCreateData, + kind: 'alert', + enabled: true, + createdBy: 'elastic', + createdAt: '2025-01-01T00:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + 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: { + stateTransition: { pendingCount: 3, recoveringCount: 5 }, + }, + }) + ).resolves.not.toThrow(); + }); + + it('allows setting stateTransition to null on a signal rule (removing it)', async () => { + const client = createClient(); + + const existingAttributes: RuleSavedObjectAttributes = { + ...baseCreateData, + kind: 'signal', + enabled: true, + createdBy: 'elastic', + createdAt: '2025-01-01T00:00:00.000Z', + updatedBy: 'elastic', + updatedAt: '2025-01-01T00:00:00.000Z', + }; + 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, + }); + + await expect( + client.updateRule({ + id: 'rule-id-alert', + data: { + stateTransition: { pendingCount: 3, recoveringCount: 5 }, + }, + }) + ).resolves.not.toThrow(); }); }); 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 c56fcd76606c2..852ef7da68f5e 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 @@ -75,6 +75,7 @@ export class RulesClient { timeField: parsed.data.timeField, lookbackWindow: parsed.data.lookbackWindow, groupingKey: parsed.data.groupingKey, + stateTransition: parsed.data.stateTransition, createdBy: username, createdAt: nowIso, updatedBy: username, @@ -148,6 +149,10 @@ export class RulesClient { throw e; } + if (existingAttrs.kind === 'signal' && parsed.data.stateTransition != null) { + throw Boom.badRequest('stateTransition is only allowed for rules of kind "alert".'); + } + const wasEnabled = Boolean(existingAttrs.enabled); const willBeEnabled = parsed.data.enabled !== undefined ? Boolean(parsed.data.enabled) : wasEnabled; diff --git a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/rule_mappings.ts b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/rule_mappings.ts index 93e2e3e615140..2197ab934900c 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/rule_mappings.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/saved_objects/rule_mappings.ts @@ -22,12 +22,20 @@ export const ruleMappings: SavedObjectsTypeMappingDefinition = { custom: { type: 'keyword' }, }, }, - query: { type: 'text' }, timeField: { type: 'keyword' }, lookbackWindow: { type: 'keyword' }, groupingKey: { type: 'keyword' }, - + stateTransition: { + properties: { + pendingOperator: { type: 'keyword' }, + pendingCount: { type: 'unsigned_long' }, + pendingTimeframe: { type: 'keyword' }, + recoveringOperator: { type: 'keyword' }, + recoveringCount: { type: 'unsigned_long' }, + recoveringTimeframe: { type: 'keyword' }, + }, + }, createdBy: { type: 'keyword' }, createdAt: { type: 'date' }, updatedBy: { type: 'keyword' }, 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 4f74498997f1b..8d4aefce7ba13 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 @@ -19,12 +19,24 @@ export const ruleSavedObjectAttributesSchema = schema.object({ custom: schema.string(), }), enabled: schema.boolean({ defaultValue: true }), - query: schema.string(), timeField: schema.string(), lookbackWindow: schema.string(), groupingKey: schema.arrayOf(schema.string(), { defaultValue: [] }), - + stateTransition: schema.maybe( + schema.nullable( + schema.object({ + pendingOperator: schema.maybe(schema.oneOf([schema.literal('AND'), schema.literal('OR')])), + pendingCount: schema.maybe(schema.number()), + pendingTimeframe: schema.maybe(schema.string()), + recoveringOperator: schema.maybe( + schema.oneOf([schema.literal('AND'), schema.literal('OR')]) + ), + recoveringCount: schema.maybe(schema.number()), + recoveringTimeframe: schema.maybe(schema.string()), + }) + ) + ), createdBy: schema.nullable(schema.string()), updatedBy: schema.nullable(schema.string()), updatedAt: schema.string(), From e8b21dd5c65a74e55b13af1f16958d3776b83069 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 14:05:50 +0200 Subject: [PATCH 02/19] Add status count to alert event --- .../shared/alerting_v2/server/resources/alert_events.ts | 3 +++ 1 file changed, 3 insertions(+) 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 893d68077d781..7766e285eedf9 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 @@ -49,6 +49,7 @@ const mappings: estypes.MappingTypeMapping = { properties: { id: { type: 'keyword' }, status: { type: 'keyword' }, // inactive | pending | active | recovering + status_count: { type: 'unsigned_long' }, // only set for pending and recovering }, }, }, @@ -57,6 +58,7 @@ const mappings: estypes.MappingTypeMapping = { 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; @@ -78,6 +80,7 @@ export const alertEventSchema = z.object({ .object({ id: z.string(), status: alertEpisodeStatusSchema, + status_count: alertEpisodeStatusCountSchema, }) .optional(), }); From 01d71eb95bce34604b8e035034da9459649face8 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 14:06:37 +0200 Subject: [PATCH 03/19] Use multiinject for rule execution steps --- .../lib/rule_executor/execution_pipeline.ts | 4 +-- .../server/setup/bind_rule_executor.ts | 35 +++++-------------- 2 files changed, 11 insertions(+), 28 deletions(-) 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/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(); }; From 0d1f2083a737c760569e6de7617d6e427c6a516a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 14:09:50 +0200 Subject: [PATCH 04/19] Change types of strategy context --- .../server/lib/director/director.mock.ts | 6 +- .../server/lib/director/director.ts | 30 +-- .../strategies/basic_strategy.test.ts | 177 +++++++++--------- .../lib/director/strategies/basic_strategy.ts | 26 +-- .../strategies/strategy_resolver.mock.ts | 16 ++ .../strategies/strategy_resolver.test.ts | 42 ++++- .../director/strategies/strategy_resolver.ts | 54 ++++-- .../server/lib/director/strategies/types.ts | 21 ++- .../lib/rule_executor/steps/director_step.ts | 2 +- .../server/lib/rule_executor/tokens.ts | 5 +- .../alerting_v2/server/setup/bind_services.ts | 6 +- 11 files changed, 233 insertions(+), 152 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/strategy_resolver.mock.ts 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.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/director.ts index d3fc50380b855..4e854350ac4d4 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 @@ -14,16 +14,18 @@ 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 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,27 +85,29 @@ 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})`, }); } @@ -110,7 +115,8 @@ export class DirectorService { ...currentAlertEvent, 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/strategies/basic_strategy.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/basic_strategy.test.ts index 491c94eac3d01..94f24dcd7fe4f 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,36 @@ */ import { BasicTransitionStrategy } from './basic_strategy'; -import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; +import { + alertEpisodeStatus, + alertEventStatus, + type AlertEpisodeStatus, + type AlertEventStatus, +} from '../../../resources/alert_events'; +import { createAlertEvent } from '../../rule_executor/test_utils'; +import { createRuleResponse } from '../../test_utils'; +import type { StateTransitionContext } from './types'; +import type { LatestAlertEventState } from '../queries'; + +const buildCtx = ( + episodeStatus: AlertEpisodeStatus | null | undefined, + eventStatus: AlertEventStatus +): StateTransitionContext => ({ + rule: createRuleResponse(), + alertEvent: createAlertEvent({ status: eventStatus }), + ...(episodeStatus !== undefined + ? { + previousEpisode: { + last_status: eventStatus, + last_episode_id: 'episode-1', + last_episode_status: episodeStatus, + last_episode_status_count: null, + last_episode_timestamp: '2025-01-01T00:00:00.000Z', + group_hash: 'hash-1', + } as LatestAlertEventState, + } + : {}), +}); describe('BasicTransitionStrategy', () => { let strategy: BasicTransitionStrategy; @@ -19,23 +48,32 @@ describe('BasicTransitionStrategy', () => { 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, - }); + describe('canHandle', () => { + it('returns true for any rule (acts as fallback)', () => { + expect(strategy.canHandle(createRuleResponse())).toBe(true); + expect(strategy.canHandle(createRuleResponse({ stateTransition: { pendingCount: 3 } }))).toBe( + true + ); + }); + }); - expect(result).toBe(alertEpisodeStatus.pending); + describe('no previous episode', () => { + it('returns pending when there is no previous episode', () => { + const result = strategy.getNextState(buildCtx(undefined, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); + }); + + it('returns pending when previous episode status is null', () => { + const result = strategy.getNextState(buildCtx(null, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('returns pending when the current state is unknown', () => { - const result = strategy.getNextState({ + const result = strategy.getNextState( // @ts-expect-error - unknown state testing - currentAlertEpisodeStatus: 'unknown_state', - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.pending); + buildCtx('unknown_state', alertEventStatus.breached) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); }); @@ -43,27 +81,18 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.inactive; it('transitions to pending on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.pending); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('stays inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); it('stays inactive on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); }); @@ -71,27 +100,18 @@ describe('BasicTransitionStrategy', () => { 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); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); - it('transitions to pending on no_data event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.no_data, - }); - expect(result).toBe(alertEpisodeStatus.pending); + it('stays pending on no_data event', () => { + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); }); @@ -99,27 +119,18 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.active; it('stays active on breached event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.breached, - }); - expect(result).toBe(alertEpisodeStatus.active); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to recovering on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.recovering); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + expect(result).toEqual({ status: 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('stays active on no_data event', () => { + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); }); @@ -127,48 +138,36 @@ describe('BasicTransitionStrategy', () => { 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); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState({ - currentAlertEpisodeStatus: currentState, - alertEventStatus: alertEventStatus.recovered, - }); - expect(result).toBe(alertEpisodeStatus.inactive); + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + expect(result).toEqual({ status: 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('stays recovering on no_data event', () => { + const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + expect(result).toEqual({ status: alertEpisodeStatus.recovering }); }); }); describe('defensive fallbacks', () => { - it('returns inactive for unknown current state', () => { - const result = strategy.getNextState({ + it('returns pending for unknown current state', () => { + const result = strategy.getNextState( // @ts-expect-error - unknown state testing - currentAlertEpisodeStatus: 'unknown_state', - alertEventStatus: alertEventStatus.breached, - }); - - expect(result).toBe(alertEpisodeStatus.pending); + buildCtx('unknown_state', alertEventStatus.breached) + ); + 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 = strategy.getNextState( + // @ts-expect-error - unknown event status testing + buildCtx(alertEpisodeStatus.active, 'unknown_event') + ); + 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/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..af3039561cd94 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 @@ -7,25 +7,51 @@ import { TransitionStrategyFactory } from './strategy_resolver'; import { BasicTransitionStrategy } from './basic_strategy'; +import { CountTimeframeStrategy } from './count_timeframe_strategy'; +import { createRuleResponse } from '../../test_utils'; describe('TransitionStrategyFactory', () => { - let strategyFactory: TransitionStrategyFactory; + let factory: TransitionStrategyFactory; let basicStrategy: BasicTransitionStrategy; + let countTimeframeStrategy: CountTimeframeStrategy; beforeEach(() => { basicStrategy = new BasicTransitionStrategy(); - strategyFactory = new TransitionStrategyFactory(basicStrategy); + countTimeframeStrategy = new CountTimeframeStrategy(); + factory = new TransitionStrategyFactory([countTimeframeStrategy, basicStrategy]); }); - describe('constructor', () => { - it('registers the basic strategy by default', () => { - const resolved = strategyFactory.getStrategy(); - expect(resolved.name).toBe('basic'); + describe('getStrategy', () => { + it('returns the basic (fallback) strategy when rule has no stateTransition', () => { + const rule = createRuleResponse({ stateTransition: undefined }); + const resolved = factory.getStrategy(rule); + expect(resolved).toBe(basicStrategy); }); - it('sets basic strategy as the default', () => { - const resolved = strategyFactory.getStrategy(); + it('returns the basic (fallback) strategy when stateTransition is null', () => { + const rule = createRuleResponse({ stateTransition: null }); + const resolved = factory.getStrategy(rule); expect(resolved).toBe(basicStrategy); }); + + it('returns the count_timeframe strategy when rule has stateTransition', () => { + const rule = createRuleResponse({ stateTransition: { pendingCount: 3 } }); + const resolved = factory.getStrategy(rule); + expect(resolved).toBe(countTimeframeStrategy); + }); + + it('returns the count_timeframe strategy when stateTransition is an empty object', () => { + const rule = createRuleResponse({ stateTransition: {} }); + const resolved = factory.getStrategy(rule); + expect(resolved).toBe(countTimeframeStrategy); + }); + }); + + 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..d72a6dcc4c0ec 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,25 @@ * 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 { name: string; - getNextState(ctx: TransitionContext): AlertEpisodeStatus; + canHandle(rule: RuleResponse): boolean; + getNextState(ctx: StateTransitionContext): StateTransitionResult; } + +export const TransitionStrategyToken = Symbol.for('TransitionStrategy'); 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/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index 76f04a7017bf1..fb027856f38c7 100644 --- a/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts +++ b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts @@ -11,6 +11,8 @@ import { AlertActionsClient } from '../lib/alert_actions_client'; import { DirectorService } from '../lib/director/director'; import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; +import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; +import { TransitionStrategyToken } from '../lib/director/strategies/types'; import { DispatcherService } from '../lib/dispatcher/dispatcher'; import { RulesClient } from '../lib/rules_client'; import { EsServiceInternalToken, EsServiceScopedToken } from '../lib/services/es_service/tokens'; @@ -103,5 +105,7 @@ 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(BasicTransitionStrategy).inSingletonScope(); } From 6c31409aedb5360959935366a68b11c8d5d05bcd Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 14:10:08 +0200 Subject: [PATCH 05/19] Implement CountTimeframeStrategy --- .../server/lib/director/director.test.ts | 133 ++++- .../server/lib/director/queries.ts | 15 +- .../count_timeframe_strategy.test.ts | 520 ++++++++++++++++++ .../strategies/count_timeframe_strategy.ts | 261 +++++++++ .../rule_executor/steps/director_step.test.ts | 2 + .../alerting_v2/server/setup/bind_services.ts | 4 +- 6 files changed, 916 insertions(+), 19 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.ts 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..9bcb5ef431bbc 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 { createTransitionStrategyFactory } from './strategies/strategy_resolver.mock'; import { alertEpisodeStatus } 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,16 +334,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: alertEpisodeStatus.active, + last_episode_status_count: null, group_hash: 'hash-1', }, ]) ); const result = await directorService.run({ - ruleId: 'rule-1', + rule, alertEvents: [alertEvent], }); @@ -330,10 +358,83 @@ describe('DirectorService', () => { 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({ + stateTransition: { pendingCount: 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], + }); + + // 1 + 1 = 2, still below threshold of 3 + 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({ + stateTransition: { pendingCount: 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], + }); + + // 2 + 1 = 3, meets threshold of 3 + expect(result[0].episode).toEqual({ + id: 'episode-1', + status: alertEpisodeStatus.active, + }); + }); }); }); 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..42d80d147b7a0 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 = LAST(@timestamp, @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/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..eff3c3cedcdb6 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts @@ -0,0 +1,520 @@ +/* + * 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 { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; +import type { AlertEpisodeStatus, AlertEventStatus } from '../../../resources/alert_events'; +import type { StateTransition } from '@kbn/alerting-v2-schemas'; +import { createAlertEvent } from '../../rule_executor/test_utils'; +import { createRuleResponse } from '../../test_utils'; +import type { StateTransitionContext } from './types'; +import type { LatestAlertEventState } from '../queries'; + +const buildCtx = ({ + episodeStatus, + eventStatus, + statusCount, + stateTransition, + eventTimestamp, + previousTimestamp, +}: { + episodeStatus?: AlertEpisodeStatus | null; + eventStatus: AlertEventStatus; + statusCount?: number | null; + stateTransition?: StateTransition; + eventTimestamp?: string; + previousTimestamp?: string | null; +}): StateTransitionContext => ({ + rule: createRuleResponse({ stateTransition }), + alertEvent: createAlertEvent({ + status: eventStatus, + '@timestamp': eventTimestamp ?? '2025-01-01T00:00:00.000Z', + }), + ...(episodeStatus !== undefined + ? { + previousEpisode: { + last_status: eventStatus, + last_episode_id: 'episode-1', + last_episode_status: episodeStatus, + last_episode_status_count: statusCount ?? null, + last_episode_timestamp: previousTimestamp ?? '2025-01-01T00:00:00.000Z', + group_hash: 'hash-1', + } as LatestAlertEventState, + } + : {}), +}); + +describe('CountTimeframeStrategy', () => { + let strategy: CountTimeframeStrategy; + + beforeEach(() => { + strategy = new CountTimeframeStrategy(); + }); + + it('has name "count_timeframe"', () => { + expect(strategy.name).toBe('count_timeframe'); + }); + + describe('canHandle', () => { + it('returns true when rule has stateTransition', () => { + expect(strategy.canHandle(createRuleResponse({ stateTransition: { pendingCount: 3 } }))).toBe( + true + ); + }); + + it('returns true when stateTransition is an empty object', () => { + expect(strategy.canHandle(createRuleResponse({ stateTransition: {} }))).toBe(true); + }); + + it('returns false when stateTransition is undefined', () => { + expect(strategy.canHandle(createRuleResponse({ stateTransition: undefined }))).toBe(false); + }); + + it('returns false when stateTransition is null', () => { + expect(strategy.canHandle(createRuleResponse({ stateTransition: null }))).toBe(false); + }); + }); + + describe('without stateTransition config (falls back to basic)', () => { + it('transitions inactive → pending on breach (no statusCount)', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); + }); + + it('transitions pending → active on breach', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + }); + + describe('pendingCount threshold', () => { + const stateTransition: StateTransition = { pendingCount: 3 }; + + it('enters pending with statusCount 1 from inactive', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + }); + + it('enters pending with statusCount 1 when no previous episode', () => { + const result = strategy.getNextState( + buildCtx({ + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + }); + + it('stays in pending when count threshold not met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + }); + + it('stays in pending when count is one below threshold', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + stateTransition: { pendingCount: 3 }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + }); + + it('transitions to active when count threshold is met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 2, + stateTransition, + }) + ); + // 2 + 1 = 3, meets threshold of 3 + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('transitions to active when count exceeds threshold', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 5, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('still transitions pending → inactive on recovered event', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + + it('stays pending on no_data event', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.no_data, + statusCount: 2, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); + }); + }); + + describe('pendingTimeframe threshold', () => { + it('transitions to active when timeframe is met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { pendingTimeframe: '2m' }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('stays pending when timeframe is not met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 2, + eventTimestamp: '2025-01-01T00:03:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { pendingTimeframe: '5m' }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 3 }); + }); + + it('uses OR to combine count and timeframe', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { + pendingCount: 5, + pendingTimeframe: '2m', + pendingOperator: 'OR', + }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('uses AND to combine count and timeframe', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { + pendingCount: 5, + pendingTimeframe: '2m', + pendingOperator: 'AND', + }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + }); + }); + + describe('pendingCount of 0 (skip pending)', () => { + const stateTransition: StateTransition = { pendingCount: 0 }; + + it('transitions directly to active from inactive on breach', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('transitions directly to active when no previous episode', () => { + const result = strategy.getNextState( + buildCtx({ + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + }); + + describe('recoveringCount threshold', () => { + const stateTransition: StateTransition = { recoveringCount: 3 }; + + it('enters recovering with statusCount 1 from active', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.recovered, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 1 }); + }); + + it('stays recovering when count threshold not met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); + }); + + it('transitions to inactive when count threshold is met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 2, + stateTransition, + }) + ); + // 2 + 1 = 3, meets threshold of 3 + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + + it('still transitions recovering → active on breached event', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.breached, + statusCount: 1, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('stays recovering on no_data event', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.no_data, + statusCount: 2, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering }); + }); + }); + + describe('recoveringTimeframe threshold', () => { + it('transitions to inactive when timeframe is met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { recoveringTimeframe: '2m' }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + + it('stays recovering when timeframe is not met', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 2, + eventTimestamp: '2025-01-01T00:03:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { recoveringTimeframe: '5m' }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 3 }); + }); + + it('uses OR to combine count and timeframe', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { + recoveringCount: 5, + recoveringTimeframe: '2m', + recoveringOperator: 'OR', + }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + + it('uses AND to combine count and timeframe', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + stateTransition: { + recoveringCount: 5, + recoveringTimeframe: '2m', + recoveringOperator: 'AND', + }, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); + }); + }); + + describe('recoveringCount of 0 (skip recovering)', () => { + const stateTransition: StateTransition = { recoveringCount: 0 }; + + it('transitions directly to inactive from active on recovered', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.recovered, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + }); + + describe('combined pending and recovering thresholds', () => { + const stateTransition: StateTransition = { pendingCount: 2, recoveringCount: 2 }; + + it('applies pending threshold independently of recovering', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + stateTransition, + }) + ); + // 1 + 1 = 2, meets pending threshold of 2 + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('applies recovering threshold independently of pending', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + stateTransition, + }) + ); + // 1 + 1 = 2, meets recovering threshold of 2 + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + }); + + describe('null/zero previous status count', () => { + it('treats null previous status count as 0', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: null, + stateTransition: { pendingCount: 3 }, + }) + ); + // 0 + 1 = 1 < 3, stay pending + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + }); + }); + + describe('unaffected transitions (same as basic)', () => { + const stateTransition: StateTransition = { pendingCount: 5, recoveringCount: 5 }; + + it('stays active on breached', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); + }); + + it('stays inactive on recovered', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.recovered, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + + it('stays inactive on no_data', () => { + const result = strategy.getNextState( + buildCtx({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.no_data, + stateTransition, + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + }); +}); 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..818f794aae9a2 --- /dev/null +++ b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.ts @@ -0,0 +1,261 @@ +/* + * 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 { StateTransition } from '@kbn/alerting-v2-schemas'; +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'; + +type Operator = 'AND' | '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.stateTransition != null; + } + + override getNextState(ctx: StateTransitionContext): StateTransitionResult { + const { rule, previousEpisode } = ctx; + const stateTransition = rule.stateTransition; + const currentEpisodeStatus = previousEpisode?.last_episode_status ?? undefined; + const currentStatusCount = previousEpisode?.last_episode_status_count ?? 0; + const elapsedMs = this.getElapsedMs( + ctx.alertEvent['@timestamp'], + previousEpisode?.last_episode_timestamp + ); + + // 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.pendingOperator, + count: stateTransition.pendingCount, + timeframeMs: stateTransition.pendingTimeframe + ? parseDurationToMs(stateTransition.pendingTimeframe) + : undefined, + successStatus: alertEpisodeStatus.active, + stayStatus: alertEpisodeStatus.pending, + }); + } + + // --- Recovering → Inactive threshold --- + if (this.isRecoveringToInactiveTransition(currentEpisodeStatus, basicResult.status)) { + return this.getNextStateTransition({ + currentStatusCount, + elapsedMs, + operator: stateTransition.recoveringOperator, + count: stateTransition.recoveringCount, + timeframeMs: stateTransition.recoveringTimeframe + ? parseDurationToMs(stateTransition.recoveringTimeframe) + : undefined, + successStatus: alertEpisodeStatus.inactive, + stayStatus: alertEpisodeStatus.recovering, + }); + } + + // --- Entering pending for the first time --- + if ( + this.isEnteringStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.pending) + ) { + return { status: alertEpisodeStatus.pending, statusCount: 1 }; + } + + // --- Entering recovering for the first time --- + if ( + this.isEnteringStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.recovering) + ) { + return { status: alertEpisodeStatus.recovering, statusCount: 1 }; + } + + return basicResult; + } + + private shouldSkipPending( + stateTransition: NonNullable, + nextStatus: AlertEpisodeStatus + ): boolean { + return stateTransition.pendingCount === 0 && nextStatus === alertEpisodeStatus.pending; + } + + private shouldSkipRecovering( + stateTransition: NonNullable, + nextStatus: AlertEpisodeStatus + ): boolean { + return stateTransition.recoveringCount === 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 isEnteringStatus( + 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 }; + } + + 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/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/setup/bind_services.ts b/x-pack/platform/plugins/shared/alerting_v2/server/setup/bind_services.ts index fb027856f38c7..99c51cea6a7a4 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 @@ -9,7 +9,7 @@ import { CoreStart, Request } from '@kbn/core-di-server'; import type { ContainerModuleLoadOptions } from 'inversify'; import { AlertActionsClient } from '../lib/alert_actions_client'; import { DirectorService } from '../lib/director/director'; -import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; +import { CountTimeframeStrategy } from '../lib/director/strategies/count_timeframe_strategy'; import { TransitionStrategyFactory } from '../lib/director/strategies/strategy_resolver'; import { BasicTransitionStrategy } from '../lib/director/strategies/basic_strategy'; import { TransitionStrategyToken } from '../lib/director/strategies/types'; @@ -105,7 +105,9 @@ export function bindServices({ bind }: ContainerModuleLoadOptions) { bind(DirectorService).toSelf().inSingletonScope(); bind(TransitionStrategyFactory).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(); } From e2d8fe1efe9159ce3d1ec7b32935ac687b7b11c5 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 17:23:19 +0200 Subject: [PATCH 06/19] Fix SO mappings --- .../current_fields.json | 7 ++++++ .../current_mappings.json | 22 +++++++++++++++++++ .../alerting_v2/server/setup/bind_services.ts | 1 - 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index ba6c5df03a7bb..234e8bab5aa8a 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -100,6 +100,13 @@ "query", "schedule", "schedule.custom", + "stateTransition", + "stateTransition.pendingCount", + "stateTransition.pendingOperator", + "stateTransition.pendingTimeframe", + "stateTransition.recoveringCount", + "stateTransition.recoveringOperator", + "stateTransition.recoveringTimeframe", "tags", "timeField", "updatedAt", diff --git a/packages/kbn-check-saved-objects-cli/current_mappings.json b/packages/kbn-check-saved-objects-cli/current_mappings.json index 158a84cbd65b8..d39acc5430307 100644 --- a/packages/kbn-check-saved-objects-cli/current_mappings.json +++ b/packages/kbn-check-saved-objects-cli/current_mappings.json @@ -332,6 +332,28 @@ } } }, + "stateTransition": { + "properties": { + "pendingCount": { + "type": "unsigned_long" + }, + "pendingOperator": { + "type": "keyword" + }, + "pendingTimeframe": { + "type": "keyword" + }, + "recoveringCount": { + "type": "unsigned_long" + }, + "recoveringOperator": { + "type": "keyword" + }, + "recoveringTimeframe": { + "type": "keyword" + } + } + }, "tags": { "type": "keyword" }, 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 50bffc7957b3c..cdfee69fa5820 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 @@ -26,7 +26,6 @@ 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'; From c9a8118af8e8eb96fbf4a4d48de7f85265357d73 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Mon, 9 Feb 2026 18:22:41 +0200 Subject: [PATCH 07/19] Improve testing --- .../strategies/basic_strategy.test.ts | 203 ++++++-- .../count_timeframe_strategy.test.ts | 474 ++++++++++-------- .../strategies/count_timeframe_strategy.ts | 53 +- .../server/lib/director/test_utils.ts | 59 +++ 4 files changed, 517 insertions(+), 272 deletions(-) create mode 100644 x-pack/platform/plugins/shared/alerting_v2/server/lib/director/test_utils.ts 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 94f24dcd7fe4f..a9a4b18f66c77 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,36 +6,9 @@ */ import { BasicTransitionStrategy } from './basic_strategy'; -import { - alertEpisodeStatus, - alertEventStatus, - type AlertEpisodeStatus, - type AlertEventStatus, -} from '../../../resources/alert_events'; -import { createAlertEvent } from '../../rule_executor/test_utils'; +import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; import { createRuleResponse } from '../../test_utils'; -import type { StateTransitionContext } from './types'; -import type { LatestAlertEventState } from '../queries'; - -const buildCtx = ( - episodeStatus: AlertEpisodeStatus | null | undefined, - eventStatus: AlertEventStatus -): StateTransitionContext => ({ - rule: createRuleResponse(), - alertEvent: createAlertEvent({ status: eventStatus }), - ...(episodeStatus !== undefined - ? { - previousEpisode: { - last_status: eventStatus, - last_episode_id: 'episode-1', - last_episode_status: episodeStatus, - last_episode_status_count: null, - last_episode_timestamp: '2025-01-01T00:00:00.000Z', - group_hash: 'hash-1', - } as LatestAlertEventState, - } - : {}), -}); +import { buildLatestAlertEvent, buildStrategyStateTransitionContext } from '../test_utils'; describe('BasicTransitionStrategy', () => { let strategy: BasicTransitionStrategy; @@ -59,19 +32,40 @@ describe('BasicTransitionStrategy', () => { describe('no previous episode', () => { it('returns pending when there is no previous episode', () => { - const result = strategy.getNextState(buildCtx(undefined, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('returns pending when previous episode status is null', () => { - const result = strategy.getNextState(buildCtx(null, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: null, + eventStatus: alertEventStatus.breached, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('returns pending when the current state is unknown', () => { const result = strategy.getNextState( - // @ts-expect-error - unknown state testing - buildCtx('unknown_state', alertEventStatus.breached) + buildStrategyStateTransitionContext({ + 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 }); }); @@ -81,17 +75,41 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.inactive; it('transitions to pending on breached event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.breached, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); it('stays inactive on recovered event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.recovered, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); it('stays inactive on no_data event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.no_data, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.no_data, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); }); @@ -100,17 +118,41 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.pending; it('transitions to active on breached event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.breached, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.recovered, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); it('stays pending on no_data event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.no_data, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.no_data, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); }); @@ -119,17 +161,41 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.active; it('stays active on breached event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.breached, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to recovering on recovered event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.recovered, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.recovering }); }); it('stays active on no_data event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.no_data, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.no_data, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.active }); }); }); @@ -138,17 +204,41 @@ describe('BasicTransitionStrategy', () => { const currentState = alertEpisodeStatus.recovering; it('transitions to active on breached event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.breached)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.breached, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.recovered)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.recovered, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); it('stays recovering on no_data event', () => { - const result = strategy.getNextState(buildCtx(currentState, alertEventStatus.no_data)); + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.no_data, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: currentState, + eventStatus: alertEventStatus.no_data, + }), + }) + ); expect(result).toEqual({ status: alertEpisodeStatus.recovering }); }); }); @@ -156,16 +246,31 @@ describe('BasicTransitionStrategy', () => { describe('defensive fallbacks', () => { it('returns pending for unknown current state', () => { const result = strategy.getNextState( - // @ts-expect-error - unknown state testing - buildCtx('unknown_state', alertEventStatus.breached) + buildStrategyStateTransitionContext({ + 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 }); }); it('returns current state for unknown event status', () => { const result = strategy.getNextState( - // @ts-expect-error - unknown event status testing - buildCtx(alertEpisodeStatus.active, 'unknown_event') + buildStrategyStateTransitionContext({ + // @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/count_timeframe_strategy.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts index eff3c3cedcdb6..10aa26e3f701e 100644 --- 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 @@ -7,46 +7,9 @@ import { CountTimeframeStrategy } from './count_timeframe_strategy'; import { alertEpisodeStatus, alertEventStatus } from '../../../resources/alert_events'; -import type { AlertEpisodeStatus, AlertEventStatus } from '../../../resources/alert_events'; import type { StateTransition } from '@kbn/alerting-v2-schemas'; -import { createAlertEvent } from '../../rule_executor/test_utils'; import { createRuleResponse } from '../../test_utils'; -import type { StateTransitionContext } from './types'; -import type { LatestAlertEventState } from '../queries'; - -const buildCtx = ({ - episodeStatus, - eventStatus, - statusCount, - stateTransition, - eventTimestamp, - previousTimestamp, -}: { - episodeStatus?: AlertEpisodeStatus | null; - eventStatus: AlertEventStatus; - statusCount?: number | null; - stateTransition?: StateTransition; - eventTimestamp?: string; - previousTimestamp?: string | null; -}): StateTransitionContext => ({ - rule: createRuleResponse({ stateTransition }), - alertEvent: createAlertEvent({ - status: eventStatus, - '@timestamp': eventTimestamp ?? '2025-01-01T00:00:00.000Z', - }), - ...(episodeStatus !== undefined - ? { - previousEpisode: { - last_status: eventStatus, - last_episode_id: 'episode-1', - last_episode_status: episodeStatus, - last_episode_status_count: statusCount ?? null, - last_episode_timestamp: previousTimestamp ?? '2025-01-01T00:00:00.000Z', - group_hash: 'hash-1', - } as LatestAlertEventState, - } - : {}), -}); +import { buildLatestAlertEvent, buildStrategyStateTransitionContext } from '../test_utils'; describe('CountTimeframeStrategy', () => { let strategy: CountTimeframeStrategy; @@ -80,26 +43,91 @@ describe('CountTimeframeStrategy', () => { }); describe('without stateTransition config (falls back to basic)', () => { - it('transitions inactive → pending on breach (no statusCount)', () => { + it('transitions inactive to pending on breach (no statusCount)', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.inactive, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.pending }); }); - it('transitions pending → active on breach', () => { + it('transitions pending to active on breach', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); + + it('transitions active to recovering on breach (no statusCount)', () => { + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.recovered, + }), + }) + ); + + expect(result).toEqual({ status: alertEpisodeStatus.recovering }); + }); + + it('transitions recovering to inactive on breach (no statusCount)', () => { + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + }), + }) + ); + + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + }); + }); + + describe('pendingCount of 0 (skip pending)', () => { + const stateTransition: StateTransition = { pendingCount: 0 }; + + it('transitions directly to active from inactive on breach', () => { + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + }), + }) + ); + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + }); + + it('transitions directly to active when no previous episode', () => { + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + stateTransition, + }) + ); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + }); }); describe('pendingCount threshold', () => { @@ -107,187 +135,193 @@ describe('CountTimeframeStrategy', () => { it('enters pending with statusCount 1 from inactive', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.inactive, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.breached, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); }); it('enters pending with statusCount 1 when no previous episode', () => { const result = strategy.getNextState( - buildCtx({ + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, stateTransition, }) ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); }); it('stays in pending when count threshold not met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); - }); - it('stays in pending when count is one below threshold', () => { - const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - stateTransition: { pendingCount: 3 }, - }) - ); expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); }); it('transitions to active when count threshold is met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 2, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 2, + }), }) ); - // 2 + 1 = 3, meets threshold of 3 - expect(result).toEqual({ status: alertEpisodeStatus.active }); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); it('transitions to active when count exceeds threshold', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 5, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 5, + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); - it('still transitions pending → inactive on recovered event', () => { + it('still transitions pending to inactive on recovered event', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.recovered, + statusCount: 3, + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); - }); - it('stays pending on no_data event', () => { - const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.no_data, - statusCount: 2, - stateTransition, - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.pending }); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); }); describe('pendingTimeframe threshold', () => { it('transitions to active when timeframe is met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { pendingTimeframe: '2m' }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); it('stays pending when timeframe is not met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 2, eventTimestamp: '2025-01-01T00:03:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { pendingTimeframe: '5m' }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 2, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 3 }); }); it('uses OR to combine count and timeframe', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { pendingCount: 5, pendingTimeframe: '2m', pendingOperator: 'OR', }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); it('uses AND to combine count and timeframe', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { pendingCount: 5, pendingTimeframe: '2m', pendingOperator: 'AND', }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); }); }); - describe('pendingCount of 0 (skip pending)', () => { - const stateTransition: StateTransition = { pendingCount: 0 }; + describe('recoveringCount of 0 (skip recovering)', () => { + const stateTransition: StateTransition = { recoveringCount: 0 }; - it('transitions directly to active from inactive on breach', () => { + it('transitions directly to inactive from active on recovered', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.breached, + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.recovered, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.recovered, + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - it('transitions directly to active when no previous episode', () => { - const result = strategy.getNextState( - buildCtx({ - eventStatus: alertEventStatus.breached, - stateTransition, - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); + expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); }); }); @@ -296,143 +330,147 @@ describe('CountTimeframeStrategy', () => { it('enters recovering with statusCount 1 from active', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.active, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.recovered, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 1 }); }); it('stays recovering when count threshold not met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); }); it('transitions to inactive when count threshold is met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 2, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 2, + }), }) ); - // 2 + 1 = 3, meets threshold of 3 - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + + expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); }); - it('still transitions recovering → active on breached event', () => { + it('still transitions recovering to active on breached event', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - it('stays recovering on no_data event', () => { - const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.no_data, - statusCount: 2, - stateTransition, - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.recovering }); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); }); describe('recoveringTimeframe threshold', () => { it('transitions to inactive when timeframe is met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { recoveringTimeframe: '2m' }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + + expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); }); it('stays recovering when timeframe is not met', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 2, eventTimestamp: '2025-01-01T00:03:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { recoveringTimeframe: '5m' }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 2, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 3 }); }); it('uses OR to combine count and timeframe', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { recoveringCount: 5, recoveringTimeframe: '2m', recoveringOperator: 'OR', }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + + expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); }); it('uses AND to combine count and timeframe', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', - previousTimestamp: '2025-01-01T00:00:00.000Z', stateTransition: { recoveringCount: 5, recoveringTimeframe: '2m', recoveringOperator: 'AND', }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + previousTimestamp: '2025-01-01T00:00:00.000Z', + }), }) ); - expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); - }); - }); - describe('recoveringCount of 0 (skip recovering)', () => { - const stateTransition: StateTransition = { recoveringCount: 0 }; - - it('transitions directly to inactive from active on recovered', () => { - const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.recovered, - stateTransition, - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); }); }); @@ -441,44 +479,64 @@ describe('CountTimeframeStrategy', () => { it('applies pending threshold independently of recovering', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: 1, + }), }) ); - // 1 + 1 = 2, meets pending threshold of 2 - expect(result).toEqual({ status: alertEpisodeStatus.active }); + + expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); }); it('applies recovering threshold independently of pending', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.recovering, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, - statusCount: 1, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.recovering, + eventStatus: alertEventStatus.recovered, + statusCount: 1, + }), }) ); - // 1 + 1 = 2, meets recovering threshold of 2 - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + + expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); }); }); - describe('null/zero previous status count', () => { - it('treats null previous status count as 0', () => { + describe('no previous episode or status count', () => { + it('treats status count as 0 when the previous episode is not present', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.pending, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, - statusCount: null, stateTransition: { pendingCount: 3 }, }) ); - // 0 + 1 = 1 < 3, stay pending + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); }); + + it('treats status count as 1 when the previous episode is present but the status count no', () => { + const result = strategy.getNextState( + buildStrategyStateTransitionContext({ + eventStatus: alertEventStatus.breached, + stateTransition: { pendingCount: 3 }, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.pending, + eventStatus: alertEventStatus.breached, + statusCount: null, + }), + }) + ); + + expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + }); }); describe('unaffected transitions (same as basic)', () => { @@ -486,34 +544,46 @@ describe('CountTimeframeStrategy', () => { it('stays active on breached', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.active, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.breached, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.active, + eventStatus: alertEventStatus.breached, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.active }); }); it('stays inactive on recovered', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.inactive, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.recovered, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.recovered, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); it('stays inactive on no_data', () => { const result = strategy.getNextState( - buildCtx({ - episodeStatus: alertEpisodeStatus.inactive, + buildStrategyStateTransitionContext({ eventStatus: alertEventStatus.no_data, stateTransition, + previousEpisode: buildLatestAlertEvent({ + episodeStatus: alertEpisodeStatus.inactive, + eventStatus: alertEventStatus.no_data, + }), }) ); + expect(result).toEqual({ status: alertEpisodeStatus.inactive }); }); }); 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 index 818f794aae9a2..ce6b655bb6597 100644 --- 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 @@ -13,11 +13,14 @@ 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 = 'AND' | 'OR'; interface ThresholdConfig { - operator?: Operator; + operator: Operator; count?: number; timeframeMs?: number; } @@ -106,14 +109,14 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { } override getNextState(ctx: StateTransitionContext): StateTransitionResult { - const { rule, previousEpisode } = ctx; + const { rule, previousEpisode, alertEvent } = ctx; const stateTransition = rule.stateTransition; - const currentEpisodeStatus = previousEpisode?.last_episode_status ?? undefined; - const currentStatusCount = previousEpisode?.last_episode_status_count ?? 0; - const elapsedMs = this.getElapsedMs( - ctx.alertEvent['@timestamp'], - previousEpisode?.last_episode_timestamp - ); + 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); @@ -124,12 +127,12 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { // --- Handle pending count of 0: skip pending, go directly to active --- if (this.shouldSkipPending(stateTransition, basicResult.status)) { - return { status: alertEpisodeStatus.active }; + return { status: alertEpisodeStatus.active, statusCount: DEFAULT_STATUS_COUNT }; } // --- Handle recovering count of 0: skip recovering, go directly to inactive --- if (this.shouldSkipRecovering(stateTransition, basicResult.status)) { - return { status: alertEpisodeStatus.inactive }; + return { status: alertEpisodeStatus.inactive, statusCount: DEFAULT_STATUS_COUNT }; } // --- Pending → Active threshold --- @@ -137,7 +140,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { return this.getNextStateTransition({ currentStatusCount, elapsedMs, - operator: stateTransition.pendingOperator, + operator: stateTransition.pendingOperator ?? 'OR', count: stateTransition.pendingCount, timeframeMs: stateTransition.pendingTimeframe ? parseDurationToMs(stateTransition.pendingTimeframe) @@ -152,7 +155,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { return this.getNextStateTransition({ currentStatusCount, elapsedMs, - operator: stateTransition.recoveringOperator, + operator: stateTransition.recoveringOperator ?? 'OR', count: stateTransition.recoveringCount, timeframeMs: stateTransition.recoveringTimeframe ? parseDurationToMs(stateTransition.recoveringTimeframe) @@ -162,23 +165,31 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { }); } - // --- Entering pending for the first time --- + // --- Changing to pending for the first time --- if ( - this.isEnteringStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.pending) + this.isChangingStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.pending) ) { - return { status: alertEpisodeStatus.pending, statusCount: 1 }; + return { status: alertEpisodeStatus.pending, statusCount: DEFAULT_STATUS_COUNT }; } - // --- Entering recovering for the first time --- + // --- Changing to recovering for the first time --- if ( - this.isEnteringStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.recovering) + this.isChangingStatus(currentEpisodeStatus, basicResult.status, alertEpisodeStatus.recovering) ) { - return { status: alertEpisodeStatus.recovering, statusCount: 1 }; + 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 @@ -209,7 +220,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { ); } - private isEnteringStatus( + private isChangingStatus( currentStatus: AlertEpisodeStatus | undefined | null, nextStatus: AlertEpisodeStatus, targetStatus: AlertEpisodeStatus @@ -228,7 +239,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { }: { currentStatusCount: number; elapsedMs: number; - operator?: Operator; + operator: Operator; count?: number; timeframeMs?: number; successStatus: AlertEpisodeStatus; @@ -238,7 +249,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { const config: ThresholdConfig = { operator, count, timeframeMs }; if (isThresholdMet(nextCount, elapsedMs, config)) { - return { status: successStatus }; + return { status: successStatus, statusCount: DEFAULT_STATUS_COUNT }; } return { status: stayStatus, statusCount: nextCount }; 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..223b0afab4c2c --- /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 { StateTransition } 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?: StateTransition; + eventTimestamp?: string; + previousEpisode?: LatestAlertEventState; +}): StateTransitionContext => ({ + rule: createRuleResponse({ stateTransition }), + alertEvent: createAlertEvent({ + status: eventStatus, + '@timestamp': eventTimestamp ?? DEFAULT_TIMESTAMP, + }), + ...(previousEpisode ? { previousEpisode } : {}), +}); From 55b3294ad6e3104517246fc065074efb49170eae Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 10 Feb 2026 11:09:26 +0200 Subject: [PATCH 08/19] Update README --- .../alerting_v2/server/lib/director/README.md | 171 +++++++++--------- 1 file changed, 82 insertions(+), 89 deletions(-) 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 From a3ba0389a3949552f987c2dfc4f25fbd8e57c609 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Tue, 10 Feb 2026 12:01:51 +0200 Subject: [PATCH 09/19] Minor fixes --- .../src/rule_data_schema.test.ts | 39 +++++++++++++++---- .../src/rule_data_schema.ts | 11 +++++- .../src/validation.test.ts | 20 ++++------ .../server/lib/director/director.test.ts | 3 +- .../strategies/strategy_resolver.test.ts | 17 +++----- .../lib/rules_client/rules_client.test.ts | 9 ----- .../server/lib/rules_client/rules_client.ts | 2 +- 7 files changed, 57 insertions(+), 44 deletions(-) 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 index 350be2c57780c..efcb0b1ac99c6 100644 --- 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 @@ -9,7 +9,7 @@ import { createRuleDataSchema, updateRuleDataSchema } from './rule_data_schema'; const validCreateData = { name: 'test rule', - kind: 'alert' as const, + kind: 'alert', schedule: { custom: '5m' }, query: 'FROM logs-* | LIMIT 1', lookbackWindow: '5m', @@ -94,6 +94,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, name: 'a'.repeat(65), }); + expect(result.success).toBe(false); }); }); @@ -111,6 +112,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, tags: Array.from({ length: 101 }, (_, i) => `tag-${i}`), }); + expect(result.success).toBe(false); }); @@ -119,6 +121,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, tags: ['a'.repeat(65)], }); + expect(result.success).toBe(false); }); }); @@ -129,14 +132,16 @@ describe('createRuleDataSchema', () => { ...validCreateData, schedule: { custom: 'bad' }, }); + expect(result.success).toBe(false); }); - it('rejects unknown keys inside schedule (strict)', () => { + it('rejects unknown keys inside schedule', () => { const result = createRuleDataSchema.safeParse({ ...validCreateData, schedule: { custom: '1m', extra: true }, }); + expect(result.success).toBe(false); }); }); @@ -159,6 +164,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, lookbackWindow: 'invalid', }); + expect(result.success).toBe(false); }); }); @@ -169,6 +175,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, groupingKey: Array.from({ length: 17 }, (_, i) => `field-${i}`), }); + expect(result.success).toBe(false); }); }); @@ -179,6 +186,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: {}, }); + expect(result.stateTransition).toEqual({}); }); @@ -187,6 +195,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingOperator: 'AND', pendingCount: 2, pendingTimeframe: '10m' }, }); + expect(result.stateTransition).toEqual({ pendingOperator: 'AND', pendingCount: 2, @@ -203,6 +212,7 @@ describe('createRuleDataSchema', () => { recoveringTimeframe: '15m', }, }); + expect(result.stateTransition).toEqual({ recoveringOperator: 'OR', recoveringCount: 5, @@ -215,6 +225,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingCount: 0 }, }); + expect(result.stateTransition?.pendingCount).toBe(0); }); @@ -223,6 +234,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { recoveringCount: 0 }, }); + expect(result.stateTransition?.recoveringCount).toBe(0); }); @@ -231,6 +243,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingCount: -1 }, }); + expect(result.success).toBe(false); }); @@ -239,6 +252,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingCount: 1.5 }, }); + expect(result.success).toBe(false); }); @@ -247,6 +261,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { recoveringCount: -1 }, }); + expect(result.success).toBe(false); }); @@ -255,6 +270,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { recoveringCount: 2.5 }, }); + expect(result.success).toBe(false); }); @@ -263,6 +279,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingOperator: 'XOR' }, }); + expect(result.success).toBe(false); }); @@ -271,6 +288,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { recoveringOperator: 'NOT' }, }); + expect(result.success).toBe(false); }); @@ -279,6 +297,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { pendingTimeframe: 'bad' }, }); + expect(result.success).toBe(false); }); @@ -287,6 +306,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { recoveringTimeframe: 'bad' }, }); + expect(result.success).toBe(false); }); @@ -295,6 +315,7 @@ describe('createRuleDataSchema', () => { ...validCreateData, stateTransition: { unknownKey: true }, }); + expect(result.success).toBe(false); }); @@ -304,12 +325,9 @@ describe('createRuleDataSchema', () => { kind: 'signal', stateTransition: { pendingCount: 1 }, }); - expect(result.success).toBe(false); - if (!result.success) { - const issue = result.error.issues.find((i) => i.path.includes('stateTransition')); - expect(issue?.message).toBe('stateTransition is only allowed when kind is "alert".'); - } + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); }); }); @@ -340,6 +358,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.parse({ stateTransition: { pendingCount: 3, recoveringCount: 5 }, }); + expect(result.stateTransition).toEqual({ pendingCount: 3, recoveringCount: 5 }); }); @@ -383,6 +402,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ tags: Array.from({ length: 101 }, (_, i) => `tag-${i}`), }); + expect(result.success).toBe(false); }); @@ -390,6 +410,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ groupingKey: Array.from({ length: 17 }, (_, i) => `field-${i}`), }); + expect(result.success).toBe(false); }); }); @@ -399,6 +420,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ stateTransition: { pendingOperator: 'XOR' }, }); + expect(result.success).toBe(false); }); @@ -406,6 +428,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ stateTransition: { pendingCount: 1.5 }, }); + expect(result.success).toBe(false); }); @@ -413,6 +436,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ stateTransition: { pendingTimeframe: 'bad' }, }); + expect(result.success).toBe(false); }); @@ -420,6 +444,7 @@ describe('updateRuleDataSchema', () => { const result = updateRuleDataSchema.safeParse({ stateTransition: { 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 755419940e434..a9cc461da069d 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 @@ -104,7 +104,16 @@ export const createRuleDataSchema = z stateTransition: stateTransitionSchema, }) .strip() - .refine((data) => !(data.kind !== 'alert' && data.stateTransition != null), { + /** + * + * The `.refine` method adds a custom validation to the schema. + * In this case, it enforces that the `stateTransition` property is only allowed when `kind` is "alert". + * The predicate `data.kind === 'alert' || data.stateTransition == null` means: + * - If the rule kind is "alert", `stateTransition` may be present (or absent). + * - For any other `kind`, `stateTransition` must be `null` or `undefined`. + * If validation fails, the specified error message will be associated with the `stateTransition` field. + */ + .refine((data) => data.kind === 'alert' || data.stateTransition == null, { message: 'stateTransition is only allowed when kind is "alert".', path: ['stateTransition'], }); 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 index de98b5e4d0361..e83e3f50d2aee 100644 --- 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 @@ -16,23 +16,17 @@ describe('validateDuration', () => { 'rejects invalid duration "%s"', (value) => { // @ts-expect-error - testing invalid values - expect(validateDuration(value)).toMatch(/Invalid duration/); + expect(validateDuration(value)).not.toBeUndefined(); } ); }); describe('validateEsqlQuery', () => { - it.each(['FROM logs-* | LIMIT 1', 'FROM index | WHERE field > 0 | LIMIT 10'])( - 'accepts valid ES|QL query "%s"', - (query) => { - expect(validateEsqlQuery(query)).toBeUndefined(); - } - ); + it('accepts valid ES|QL query', () => { + expect(validateEsqlQuery('FROM logs-* | LIMIT 1')).toBeUndefined(); + }); - it.each(['FROM |', '| LIMIT 1', 'NOT A QUERY |||'])( - 'rejects invalid ES|QL query "%s"', - (query) => { - expect(validateEsqlQuery(query)).toMatch(/Invalid ES\|QL query/); - } - ); + it('rejects invalid ES|QL query', () => { + expect(validateEsqlQuery('FROM |')).toMatch(/Invalid ES\|QL query/); + }); }); 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 9bcb5ef431bbc..57587e9d8cd04 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 @@ -393,7 +393,6 @@ describe('DirectorService', () => { alertEvents: [alertEvent], }); - // 1 + 1 = 2, still below threshold of 3 expect(result[0].episode).toEqual({ id: 'episode-1', status: alertEpisodeStatus.pending, @@ -430,10 +429,10 @@ describe('DirectorService', () => { alertEvents: [alertEvent], }); - // 2 + 1 = 3, meets threshold of 3 expect(result[0].episode).toEqual({ id: 'episode-1', status: alertEpisodeStatus.active, + status_count: 1, }); }); }); 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 af3039561cd94..6c5467c468acc 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,44 +6,39 @@ */ import { TransitionStrategyFactory } from './strategy_resolver'; -import { BasicTransitionStrategy } from './basic_strategy'; -import { CountTimeframeStrategy } from './count_timeframe_strategy'; +import { createTransitionStrategyFactory } from './strategy_resolver.mock'; import { createRuleResponse } from '../../test_utils'; describe('TransitionStrategyFactory', () => { let factory: TransitionStrategyFactory; - let basicStrategy: BasicTransitionStrategy; - let countTimeframeStrategy: CountTimeframeStrategy; beforeEach(() => { - basicStrategy = new BasicTransitionStrategy(); - countTimeframeStrategy = new CountTimeframeStrategy(); - factory = new TransitionStrategyFactory([countTimeframeStrategy, basicStrategy]); + factory = createTransitionStrategyFactory(); }); describe('getStrategy', () => { it('returns the basic (fallback) strategy when rule has no stateTransition', () => { const rule = createRuleResponse({ stateTransition: undefined }); const resolved = factory.getStrategy(rule); - expect(resolved).toBe(basicStrategy); + expect(resolved.name).toBe('basic'); }); it('returns the basic (fallback) strategy when stateTransition is null', () => { const rule = createRuleResponse({ stateTransition: null }); const resolved = factory.getStrategy(rule); - expect(resolved).toBe(basicStrategy); + expect(resolved.name).toBe('basic'); }); it('returns the count_timeframe strategy when rule has stateTransition', () => { const rule = createRuleResponse({ stateTransition: { pendingCount: 3 } }); const resolved = factory.getStrategy(rule); - expect(resolved).toBe(countTimeframeStrategy); + expect(resolved.name).toBe('count_timeframe'); }); it('returns the count_timeframe strategy when stateTransition is an empty object', () => { const rule = createRuleResponse({ stateTransition: {} }); const resolved = factory.getStrategy(rule); - expect(resolved).toBe(countTimeframeStrategy); + expect(resolved.name).toBe('count_timeframe'); }); }); 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 3d11c89748061..d35beb4df6305 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 @@ -416,15 +416,6 @@ describe('RulesClient', () => { id: 'rule-id-signal-null', data: { stateTransition: null } as unknown as UpdateRuleData, }); - - await expect( - client.updateRule({ - id: 'rule-id-alert', - data: { - stateTransition: { pendingCount: 3, recoveringCount: 5 }, - }, - }) - ).resolves.not.toThrow(); }); }); 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 30b0fb3d1d4cc..a662b3e88631f 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 @@ -146,7 +146,7 @@ export class RulesClient { throw e; } - if (existingAttrs.kind === 'signal' && parsed.data.stateTransition != null) { + if (existingAttrs.kind !== 'alert' && parsed.data.stateTransition != null) { throw Boom.badRequest('stateTransition is only allowed for rules of kind "alert".'); } From 04cafa9938c9773fc397b537dd99d6595231afe3 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 10 Feb 2026 10:57:16 +0000 Subject: [PATCH 10/19] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../ci_checks/saved_objects/check_registered_types.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 c4b044ef5fc1f..c5486e44505cc 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 @@ -61,7 +61,7 @@ describe('checking migration metadata changes on all registered SO types', () => "action_task_params": "6751dc8a4707a432bc9b90f5a025f183aefc84bca5ec26c29ce6939b24ea81e4", "ad_hoc_run_params": "9c372f2a8f8b468e9b699a6df633c7f14fab7f13216c9ec160813e75bae56098", "alert": "119624b6025ea6794d2c33e2b41c2e4730d10446430b285691f7638ee6787af5", - "alerting_rule": "a38923393b07103959ab200baa94ab10f0e5cb18d54b2741f41def5eb94072a2", + "alerting_rule": "320b110b40a34e88361f50afa31a9d10258ad5b124bdf5a134b2293b67615354", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", @@ -295,9 +295,9 @@ describe('checking migration metadata changes on all registered SO types', () => "alert|warning: The SO type owner should ensure these transform functions DO NOT mutate after they are defined.", "==============================================================================================================", "alerting_rule|global: e78adb1490c02adb4c705491c87e08332c0f668e", - "alerting_rule|mappings: 2b5b6a304eca69896b2ea95243e2e437b12d07e1", + "alerting_rule|mappings: db1139fc8825037946b02802d403678c38b80a29", "alerting_rule|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "alerting_rule|10.1.0: 08b87a15dd1b4d3adb7b12deb9c16892d09cb4427fa24141515c0d51e7ba5f2f", + "alerting_rule|10.1.0: 609e8a9dda722696cd3d61c1e6ea44782b5c1c4f3a41515efcd99ff3fb9eb020", "======================================================================================", "alerting_rule_template|global: a8ee387a4bc794ff6450017a92742b39b79e0446", "alerting_rule_template|mappings: eccf889027b5ea2d292c1bf0f9280348deaec0ef", From c268e8ed9c1ec66b1446477ee1b41a3294bb5691 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:21:23 +0000 Subject: [PATCH 11/19] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../ci_checks/saved_objects/check_registered_types.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 7214c7542e3f7..f711a678fc9bc 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 @@ -61,7 +61,7 @@ describe('checking migration metadata changes on all registered SO types', () => "action_task_params": "6751dc8a4707a432bc9b90f5a025f183aefc84bca5ec26c29ce6939b24ea81e4", "ad_hoc_run_params": "9c372f2a8f8b468e9b699a6df633c7f14fab7f13216c9ec160813e75bae56098", "alert": "119624b6025ea6794d2c33e2b41c2e4730d10446430b285691f7638ee6787af5", - "alerting_rule": "8ba5bc7017a47dec5092a51dc4ce14d9a651f336835b11f46fa8362725acd5d6", + "alerting_rule": "320b110b40a34e88361f50afa31a9d10258ad5b124bdf5a134b2293b67615354", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", @@ -295,9 +295,9 @@ describe('checking migration metadata changes on all registered SO types', () => "alert|warning: The SO type owner should ensure these transform functions DO NOT mutate after they are defined.", "==============================================================================================================", "alerting_rule|global: e78adb1490c02adb4c705491c87e08332c0f668e", - "alerting_rule|mappings: bfef9f3cf50348c229a29b190cca6ff6213cf2e2", + "alerting_rule|mappings: db1139fc8825037946b02802d403678c38b80a29", "alerting_rule|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "alerting_rule|10.1.0: 7f6770acbb37accab4934331cb067aa6a7321500425b2f9a48dab552a5c1f8cc", + "alerting_rule|10.1.0: 609e8a9dda722696cd3d61c1e6ea44782b5c1c4f3a41515efcd99ff3fb9eb020", "======================================================================================", "alerting_rule_template|global: a8ee387a4bc794ff6450017a92742b39b79e0446", "alerting_rule_template|mappings: eccf889027b5ea2d292c1bf0f9280348deaec0ef", From 5fb3116173ac855a07ab6c98125723ad82d69039 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:42:57 +0000 Subject: [PATCH 12/19] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../ci_checks/saved_objects/check_registered_types.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 405ca5c6afd14..fb6b4ec1e9a58 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": "8ba5bc7017a47dec5092a51dc4ce14d9a651f336835b11f46fa8362725acd5d6", + "alerting_rule": "320b110b40a34e88361f50afa31a9d10258ad5b124bdf5a134b2293b67615354", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", @@ -301,9 +301,9 @@ describe('checking migration metadata changes on all registered SO types', () => "alerting_notification_policy|10.1.0: decd8733c771f6e7dbbf0148ac4b851a496da3c690ae7998a25296d08e750f7a", "=====================================================================================================", "alerting_rule|global: e78adb1490c02adb4c705491c87e08332c0f668e", - "alerting_rule|mappings: bfef9f3cf50348c229a29b190cca6ff6213cf2e2", + "alerting_rule|mappings: db1139fc8825037946b02802d403678c38b80a29", "alerting_rule|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "alerting_rule|10.1.0: 7f6770acbb37accab4934331cb067aa6a7321500425b2f9a48dab552a5c1f8cc", + "alerting_rule|10.1.0: 609e8a9dda722696cd3d61c1e6ea44782b5c1c4f3a41515efcd99ff3fb9eb020", "======================================================================================", "alerting_rule_template|global: a8ee387a4bc794ff6450017a92742b39b79e0446", "alerting_rule_template|mappings: eccf889027b5ea2d292c1bf0f9280348deaec0ef", From 211d59ea4898a74f7537b9e439fdf22d73622903 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 13 Feb 2026 13:24:28 +0200 Subject: [PATCH 13/19] PR feedback --- .../strategies/basic_strategy.test.ts | 299 +++---- .../count_timeframe_strategy.test.ts | 766 ++++++++---------- .../strategies/count_timeframe_strategy.ts | 35 +- .../strategies/strategy_resolver.test.ts | 4 +- .../server/lib/director/strategies/types.ts | 12 + .../shared/alerting_v2/server/lib/duration.ts | 2 + 6 files changed, 461 insertions(+), 657 deletions(-) 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 6f1618138dd23..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,6 +6,7 @@ */ 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'; @@ -17,6 +18,33 @@ 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'); }); @@ -32,246 +60,101 @@ describe('BasicTransitionStrategy', () => { describe('no previous episode', () => { it('returns pending when there is no previous episode', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.pending }); + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + }); }); it('returns pending when previous episode status is null', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: null, - eventStatus: alertEventStatus.breached, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.pending }); + expectTransition({ + from: null, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + }); }); it('returns pending when the current state is unknown', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - 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 inactive', () => { - const currentState = alertEpisodeStatus.inactive; - - it('transitions to pending on breached event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, + 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 }); }); + }); - it('stays inactive on recovered event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); - }); - - it('stays inactive on no_data event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.no_data, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.no_data, - }), - }) - ); - expect(result).toEqual({ status: 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 }); }); }); describe('state transitions from pending', () => { - const currentState = alertEpisodeStatus.pending; - - it('transitions to active on breached event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.breached, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - - it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); - }); - - it('stays pending on no_data event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.no_data, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.no_data, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.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( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.breached, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - - it('transitions to recovering on recovered event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.recovering }); - }); - - it('stays active on no_data event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.no_data, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.no_data, - }), - }) - ); - expect(result).toEqual({ status: 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( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.breached, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - - it('transitions to inactive on recovered event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); - }); - - it('stays recovering on no_data event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.no_data, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: currentState, - eventStatus: alertEventStatus.no_data, - }), - }) - ); - expect(result).toEqual({ status: 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 pending for unknown current state', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: { - ...buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - }), - // @ts-expect-error - unknown state testing - last_episode_status: 'unknown_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).toEqual({ status: alertEpisodeStatus.pending }); }); it('returns current state for unknown event status', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - // @ts-expect-error - unknown event status testing - eventStatus: 'unknown_event', - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.breached, - }), - }) - ); + 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/count_timeframe_strategy.test.ts b/x-pack/platform/plugins/shared/alerting_v2/server/lib/director/strategies/count_timeframe_strategy.test.ts index 606e4cd8a3a53..412ef9bac79b2 100644 --- 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 @@ -6,6 +6,7 @@ */ 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'; @@ -18,6 +19,50 @@ describe('CountTimeframeStrategy', () => { 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'); }); @@ -29,8 +74,8 @@ describe('CountTimeframeStrategy', () => { ).toBe(true); }); - it('returns true when stateTransition is an empty object', () => { - expect(strategy.canHandle(createRuleResponse({ state_transition: {} }))).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', () => { @@ -43,61 +88,28 @@ describe('CountTimeframeStrategy', () => { }); describe('without stateTransition config (falls back to basic)', () => { - it('transitions inactive to pending on breach (no statusCount)', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.breached, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending }); - }); - - it('transitions pending to active on breach', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - - it('transitions active to recovering on breach (no statusCount)', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.recovering }); - }); - - it('transitions recovering to inactive on breach (no statusCount)', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + 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 }); }); }); @@ -105,28 +117,22 @@ describe('CountTimeframeStrategy', () => { const stateTransition: RuleResponse['state_transition'] = { pending_count: 0 }; it('transitions directly to active from inactive on breach', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.breached, - }), - }) - ); - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.inactive, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + expectedStatusCount: 1, + }); }); it('transitions directly to active when no previous episode', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + expectedStatusCount: 1, + }); }); }); @@ -134,175 +140,127 @@ describe('CountTimeframeStrategy', () => { const stateTransition: RuleResponse['state_transition'] = { pending_count: 3 }; it('enters pending with statusCount 1 from inactive', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.breached, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.inactive, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + expectedStatusCount: 1, + }); }); it('enters pending with statusCount 1 when no previous episode', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + expectTransition({ + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + expectedStatusCount: 1, + }); }); it('stays in pending when count threshold not met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.pending, + stateTransition, + statusCount: 1, + expectedStatusCount: 2, + }); }); it('transitions to active when count threshold is met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 2, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 2, + expectedStatusCount: 1, + }); }); it('transitions to active when count exceeds threshold', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 5, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 5, + expectedStatusCount: 1, + }); }); it('still transitions pending to inactive on recovered event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.recovered, - statusCount: 3, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 3, + }); }); }); describe('pendingTimeframe threshold', () => { it('transitions to active when timeframe is met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { pending_timeframe: '2m' }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition: { pending_timeframe: '2m' }, + statusCount: 1, + expectedStatusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); }); it('stays pending when timeframe is not met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - eventTimestamp: '2025-01-01T00:03:00.000Z', - stateTransition: { pending_timeframe: '5m' }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 2, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 3 }); + 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', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { - pending_count: 5, - pending_timeframe: '2m', - pending_operator: 'OR', - }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition: { + pending_count: 5, + pending_timeframe: '2m', + pending_operator: 'OR', + }, + statusCount: 1, + expectedStatusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); }); it('uses AND to combine count and timeframe', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { - pending_count: 5, - pending_timeframe: '2m', - pending_operator: 'AND', - }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + 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', + }); }); }); @@ -310,18 +268,13 @@ describe('CountTimeframeStrategy', () => { const stateTransition: RuleResponse['state_transition'] = { recovering_count: 0 }; it('transitions directly to inactive from active on recovered', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.active, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + expectedStatusCount: 1, + }); }); }); @@ -329,148 +282,107 @@ describe('CountTimeframeStrategy', () => { const stateTransition: RuleResponse['state_transition'] = { recovering_count: 3 }; it('enters recovering with statusCount 1 from active', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.active, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition, + expectedStatusCount: 1, + }); }); it('stays recovering when count threshold not met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.recovering, + stateTransition, + statusCount: 1, + expectedStatusCount: 2, + }); }); it('transitions to inactive when count threshold is met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 2, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 2, + expectedStatusCount: 1, + }); }); it('still transitions recovering to active on breached event', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.breached, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 1, + }); }); }); describe('recoveringTimeframe threshold', () => { it('transitions to inactive when timeframe is met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { recovering_timeframe: '2m' }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition: { recovering_timeframe: '2m' }, + statusCount: 1, + expectedStatusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); }); it('stays recovering when timeframe is not met', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - eventTimestamp: '2025-01-01T00:03:00.000Z', - stateTransition: { recovering_timeframe: '5m' }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 2, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 3 }); + 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', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { - recovering_count: 5, - recovering_timeframe: '2m', - recovering_operator: 'OR', - }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition: { + recovering_count: 5, + recovering_timeframe: '2m', + recovering_operator: 'OR', + }, + statusCount: 1, + expectedStatusCount: 1, + eventTimestamp: '2025-01-01T00:02:00.000Z', + previousTimestamp: '2025-01-01T00:00:00.000Z', + }); }); it('uses AND to combine count and timeframe', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - eventTimestamp: '2025-01-01T00:02:00.000Z', - stateTransition: { - recovering_count: 5, - recovering_timeframe: '2m', - recovering_operator: 'AND', - }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 1, - previousTimestamp: '2025-01-01T00:00:00.000Z', - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.recovering, statusCount: 2 }); + 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', + }); }); }); @@ -481,64 +393,71 @@ describe('CountTimeframeStrategy', () => { }; it('applies pending threshold independently of recovering', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.pending, + on: alertEventStatus.breached, + to: alertEpisodeStatus.active, + stateTransition, + statusCount: 1, + expectedStatusCount: 1, + }); }); it('applies recovering threshold independently of pending', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.recovering, - eventStatus: alertEventStatus.recovered, - statusCount: 1, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive, statusCount: 1 }); + expectTransition({ + from: alertEpisodeStatus.recovering, + on: alertEventStatus.recovered, + to: alertEpisodeStatus.inactive, + stateTransition, + statusCount: 1, + expectedStatusCount: 1, + }); }); }); describe('no previous episode or status count', () => { it('treats status count as 0 when the previous episode is not present', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition: { pending_count: 3 }, - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 1 }); + 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', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition: { pending_count: 3 }, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.pending, - eventStatus: alertEventStatus.breached, - statusCount: null, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.pending, statusCount: 2 }); + 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, + expectedStatusCount: 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, + expectedStatusCount: 1, + }); }); }); @@ -548,49 +467,22 @@ describe('CountTimeframeStrategy', () => { recovering_count: 5, }; - it('stays active on breached', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.breached, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.active, - eventStatus: alertEventStatus.breached, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.active }); - }); - - it('stays inactive on recovered', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.recovered, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.recovered, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); - }); - - it('stays inactive on no_data', () => { - const result = strategy.getNextState( - buildStrategyStateTransitionContext({ - eventStatus: alertEventStatus.no_data, - stateTransition, - previousEpisode: buildLatestAlertEvent({ - episodeStatus: alertEpisodeStatus.inactive, - eventStatus: alertEventStatus.no_data, - }), - }) - ); - - expect(result).toEqual({ status: alertEpisodeStatus.inactive }); + 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 index 39dd5c3ad25b5..09c227b418b28 100644 --- 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 @@ -16,7 +16,8 @@ import type { LatestAlertEventState } from '../queries'; const DEFAULT_STATUS_COUNT = 1; -type Operator = 'AND' | 'OR'; +type Operator = NonNullable['pending_operator']>; +const DEFAULT_OPERATOR: Operator = 'OR'; interface ThresholdConfig { operator: Operator; @@ -104,7 +105,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { override readonly name = 'count_timeframe'; override canHandle(rule: RuleResponse): boolean { - return rule.state_transition != null; + return rule.state_transition != null && Object.keys(rule.state_transition).length > 0; } override getNextState(ctx: StateTransitionContext): StateTransitionResult { @@ -139,11 +140,9 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { return this.getNextStateTransition({ currentStatusCount, elapsedMs, - operator: stateTransition.pending_operator ?? 'OR', + operator: stateTransition.pending_operator ?? DEFAULT_OPERATOR, count: stateTransition.pending_count, - timeframeMs: stateTransition.pending_timeframe - ? parseDurationToMs(stateTransition.pending_timeframe) - : undefined, + timeframeMs: this.safeParseDurationToMs(stateTransition.pending_timeframe), successStatus: alertEpisodeStatus.active, stayStatus: alertEpisodeStatus.pending, }); @@ -154,11 +153,9 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { return this.getNextStateTransition({ currentStatusCount, elapsedMs, - operator: stateTransition.recovering_operator ?? 'OR', + operator: stateTransition.recovering_operator ?? DEFAULT_OPERATOR, count: stateTransition.recovering_count, - timeframeMs: stateTransition.recovering_timeframe - ? parseDurationToMs(stateTransition.recovering_timeframe) - : undefined, + timeframeMs: this.safeParseDurationToMs(stateTransition.recovering_timeframe), successStatus: alertEpisodeStatus.inactive, stayStatus: alertEpisodeStatus.recovering, }); @@ -254,6 +251,24 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { 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; 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 6ee9fd7e0e508..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 @@ -35,10 +35,10 @@ describe('TransitionStrategyFactory', () => { expect(resolved.name).toBe('count_timeframe'); }); - it('returns the count_timeframe strategy when stateTransition is an empty object', () => { + 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('count_timeframe'); + expect(resolved.name).toBe('basic'); }); }); 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 d72a6dcc4c0ec..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 @@ -21,8 +21,20 @@ export interface StateTransitionResult { } export interface ITransitionStrategy { + /** Unique identifier for this strategy, used for logging and debugging. */ name: string; + + /** + * 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; } 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; From 84a69a8444ee5ecd47363d9bb68caf3048c21008 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:37:37 +0000 Subject: [PATCH 14/19] Changes from node scripts/check_mappings_update --fix --- .../current_fields.json | 425 +++++++++++++++--- 1 file changed, 352 insertions(+), 73 deletions(-) diff --git a/packages/kbn-check-saved-objects-cli/current_fields.json b/packages/kbn-check-saved-objects-cli/current_fields.json index 40f10a5839206..643654d259e09 100644 --- a/packages/kbn-check-saved-objects-cli/current_fields.json +++ b/packages/kbn-check-saved-objects-cli/current_fields.json @@ -1,6 +1,11 @@ { - "action": ["actionTypeId", "name"], - "action_task_params": ["apiKeyId"], + "action": [ + "actionTypeId", + "name" + ], + "action_task_params": [ + "apiKeyId" + ], "ad_hoc_run_params": [ "apiKeyId", "createdAt", @@ -119,9 +124,20 @@ "updatedAt", "updatedBy" ], - "alerting_rule_template": ["description", "name", "ruleTypeId", "tags"], - "api_key_pending_invalidation": ["apiKeyId", "createdAt"], - "api_key_to_invalidate": ["apiKeyId", "createdAt"], + "alerting_rule_template": [ + "description", + "name", + "ruleTypeId", + "tags" + ], + "api_key_pending_invalidation": [ + "apiKeyId", + "createdAt" + ], + "api_key_to_invalidate": [ + "apiKeyId", + "createdAt" + ], "apm-custom-dashboards": [ "dashboardSavedObjectId", "kuery", @@ -129,16 +145,44 @@ "serviceNameFilterEnabled" ], "apm-indices": [], - "apm-server-schema": ["schemaJson"], - "apm-service-group": ["color", "description", "groupName", "kuery"], + "apm-server-schema": [ + "schemaJson" + ], + "apm-service-group": [ + "color", + "description", + "groupName", + "kuery" + ], "apm-telemetry": [], "app_search_telemetry": [], - "application_usage_daily": ["timestamp"], + "application_usage_daily": [ + "timestamp" + ], "application_usage_totals": [], - "background-task-node": ["id", "last_seen"], - "canvas-element": ["@created", "@timestamp", "content", "help", "image", "name"], - "canvas-workpad": ["@created", "@timestamp", "name"], - "canvas-workpad-template": ["help", "name", "tags", "template_key"], + "background-task-node": [ + "id", + "last_seen" + ], + "canvas-element": [ + "@created", + "@timestamp", + "content", + "help", + "image", + "name" + ], + "canvas-workpad": [ + "@created", + "@timestamp", + "name" + ], + "canvas-workpad-template": [ + "help", + "name", + "tags", + "template_key" + ], "cases": [ "assignees", "assignees.uid", @@ -218,10 +262,26 @@ "type", "updated_at" ], - "cases-configure": ["closure_type", "created_at", "owner"], - "cases-connector-mappings": ["owner"], - "cases-incrementing-id": ["@timestamp", "last_id", "updated_at"], - "cases-rules": ["counter", "createdAt", "rules", "rules.id", "updatedAt"], + "cases-configure": [ + "closure_type", + "created_at", + "owner" + ], + "cases-connector-mappings": [ + "owner" + ], + "cases-incrementing-id": [ + "@timestamp", + "last_id", + "updated_at" + ], + "cases-rules": [ + "counter", + "createdAt", + "rules", + "rules.id", + "updatedAt" + ], "cases-telemetry": [], "cases-user-actions": [ "action", @@ -241,11 +301,23 @@ "type" ], "cloud": [], - "cloud-connect-api-key": ["apiKey", "clusterId", "createdAt", "updatedAt"], + "cloud-connect-api-key": [ + "apiKey", + "clusterId", + "createdAt", + "updatedAt" + ], "cloud-security-posture-settings": [], - "config": ["buildNum"], - "config-global": ["buildNum"], - "connector_token": ["connectorId", "tokenType"], + "config": [ + "buildNum" + ], + "config-global": [ + "buildNum" + ], + "connector_token": [ + "connectorId", + "tokenType" + ], "core-usage-stats": [], "csp-rule-template": [ "metadata", @@ -285,7 +357,13 @@ "title", "version" ], - "data_connector": ["kscIds", "name", "toolIds", "type", "workflowIds"], + "data_connector": [ + "kscIds", + "name", + "toolIds", + "type", + "workflowIds" + ], "data_stream-config": [ "created_by", "data_stream_id", @@ -298,8 +376,15 @@ "result" ], "dynamic-config-overrides": [], - "endpoint:unified-user-artifact-manifest": ["artifactIds", "policyId", "semanticVersion"], - "endpoint:user-artifact-manifest": ["artifacts", "schemaVersion"], + "endpoint:unified-user-artifact-manifest": [ + "artifactIds", + "policyId", + "semanticVersion" + ], + "endpoint:user-artifact-manifest": [ + "artifacts", + "schemaVersion" + ], "enterprise_search_telemetry": [], "entity-analytics-monitoring-entity-source": [ "enabled", @@ -325,7 +410,9 @@ "type", "version" ], - "entity-discovery-api-key": ["apiKey"], + "entity-discovery-api-key": [ + "apiKey" + ], "entity-engine-descriptor-v2": [ "error", "error.action", @@ -396,8 +483,13 @@ "package_name", "package_version" ], - "event-annotation-group": ["description", "title"], - "event_loop_delays_daily": ["lastUpdatedAt"], + "event-annotation-group": [ + "description", + "title" + ], + "event_loop_delays_daily": [ + "lastUpdatedAt" + ], "exception-list": [ "_tags", "comments", @@ -476,7 +568,12 @@ "updated_by", "version" ], - "favorites": ["favoriteIds", "favoriteMetadata", "type", "userId"], + "favorites": [ + "favoriteIds", + "favoriteMetadata", + "type", + "userId" + ], "file": [ "FileKind", "Meta", @@ -490,8 +587,16 @@ "size", "user" ], - "file-upload-usage-collection-telemetry": ["file_upload", "file_upload.index_creation_count"], - "fileShare": ["created", "name", "token", "valid_until"], + "file-upload-usage-collection-telemetry": [ + "file_upload", + "file_upload.index_creation_count" + ], + "fileShare": [ + "created", + "name", + "token", + "valid_until" + ], "fleet-agent-policies": [ "advanced_settings", "agent_features", @@ -575,7 +680,9 @@ "updated_by", "vars" ], - "fleet-preconfiguration-deletion-record": ["id"], + "fleet-preconfiguration-deletion-record": [ + "id" + ], "fleet-proxy": [ "certificate", "certificate_authorities", @@ -585,10 +692,24 @@ "proxy_headers", "url" ], - "fleet-setup-lock": ["started_at", "status", "uuid"], + "fleet-setup-lock": [ + "started_at", + "status", + "uuid" + ], "fleet-space-settings": [], - "fleet-uninstall-tokens": ["namespaces", "policy_id", "token_plain"], - "gap_auto_fill_scheduler": ["createdAt", "enabled", "name", "ruleTypeConsumerPairs", "updatedAt"], + "fleet-uninstall-tokens": [ + "namespaces", + "policy_id", + "token_plain" + ], + "gap_auto_fill_scheduler": [ + "createdAt", + "enabled", + "name", + "ruleTypeConsumerPairs", + "updatedAt" + ], "graph-workspace": [ "description", "kibanaSavedObjectMeta", @@ -600,13 +721,19 @@ "version", "wsState" ], - "index-pattern": ["name", "title", "type"], + "index-pattern": [ + "name", + "title", + "type" + ], "infra-custom-dashboards": [ "assetType", "dashboardFilterAssetIdEnabled", "dashboardSavedObjectId" ], - "infrastructure-monitoring-log-view": ["name"], + "infrastructure-monitoring-log-view": [ + "name" + ], "infrastructure-ui-source": [], "ingest-agent-policies": [ "advanced_settings", @@ -644,7 +771,13 @@ "updated_at", "updated_by" ], - "ingest-download-sources": ["host", "is_default", "name", "proxy_id", "source_id"], + "ingest-download-sources": [ + "host", + "is_default", + "name", + "proxy_id", + "source_id" + ], "ingest-outputs": [ "allow_edit", "auth_type", @@ -755,9 +888,20 @@ "use_space_awareness_migration_started_at", "use_space_awareness_migration_status" ], - "integration-config": ["created_by", "data_stream_count", "integration_id", "metadata", "status"], + "integration-config": [ + "created_by", + "data_stream_count", + "integration_id", + "metadata", + "status" + ], "intercept_interaction_record": [], - "intercept_trigger_record": ["firstRegisteredAt", "installedOn", "recurrent", "triggerAfter"], + "intercept_trigger_record": [ + "firstRegisteredAt", + "installedOn", + "recurrent", + "triggerAfter" + ], "inventory-view": [], "kql-telemetry": [], "legacy-url-alias": [ @@ -768,10 +912,31 @@ "targetNamespace", "targetType" ], - "lens": ["description", "state", "title", "visualizationType"], - "lens-ui-telemetry": ["count", "date", "name", "type"], - "links": ["description", "links", "title"], - "maintenance-window": ["createdBy", "enabled", "events", "expirationDate", "title", "updatedAt"], + "lens": [ + "description", + "state", + "title", + "visualizationType" + ], + "lens-ui-telemetry": [ + "count", + "date", + "name", + "type" + ], + "links": [ + "description", + "links", + "title" + ], + "maintenance-window": [ + "createdBy", + "enabled", + "events", + "expirationDate", + "title", + "updatedAt" + ], "map": [ "bounds", "description", @@ -783,7 +948,11 @@ ], "metrics-data-source": [], "metrics-explorer-view": [], - "ml-job": ["datafeed_id", "job_id", "type"], + "ml-job": [ + "datafeed_id", + "job_id", + "type" + ], "ml-module": [ "datafeeds", "defaultIndexPattern", @@ -796,10 +965,24 @@ "title", "type" ], - "ml-trained-model": ["job", "job.create_time", "job.job_id", "model_id"], - "monitoring-telemetry": ["reportedClusterUuids"], - "observability-onboarding-state": ["progress", "state", "type"], - "osquery-manager-usage-metric": ["count", "errors"], + "ml-trained-model": [ + "job", + "job.create_time", + "job.job_id", + "model_id" + ], + "monitoring-telemetry": [ + "reportedClusterUuids" + ], + "observability-onboarding-state": [ + "progress", + "state", + "type" + ], + "osquery-manager-usage-metric": [ + "count", + "errors" + ], "osquery-pack": [ "created_at", "created_by", @@ -847,8 +1030,12 @@ "updated_by", "version" ], - "policy-settings-protection-updates-note": ["note"], - "privilege-monitoring-status": ["status"], + "policy-settings-protection-updates-note": [ + "note" + ], + "privilege-monitoring-status": [ + "status" + ], "privmon-api-key": [], "product-doc-install-status": [ "index_name", @@ -859,7 +1046,11 @@ "product_version", "resource_type" ], - "query": ["description", "title", "titleKeyword"], + "query": [ + "description", + "title", + "titleKeyword" + ], "risk-engine-configuration": [ "alertSampleSizePerShard", "dataViewId", @@ -877,13 +1068,33 @@ "range.end", "range.start" ], - "rules-settings": ["flapping"], - "sample-data-telemetry": ["installCount", "unInstallCount"], - "scheduled_report": ["createdBy", "title"], - "search": ["description", "title"], - "search-session": ["created", "realmName", "realmType", "sessionId", "status", "username"], + "rules-settings": [ + "flapping" + ], + "sample-data-telemetry": [ + "installCount", + "unInstallCount" + ], + "scheduled_report": [ + "createdBy", + "title" + ], + "search": [ + "description", + "title" + ], + "search-session": [ + "created", + "realmName", + "realmType", + "sessionId", + "status", + "username" + ], "search-telemetry": [], - "search_playground": ["name"], + "search_playground": [ + "name" + ], "security-ai-prompt": [ "description", "model", @@ -893,9 +1104,24 @@ "promptId", "provider" ], - "security-rule": ["name", "risk_score", "rule_id", "severity", "tags", "version"], - "security-solution-signals-migration": ["sourceIndex", "updated", "version"], - "security:reference-data": ["id", "owner", "type"], + "security-rule": [ + "name", + "risk_score", + "rule_id", + "severity", + "tags", + "version" + ], + "security-solution-signals-migration": [ + "sourceIndex", + "updated", + "version" + ], + "security:reference-data": [ + "id", + "owner", + "type" + ], "siem-detection-engine-rule-actions": [ "actions", "actions.actionRef", @@ -1006,8 +1232,21 @@ "updated", "updatedBy" ], - "siem-ui-timeline-note": ["created", "createdBy", "eventId", "note", "updated", "updatedBy"], - "siem-ui-timeline-pinned-event": ["created", "createdBy", "eventId", "updated", "updatedBy"], + "siem-ui-timeline-note": [ + "created", + "createdBy", + "eventId", + "note", + "updated", + "updatedBy" + ], + "siem-ui-timeline-pinned-event": [ + "created", + "createdBy", + "eventId", + "updated", + "updatedBy" + ], "slo": [ "budgetingMethod", "description", @@ -1021,8 +1260,15 @@ "version" ], "slo-settings": [], - "slo_template": ["name", "tags"], - "space": ["disabledFeatures", "name", "solution"], + "slo_template": [ + "name", + "tags" + ], + "space": [ + "disabledFeatures", + "name", + "solution" + ], "spaces-usage-stats": [], "stream-prompts": [], "synthetics-dynamic-settings": [], @@ -1085,7 +1331,11 @@ "synthetics-param": [], "synthetics-private-location": [], "synthetics-privates-locations": [], - "tag": ["color", "description", "name"], + "tag": [ + "color", + "description", + "name" + ], "task": [ "attempts", "enabled", @@ -1105,15 +1355,44 @@ ], "telemetry": [], "threshold-explorer-view": [], - "trial-companion-nba-milestone": ["dismiss", "openTODOs"], - "ui-metric": ["count"], - "upgrade-assistant-ml-upgrade-operation": ["snapshotId"], - "upgrade-assistant-reindex-operation": ["indexName", "status"], + "trial-companion-nba-milestone": [ + "dismiss", + "openTODOs" + ], + "ui-metric": [ + "count" + ], + "upgrade-assistant-ml-upgrade-operation": [ + "snapshotId" + ], + "upgrade-assistant-reindex-operation": [ + "indexName", + "status" + ], "uptime-dynamic-settings": [], - "uptime-synthetics-api-key": ["apiKey"], - "url": ["accessDate", "createDate", "slug"], - "usage-counter": ["count", "counterName", "counterType", "domainId", "source"], - "usage-counters": ["domainId"], - "visualization": ["description", "kibanaSavedObjectMeta", "title", "version"], + "uptime-synthetics-api-key": [ + "apiKey" + ], + "url": [ + "accessDate", + "createDate", + "slug" + ], + "usage-counter": [ + "count", + "counterName", + "counterType", + "domainId", + "source" + ], + "usage-counters": [ + "domainId" + ], + "visualization": [ + "description", + "kibanaSavedObjectMeta", + "title", + "version" + ], "workplace_search_telemetry": [] } From 0386cc14527eb40477d3f8592fbe23bf00080a7d Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:54:00 +0000 Subject: [PATCH 15/19] Changes from node scripts/jest_integration -u src/core/server/integration_tests/ci_checks --- .../ci_checks/saved_objects/check_registered_types.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 de224e2b48b8e..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": "320b110b40a34e88361f50afa31a9d10258ad5b124bdf5a134b2293b67615354", + "alerting_rule": "9e26ddcbca3edb3803dcfe3d6bf908341441fb32c67005cab71ae0f7b9841387", "alerting_rule_template": "a26521005d8a51af336ec95a2097c4bd073980c050e3c675cec3851acff78fd9", "api_key_pending_invalidation": "b5a0fe007bff147bbb0ef7d0393c976f777ccb470359090d79890a769baf3c68", "api_key_to_invalidate": "5add5ee737ccc61cc16bbf68423d634d1354971f20926b5ff465a2a853d1723a", @@ -301,9 +301,9 @@ describe('checking migration metadata changes on all registered SO types', () => "alerting_notification_policy|10.1.0: decd8733c771f6e7dbbf0148ac4b851a496da3c690ae7998a25296d08e750f7a", "=====================================================================================================", "alerting_rule|global: e78adb1490c02adb4c705491c87e08332c0f668e", - "alerting_rule|mappings: db1139fc8825037946b02802d403678c38b80a29", + "alerting_rule|mappings: 05a17ab7488d3b86ec25c724f21a19cd7ebb9778", "alerting_rule|schemas: da39a3ee5e6b4b0d3255bfef95601890afd80709", - "alerting_rule|10.1.0: 609e8a9dda722696cd3d61c1e6ea44782b5c1c4f3a41515efcd99ff3fb9eb020", + "alerting_rule|10.1.0: 8b54e0f9a45fff3fbb39dd35dde15784674293f7dfb199bc10218f3658266207", "======================================================================================", "alerting_rule_template|global: a8ee387a4bc794ff6450017a92742b39b79e0446", "alerting_rule_template|mappings: eccf889027b5ea2d292c1bf0f9280348deaec0ef", From 66fe609d909d3ad3430fc2429b6d2c6b0ec4778a Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 13 Feb 2026 15:59:06 +0200 Subject: [PATCH 16/19] Remove status counts from active and inactive documents --- .../server/lib/director/director.test.ts | 1 - .../strategies/count_timeframe_strategy.test.ts | 14 -------------- .../strategies/count_timeframe_strategy.ts | 6 +++--- 3 files changed, 3 insertions(+), 18 deletions(-) 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 abfda97fed1c8..f07d50af48bec 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 @@ -432,7 +432,6 @@ describe('DirectorService', () => { expect(result[0].episode).toEqual({ id: 'episode-1', status: alertEpisodeStatus.active, - status_count: 1, }); }); }); 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 index 412ef9bac79b2..29470f22f5ff6 100644 --- 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 @@ -122,7 +122,6 @@ describe('CountTimeframeStrategy', () => { on: alertEventStatus.breached, to: alertEpisodeStatus.active, stateTransition, - expectedStatusCount: 1, }); }); @@ -131,7 +130,6 @@ describe('CountTimeframeStrategy', () => { on: alertEventStatus.breached, to: alertEpisodeStatus.active, stateTransition, - expectedStatusCount: 1, }); }); }); @@ -176,7 +174,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.active, stateTransition, statusCount: 2, - expectedStatusCount: 1, }); }); @@ -187,7 +184,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.active, stateTransition, statusCount: 5, - expectedStatusCount: 1, }); }); @@ -210,7 +206,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.active, stateTransition: { pending_timeframe: '2m' }, statusCount: 1, - expectedStatusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', previousTimestamp: '2025-01-01T00:00:00.000Z', }); @@ -240,7 +235,6 @@ describe('CountTimeframeStrategy', () => { pending_operator: 'OR', }, statusCount: 1, - expectedStatusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', previousTimestamp: '2025-01-01T00:00:00.000Z', }); @@ -273,7 +267,6 @@ describe('CountTimeframeStrategy', () => { on: alertEventStatus.recovered, to: alertEpisodeStatus.inactive, stateTransition, - expectedStatusCount: 1, }); }); }); @@ -309,7 +302,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.inactive, stateTransition, statusCount: 2, - expectedStatusCount: 1, }); }); @@ -332,7 +324,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.inactive, stateTransition: { recovering_timeframe: '2m' }, statusCount: 1, - expectedStatusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', previousTimestamp: '2025-01-01T00:00:00.000Z', }); @@ -362,7 +353,6 @@ describe('CountTimeframeStrategy', () => { recovering_operator: 'OR', }, statusCount: 1, - expectedStatusCount: 1, eventTimestamp: '2025-01-01T00:02:00.000Z', previousTimestamp: '2025-01-01T00:00:00.000Z', }); @@ -399,7 +389,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.active, stateTransition, statusCount: 1, - expectedStatusCount: 1, }); }); @@ -410,7 +399,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.inactive, stateTransition, statusCount: 1, - expectedStatusCount: 1, }); }); }); @@ -445,7 +433,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.active, stateTransition: { pending_count: 2, pending_timeframe: 'bad' }, statusCount: 1, - expectedStatusCount: 1, }); }); @@ -456,7 +443,6 @@ describe('CountTimeframeStrategy', () => { to: alertEpisodeStatus.inactive, stateTransition: { recovering_count: 2, recovering_timeframe: 'bad' }, statusCount: 1, - expectedStatusCount: 1, }); }); }); 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 index 09c227b418b28..049081766dff2 100644 --- 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 @@ -127,12 +127,12 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { // --- Handle pending count of 0: skip pending, go directly to active --- if (this.shouldSkipPending(stateTransition, basicResult.status)) { - return { status: alertEpisodeStatus.active, statusCount: DEFAULT_STATUS_COUNT }; + 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, statusCount: DEFAULT_STATUS_COUNT }; + return { status: alertEpisodeStatus.inactive }; } // --- Pending → Active threshold --- @@ -245,7 +245,7 @@ export class CountTimeframeStrategy extends BasicTransitionStrategy { const config: ThresholdConfig = { operator, count, timeframeMs }; if (isThresholdMet(nextCount, elapsedMs, config)) { - return { status: successStatus, statusCount: DEFAULT_STATUS_COUNT }; + return { status: successStatus }; } return { status: stayStatus, statusCount: nextCount }; From ce1b842219bb90f6441969b8f43b3db98a1fd97f Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 13 Feb 2026 17:06:13 +0200 Subject: [PATCH 17/19] Fix query and alert event mapping --- .../plugins/shared/alerting_v2/server/lib/director/queries.ts | 2 +- .../plugins/shared/alerting_v2/server/resources/alert_events.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 42d80d147b7a0..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 @@ -36,7 +36,7 @@ export const getLatestAlertEventStateQuery = ({ last_episode_id = LAST(episode.id, @timestamp), last_episode_status = LAST(episode.status, @timestamp), last_episode_status_count = LAST(episode.status_count, @timestamp), - last_episode_timestamp = LAST(@timestamp, @timestamp) + last_episode_timestamp = MAX(@timestamp) BY group_hash`; query = query.keep( 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 3a58d8efcf260..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,7 +52,7 @@ const mappings: MappingsDefinition = { properties: { id: { type: 'keyword' }, status: { type: 'keyword' }, // inactive | pending | active | recovering - status_count: { type: 'unsigned_long' }, // only set for pending and recovering + status_count: { type: 'long' }, // only set for pending and recovering }, }, }, From 549ac046d13139ea6dea0896138dd799a139a918 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 13 Feb 2026 17:06:23 +0200 Subject: [PATCH 18/19] Fix integration jest tests paths --- .../plugins/shared/alerting_v2/jest.integration.config.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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', + ], }; From 2a3ab427e909bbef2deca1a6fc7b18b6afb35854 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Fri, 13 Feb 2026 17:36:44 +0200 Subject: [PATCH 19/19] Change the type of alert events to alerts if they are alertable --- .../server/lib/director/director.test.ts | 20 ++++++++++++++++++- .../server/lib/director/director.ts | 3 ++- 2 files changed, 21 insertions(+), 2 deletions(-) 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 f07d50af48bec..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 @@ -11,7 +11,7 @@ import { DirectorService } from './director'; import { createLoggerService } from '../services/logger_service/logger_service.mock'; import { createQueryService } from '../services/query_service/query_service.mock'; import { createTransitionStrategyFactory } from './strategies/strategy_resolver.mock'; -import { alertEpisodeStatus } from '../../resources/alert_events'; +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'; @@ -352,6 +352,24 @@ describe('DirectorService', () => { 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')); 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 4e854350ac4d4..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,7 +13,7 @@ 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'; @@ -113,6 +113,7 @@ export class DirectorService { return { ...currentAlertEvent, + type: alertEventType.alert, episode: { id: episodeId, status: result.status,