[AO][SERVERLESS] Create serverless integration tests for the Threshold rule #161458 (#161569)

## Summary

It fixes #161458 by adding API integration tests for the Threshold rule,
with many scenarios (file per scenario), and each scenario has a
complete life-cycle

### The scenario life-cycle 
- Generating data using the `fake_host` dataset from the high-card
- Create a DataView based on the generated data
- Create the rule and wait to be active
- Get the fired alert and matches its value 
- Clean up

### The covered scenarios 
- Avg. percentage, fires alert
- Avg. percentage, fires alert with no data
- Custom equation on bytes filed, fires alert
- Doc count, fires alert
- Group by two fields, fires alert.

---------
This commit is contained in:
Faisal Kanout 2023-07-24 18:59:44 +02:00 committed by GitHub
parent c3a5da8825
commit 6bae659a1d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1252 additions and 2 deletions

View file

@ -47,7 +47,7 @@ export const generateEvent = (index: number, timestamp: Moment, interval: number
cores: 4,
total: {
norm: {
pct: randomBetween(),
pct: 0.8,
},
},
user: {
@ -134,6 +134,20 @@ export const generateEvent = (index: number, timestamp: Moment, interval: number
container: {
id: `container-${index}`,
name: 'container-name',
cpu: {
cores: 4,
total: {
norm: {
pct: 0.8,
},
},
user: {
pct: randomBetween(1, 4),
},
system: {
pct: randomBetween(1, 4),
},
},
},
},
];

View file

@ -165,6 +165,49 @@ export const template = {
type: 'keyword',
ignore_above: 256,
},
cpu: {
properties: {
cores: {
type: 'long',
},
total: {
properties: {
norm: {
properties: {
pct: {
scaling_factor: 1000,
type: 'scaled_float',
},
},
},
},
},
user: {
properties: {
pct: {
scaling_factor: 1000,
type: 'scaled_float',
},
norm: {
properties: {
pct: {
scaling_factor: 1000,
type: 'scaled_float',
},
},
},
},
},
system: {
properties: {
pct: {
scaling_factor: 1000,
type: 'scaled_float',
},
},
},
},
},
},
},
},

View file

