Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,7 @@
export interface MatcherContextRule {
id: string;
name: string;
description: string;
tags: string[];
enabled: boolean;
createdAt: string;
updatedAt: string;
}
Comment on lines 8 to 12
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image


export interface MatcherContext {
Expand All @@ -38,10 +34,6 @@ export const MATCHER_CONTEXT_FIELDS: MatcherContextFieldDescriptor[] = [
{ path: 'severity', type: 'string' },
{ path: 'rule.id', type: 'string' },
{ path: 'rule.name', type: 'string' },
{ path: 'rule.description', type: 'string' },
{ path: 'rule.tags', type: 'string[]' },
{ path: 'rule.enabled', type: 'boolean' },
{ path: 'rule.createdAt', type: 'string' },
{ path: 'rule.updatedAt', type: 'string' },
{ path: 'data', type: 'object' },
];
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ export const DISPATCH_PAYLOAD_VARIABLES: readonly PayloadVariable[] = [
detail: 'AlertEpisode[]',
documentation: 'Alert episodes included in this dispatch.',
},
{
path: 'rules',
detail: 'Record<string, { name: string }>',
documentation:
'Rule metadata keyed by rule id. Covers all rules present in `episodes`. Access via `rules[episode.rule_id].name`.',
},
Comment on lines +35 to +40
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are going to have some conflict when #271770 land :D

];

