[Response Ops][Rule Registry] Add data provider to retrieve new, ongoing and recovered alerts from alerts-as-data (#143466)

* Register rule data client with alerting

* wip

* get summarized alerts

* cleanup

* Adding queries

* Adding unit tests

* Adding condition to queries in order to limit number of alerts returned

* Fixing runtime mapping script

* Removing runtime mappings

* Adding function to persistence and lifecycle wrappers

* Adding functional test

* Updating README

* Adding comments

* lte to lt

* Revert "lte to lt"

This reverts commit bbc2604a00.

* lte to lt

* Fixing test

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Ying Mao 2022-11-07 08:19:02 -05:00 committed by GitHub
parent 5cf50c6303
commit 5bc408fc89
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 1692 additions and 3 deletions

View file

@ -101,6 +101,7 @@ The following table describes the properties of the `options` object.
|isExportable|Whether the rule type is exportable from the Saved Objects Management UI.|boolean|
|defaultScheduleInterval|The default interval that will show up in the UI when creating a rule of this rule type.|boolean|
|doesSetRecoveryContext|Whether the rule type will set context variables for recovered alerts. Defaults to `false`. If this is set to true, context variables are made available for the recovery action group and executors will be provided with the ability to set recovery context.|boolean|
|getSummarizedAlerts|(Optional) When developing a rule type, you can choose to implement this hook for retrieving summarized alerts based on execution UUID or time range. This hook will be invoked when an alert summary action is configured for the rule.|Function|
### Executor

View file

@ -28,6 +28,7 @@ export type {
AlertInstanceContext,
AlertingApiRequestHandlerContext,
RuleParamsAndRefs,
GetSummarizedAlertsFnOpts,
} from './types';
export { DEFAULT_MAX_EPHEMERAL_ACTIONS_PER_ALERT } from './config';
export type { PluginSetupContract, PluginStartContract } from './plugin';

View file

@ -122,6 +122,32 @@ export interface RuleTypeParamsValidator<Params extends RuleTypeParams> {
validateMutatedParams?: (mutatedOject: Params, origObject?: Params) => Params;
}
export interface GetSummarizedAlertsFnOpts {
start?: Date;
end?: Date;
executionUuid?: string;
ruleId: string;
spaceId: string;
}
// TODO - add type for these alerts when we determine which alerts-as-data
// fields will be made available in https://github.com/elastic/kibana/issues/143741
export interface SummarizedAlerts {
new: {
count: number;
alerts: unknown[];
};
ongoing: {
count: number;
alerts: unknown[];
};
recovered: {
count: number;
alerts: unknown[];
};
}
export type GetSummarizedAlertsFn = (opts: GetSummarizedAlertsFnOpts) => Promise<SummarizedAlerts>;
export interface RuleType<
Params extends RuleTypeParams = never,
ExtractedParams extends RuleTypeParams = never,
@ -166,6 +192,7 @@ export interface RuleType<
ruleTaskTimeout?: string;
cancelAlertsOnRuleTimeout?: boolean;
doesSetRecoveryContext?: boolean;
getSummarizedAlerts?: GetSummarizedAlertsFn;
}
export type UntypedRuleType = RuleType<
RuleTypeParams,

View file

@ -126,5 +126,6 @@ export async function registerMetricInventoryThresholdRuleType(
{ name: 'tags', description: tagsActionVariableDescription },
],
},
getSummarizedAlerts: libs.metricsRules.createGetSummarizedAlerts(),
});
}

View file

@ -134,5 +134,6 @@ export async function registerLogThresholdRuleType(
],
},
producer: 'logs',
getSummarizedAlerts: libs.logsRules.createGetSummarizedAlerts(),
});
}

View file

@ -113,5 +113,6 @@ export async function registerMetricThresholdRuleType(
],
},
producer: 'infrastructure',
getSummarizedAlerts: libs.metricsRules.createGetSummarizedAlerts(),
});
}

View file

@ -6,7 +6,10 @@
*/
import { CoreSetup, Logger } from '@kbn/core/server';
import { createLifecycleExecutor } from '@kbn/rule-registry-plugin/server';
import {
createLifecycleExecutor,
createGetSummarizedAlertsFn,
} from '@kbn/rule-registry-plugin/server';
import { InfraFeatureId } from '../../../common/constants';
import { createRuleDataClient } from './rule_data_client';
import {
@ -37,9 +40,15 @@ export class RulesService {
});
const createLifecycleRuleExecutor = createLifecycleExecutor(this.logger, ruleDataClient);
const createGetSummarizedAlerts = createGetSummarizedAlertsFn({
ruleDataClient,
useNamespace: false,
isLifecycleAlert: true,
});
return {
createLifecycleRuleExecutor,
createGetSummarizedAlerts,
ruleDataClient,
};
}

View file