@ -209,6 +209,7 @@ export interface ThresholdParams {
alertOnNoData?: boolean;
alertOnGroupDisappear?: boolean;
searchConfiguration: SerializedSearchSourceFields;
groupBy?: string[];
}
interface BaseMetricExpressionParams {

View file

@ -15,13 +15,16 @@ export function createTestConfig(options: CreateTestConfigOptions) {
return {
...svlSharedConfig.getAll(),
services,
kbnTestServer: {
...svlSharedConfig.get('kbnTestServer'),
serverArgs: [
...svlSharedConfig.get('kbnTestServer.serverArgs'),
`--serverless=${options.serverlessProject}`,
`--xpack.alerting.enableFrameworkAlerts=true`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
'--xpack.observability.unsafe.thresholdRule.enabled=true',
'--server.publicBaseUrl=https://localhost:5601',
],
},
testFiles: options.testFiles,

View file

@ -0,0 +1,69 @@
/*
* 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 { MetricThresholdParams } from '@kbn/infra-plugin/common/alerting/metrics';
import { ThresholdParams } from '@kbn/observability-plugin/common/threshold_rule/types';
import type { SuperTest, Test } from 'supertest';
export async function createIndexConnector({
supertest,
name,
indexName,
}: {
supertest: SuperTest<Test>;
name: string;
indexName: string;
}) {
const { body } = await supertest
.post(`/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name,
config: {
index: indexName,
refresh: true,
},
connector_type_id: '.index',
});
return body.id as string;
}
export async function createRule({
supertest,
name,
ruleTypeId,
params,
actions = [],
tags = [],
schedule,
consumer,
}: {
supertest: SuperTest<Test>;
ruleTypeId: string;
name: string;
params: MetricThresholdParams | ThresholdParams;
actions?: any[];
tags?: any[];
schedule?: { interval: string };
consumer: string;
}) {
const { body } = await supertest
.post(`/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send({
params,
consumer,
schedule: schedule || {
interval: '5m',
},
tags,
name,
rule_type_id: ruleTypeId,
actions,
});
return body;
}

View file

@ -0,0 +1,87 @@
/*
* 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 pRetry from 'p-retry';
import type SuperTest from 'supertest';
import type { Client } from '@elastic/elasticsearch';
import type {
AggregationsAggregate,
SearchResponse,
} from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
export async function waitForRuleStatus({
id,
expectedStatus,
supertest,
}: {
id: string;
expectedStatus: string;
supertest: SuperTest.SuperTest<SuperTest.Test>;
}): Promise<Record<string, any>> {
return pRetry(
async () => {
const response = await supertest.get(`/api/alerting/rule/${id}`);
const { execution_status: executionStatus } = response.body || {};
const { status } = executionStatus || {};
if (status !== expectedStatus) {
throw new Error(`waitForStatus(${expectedStatus}): got ${status}`);
}
return executionStatus;
},
{ retries: 10 }
);
}
export async function waitForDocumentInIndex<T>({
esClient,
indexName,
}: {
esClient: Client;
indexName: string;
}): Promise<SearchResponse<T, Record<string, AggregationsAggregate>>> {
return pRetry(
async () => {
const response = await esClient.search<T>({ index: indexName });
if (response.hits.hits.length === 0) {
throw new Error('No hits found');
}
return response;
},
{ retries: 10 }
);
}
export async function waitForAlertInIndex<T>({
esClient,
indexName,
ruleId,
}: {
esClient: Client;
indexName: string;
ruleId: string;
}): Promise<SearchResponse<T, Record<string, AggregationsAggregate>>> {
return pRetry(
async () => {
const response = await esClient.search<T>({
index: indexName,
body: {
query: {
term: {
'kibana.alert.rule.uuid': ruleId,
},
},
},
});
if (response.hits.hits.length === 0) {
throw new Error('No hits found');
}
return response;
},
{ retries: 10 }
);
}

View file

@ -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 { SuperTest, Test } from 'supertest';
export const createDataView = async ({
supertest,
id,
name,
title,
}: {
supertest: SuperTest<Test>;
id: string;
name: string;
title: string;
}) => {
const { body } = await supertest
.post(`/api/content_management/rpc/create`)
.set('kbn-xsrf', 'foo')
.send({
contentTypeId: 'index-pattern',
data: {
fieldAttrs: '{}',
title,
timeFieldName: '@timestamp',
sourceFilters: '[]',
fields: '[]',
fieldFormatMap: '{}',
typeMeta: '{}',
runtimeFieldMap: '{}',
name,
},
options: { id },
version: 1,
});
return body;
};
export const deleteDataView = async ({
supertest,
id,
}: {
supertest: SuperTest<Test>;
id: string;
}) => {
const { body } = await supertest
.post(`/api/content_management/rpc/delete`)
.set('kbn-xsrf', 'foo')
.send({
contentTypeId: 'index-pattern',
id,
options: { force: true },
version: 1,
});
return body;
};

View file

@ -11,5 +11,10 @@ export default function ({ loadTestFile }: FtrProviderContext) {
describe('serverless observability API', function () {
loadTestFile(require.resolve('./fleet'));
loadTestFile(require.resolve('./snapshot_telemetry'));
loadTestFile(require.resolve('./threshold_rule/avg_pct_fired'));
loadTestFile(require.resolve('./threshold_rule/avg_pct_no_data'));
loadTestFile(require.resolve('./threshold_rule/documents_count_fired'));
loadTestFile(require.resolve('./threshold_rule/custom_eq_avg_bytes_fired'));
loadTestFile(require.resolve('./threshold_rule/group_by_fired'));
});
}

View file

@ -0,0 +1,181 @@
/*
* 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 { cleanup, generate } from '@kbn/infra-forge';
import { Aggregators, Comparator } from '@kbn/observability-plugin/common/threshold_rule/types';
import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/threshold/threshold_executor';
import expect from '@kbn/expect';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
import { createDataView, deleteDataView } from '../helpers/data_view';
import { waitForAlertInIndex, waitForRuleStatus } from '../helpers/alerting_wait_for_helpers';
export default function ({ getService }: FtrProviderContext) {
const esClient = getService('es');
const supertest = getService('supertest');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const logger = getService('log');
describe('Threshold rule - AVG - PCT - FIRED', () => {
const THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW_ID = 'data-view-id';
let infraDataIndex: string;
let actionId: string;
let ruleId: string;
before(async () => {
infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger });
await createDataView({
supertest,
name: 'metrics-fake_hosts',
id: DATA_VIEW_ID,
title: 'metrics-fake_hosts',
});
});
after(async () => {
await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo');
await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo');
await esClient.deleteByQuery({
index: THRESHOLD_RULE_ALERT_INDEX,
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'kibana.alert.rule.consumer': 'alerts' } },
});
await deleteDataView({
supertest,
id: DATA_VIEW_ID,
});
await esDeleteAllIndices([ALERT_ACTION_INDEX, infraDataIndex]);
await cleanup({ esClient, logger });
});
describe('Rule creation', () => {
it('creates rule successfully', async () => {
actionId = await createIndexConnector({
supertest,
name: 'Index Connector: Threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
supertest,
tags: ['observability'],
consumer: 'alerts',
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,
timeUnit: 'm',
customMetrics: [
{ name: 'A', field: 'system.cpu.user.pct', aggType: Aggregators.AVERAGE },
],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: DATA_VIEW_ID,
},
},
actions: [
{
group: FIRED_ACTIONS_ID,
id: actionId,
params: {
documents: [
{
ruleType: '{{rule.type}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await waitForRuleStatus({
id: ruleId,
expectedStatus: 'active',
supertest,
});
expect(executionStatus.status).to.be('active');
});
it('should set correct information in the alert document', async () => {
const resp = await waitForAlertInIndex({
esClient,
indexName: THRESHOLD_RULE_ALERT_INDEX,
ruleId,
});
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
'Threshold (Technical Preview)'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'alerts');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0);
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.rule_type_id',
'observability.rules.threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId);
expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.tags')
.contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.action_group', 'threshold.fired');
expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*');
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters')
.eql({
criteria: [
{
aggType: 'custom',
comparator: '>',
threshold: [0.5],
timeSize: 5,
timeUnit: 'm',
customMetrics: [{ name: 'A', field: 'system.cpu.user.pct', aggType: 'avg' }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } },
});
});
});
});
}

View file

@ -0,0 +1,177 @@
/*
* 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 { Aggregators, Comparator } from '@kbn/observability-plugin/common/threshold_rule/types';
import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/threshold/threshold_executor';
import expect from '@kbn/expect';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
import { createDataView, deleteDataView } from '../helpers/data_view';
import { waitForAlertInIndex, waitForRuleStatus } from '../helpers/alerting_wait_for_helpers';
export default function ({ getService }: FtrProviderContext) {
const esClient = getService('es');
const supertest = getService('supertest');
describe('Threshold rule - AVG - PCT - NoData', () => {
const THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW_ID = 'data-view-id-no-data';
let actionId: string;
let ruleId: string;
before(async () => {
await createDataView({
supertest,
name: 'no-data-pattern',
id: DATA_VIEW_ID,
title: 'no-data-pattern',
});
});
after(async () => {
await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo');
await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo');
await esClient.deleteByQuery({
index: THRESHOLD_RULE_ALERT_INDEX,
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'kibana.alert.rule.consumer': 'alerts' } },
});
await deleteDataView({
supertest,
id: DATA_VIEW_ID,
});
});
describe('Rule creation', () => {
it('creates rule successfully', async () => {
actionId = await createIndexConnector({
supertest,
name: 'Index Connector: Threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
supertest,
tags: ['observability'],
consumer: 'alerts',
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [0.5],
timeSize: 5,
timeUnit: 'm',
customMetrics: [
{ name: 'A', field: 'system.cpu.user.pct', aggType: Aggregators.AVERAGE },
],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: DATA_VIEW_ID,
},
},
actions: [
{
group: FIRED_ACTIONS_ID,
id: actionId,
params: {
documents: [
{
ruleType: '{{rule.type}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await waitForRuleStatus({
id: ruleId,
expectedStatus: 'active',
supertest,
});
expect(executionStatus.status).to.be('active');
});
it('should set correct information in the alert document', async () => {
const resp = await waitForAlertInIndex({
esClient,
indexName: THRESHOLD_RULE_ALERT_INDEX,
ruleId,
});
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
'Threshold (Technical Preview)'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'alerts');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0);
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.rule_type_id',
'observability.rules.threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId);
expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.tags')
.contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.action_group', 'threshold.nodata');
expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*');
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters')
.eql({
criteria: [
{
aggType: 'custom',
comparator: '>',
threshold: [0.5],
timeSize: 5,
timeUnit: 'm',
customMetrics: [{ name: 'A', field: 'system.cpu.user.pct', aggType: 'avg' }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
index: 'data-view-id-no-data',
query: { query: '', language: 'kuery' },
},
});
});
});
});
}

View file

@ -0,0 +1,193 @@
/*
* 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.
*/
/*
* 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 { cleanup, generate } from '@kbn/infra-forge';
import { Aggregators, Comparator } from '@kbn/observability-plugin/common/threshold_rule/types';
import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/threshold/threshold_executor';
import expect from '@kbn/expect';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
import { createDataView, deleteDataView } from '../helpers/data_view';
import { waitForAlertInIndex, waitForRuleStatus } from '../helpers/alerting_wait_for_helpers';
export default function ({ getService }: FtrProviderContext) {
const esClient = getService('es');
const supertest = getService('supertest');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const logger = getService('log');
describe('Threshold rule - CUSTOM_EQ - AVG - BYTES - FIRED', () => {
const THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW_ID = 'data-view-id';
let infraDataIndex: string;
let actionId: string;
let ruleId: string;
before(async () => {
infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger });
await createDataView({
supertest,
name: 'metrics-fake_hosts',
id: DATA_VIEW_ID,
title: 'metrics-fake_hosts',
});
});
after(async () => {
await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo');
await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo');
await esClient.deleteByQuery({
index: THRESHOLD_RULE_ALERT_INDEX,
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'kibana.alert.rule.consumer': 'alerts' } },
});
await deleteDataView({
supertest,
id: DATA_VIEW_ID,
});
await esDeleteAllIndices([ALERT_ACTION_INDEX, infraDataIndex]);
await cleanup({ esClient, logger });
});
describe('Rule creation', () => {
it('creates rule successfully', async () => {
actionId = await createIndexConnector({
supertest,
name: 'Index Connector: Threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
supertest,
tags: ['observability'],
consumer: 'alerts',
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,
timeUnit: 'm',
customMetrics: [
{ name: 'A', field: 'system.network.in.bytes', aggType: Aggregators.AVERAGE },
{ name: 'B', field: 'system.network.out.bytes', aggType: Aggregators.AVERAGE },
],
equation: 'A / B ',
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: DATA_VIEW_ID,
},
},
actions: [
{
group: FIRED_ACTIONS_ID,
id: actionId,
params: {
documents: [
{
ruleType: '{{rule.type}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await waitForRuleStatus({
id: ruleId,
expectedStatus: 'active',
supertest,
});
expect(executionStatus.status).to.be('active');
});
it('should set correct information in the alert document', async () => {
const resp = await waitForAlertInIndex({
esClient,
indexName: THRESHOLD_RULE_ALERT_INDEX,
ruleId,
});
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
'Threshold (Technical Preview)'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'alerts');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0);
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.rule_type_id',
'observability.rules.threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId);
expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.tags')
.contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.action_group', 'threshold.fired');
expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*');
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters')
.eql({
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [0.9],
timeSize: 1,
timeUnit: 'm',
customMetrics: [
{ name: 'A', field: 'system.network.in.bytes', aggType: Aggregators.AVERAGE },
{ name: 'B', field: 'system.network.out.bytes', aggType: Aggregators.AVERAGE },
],
equation: 'A / B ',
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } },
});
});
});
});
}

View file

@ -0,0 +1,179 @@
/*
* 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 { cleanup, generate } from '@kbn/infra-forge';
import { Aggregators, Comparator } from '@kbn/observability-plugin/common/threshold_rule/types';
import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/threshold/threshold_executor';
import expect from '@kbn/expect';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
import { createDataView, deleteDataView } from '../helpers/data_view';
import { waitForAlertInIndex, waitForRuleStatus } from '../helpers/alerting_wait_for_helpers';
export default function ({ getService }: FtrProviderContext) {
const esClient = getService('es');
const supertest = getService('supertest');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const logger = getService('log');
describe('Threshold rule - DOCUMENTS_COUNT - FIRED', () => {
const THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW_ID = 'data-view-id';
let infraDataIndex: string;
let actionId: string;
let ruleId: string;
before(async () => {
infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger });
await createDataView({
supertest,
name: 'metrics-fake_hosts',
id: DATA_VIEW_ID,
title: 'metrics-fake_hosts',
});
});
after(async () => {
await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo');
await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo');
await esClient.deleteByQuery({
index: THRESHOLD_RULE_ALERT_INDEX,
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'kibana.alert.rule.consumer': 'alerts' } },
});
await deleteDataView({
supertest,
id: DATA_VIEW_ID,
});
await esDeleteAllIndices([ALERT_ACTION_INDEX, infraDataIndex]);
await cleanup({ esClient, logger });
});
describe('Rule creation', () => {
it('creates rule successfully', async () => {
actionId = await createIndexConnector({
supertest,
name: 'Index Connector: Threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
supertest,
tags: ['observability'],
consumer: 'alerts',
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT,
threshold: [2],
timeSize: 1,
timeUnit: 'm',
customMetrics: [{ name: 'A', filter: '', aggType: Aggregators.COUNT }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: DATA_VIEW_ID,
},
},
actions: [
{
group: FIRED_ACTIONS_ID,
id: actionId,
params: {
documents: [
{
ruleType: '{{rule.type}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await waitForRuleStatus({
id: ruleId,
expectedStatus: 'active',
supertest,
});
expect(executionStatus.status).to.be('active');
});
it('should set correct information in the alert document', async () => {
const resp = await waitForAlertInIndex({
esClient,
indexName: THRESHOLD_RULE_ALERT_INDEX,
ruleId,
});
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
'Threshold (Technical Preview)'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'alerts');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0);
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.rule_type_id',
'observability.rules.threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId);
expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.tags')
.contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.action_group', 'threshold.fired');
expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', '*');
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters')
.eql({
criteria: [
{
aggType: 'custom',
comparator: '>',
threshold: [2],
timeSize: 1,
timeUnit: 'm',
customMetrics: [{ name: 'A', filter: '', aggType: 'count' }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } },
});
});
});
});
}

View file

@ -0,0 +1,236 @@
/*
* 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.
*/
/*
* 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 moment from 'moment';
import { cleanup, generate } from '@kbn/infra-forge';
import { Aggregators, Comparator } from '@kbn/observability-plugin/common/threshold_rule/types';
import { FIRED_ACTIONS_ID } from '@kbn/observability-plugin/server/lib/rules/threshold/threshold_executor';
import expect from '@kbn/expect';
import { OBSERVABILITY_THRESHOLD_RULE_TYPE_ID } from '@kbn/observability-plugin/common/constants';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { createIndexConnector, createRule } from '../helpers/alerting_api_helper';
import { createDataView, deleteDataView } from '../helpers/data_view';
import {
waitForAlertInIndex,
waitForDocumentInIndex,
waitForRuleStatus,
} from '../helpers/alerting_wait_for_helpers';
export default function ({ getService }: FtrProviderContext) {
const esClient = getService('es');
const supertest = getService('supertest');
const esDeleteAllIndices = getService('esDeleteAllIndices');
const logger = getService('log');
let alertId: string;
let startedAt: string;
describe('Threshold rule - GROUP_BY - FIRED', () => {
const THRESHOLD_RULE_ALERT_INDEX = '.alerts-observability.threshold.alerts-default';
const ALERT_ACTION_INDEX = 'alert-action-threshold';
const DATA_VIEW_ID = 'data-view-id';
let infraDataIndex: string;
let actionId: string;
let ruleId: string;
before(async () => {
infraDataIndex = await generate({ esClient, lookback: 'now-15m', logger });
await createDataView({
supertest,
name: 'metrics-fake_hosts',
id: DATA_VIEW_ID,
title: 'metrics-fake_hosts',
});
});
after(async () => {
await supertest.delete(`/api/alerting/rule/${ruleId}`).set('kbn-xsrf', 'foo');
await supertest.delete(`/api/actions/connector/${actionId}`).set('kbn-xsrf', 'foo');
await esClient.deleteByQuery({
index: THRESHOLD_RULE_ALERT_INDEX,
query: { term: { 'kibana.alert.rule.uuid': ruleId } },
});
await esClient.deleteByQuery({
index: '.kibana-event-log-*',
query: { term: { 'kibana.alert.rule.consumer': 'alerts' } },
});
await deleteDataView({
supertest,
id: DATA_VIEW_ID,
});
await esDeleteAllIndices([ALERT_ACTION_INDEX, infraDataIndex]);
await cleanup({ esClient, logger });
});
describe('Rule creation', () => {
it('creates rule successfully', async () => {
actionId = await createIndexConnector({
supertest,
name: 'Index Connector: Threshold API test',
indexName: ALERT_ACTION_INDEX,
});
const createdRule = await createRule({
supertest,
tags: ['observability'],
consumer: 'alerts',
name: 'Threshold rule',
ruleTypeId: OBSERVABILITY_THRESHOLD_RULE_TYPE_ID,
params: {
criteria: [
{
aggType: Aggregators.CUSTOM,
comparator: Comparator.GT_OR_EQ,
threshold: [0.2],
timeSize: 1,
timeUnit: 'm',
customMetrics: [
{ name: 'A', field: 'system.cpu.total.norm.pct', aggType: Aggregators.AVERAGE },
],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: DATA_VIEW_ID,
},
groupBy: ['host.name'],
},
actions: [
{
group: FIRED_ACTIONS_ID,
id: actionId,
params: {
documents: [
{
ruleType: '{{rule.type}}',
alertDetailsUrl: '{{context.alertDetailsUrl}}',
reason: '{{context.reason}}',
value: '{{context.value}}',
host: '{{context.host}}',
},
],
},
frequency: {
notify_when: 'onActionGroupChange',
throttle: null,
summary: false,
},
},
],
});
ruleId = createdRule.id;
expect(ruleId).not.to.be(undefined);
});
it('should be active', async () => {
const executionStatus = await waitForRuleStatus({
id: ruleId,
expectedStatus: 'active',
supertest,
});
expect(executionStatus.status).to.be('active');
});
it('should set correct information in the alert document', async () => {
const resp = await waitForAlertInIndex({
esClient,
indexName: THRESHOLD_RULE_ALERT_INDEX,
ruleId,
});
alertId = (resp.hits.hits[0]._source as any)['kibana.alert.uuid'];
startedAt = (resp.hits.hits[0]._source as any)['kibana.alert.start'];
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.category',
'Threshold (Technical Preview)'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.consumer', 'alerts');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.name', 'Threshold rule');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.producer', 'observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.revision', 0);
expect(resp.hits.hits[0]._source).property(
'kibana.alert.rule.rule_type_id',
'observability.rules.threshold'
);
expect(resp.hits.hits[0]._source).property('kibana.alert.rule.uuid', ruleId);
expect(resp.hits.hits[0]._source).property('kibana.space_ids').contain('default');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.tags')
.contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.action_group', 'threshold.fired');
expect(resp.hits.hits[0]._source).property('tags').contain('observability');
expect(resp.hits.hits[0]._source).property('kibana.alert.instance.id', 'host-0');
expect(resp.hits.hits[0]._source).property('kibana.alert.workflow_status', 'open');
expect(resp.hits.hits[0]._source).property('event.kind', 'signal');
expect(resp.hits.hits[0]._source).property('event.action', 'open');
expect(resp.hits.hits[0]._source).property('host.name', 'host-0');
expect(resp.hits.hits[0]._source)
.property('host.mac')
.eql(['00-00-5E-00-53-23', '00-00-5E-00-53-24']);
expect(resp.hits.hits[0]._source).property('container.id', 'container-0');
expect(resp.hits.hits[0]._source).property('container.name', 'container-name');
expect(resp.hits.hits[0]._source).not.property('container.cpu');
expect(resp.hits.hits[0]._source)
.property('kibana.alert.rule.parameters')
.eql({
criteria: [
{
aggType: 'custom',
comparator: '>=',
threshold: [0.2],
timeSize: 1,
timeUnit: 'm',
customMetrics: [{ name: 'A', field: 'system.cpu.total.norm.pct', aggType: 'avg' }],
},
],
alertOnNoData: true,
alertOnGroupDisappear: true,
searchConfiguration: { index: 'data-view-id', query: { query: '', language: 'kuery' } },
groupBy: ['host.name'],
});
});
it('should set correct action variables', async () => {
const rangeFrom = moment(startedAt).subtract('5', 'minute').toISOString();
const resp = await waitForDocumentInIndex<{
ruleType: string;
alertDetailsUrl: string;
reason: string;
value: string;
host: string;
}>({
esClient,
indexName: ALERT_ACTION_INDEX,
});
expect(resp.hits.hits[0]._source?.ruleType).eql('observability.rules.threshold');
expect(resp.hits.hits[0]._source?.alertDetailsUrl).eql(
`https://localhost:5601/app/observability/alerts?_a=(kuery:%27kibana.alert.uuid:%20%22${alertId}%22%27%2CrangeFrom:%27${rangeFrom}%27%2CrangeTo:now%2Cstatus:all)`
);
expect(resp.hits.hits[0]._source?.reason).eql(
'Custom equation is 0.8 in the last 1 min for host-0. Alert when >= 0.2.'
);
expect(resp.hits.hits[0]._source?.value).eql('0.8');
expect(resp.hits.hits[0]._source?.host).eql(
'{"name":"host-0","mac":["00-00-5E-00-53-23","00-00-5E-00-53-24"]}'
);
});
});
});
}

View file

@ -26,6 +26,9 @@
"@kbn/telemetry-plugin",
"@kbn/telemetry-collection-xpack-plugin",
"@kbn/telemetry-tools",
"@kbn/infra-plugin",
"@kbn/observability-plugin",
"@kbn/infra-forge",
"@kbn/ftr-common-functional-services",
"@kbn/core-chrome-browser",
"@kbn/default-nav-ml",