[Response Ops][Alerting] Aliasing context and state variables for detection rules in alert summary mode (#154864)

Toward enabling per-action-alerts for detection rules
https://github.com/elastic/kibana/pull/154977

## Summary

This PR provides the necessary changes for detection rules to fully
onboard onto alerting framework alert summaries.
* Aliases detection rule context and state variables for summary action
variables. This provides backwards compatibility with the
`context.alerts`, `context.results_link` and `state.signals_count`
action variables that are currently used by detection rules.
* Calculates time bounds for summary alerts that can be passed back to
the view in app URL generator. This allows rule types to generate view
in app URLs limited to the timeframe that will match the summary alerts
time range
* For throttled summary alerts, the time range is generated as `now -
throttle duration`
* For per execution summary alerts, we use the `previousStartedAt` time
from the task state if available and the schedule duration if not
available. This is because some rules write out alerts with `@timestamp:
task.startedAt` so just using `now - schedule duration` may not capture
those alerts due to task manager schedule delays.

---------

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ying Mao 2023-04-19 10:08:05 -04:00 committed by GitHub
parent fce3664397
commit fc3496902a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 221 additions and 5 deletions

View file

@ -21,6 +21,7 @@ import {
RuleTypeParams,
RuleTypeState,
SanitizedRule,
GetViewInAppRelativeUrlFnOpts,
} from '../types';
import { RuleRunMetricsStore } from '../lib/rule_run_metrics_store';
import { alertingEventLoggerMock } from '../lib/alerting_event_logger/alerting_event_logger.mock';
@ -80,6 +81,7 @@ const rule = {
contextVal: 'My other {{context.value}} goes here',
stateVal: 'My other {{state.value}} goes here',
},
schedule: { interval: '1m' },
notifyWhen: 'onActiveAlert',
actions: [
{
@ -116,6 +118,7 @@ const defaultExecutionParams = {
ruleLabel: 'rule-label',
request: {} as KibanaRequest,
alertingEventLogger,
previousStartedAt: null,
taskInstance: {
params: { spaceId: 'test1', alertId: '1' },
} as unknown as ConcreteTaskInstance,
@ -1447,6 +1450,25 @@ describe('Execution Handler', () => {
],
} as unknown as SanitizedRule<RuleTypeParams>;
const summaryRuleWithUrl = {
...rule,
actions: [
{
id: '1',
group: null,
actionTypeId: 'test',
frequency: {
summary: true,
notifyWhen: 'onActiveAlert',
throttle: null,
},
params: {
val: 'rule url: {{rule.url}}',
},
},
],
} as unknown as SanitizedRule<RuleTypeParams>;
it('populates the rule.url in the action params when the base url and rule id are specified', async () => {
const execParams = {
...defaultExecutionParams,
@ -1474,6 +1496,55 @@ describe('Execution Handler', () => {
`);
});
it('populates the rule.url with start and stop time when available', async () => {
clock.reset();
clock.tick(90000);
getSummarizedAlertsMock.mockResolvedValue({
new: {
count: 2,
data: [
mockAAD,
{
...mockAAD,
'@timestamp': '2022-12-07T15:45:41.4672Z',
alert: { instance: { id: 'all' } },
},
],
},
ongoing: { count: 0, data: [] },
recovered: { count: 0, data: [] },
});
const execParams = {
...defaultExecutionParams,
ruleType: {
...ruleType,
getViewInAppRelativeUrl: (opts: GetViewInAppRelativeUrlFnOpts<RuleTypeParams>) =>
`/app/test/rule/${opts.rule.id}?start=${opts.start ?? 0}&end=${opts.end ?? 0}`,
},
rule: summaryRuleWithUrl,
taskRunnerContext: {
...defaultExecutionParams.taskRunnerContext,
kibanaBaseUrl: 'http://localhost:12345',
},
};
const executionHandler = new ExecutionHandler(generateExecutionParams(execParams));
await executionHandler.run(generateAlert({ id: 1 }));
expect(injectActionParamsMock.mock.calls[0]).toMatchInlineSnapshot(`
Array [
Object {
"actionParams": Object {
"val": "rule url: http://localhost:12345/s/test1/app/test/rule/1?start=30000&end=90000",
},
"actionTypeId": "test",
"ruleId": "1",
"spaceId": "test1",
},
]
`);
});
it('populates the rule.url without the space specifier when the spaceId is the string "default"', async () => {
const execParams = {
...defaultExecutionParams,

View file

@ -40,6 +40,7 @@ import {
import {
generateActionHash,
getSummaryActionsFromTaskState,
getSummaryActionTimeBounds,
isActionOnInterval,
isSummaryAction,
isSummaryActionOnInterval,
@ -91,6 +92,7 @@ export class ExecutionHandler<
private actionsClient: PublicMethodsOf<ActionsClient>;
private ruleTypeActionGroups?: Map<ActionGroupIds | RecoveryActionGroupId, string>;
private mutedAlertIdsSet: Set<string> = new Set();
private previousStartedAt: Date | null;
constructor({
rule,
@ -104,6 +106,7 @@ export class ExecutionHandler<
ruleConsumer,
executionId,
ruleLabel,
previousStartedAt,
actionsClient,
}: ExecutionHandlerOptions<
Params,
@ -130,6 +133,7 @@ export class ExecutionHandler<
this.ruleTypeActionGroups = new Map(
ruleType.actionGroups.map((actionGroup) => [actionGroup.id, actionGroup.name])
);
this.previousStartedAt = previousStartedAt;
this.mutedAlertIdsSet = new Set(rule.mutedInstanceIds);
}
@ -205,6 +209,11 @@ export class ExecutionHandler<
ruleRunMetricsStore.incrementNumberOfTriggeredActionsByConnectorType(actionTypeId);
if (isSummaryAction(action) && summarizedAlerts) {
const { start, end } = getSummaryActionTimeBounds(
action,
this.rule.schedule,
this.previousStartedAt
);
const actionToRun = {
...action,
params: injectActionParams({
@ -221,7 +230,7 @@ export class ExecutionHandler<
actionsPlugin,
actionTypeId,
kibanaBaseUrl: this.taskRunnerContext.kibanaBaseUrl,
ruleUrl: this.buildRuleUrl(spaceId),
ruleUrl: this.buildRuleUrl(spaceId, start, end),
}),
}),
};
@ -419,13 +428,13 @@ export class ExecutionHandler<
return alert.getScheduledActionOptions()?.actionGroup || this.ruleType.recoveryActionGroup.id;
}
private buildRuleUrl(spaceId: string): string | undefined {
private buildRuleUrl(spaceId: string, start?: number, end?: number): string | undefined {
if (!this.taskRunnerContext.kibanaBaseUrl) {
return;
}
const relativePath = this.ruleType.getViewInAppRelativeUrl
? this.ruleType.getViewInAppRelativeUrl({ rule: this.rule })
? this.ruleType.getViewInAppRelativeUrl({ rule: this.rule, start, end })
: `${triggersActionsRoute}${getRuleDetailsRoute(this.rule.id)}`;
try {

View file

@ -14,8 +14,12 @@ import {
isSummaryAction,
isSummaryActionOnInterval,
isSummaryActionThrottled,
getSummaryActionTimeBounds,
} from './rule_action_helper';
const now = '2021-05-13T12:33:37.000Z';
Date.now = jest.fn().mockReturnValue(new Date(now));
const mockOldAction: RuleAction = {
id: '1',
group: 'default',
@ -309,4 +313,60 @@ describe('rule_action_helper', () => {
).toBe(false);
});
});
describe('getSummaryActionTimeBounds', () => {
test('returns undefined start and end action is not summary action', () => {
expect(getSummaryActionTimeBounds(mockAction, { interval: '1m' }, null)).toEqual({
start: undefined,
end: undefined,
});
});
test('returns start and end for summary action with throttle', () => {
const { start, end } = getSummaryActionTimeBounds(
mockSummaryAction,
{ interval: '1m' },
null
);
expect(end).toEqual(1620909217000);
expect(end).toEqual(new Date(now).valueOf());
expect(start).toEqual(1620822817000);
// start is end - throttle interval (1d)
expect(start).toEqual(new Date('2021-05-12T12:33:37.000Z').valueOf());
});
test('returns start and end for summary action without throttle with previousStartedAt', () => {
const { start, end } = getSummaryActionTimeBounds(
{
...mockSummaryAction,
frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null },
},
{ interval: '1m' },
new Date('2021-05-13T12:31:57.000Z')
);
expect(end).toEqual(1620909217000);
expect(end).toEqual(new Date(now).valueOf());
expect(start).toEqual(1620909117000);
// start is previous started at time
expect(start).toEqual(new Date('2021-05-13T12:31:57.000Z').valueOf());
});
test('returns start and end for summary action without throttle without previousStartedAt', () => {
const { start, end } = getSummaryActionTimeBounds(
{
...mockSummaryAction,
frequency: { summary: true, notifyWhen: 'onActiveAlert', throttle: null },
},
{ interval: '1m' },
null
);
expect(end).toEqual(1620909217000);
expect(end).toEqual(new Date(now).valueOf());
expect(start).toEqual(1620909157000);
// start is end - schedule interval (1m)
expect(start).toEqual(new Date('2021-05-13T12:32:37.000Z').valueOf());
});
});
});

View file

@ -7,6 +7,7 @@
import { Logger } from '@kbn/logging';
import {
IntervalSchedule,
parseDuration,
RuleAction,
RuleNotifyWhenTypeValues,
@ -99,3 +100,32 @@ export const getSummaryActionsFromTaskState = ({
}
}, {});
};
export const getSummaryActionTimeBounds = (
action: RuleAction,
ruleSchedule: IntervalSchedule,
previousStartedAt: Date | null
): { start?: number; end?: number } => {
if (!isSummaryAction(action)) {
return { start: undefined, end: undefined };
}
let startDate: Date;
const now = Date.now();
if (isActionOnInterval(action)) {
// If action is throttled, set time bounds using throttle interval
const throttleMills = parseDuration(action.frequency!.throttle!);
startDate = new Date(now - throttleMills);
} else {
// If action is not throttled, set time bounds to previousStartedAt - now
// If previousStartedAt is null, use the rule schedule interval
if (previousStartedAt) {
startDate = previousStartedAt;
} else {
const scheduleMillis = parseDuration(ruleSchedule.interval);
startDate = new Date(now - scheduleMillis);
}
}
return { start: startDate.valueOf(), end: now.valueOf() };
};

View file

@ -458,6 +458,7 @@ export class TaskRunner<
ruleConsumer: this.ruleConsumer!,
executionId: this.executionId,
ruleLabel,
previousStartedAt: previousStartedAt ? new Date(previousStartedAt) : null,
alertingEventLogger: this.alertingEventLogger,
actionsClient: await this.context.actionsPlugin.getActionsClientWithRequest(fakeRequest),
});

View file

@ -626,13 +626,13 @@ describe('transformSummaryActionParams', () => {
new: { count: 1, data: [mockAAD] },
ongoing: { count: 0, data: [] },
recovered: { count: 0, data: [] },
all: { count: 0, data: [] },
all: { count: 1, data: [mockAAD] },
},
rule: {
id: '1',
name: 'test-rule',
tags: ['test-tag'],
params: {},
params: { foo: 'bar', fooBar: true },
} as SanitizedRule,
ruleTypeId: 'rule-type-id',
actionId: 'action-id',
@ -657,6 +657,33 @@ describe('transformSummaryActionParams', () => {
`);
});
test('renders aliased context values', () => {
const actionParams = {
message:
'Value "{{context.alerts}}", "{{context.results_link}}" and "{{context.rule}}" exist',
};
const result = transformSummaryActionParams({ ...params, actionParams });
expect(result).toMatchInlineSnapshot(`
Object {
"message": "Value \\"{\\"@timestamp\\":\\"2022-12-07T15:38:43.472Z\\",\\"event\\":{\\"kind\\":\\"signal\\",\\"action\\":\\"active\\"},\\"kibana\\":{\\"version\\":\\"8.7.0\\",\\"space_ids\\":[\\"default\\"],\\"alert\\":{\\"instance\\":{\\"id\\":\\"*\\"},\\"uuid\\":\\"2d3e8fe5-3e8b-4361-916e-9eaab0bf2084\\",\\"status\\":\\"active\\",\\"workflow_status\\":\\"open\\",\\"reason\\":\\"system.cpu is 90% in the last 1 min for all hosts. Alert when > 50%.\\",\\"time_range\\":{\\"gte\\":\\"2022-01-01T12:00:00.000Z\\"},\\"start\\":\\"2022-12-07T15:23:13.488Z\\",\\"duration\\":{\\"us\\":100000},\\"flapping\\":false,\\"rule\\":{\\"category\\":\\"Metric threshold\\",\\"consumer\\":\\"alerts\\",\\"execution\\":{\\"uuid\\":\\"c35db7cc-5bf7-46ea-b43f-b251613a5b72\\"},\\"name\\":\\"test-rule\\",\\"producer\\":\\"infrastructure\\",\\"rule_type_id\\":\\"metrics.alert.threshold\\",\\"uuid\\":\\"0de91960-7643-11ed-b719-bb9db8582cb6\\",\\"tags\\":[]}}}}\\", \\"http://ruleurl\\" and \\"{\\"foo\\":\\"bar\\",\\"foo_bar\\":true,\\"name\\":\\"test-rule\\",\\"id\\":\\"1\\"}\\" exist",
}
`);
});
test('renders aliased state values', () => {
const actionParams = {
message: 'Value "{{state.signals_count}}" exists',
};
const result = transformSummaryActionParams({ ...params, actionParams });
expect(result).toMatchInlineSnapshot(`
Object {
"message": "Value \\"1\\" exists",
}
`);
});
test('renders alerts values', () => {
const actionParams = {
message:

View file

@ -6,6 +6,7 @@
*/
import { PluginStartContract as ActionsPluginStartContract } from '@kbn/actions-plugin/server';
import { mapKeys, snakeCase } from 'lodash/fp';
import {
RuleActionParams,
AlertInstanceState,
@ -143,6 +144,19 @@ export function transformSummaryActionParams({
const variables = {
kibanaBaseUrl,
date: new Date().toISOString(),
// For backwards compatibility with security solutions rules
context: {
alerts: alerts.all.data ?? [],
results_link: ruleUrl,
rule: mapKeys(snakeCase, {
...rule.params,
name: rule.name,
id: rule.id,
}),
},
state: {
signals_count: alerts.all.count ?? 0,
},
rule: {
params: rule.params,
id: rule.id,

View file

@ -85,6 +85,7 @@ export interface ExecutionHandlerOptions<
ruleConsumer: string;
executionId: string;
ruleLabel: string;
previousStartedAt: Date | null;
actionsClient: PublicMethodsOf<ActionsClient>;
}

View file

@ -168,6 +168,9 @@ export interface CombinedSummarizedAlerts extends SummarizedAlerts {
export type GetSummarizedAlertsFn = (opts: GetSummarizedAlertsFnOpts) => Promise<SummarizedAlerts>;
export interface GetViewInAppRelativeUrlFnOpts<Params extends RuleTypeParams> {
rule: Omit<SanitizedRule<Params>, 'viewInAppRelativeUrl'>;
// Optional time bounds
start?: number;
end?: number;
}
export type GetViewInAppRelativeUrlFn<Params extends RuleTypeParams> = (
opts: GetViewInAppRelativeUrlFnOpts<Params>