@ -8,12 +8,13 @@
import { PluginSetupContract as AlertingPluginSetup } from '@kbn/alerting-plugin/server';
import {
createLifecycleExecutor,
createGetSummarizedAlertsFn,
IRuleDataClient,
RuleRegistryPluginSetupContract,
} from '@kbn/rule-registry-plugin/server';
type LifecycleRuleExecutorCreator = ReturnType<typeof createLifecycleExecutor>;
type GetSummarizedAlertsFn = ReturnType<typeof createGetSummarizedAlertsFn>;
export interface RulesServiceSetupDeps {
alerting: AlertingPluginSetup;
ruleRegistry: RuleRegistryPluginSetupContract;
@ -25,6 +26,7 @@ export interface RulesServiceStartDeps {}
export interface RulesServiceSetup {
createLifecycleRuleExecutor: LifecycleRuleExecutorCreator;
ruleDataClient: IRuleDataClient;
createGetSummarizedAlerts: GetSummarizedAlertsFn;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface

View file

@ -34,6 +34,7 @@ export type {
} from './utils/create_lifecycle_executor';
export { createLifecycleExecutor } from './utils/create_lifecycle_executor';
export { createPersistenceRuleTypeWrapper } from './utils/create_persistence_rule_type_wrapper';
export { createGetSummarizedAlertsFn } from './utils/create_get_summarized_alerts_fn';
export * from './utils/persistence_types';
export type { AlertsClient } from './alert_data_client/alerts_client';

View file

@ -13,7 +13,7 @@ type MockInstances<T extends Record<string, any>> = {
: never;
};
type RuleDataClientMock = jest.Mocked<Omit<IRuleDataClient, 'getWriter' | 'getReader'>> & {
export type RuleDataClientMock = jest.Mocked<Omit<IRuleDataClient, 'getWriter' | 'getReader'>> & {
getReader: (...args: Parameters<IRuleDataClient['getReader']>) => MockInstances<IRuleDataReader>;
getWriter: (
...args: Parameters<IRuleDataClient['getWriter']>

View file

@ -0,0 +1,995 @@
/*
* 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 {
createRuleDataClientMock,
RuleDataClientMock,
} from '../rule_data_client/rule_data_client.mock';
import {
ALERT_END,
ALERT_INSTANCE_ID,
ALERT_RULE_EXECUTION_UUID,
ALERT_RULE_UUID,
ALERT_START,
ALERT_UUID,
EVENT_ACTION,
TIMESTAMP,
} from '../../common/technical_rule_data_field_names';
import { createGetSummarizedAlertsFn } from './create_get_summarized_alerts_fn';
describe('createGetSummarizedAlertsFn', () => {
let ruleDataClientMock: RuleDataClientMock;
beforeEach(() => {
jest.resetAllMocks();
ruleDataClientMock = createRuleDataClientMock();
ruleDataClientMock.getReader().search.mockResolvedValue({
hits: {
total: {
value: 0,
},
hits: [],
},
} as any);
});
it('creates function that uses namespace to getReader if useNamespace is true', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await getSummarizedAlertsFn({ executionUuid: 'abc', ruleId: 'rule-id', spaceId: 'space-id' });
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith({ namespace: 'space-id' });
});
it('creates function that does not use namespace to getReader if useNamespace is false', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: false,
isLifecycleAlert: false,
})();
await getSummarizedAlertsFn({ executionUuid: 'abc', ruleId: 'rule-id', spaceId: 'space-id' });
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith();
});
it('creates function that correctly returns lifecycle alerts using execution Uuid', async () => {
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 2,
},
hits: [
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 3,
},
hits: [
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 1,
},
hits: [
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'close',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
},
],
},
} as any);
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: false,
isLifecycleAlert: true,
})();
const summarizedAlerts = await getSummarizedAlertsFn({
executionUuid: 'abc',
ruleId: 'rule-id',
spaceId: 'space-id',
});
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith();
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledTimes(3);
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(1, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'open',
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(2, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'active',
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(3, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
term: {
[EVENT_ACTION]: 'close',
},
},
],
},
},
},
});
expect(summarizedAlerts.new.count).toEqual(2);
expect(summarizedAlerts.ongoing.count).toEqual(3);
expect(summarizedAlerts.recovered.count).toEqual(1);
expect(summarizedAlerts.new.alerts).toEqual([
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'open',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
]);
expect(summarizedAlerts.ongoing.alerts).toEqual([
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
]);
expect(summarizedAlerts.recovered.alerts).toEqual([
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'close',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
]);
});
it('creates function that correctly returns lifecycle alerts using time range', async () => {
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 3,
},
hits: [
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'new',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'new',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:10:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
[ALERT_START]: '2020-01-01T12:10:00.000Z',
alert_type: 'new',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 2,
},
hits: [
{
_source: {
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'ongoing',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
[ALERT_START]: '2020-01-01T11:00:00.000Z',
alert_type: 'ongoing',
},
},
],
},
} as any);
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 1,
},
hits: [
{
_source: {
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
[ALERT_START]: '2020-01-01T11:00:00.000Z',
[ALERT_END]: '2020-01-01T12:20:00.000Z',
alert_type: 'recovered',
},
},
],
},
} as any);
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: false,
isLifecycleAlert: true,
})();
const summarizedAlerts = await getSummarizedAlertsFn({
start: new Date('2020-01-01T11:00:00.000Z'),
end: new Date('2020-01-01T12:25:00.000Z'),
ruleId: 'rule-id',
spaceId: 'space-id',
});
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith();
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledTimes(3);
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(1, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
range: {
[TIMESTAMP]: {
gte: '2020-01-01T11:00:00.000Z',
lt: '2020-01-01T12:25:00.000Z',
},
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
range: {
[ALERT_START]: {
gte: '2020-01-01T11:00:00.000Z',
},
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(2, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
range: {
[TIMESTAMP]: {
gte: '2020-01-01T11:00:00.000Z',
lt: '2020-01-01T12:25:00.000Z',
},
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
range: {
[ALERT_START]: {
lt: '2020-01-01T11:00:00.000Z',
},
},
},
{
bool: {
must_not: {
exists: {
field: ALERT_END,
},
},
},
},
],
},
},
},
});
expect(ruleDataClientMock.getReader().search).toHaveBeenNthCalledWith(3, {
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
range: {
[TIMESTAMP]: {
gte: '2020-01-01T11:00:00.000Z',
lt: '2020-01-01T12:25:00.000Z',
},
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
{
range: {
[ALERT_END]: {
gte: '2020-01-01T11:00:00.000Z',
lt: '2020-01-01T12:25:00.000Z',
},
},
},
],
},
},
},
});
expect(summarizedAlerts.new.count).toEqual(3);
expect(summarizedAlerts.ongoing.count).toEqual(2);
expect(summarizedAlerts.recovered.count).toEqual(1);
expect(summarizedAlerts.new.alerts).toEqual([
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'new',
},
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'new',
},
{
[TIMESTAMP]: '2020-01-01T12:10:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
[ALERT_START]: '2020-01-01T12:10:00.000Z',
alert_type: 'new',
},
]);
expect(summarizedAlerts.ongoing.alerts).toEqual([
{
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
[ALERT_START]: '2020-01-01T12:00:00.000Z',
alert_type: 'ongoing',
},
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
[ALERT_START]: '2020-01-01T11:00:00.000Z',
alert_type: 'ongoing',
},
]);
expect(summarizedAlerts.recovered.alerts).toEqual([
{
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
[ALERT_START]: '2020-01-01T11:00:00.000Z',
[ALERT_END]: '2020-01-01T12:20:00.000Z',
alert_type: 'recovered',
},
]);
});
it('creates function that correctly returns non-lifecycle alerts using execution Uuid', async () => {
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 6,
},
hits: [
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
},
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
},
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
},
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
},
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
},
{
_source: {
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
},
],
},
} as any);
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
const summarizedAlerts = await getSummarizedAlertsFn({
executionUuid: 'abc',
ruleId: 'rule-id',
spaceId: 'space-id',
});
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith({ namespace: 'space-id' });
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledTimes(1);
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledWith({
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: 'abc',
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
],
},
},
},
});
expect(summarizedAlerts.new.count).toEqual(6);
expect(summarizedAlerts.ongoing.count).toEqual(0);
expect(summarizedAlerts.recovered.count).toEqual(0);
expect(summarizedAlerts.new.alerts).toEqual([
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[EVENT_ACTION]: 'active',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
{
'@timestamp': '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
]);
expect(summarizedAlerts.ongoing.alerts).toEqual([]);
expect(summarizedAlerts.recovered.alerts).toEqual([]);
});
it('creates function that correctly returns non-lifecycle alerts using time range', async () => {
ruleDataClientMock.getReader().search.mockResolvedValueOnce({
hits: {
total: {
value: 6,
},
hits: [
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:10:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
},
{
_source: {
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
},
],
},
} as any);
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
const summarizedAlerts = await getSummarizedAlertsFn({
start: new Date('2020-01-01T11:00:00.000Z'),
end: new Date('2020-01-01T12:25:00.000Z'),
ruleId: 'rule-id',
spaceId: 'space-id',
});
expect(ruleDataClientMock.getReader).toHaveBeenCalledWith({ namespace: 'space-id' });
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledTimes(1);
expect(ruleDataClientMock.getReader().search).toHaveBeenCalledWith({
body: {
size: 1000,
track_total_hits: true,
query: {
bool: {
filter: [
{
range: {
[TIMESTAMP]: {
gte: '2020-01-01T11:00:00.000Z',
lt: '2020-01-01T12:25:00.000Z',
},
},
},
{
term: {
[ALERT_RULE_UUID]: 'rule-id',
},
},
],
},
},
},
});
expect(summarizedAlerts.new.count).toEqual(6);
expect(summarizedAlerts.ongoing.count).toEqual(0);
expect(summarizedAlerts.recovered.count).toEqual(0);
expect(summarizedAlerts.new.alerts).toEqual([
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_3',
[ALERT_UUID]: 'uuid1',
},
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_4',
[ALERT_UUID]: 'uuid2',
},
{
[TIMESTAMP]: '2020-01-01T12:10:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_1',
[ALERT_UUID]: 'uuid3',
},
{
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_2',
[ALERT_UUID]: 'uuid4',
},
{
[TIMESTAMP]: '2020-01-01T12:00:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_5',
[ALERT_UUID]: 'uuid5',
},
{
[TIMESTAMP]: '2020-01-01T12:20:00.000Z',
[ALERT_RULE_EXECUTION_UUID]: 'abc',
[ALERT_RULE_UUID]: 'rule-id',
[ALERT_INSTANCE_ID]: 'TEST_ALERT_9',
[ALERT_UUID]: 'uuid6',
},
]);
expect(summarizedAlerts.ongoing.alerts).toEqual([]);
expect(summarizedAlerts.recovered.alerts).toEqual([]);
});
it('throws error if search throws error', async () => {
ruleDataClientMock.getReader().search.mockImplementation(() => {
throw new Error('search error');
});
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
getSummarizedAlertsFn({ executionUuid: 'abc', ruleId: 'rule-id', spaceId: 'space-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(`"search error"`);
});
it('throws error if start, end and execution UUID are not defined', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
getSummarizedAlertsFn({ ruleId: 'rule-id', spaceId: 'space-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify either execution UUID or time range for summarized alert query."`
);
});
it('throws error if start, end and execution UUID are all defined', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
getSummarizedAlertsFn({
executionUuid: 'abc',
start: new Date(),
end: new Date(),
ruleId: 'rule-id',
spaceId: 'space-id',
})
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify either execution UUID or time range for summarized alert query."`
);
});
it('throws error if start is defined but end is not', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
getSummarizedAlertsFn({ start: new Date(), ruleId: 'rule-id', spaceId: 'space-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify either execution UUID or time range for summarized alert query."`
);
});
it('throws error if end is defined but start is not', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
getSummarizedAlertsFn({ end: new Date(), ruleId: 'rule-id', spaceId: 'space-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify either execution UUID or time range for summarized alert query."`
);
});
it('throws error if ruleId is not defined', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
// @ts-expect-error
getSummarizedAlertsFn({ executionUuid: 'abc', spaceId: 'space-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify both rule ID and space ID for summarized alert query."`
);
});
it('throws error if spaceId is not defined', async () => {
const getSummarizedAlertsFn = createGetSummarizedAlertsFn({
ruleDataClient: ruleDataClientMock,
useNamespace: true,
isLifecycleAlert: false,
})();
await expect(
// @ts-expect-error
getSummarizedAlertsFn({ executionUuid: 'abc', ruleId: 'rule-id' })
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Must specify both rule ID and space ID for summarized alert query."`
);
});
});

View file

@ -0,0 +1,359 @@
/*
* 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 { PublicContract } from '@kbn/utility-types';
import { ESSearchRequest, ESSearchResponse } from '@kbn/es-types';
import type { GetSummarizedAlertsFnOpts } from '@kbn/alerting-plugin/server';
import {
ALERT_END,
ALERT_RULE_EXECUTION_UUID,
ALERT_RULE_UUID,
ALERT_START,
EVENT_ACTION,
TIMESTAMP,
} from '@kbn/rule-data-utils';
import {
QueryDslQueryContainer,
SearchTotalHits,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { ParsedTechnicalFields } from '../../common';
import { ParsedExperimentalFields } from '../../common/parse_experimental_fields';
import { IRuleDataClient, IRuleDataReader } from '../rule_data_client';
const MAX_ALERT_DOCS_TO_RETURN = 1000;
type AlertDocument = Partial<ParsedTechnicalFields & ParsedExperimentalFields>;
interface CreateGetSummarizedAlertsFnOpts {
ruleDataClient: PublicContract<IRuleDataClient>;
useNamespace: boolean;
isLifecycleAlert: boolean;
}
export const createGetSummarizedAlertsFn =
(opts: CreateGetSummarizedAlertsFnOpts) =>
() =>
async ({ start, end, executionUuid, ruleId, spaceId }: GetSummarizedAlertsFnOpts) => {
if (!ruleId || !spaceId) {
throw new Error(`Must specify both rule ID and space ID for summarized alert query.`);
}
const queryByExecutionUuid: boolean = !!executionUuid;
const queryByTimeRange: boolean = !!start && !!end;
// Either executionUuid or start/end dates must be specified, but not both
if (
(!queryByExecutionUuid && !queryByTimeRange) ||
(queryByExecutionUuid && queryByTimeRange)
) {
throw new Error(
`Must specify either execution UUID or time range for summarized alert query.`
);
}
// Get the rule data client reader
const { ruleDataClient, useNamespace } = opts;
const ruleDataClientReader = useNamespace
? ruleDataClient.getReader({ namespace: spaceId })
: ruleDataClient.getReader();
if (queryByExecutionUuid) {
return await getAlertsByExecutionUuid({
ruleDataClientReader,
ruleId,
executionUuid: executionUuid!,
isLifecycleAlert: opts.isLifecycleAlert,
});
}
return await getAlertsByTimeRange({
ruleDataClientReader,
ruleId,
start: start!,
end: end!,
isLifecycleAlert: opts.isLifecycleAlert,
});
};
interface GetAlertsByExecutionUuidOpts {
executionUuid: string;
ruleId: string;
ruleDataClientReader: IRuleDataReader;
isLifecycleAlert: boolean;
}
const getAlertsByExecutionUuid = async ({
executionUuid,
ruleId,
ruleDataClientReader,
isLifecycleAlert,
}: GetAlertsByExecutionUuidOpts) => {
if (isLifecycleAlert) {
return getLifecycleAlertsByExecutionUuid({ executionUuid, ruleId, ruleDataClientReader });
}
return getPersistentAlertsByExecutionUuid({ executionUuid, ruleId, ruleDataClientReader });
};
interface GetAlertsByExecutionUuidHelperOpts {
executionUuid: string;
ruleId: string;
ruleDataClientReader: IRuleDataReader;
}
const getPersistentAlertsByExecutionUuid = async <TSearchRequest extends ESSearchRequest>({
executionUuid,
ruleId,
ruleDataClientReader,
}: GetAlertsByExecutionUuidHelperOpts) => {
// persistent alerts only create new alerts so query by execution UUID to
// get all alerts created during an execution
const request = getQueryByExecutionUuid(executionUuid, ruleId);
const response = (await ruleDataClientReader.search(request)) as ESSearchResponse<
AlertDocument,
TSearchRequest
>;
return {
new: getHitsWithCount(response),
ongoing: {
count: 0,
alerts: [],
},
recovered: {
count: 0,
alerts: [],
},
};
};
const getLifecycleAlertsByExecutionUuid = async ({
executionUuid,
ruleId,
ruleDataClientReader,
}: GetAlertsByExecutionUuidHelperOpts) => {
// lifecycle alerts assign a different action to an alert depending
// on whether it is new/ongoing/recovered. query for each action in order
// to get the count of each action type as well as up to the maximum number
// of each type of alert.
const requests = [
getQueryByExecutionUuid(executionUuid, ruleId, 'open'),
getQueryByExecutionUuid(executionUuid, ruleId, 'active'),
getQueryByExecutionUuid(executionUuid, ruleId, 'close'),
];
const responses = await Promise.all(
requests.map((request) => ruleDataClientReader.search(request))
);
return {
new: getHitsWithCount(responses[0]),
ongoing: getHitsWithCount(responses[1]),
recovered: getHitsWithCount(responses[2]),
};
};
const getHitsWithCount = <TSearchRequest extends ESSearchRequest>(
response: ESSearchResponse<AlertDocument, TSearchRequest>
) => {
return {
count: (response.hits.total as SearchTotalHits).value,
alerts: response.hits.hits.map((r) => r._source),
};
};
const getQueryByExecutionUuid = (executionUuid: string, ruleId: string, action?: string) => {
const filter: QueryDslQueryContainer[] = [
{
term: {
[ALERT_RULE_EXECUTION_UUID]: executionUuid,
},
},
{
term: {
[ALERT_RULE_UUID]: ruleId,
},
},
];
if (action) {
filter.push({
term: {
[EVENT_ACTION]: action,
},
});
}
return {
body: {
size: MAX_ALERT_DOCS_TO_RETURN,
track_total_hits: true,
query: {
bool: {
filter,
},
},
},
};
};
interface GetAlertsByTimeRangeOpts {
start: Date;
end: Date;
ruleId: string;
ruleDataClientReader: IRuleDataReader;
isLifecycleAlert: boolean;
}
const getAlertsByTimeRange = async ({
start,
end,
ruleId,
ruleDataClientReader,
isLifecycleAlert,
}: GetAlertsByTimeRangeOpts) => {
if (isLifecycleAlert) {
return getLifecycleAlertsByTimeRange({ start, end, ruleId, ruleDataClientReader });
}
return getPersistentAlertsByTimeRange({ start, end, ruleId, ruleDataClientReader });
};
interface GetAlertsByTimeRangeHelperOpts {
start: Date;
end: Date;
ruleId: string;
ruleDataClientReader: IRuleDataReader;
}
enum AlertTypes {
NEW = 0,
ONGOING,
RECOVERED,
}
const getPersistentAlertsByTimeRange = async <TSearchRequest extends ESSearchRequest>({
start,
end,
ruleId,
ruleDataClientReader,
}: GetAlertsByTimeRangeHelperOpts) => {
// persistent alerts only create new alerts so query for all alerts within the time
// range and treat them as NEW
const request = getQueryByTimeRange(start, end, ruleId);
const response = (await ruleDataClientReader.search(request)) as ESSearchResponse<
AlertDocument,
TSearchRequest
>;
return {
new: getHitsWithCount(response),
ongoing: {
count: 0,
alerts: [],
},
recovered: {
count: 0,
alerts: [],
},
};
};
const getLifecycleAlertsByTimeRange = async ({
start,
end,
ruleId,
ruleDataClientReader,
}: GetAlertsByTimeRangeHelperOpts) => {
const requests = [
getQueryByTimeRange(start, end, ruleId, AlertTypes.NEW),
getQueryByTimeRange(start, end, ruleId, AlertTypes.ONGOING),
getQueryByTimeRange(start, end, ruleId, AlertTypes.RECOVERED),
];
const responses = await Promise.all(
requests.map((request) => ruleDataClientReader.search(request))
);
return {
new: getHitsWithCount(responses[0]),
ongoing: getHitsWithCount(responses[1]),
recovered: getHitsWithCount(responses[2]),
};
};
const getQueryByTimeRange = (start: Date, end: Date, ruleId: string, type?: AlertTypes) => {
// base query filters the alert documents for a rule by the given time range
let filter: QueryDslQueryContainer[] = [
{
range: {
[TIMESTAMP]: {
gte: start.toISOString(),
lt: end.toISOString(),
},
},
},
{
term: {
[ALERT_RULE_UUID]: ruleId,
},
},
];
if (type === AlertTypes.NEW) {
// alerts are considered NEW within the time range if they started after
// the query start time
filter.push({
range: {
[ALERT_START]: {
gte: start.toISOString(),
},
},
});
} else if (type === AlertTypes.ONGOING) {
// alerts are considered ONGOING within the time range if they started
// before the query start time and they have not been recovered (no end time)
filter = [
...filter,
{
range: {
[ALERT_START]: {
lt: start.toISOString(),
},
},
},
{
bool: {
must_not: {
exists: {
field: ALERT_END,
},
},
},
},
];
} else if (type === AlertTypes.RECOVERED) {
// alerts are considered RECOVERED within the time range if they recovered
// within the query time range
filter.push({
range: {
[ALERT_END]: {
gte: start.toISOString(),
lt: end.toISOString(),
},
},
});
}
return {
body: {
size: MAX_ALERT_DOCS_TO_RETURN,
track_total_hits: true,
query: {
bool: {
filter,
},
},
},
};
};

View file

@ -14,6 +14,7 @@ import {
import { IRuleDataClient } from '../rule_data_client';
import { AlertTypeWithExecutor } from '../types';
import { LifecycleAlertService, createLifecycleExecutor } from './create_lifecycle_executor';
import { createGetSummarizedAlertsFn } from './create_get_summarized_alerts_fn';
export const createLifecycleRuleTypeFactory =
({ logger, ruleDataClient }: { logger: Logger; ruleDataClient: IRuleDataClient }) =>
@ -27,6 +28,11 @@ export const createLifecycleRuleTypeFactory =
type: AlertTypeWithExecutor<Record<string, any>, TParams, TAlertInstanceContext, TServices>
): AlertTypeWithExecutor<Record<string, any>, TParams, TAlertInstanceContext, any> => {
const createBoundLifecycleExecutor = createLifecycleExecutor(logger, ruleDataClient);
const createGetSummarizedAlerts = createGetSummarizedAlertsFn({
ruleDataClient,
useNamespace: false,
isLifecycleAlert: true,
});
const executor = createBoundLifecycleExecutor<
TParams,
RuleTypeState,
@ -37,5 +43,6 @@ export const createLifecycleRuleTypeFactory =
return {
...type,
executor: executor as any,
getSummarizedAlerts: createGetSummarizedAlerts(),
};
};

View file

@ -11,6 +11,7 @@ import { ALERT_UUID, VERSION } from '@kbn/rule-data-utils';
import { getCommonAlertFields } from './get_common_alert_fields';
import { CreatePersistenceRuleTypeWrapper } from './persistence_types';
import { errorAggregator } from './utils';
import { createGetSummarizedAlertsFn } from './create_get_summarized_alerts_fn';
export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper =
({ logger, ruleDataClient }) =>
@ -150,5 +151,10 @@ export const createPersistenceRuleTypeWrapper: CreatePersistenceRuleTypeWrapper
return state;
},
getSummarizedAlerts: createGetSummarizedAlertsFn({
ruleDataClient,
useNamespace: true,
isLifecycleAlert: false,
})(),
};
};

View file

@ -0,0 +1,277 @@
/*
* 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 Subject, ReplaySubject } from 'rxjs';
import type { ElasticsearchClient, Logger, LogMeta } from '@kbn/core/server';
import sinon from 'sinon';
import uuid from 'uuid';
import expect from '@kbn/expect';
import { mappingFromFieldMap } from '@kbn/rule-registry-plugin/common/mapping_from_field_map';
import {
AlertConsumers,
ALERT_REASON,
} from '@kbn/rule-registry-plugin/common/technical_rule_data_field_names';
import {
createLifecycleExecutor,
TrackedLifecycleAlertState,
WrappedLifecycleRuleState,
} from '@kbn/rule-registry-plugin/server/utils/create_lifecycle_executor';
import {
createGetSummarizedAlertsFn,
Dataset,
IRuleDataClient,
RuleDataService,
} from '@kbn/rule-registry-plugin/server';
import { RuleExecutorOptions } from '@kbn/alerting-plugin/server';
import type { FtrProviderContext } from '../../../common/ftr_provider_context';
import {
MockRuleParams,
MockAlertContext,
MockAlertState,
MockAllowedActionGroups,
} from '../../../common/types';
import { cleanupRegistryIndices } from '../../../common/lib/helpers/cleanup_registry_indices';
// eslint-disable-next-line import/no-default-export
export default function createGetSummarizedAlertsTest({ getService }: FtrProviderContext) {
const es = getService('es');
const log = getService('log');
const fakeLogger = <Meta extends LogMeta = LogMeta>(msg: string, meta?: Meta) =>
meta ? log.debug(msg, meta) : log.debug(msg);
const logger = {
trace: fakeLogger,
debug: fakeLogger,
info: fakeLogger,
warn: fakeLogger,
error: fakeLogger,
fatal: fakeLogger,
log: sinon.stub(),
get: sinon.stub(),
isLevelEnabled: sinon.stub(),
} as Logger;
const getClusterClient = () => {
const client = es as ElasticsearchClient;
return Promise.resolve(client);
};
describe('getSummarizedAlerts', () => {
let ruleDataClient: IRuleDataClient;
let pluginStop$: Subject<void>;
before(async () => {
// First we need to setup the data service. This happens within the
// Rule Registry plugin as part of the server side setup phase.
pluginStop$ = new ReplaySubject(1);
const ruleDataService = new RuleDataService({
getClusterClient,
logger,
kibanaVersion: '8.0.0',
isWriteEnabled: true,
isWriterCacheEnabled: false,
disabledRegistrationContexts: [] as string[],
pluginStop$,
});
// This initializes the service. This happens immediately after the creation
// of the RuleDataService in the setup phase of the Rule Registry plugin
ruleDataService.initializeService();
// This initializes the index and templates and returns the data client.
// This happens in each solution plugin before they can register lifecycle
// executors.
ruleDataClient = ruleDataService.initializeIndex({
feature: AlertConsumers.OBSERVABILITY,
registrationContext: 'observability.test.alerts',
dataset: Dataset.alerts,
componentTemplateRefs: [],
componentTemplates: [
{
name: 'mappings',
mappings: mappingFromFieldMap(
{
testObject: {
type: 'object',
required: false,
array: false,
},
},
false
),
},
],
});
});
after(async () => {
cleanupRegistryIndices(getService, ruleDataClient);
pluginStop$.next();
pluginStop$.complete();
});
it('should return new, ongoing and recovered alerts', async () => {
const id = 'host-01';
// This creates the function that will wrap the solution's rule executor with the RuleRegistry lifecycle
const createLifecycleRuleExecutor = createLifecycleExecutor(logger, ruleDataClient);
const createGetSummarizedAlerts = createGetSummarizedAlertsFn({
ruleDataClient,
useNamespace: false,
isLifecycleAlert: true,
});
// This creates the executor that is passed to the Alerting framework.
const executor = createLifecycleRuleExecutor<
MockRuleParams,
{ shouldTriggerAlert: boolean },
MockAlertState,
MockAlertContext,
MockAllowedActionGroups
>(async function (options) {
const { services, state: previousState } = options;
const { alertWithLifecycle } = services;
const triggerAlert = previousState.shouldTriggerAlert;
if (triggerAlert) {
alertWithLifecycle({
id,
fields: {
[ALERT_REASON]: 'Test alert is firing',
},
});
}
return Promise.resolve({ shouldTriggerAlert: triggerAlert });
});
const getSummarizedAlerts = createGetSummarizedAlerts();
// Create the options with the minimal amount of values to test the lifecycle executor
const options = {
spaceId: 'default',
rule: {
id,
name: 'test rule',
ruleTypeId: 'observability.test.fake',
ruleTypeName: 'test',
consumer: 'observability',
producer: 'observability.test',
},
services: {
alertFactory: { create: sinon.stub() },
shouldWriteAlerts: sinon.stub().returns(true),
},
} as unknown as RuleExecutorOptions<
MockRuleParams,
WrappedLifecycleRuleState<{ shouldTriggerAlert: boolean }>,
{ [x: string]: unknown },
{ [x: string]: unknown },
string
>;
const getState = (
shouldTriggerAlert: boolean,
alerts: Record<string, TrackedLifecycleAlertState>
) => ({ wrapped: { shouldTriggerAlert }, trackedAlerts: alerts });
// Execute the rule the first time - this creates a new alert
const preExecution1Start = new Date();
const execution1Uuid = uuid.v4();
const execution1Results = await executor({
...options,
startedAt: new Date(),
state: getState(true, {}),
executionId: execution1Uuid,
});
// Refresh the index so the data is available for reading
await es.indices.refresh({ index: `${ruleDataClient.indexName}*` });
const execution1SummarizedAlerts = await getSummarizedAlerts({
ruleId: id,
executionUuid: execution1Uuid,
spaceId: 'default',
});
expect(execution1SummarizedAlerts.new.count).to.eql(1);
expect(execution1SummarizedAlerts.ongoing.count).to.eql(0);
expect(execution1SummarizedAlerts.recovered.count).to.eql(0);
// Execute again to update the existing alert
const preExecution2Start = new Date();
const execution2Uuid = uuid.v4();
const execution2Results = await executor({
...options,
startedAt: new Date(),
state: getState(true, execution1Results.trackedAlerts),
executionId: execution2Uuid,
});
// Refresh the index so the data is available for reading
await es.indices.refresh({ index: `${ruleDataClient.indexName}*` });
const execution2SummarizedAlerts = await getSummarizedAlerts({
ruleId: id,
executionUuid: execution2Uuid,
spaceId: 'default',
});
expect(execution2SummarizedAlerts.new.count).to.eql(0);
expect(execution2SummarizedAlerts.ongoing.count).to.eql(1);
expect(execution2SummarizedAlerts.recovered.count).to.eql(0);
// Execute again to recover the alert
const execution3Uuid = uuid.v4();
await executor({
...options,
startedAt: new Date(),
state: getState(false, execution2Results.trackedAlerts),
executionId: execution3Uuid,
});
// Refresh the index so the data is available for reading
await es.indices.refresh({ index: `${ruleDataClient.indexName}*` });
const execution3SummarizedAlerts = await getSummarizedAlerts({
ruleId: id,
executionUuid: execution3Uuid,
spaceId: 'default',
});
expect(execution3SummarizedAlerts.new.count).to.eql(0);
expect(execution3SummarizedAlerts.ongoing.count).to.eql(0);
expect(execution3SummarizedAlerts.recovered.count).to.eql(1);
// Get summarized alerts across all 3 executions
// Should return the new and recovered alert but not count it as ongoing because
// it triggered and recovered within the time range
const timeRangeSummarizedAlerts1 = await getSummarizedAlerts({
ruleId: id,
start: preExecution1Start,
end: new Date(),
spaceId: 'default',
});
expect(timeRangeSummarizedAlerts1.new.count).to.eql(1);
expect(timeRangeSummarizedAlerts1.ongoing.count).to.eql(0);
expect(timeRangeSummarizedAlerts1.recovered.count).to.eql(1);
// Get summarized alerts across last 2 executions
// Should return the recovered alert but not count it as new or ongoing because
// it recovered before the time range
const timeRangeSummarizedAlerts2 = await getSummarizedAlerts({
ruleId: id,
start: preExecution2Start,
end: new Date(),
spaceId: 'default',
});
expect(timeRangeSummarizedAlerts2.new.count).to.eql(0);
expect(timeRangeSummarizedAlerts2.ongoing.count).to.eql(0);
expect(timeRangeSummarizedAlerts2.recovered.count).to.eql(1);
});
});
}

View file

@ -24,5 +24,6 @@ export default ({ loadTestFile, getService }: FtrProviderContext): void => {
loadTestFile(require.resolve('./update_alert'));
loadTestFile(require.resolve('./create_rule'));
loadTestFile(require.resolve('./lifecycle_executor'));
loadTestFile(require.resolve('./get_summarized_alerts'));
});
};