Skip to content
Merged
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
@@ -0,0 +1,102 @@
/*
* 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", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type { CombinedSummarizedAlerts } from '@kbn/alerting-plugin/server/types';
import { buildAlertEvent } from './build_alert_event';

const mockRule = {
id: 'rule-id',
name: 'test rule',
tags: ['test-tag'],
consumer: 'test-consumer',
producer: 'test-producer',
ruleTypeId: 'test-rule-type',
};

const makeAlertGroup = (ids: string[]) => ({
count: ids.length,
data: ids.map((id) => ({ _id: id, _index: 'test-index' })),
alert_count: { active: ids.length, recovered: 0, ignored: 0 },
});

describe('buildAlertEvent', () => {
it('should merge new, ongoing, and recovered alerts into a flat array', () => {
const alerts = {
all: makeAlertGroup(['a1', 'a2', 'a3']),
new: makeAlertGroup(['a1']),
ongoing: makeAlertGroup(['a2']),
recovered: makeAlertGroup(['a3']),
} as unknown as CombinedSummarizedAlerts;

const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' });

expect(result.alerts).toHaveLength(3);
expect(result.alerts.map((a) => a._id)).toEqual(['a1', 'a2', 'a3']);
});

it('should return only new alerts when ongoing and recovered are empty', () => {
const alerts = {
all: makeAlertGroup(['a1']),
new: makeAlertGroup(['a1']),
ongoing: makeAlertGroup([]),
recovered: makeAlertGroup([]),
} as unknown as CombinedSummarizedAlerts;

const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' });

expect(result.alerts).toHaveLength(1);
expect(result.alerts[0]._id).toBe('a1');
});

it('should handle undefined data gracefully', () => {
const alerts = {
all: { count: 0, data: [] },
new: { count: 0, data: undefined },
ongoing: { count: 0, data: undefined },
recovered: { count: 0, data: undefined },
} as unknown as CombinedSummarizedAlerts;

const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' });

expect(result.alerts).toEqual([]);
});

it('should include rule information', () => {
const alerts = {
all: makeAlertGroup([]),
new: makeAlertGroup([]),
ongoing: makeAlertGroup([]),
recovered: makeAlertGroup([]),
} as unknown as CombinedSummarizedAlerts;

const result = buildAlertEvent({
alerts,
rule: mockRule,
ruleUrl: 'https://example.com/rule',
spaceId: 'my-space',
});

expect(result.rule).toEqual(mockRule);
expect(result.ruleUrl).toBe('https://example.com/rule');
expect(result.spaceId).toBe('my-space');
});

it('should handle missing ruleUrl', () => {
const alerts = {
all: makeAlertGroup([]),
new: makeAlertGroup([]),
ongoing: makeAlertGroup([]),
recovered: makeAlertGroup([]),
} as unknown as CombinedSummarizedAlerts;

const result = buildAlertEvent({ alerts, rule: mockRule, spaceId: 'default' });

expect(result.ruleUrl).toBeUndefined();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ export function buildAlertEvent(params: {
spaceId: string;
}): AlertEvent {
return {
alerts: params.alerts.new.data,
alerts: [
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.

I'm surprised these are being collapsed into a single collection alerts, instead of keeping them in different collections. These are passed to the workflow? I guess there's likely enough info in the alert data itself to determine if it's new/ongoing/recovered, but since we already have them partitioned that way, not clear why we are sending them that way.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@pmuellr this is the pattern workflows work by as of today (events.alerts) - without a clear distinguish between the state (we didn't have anything else besides the new alerts up until now :)).
We don't want to make people migrate the workflows (and we don't have anything automatic for that now), and as you said, the pattern currently works for people and there's a great way they can get the state of the alert from the alert itself.

...(params.alerts.new?.data ?? []),
...(params.alerts.ongoing?.data ?? []),
...(params.alerts.recovered?.data ?? []),
],
rule: {
id: params.rule.id,
name: params.rule.name,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getConnectorType,
getWorkflowsConnectorAdapter,
type GetWorkflowsConnectorTypeArgs,
resolveAlertStates,
} from '.';

describe('Workflows Connector', () => {
Expand Down Expand Up @@ -331,5 +332,179 @@ describe('Workflows Connector', () => {
expect(result.subActionParams.workflowId).toBe('unknown');
expect(result.subActionParams.inputs).toBeUndefined();
});

it('should default alertStates to new=true, ongoing=false, recovered=false when not provided', () => {
const adapter = getWorkflowsConnectorAdapter();

const mockAlerts = {
all: { data: [{ _id: 'a1', _index: 'idx' }], count: 1 },
new: { data: [{ _id: 'a1', _index: 'idx' }], count: 1 },
ongoing: { data: [{ _id: 'a2', _index: 'idx' }], count: 1 },
recovered: { data: [{ _id: 'a3', _index: 'idx' }], count: 1 },
};

const mockRule = {
id: 'rule-id',
name: 'test rule',
tags: ['test-tag'],
consumer: 'test-consumer',
producer: 'test-producer',
ruleTypeId: 'test-rule-type',
};

const params = {
subAction: 'run' as const,
subActionParams: { workflowId: 'wf-1' },
};

const result = adapter.buildActionParams({
alerts: mockAlerts as any,
rule: mockRule,
params,
spaceId: 'default',
});

expect(result.subActionParams.alertStates).toEqual({
new: true,
ongoing: false,
recovered: false,
});
expect(result.subActionParams.inputs?.event?.alerts).toHaveLength(1);
expect(result.subActionParams.inputs?.event?.alerts[0]._id).toBe('a1');
});

it('should include ongoing and recovered alerts when alertStates enables them', () => {
const adapter = getWorkflowsConnectorAdapter();

const mockAlerts = {
all: { data: [], count: 3 },
new: {
data: [{ _id: 'a1', _index: 'idx' }],
count: 1,
alert_count: { active: 1, recovered: 0, ignored: 0 },
},
ongoing: {
data: [{ _id: 'a2', _index: 'idx' }],
count: 1,
alert_count: { active: 1, recovered: 0, ignored: 0 },
},
recovered: {
data: [{ _id: 'a3', _index: 'idx' }],
count: 1,
alert_count: { active: 0, recovered: 1, ignored: 0 },
},
};

const mockRule = {
id: 'rule-id',
name: 'test rule',
tags: ['test-tag'],
consumer: 'test-consumer',
producer: 'test-producer',
ruleTypeId: 'test-rule-type',
};

const params = {
subAction: 'run' as const,
subActionParams: {
workflowId: 'wf-1',
alertStates: { new: true, ongoing: true, recovered: true },
},
};

const result = adapter.buildActionParams({
alerts: mockAlerts as any,
rule: mockRule,
params,
spaceId: 'default',
});

expect(result.subActionParams.alertStates).toEqual({
new: true,
ongoing: true,
recovered: true,
});
expect(result.subActionParams.inputs?.event?.alerts).toHaveLength(3);
});

it('should exclude new alerts when alertStates.new is false', () => {
const adapter = getWorkflowsConnectorAdapter();

const mockAlerts = {
all: { data: [], count: 2 },
new: {
data: [{ _id: 'a1', _index: 'idx' }],
count: 1,
alert_count: { active: 1, recovered: 0, ignored: 0 },
},
ongoing: {
data: [],
count: 0,
alert_count: { active: 0, recovered: 0, ignored: 0 },
},
recovered: {
data: [{ _id: 'a2', _index: 'idx' }],
count: 1,
alert_count: { active: 0, recovered: 1, ignored: 0 },
},
};

const mockRule = {
id: 'rule-id',
name: 'test rule',
tags: ['test-tag'],
consumer: 'test-consumer',
producer: 'test-producer',
ruleTypeId: 'test-rule-type',
};

const params = {
subAction: 'run' as const,
subActionParams: {
workflowId: 'wf-1',
alertStates: { new: false, ongoing: false, recovered: true },
},
};

const result = adapter.buildActionParams({
alerts: mockAlerts as any,
rule: mockRule,
params,
spaceId: 'default',
});

expect(result.subActionParams.inputs?.event?.alerts).toHaveLength(1);
expect(result.subActionParams.inputs?.event?.alerts[0]._id).toBe('a2');
});
});

describe('resolveAlertStates', () => {
it('should return defaults when no alertStates provided', () => {
expect(resolveAlertStates()).toEqual({ new: true, ongoing: false, recovered: false });
});

it('should return defaults when undefined is passed', () => {
expect(resolveAlertStates(undefined)).toEqual({
new: true,
ongoing: false,
recovered: false,
});
});

it('should respect explicit values', () => {
expect(resolveAlertStates({ new: false, ongoing: true, recovered: true })).toEqual({
new: false,
ongoing: true,
recovered: true,
});
});

it('should fill in missing fields with defaults', () => {
expect(resolveAlertStates({ recovered: true })).toEqual({
new: true,
ongoing: false,
recovered: true,
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from './service';
import * as i18n from './translations';
import type {
AlertStates,
ExecutorParams,
ExecutorSubActionRunParams,
WorkflowsActionParamsType,
Expand All @@ -41,6 +42,7 @@ export interface WorkflowsRuleActionParams {
subActionParams: {
workflowId: string;
summaryMode?: boolean;
alertStates?: AlertStates;
};
[key: string]: unknown;
}
Expand Down Expand Up @@ -155,6 +157,20 @@ export async function executor(
return { status: 'ok', data, actionId };
}

const DEFAULT_ALERT_STATES: AlertStates = {
new: true,
ongoing: false,
recovered: false,
};

export function resolveAlertStates(alertStates?: Partial<AlertStates>): AlertStates {
return {
new: alertStates?.new ?? DEFAULT_ALERT_STATES.new,
ongoing: alertStates?.ongoing ?? DEFAULT_ALERT_STATES.ongoing,
recovered: alertStates?.recovered ?? DEFAULT_ALERT_STATES.recovered,
};
}

// Connector adapter for system action
export function getWorkflowsConnectorAdapter(): ConnectorAdapter<
WorkflowsRuleActionParams,
Expand All @@ -177,9 +193,23 @@ export function getWorkflowsConnectorAdapter(): ConnectorAdapter<
);
}

// Build alert event using shared utility function
const resolvedStates = resolveAlertStates(subActionParams.alertStates);

const emptyAlertGroup = {
count: 0,
data: [],
alert_count: { active: 0, recovered: 0, ignored: 0 },
};

const filteredAlerts = {
...alerts,
new: resolvedStates.new ? alerts.new : emptyAlertGroup,
ongoing: resolvedStates.ongoing ? alerts.ongoing : emptyAlertGroup,
recovered: resolvedStates.recovered ? alerts.recovered : emptyAlertGroup,
};

const alertEvent = buildAlertEvent({
alerts,
alerts: filteredAlerts,
rule,
ruleUrl,
spaceId,
Expand All @@ -192,6 +222,7 @@ export function getWorkflowsConnectorAdapter(): ConnectorAdapter<
inputs: { event: alertEvent },
spaceId,
summaryMode,
alertStates: resolvedStates,
},
};
} catch (error) {
Expand All @@ -201,6 +232,7 @@ export function getWorkflowsConnectorAdapter(): ConnectorAdapter<
workflowId: params?.subActionParams?.workflowId || 'unknown',
spaceId,
summaryMode: params?.subActionParams?.summaryMode ?? true,
alertStates: resolveAlertStates(params?.subActionParams?.alertStates),
},
};
}
Expand Down
Loading
Loading