// Mirrors `AlertEpisode` from server/lib/dispatcher/types.ts — keep in sync.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,7 @@ When the dispatcher evaluates a policy's KQL matcher, these fields are available
| \`last_event_timestamp\` | Timestamp of the most recent event |
| \`rule.id\` | The rule's saved object ID |
| \`rule.name\` | The rule's display name |
| \`rule.description\` | The rule's description |
| \`rule.tags\` | The rule's tags array |
| \`rule.enabled\` | Whether the rule is enabled |
| \`data.*\` | Rule-specific ES|QL output columns (e.g. \`data.host.name\`, \`data.error_count\`) |

### Grouping Modes
Expand Down Expand Up @@ -265,7 +263,7 @@ For an existing policy, pass the \`actionPolicyAttachmentId\` and only include t
3. **\`set_matcher\`** — set a KQL query to filter which alert episodes trigger this policy. Set to \`null\` for a catch-all that matches all episodes. Available matcher fields:
- \`episode_id\`, \`episode_status\` (inactive | pending | active | recovering)
- \`group_hash\`, \`last_event_timestamp\`
- \`rule.id\`, \`rule.name\`, \`rule.description\`, \`rule.tags\`, \`rule.enabled\`
- \`rule.id\`, \`rule.name\`, \`rule.tags\`
- \`data.*\` (rule-specific fields)
4. **\`set_grouping\`** — set \`groupingMode\` and optionally \`groupBy\` fields:
- \`per_episode\` (default): one notification per alert episode lifecycle.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,7 @@ export class ActionPolicyClient {
rule: {
id: ruleId ?? '',
name: resolvedName,
description: '',
tags: resolvedTags,
enabled: true,
createdAt: '',
updatedAt: '',
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,8 @@ export function createRule(overrides: Partial<Rule> = {}): Rule {
return {
id: 'rule-1',
spaceId: 'default',
kind: 'alert',
name: 'Test rule',
description: '',
tags: [],
enabled: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
Expand Down Expand Up @@ -127,6 +122,7 @@ export function createActionGroup(overrides: Partial<ActionGroup> = {}): ActionG
destinations: [{ type: 'workflow' as const, id: 'workflow-1' }],
groupKey: {},
episodes: [createAlertEpisode()],
rules: {},
...overrides,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
createDispatcherPipelineState,
createMatchedPair,
createActionPolicy,
createRule,
} from '../fixtures/test_utils';

describe('BuildGroupsStep', () => {
Expand Down Expand Up @@ -294,6 +295,65 @@ describe('buildActionGroups', () => {
expect(groups[0].episodes[1].rule_id).toBe('r2');
});

it('populates rules map from state.rules for episodes in the group', () => {
const policy = createActionPolicy({ id: 'p1', groupingMode: 'all' });
const rules = new Map([
['r1', createRule({ id: 'r1', name: 'CPU spike' })],
['r2', createRule({ id: 'r2', name: 'Cert expiry' })],
]);
const matched = [
createMatchedPair({
episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e1' }),
policy,
}),
createMatchedPair({
episode: createAlertEpisode({ rule_id: 'r2', episode_id: 'e2' }),
policy,
}),
];

const groups = buildActionGroups(matched, rules);

expect(groups[0].rules).toEqual({
r1: { name: 'CPU spike' },
r2: { name: 'Cert expiry' },
});
});

it('omits rules not present in state.rules', () => {
const policy = createActionPolicy({ id: 'p1' });
const rules = new Map([['r1', createRule({ id: 'r1', name: 'CPU spike' })]]);
const matched = [
createMatchedPair({
episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e1' }),
policy,
}),
createMatchedPair({
episode: createAlertEpisode({ rule_id: 'r-missing', episode_id: 'e2' }),
policy,
}),
];

const groups = buildActionGroups(matched, rules);

expect(groups[0].rules).toEqual({ r1: { name: 'CPU spike' } });
expect(groups[1].rules).toEqual({});
});

it('returns empty rules map when state.rules is undefined', () => {
const policy = createActionPolicy({ id: 'p1' });
const matched = [
createMatchedPair({
episode: createAlertEpisode({ rule_id: 'r1', episode_id: 'e1' }),
policy,
}),
];

const groups = buildActionGroups(matched);

expect(groups[0].rules).toEqual({});
});

it('creates one group per episode for explicit per_episode mode', () => {
const policy = createActionPolicy({
id: 'p1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,27 @@ import type {
DispatcherStep,
DispatcherStepOutput,
MatchedPair,
Rule,
RuleId,
} from '../types';

@injectable()
export class BuildGroupsStep implements DispatcherStep {
public readonly name = 'build_groups';

public async execute(state: Readonly<DispatcherPipelineState>): Promise<DispatcherStepOutput> {
const { matched = [] } = state;
const { matched = [], rules } = state;

const groups = buildActionGroups(matched);
const groups = buildActionGroups(matched, rules);

return { type: 'continue', data: { groups } };
}
}

export function buildActionGroups(matched: readonly MatchedPair[]): ActionGroup[] {
export function buildActionGroups(
matched: readonly MatchedPair[],
rules?: Map<RuleId, Rule>
): ActionGroup[] {
const groupMap = new Map<string, ActionGroup>();

for (const { episode, policy } of matched) {
Expand Down Expand Up @@ -64,10 +69,16 @@ export function buildActionGroups(matched: readonly MatchedPair[]): ActionGroup[
destinations: policy.destinations,
groupKey,
episodes: [],
rules: {},
});
}

groupMap.get(actionGroupId)!.episodes.push(episode);
const group = groupMap.get(actionGroupId)!;
group.episodes.push(episode);
const rule = rules?.get(episode.rule_id);
if (rule) {
group.rules[episode.rule_id] = { name: rule.name };
}
}

return [...groupMap.values()];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
createDispatcherPipelineState,
createActionGroup,
createActionPolicy,
createAlertEpisode,
} from '../fixtures/test_utils';
import { DispatchStep } from './dispatch_step';

Expand Down Expand Up @@ -300,6 +301,75 @@ describe('DispatchStep', () => {
expect(mockLogger.error).toHaveBeenCalledTimes(1);
});

it('includes rule metadata in the workflow payload', async () => {
const { loggerService } = createLoggerService();
const step = new DispatchStep(loggerService, mockWfm);

mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto());
mockWfm.scheduleWorkflow.mockResolvedValue('exec-1');

const episode = createAlertEpisode({ rule_id: 'rule-1' });
const group = createActionGroup({
id: 'g1',
policyId: 'p1',
destinations: [{ type: 'workflow', id: 'workflow-1' }],
episodes: [episode],
rules: { 'rule-1': { name: 'CPU spike monitor' } },
});
const policy = createActionPolicy({ id: 'p1', apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==' });

const state = createDispatcherPipelineState({
dispatch: [group],
policies: new Map([['p1', policy]]),
});

await step.execute(state);

expect(mockWfm.scheduleWorkflow).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({
rules: { 'rule-1': { name: 'CPU spike monitor' } },
}),
expect.anything(),
expect.anything()
);
});

it('omits rules missing from state.rules in the payload', async () => {
const { loggerService } = createLoggerService();
const step = new DispatchStep(loggerService, mockWfm);

mockWfm.getWorkflow.mockResolvedValue(createWorkflowDetailDto());
mockWfm.scheduleWorkflow.mockResolvedValue('exec-1');

const episode = createAlertEpisode({ rule_id: 'rule-unknown' });
const group = createActionGroup({
id: 'g1',
policyId: 'p1',
destinations: [{ type: 'workflow', id: 'workflow-1' }],
episodes: [episode],
rules: {},
});
const policy = createActionPolicy({ id: 'p1', apiKey: 'dGVzdC1pZDp0ZXN0LWtleQ==' });

const state = createDispatcherPipelineState({
dispatch: [group],
policies: new Map([['p1', policy]]),
rules: new Map(), // rule-unknown not present
});

await step.execute(state);

expect(mockWfm.scheduleWorkflow).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining({ rules: {} }),
expect.anything(),
expect.anything()
);
});

it('dispatches multiple groups concurrently with a max concurrency of 3', async () => {
jest.useFakeTimers();
const { loggerService } = createLoggerService();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ export class DispatchStep implements DispatcherStep {
policyId: group.policyId,
groupKey: group.groupKey,
episodes: group.episodes,
rules: group.rules,
};

this.logger.debug({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ describe('FetchRulesStep', () => {
expect(result.data?.rules?.size).toBe(1);
expect(result.data?.rules?.get('r1')?.name).toBe('Rule 1');
expect(result.data?.rules?.get('r1')?.spaceId).toBe('default');
expect(result.data?.rules?.get('r1')?.kind).toBe('alert');
expect(mockFindByIds).toHaveBeenCalledWith(['r1']);
});

Expand Down Expand Up @@ -89,8 +88,6 @@ describe('FetchRulesStep', () => {
expect(result.type).toBe('continue');
if (result.type !== 'continue') return;
expect(result.data?.rules?.size).toBe(2);
expect(result.data?.rules?.get('r1')?.kind).toBe('alert');
expect(result.data?.rules?.get('r2')?.kind).toBe('alert');
expect(mockFindByIds).toHaveBeenCalledWith(['r1', 'r2']);
});

Expand All @@ -113,7 +110,6 @@ describe('FetchRulesStep', () => {
expect(result.type).toBe('continue');
if (result.type !== 'continue') return;
expect(result.data?.rules?.get('r1')?.spaceId).toBe('my-space');
expect(result.data?.rules?.get('r1')?.kind).toBe('alert');
});

it('defaults spaceId to default when namespaces is undefined', async () => {
Expand All @@ -134,6 +130,5 @@ describe('FetchRulesStep', () => {
expect(result.type).toBe('continue');
if (result.type !== 'continue') return;
expect(result.data?.rules?.get('r1')?.spaceId).toBe('default');
expect(result.data?.rules?.get('r1')?.kind).toBe('alert');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,8 @@ export class FetchRulesStep implements DispatcherStep {
rules.set(doc.id, {
id: doc.id,
spaceId: savedObjectNamespacesToSpaceId(doc.namespaces),
kind: doc.attributes.kind,
name: doc.attributes.metadata.name,
description: doc.attributes.metadata.owner ?? '',
tags: doc.attributes.metadata.tags ?? [],
enabled: doc.attributes.enabled,
createdAt: doc.attributes.createdAt,
updatedAt: doc.attributes.updatedAt,
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ describe('StoreExecutionHistoryStep', () => {
});

it('emits one dispatched summary per policy with aggregated episode/rule/group counts', async () => {
const ruleA = createRule({ id: 'rule-a', kind: 'alert', spaceId: 'default' });
const ruleB = createRule({ id: 'rule-b', kind: 'alert', spaceId: 'default' });
const ruleA = createRule({ id: 'rule-a', spaceId: 'default' });
const ruleB = createRule({ id: 'rule-b', spaceId: 'default' });
const policy = createActionPolicy({ id: 'policy-1', spaceId: 'default' });
const episodes = [
createAlertEpisode({ rule_id: 'rule-a', episode_id: 'ep-1' }),
Expand Down Expand Up @@ -171,8 +171,8 @@ describe('StoreExecutionHistoryStep', () => {
});

it('emits one unmatched summary per rule with episode_ids for that rule', async () => {
const ruleA = createRule({ id: 'rule-a', kind: 'alert' });
const ruleB = createRule({ id: 'rule-b', kind: 'signal' });
const ruleA = createRule({ id: 'rule-a' });
const ruleB = createRule({ id: 'rule-b' });
const unmatchedA1 = createAlertEpisode({ rule_id: 'rule-a', episode_id: 'ep-a1' });
const unmatchedA2 = createAlertEpisode({ rule_id: 'rule-a', episode_id: 'ep-a2' });
const unmatchedB1 = createAlertEpisode({ rule_id: 'rule-b', episode_id: 'ep-b1' });
Expand Down Expand Up @@ -215,7 +215,7 @@ describe('StoreExecutionHistoryStep', () => {
episode_ids: ['ep-b1'],
execution: { uuid: '00000000-0000-4000-8000-000000000000' },
});
expect(eventB?.kibana?.saved_objects?.[0]?.type_id).toBe('signal');
expect(eventB?.kibana?.saved_objects?.[0]?.type_id).toBe('alert');
});

it('excludes episodes handled by dispatch or throttled from the unmatched set', async () => {
Expand Down Expand Up @@ -361,7 +361,7 @@ describe('StoreExecutionHistoryStep', () => {
const policy = createActionPolicy({ id: 'policy-1' });
const ruleIds = Array.from({ length: 55 }, (_, i) => `rule-${i}`);
const rules = new Map<RuleId, Rule>(
ruleIds.map((id) => [id, createRule({ id, kind: 'alert', spaceId: 'default' })])
ruleIds.map((id) => [id, createRule({ id, spaceId: 'default' })])
);
const episodes = ruleIds.map((rule_id, i) =>
createAlertEpisode({ rule_id, episode_id: `ep-${i}` })
Expand Down
Loading
Loading