[Alerting] Search alert (#88528)

* Adding es query alert type to server with commented out executor

* Adding skeleton es query alert to client with JSON editor. Pulled out index popoover into component for reuse between index threshold and es query alert types

* Implementing alert executor that performs query and matches condition against doc count

* Added tests for server side alert type

* Updated alert executor to de-duplicate matches and create instance for every document if threshold is not defined

* Moving more index popover code out of index threshold and es query expression components

* Ability to remove threshold condition from es query alert

* Validation tests

* Adding ability to test out query. Need to add error handling and it looks ugly

* Fixing bug with creating alert with threshold and i18n

* wip

* Fixing tests

* Simplifying executor logic to only handle threshold and store hits in action context

* Adding functional test for es query alert

* Types

* Adding functional test for query testing

* Fixing unit test

* Adding link to ES docs. Cleaning up logger statements

* Adding docs

* Updating docs based on feedback

* PR fixes

* Using ES client typings

* Fixing unit test

* Fixing copy based on comments

* Fixing copy based on comments

* Fixing bug in index select popover

* Fixing unit tests

* Making track_total_hits configurable

* Fixing functional test

* PR fixes

* Added unit test

* Removing unused import

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
ymao1 2021-01-29 07:45:00 -05:00 committed by GitHub
parent 9733d2fdaa
commit 049135192e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 3073 additions and 295 deletions

View file

@ -8,7 +8,7 @@ This section covers stack alerts. For domain-specific alert types, refer to the
Users will need `all` access to the *Stack Alerts* feature to be able to create and edit any of the alerts listed below.
See <<kibana-feature-privileges, feature privileges>> for more information on configuring roles that provide access to this feature.
Currently {kib} provides one stack alert: the <<alert-type-index-threshold>> type.
Currently {kib} provides two stack alerts: <<alert-type-index-threshold>> and <<alert-type-es-query>>.
[float]
[[alert-type-index-threshold]]
@ -112,6 +112,47 @@ You can interactively change the time window and observe the effect it has on th
[role="screenshot"]
image::images/alert-types-index-threshold-example-comparison.png[Comparing two time windows]
[float]
[[alert-type-es-query]]
=== ES query
The ES query alert type is designed to run a user-configured {es} query over indices, compare the number of matches to a configured threshold, and schedule
actions to run when the threshold condition is met.
[float]
==== Creating the alert
An ES query alert can be created from the *Create* button in the <<alert-management, alert management UI>>. Fill in the <<defining-alerts-general-details, general alert details>>, then select *ES query*.
[role="screenshot"]
image::images/alert-types-es-query-select.png[Choosing an ES query alert type]
[float]
==== Defining the conditions
The ES query alert has 4 clauses that define the condition to detect.
[role="screenshot"]
image::images/alert-types-es-query-conditions.png[Four clauses define the condition to detect]
Index:: This clause requires an *index or index pattern* and a *time field* that will be used for the *time window*.
ES query:: This clause specifies the ES DSL query to execute. The number of documents that match this query will be evaulated against the threshold
condition. Aggregations are not supported at this time.
Threshold:: This clause defines a threshold value and a comparison operator (`is above`, `is above or equals`, `is below`, `is below or equals`, or `is between`). The number of documents that match the specified query is compared to this threshold.
Time window:: This clause determines how far back to search for documents, using the *time field* set in the *index* clause. Generally this value should be set to a value higher than the *check every* value in the <<defining-alerts-general-details, general alert details>>, to avoid gaps in detection.
[float]
==== Testing your query
Use the *Test query* feature to verify that your query DSL is valid.
When your query is valid:: Valid queries will be executed against the configured *index* using the configured *time window*. The number of documents that
match the query will be displayed.
[role="screenshot"]
image::images/alert-types-es-query-valid.png[Test ES query returns number of matches when valid]
When your query is invalid:: An error message is shown if the query is invalid.
[role="screenshot"]
image::images/alert-types-es-query-invalid.png[Test ES query shows error when invalid]

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -15,6 +15,7 @@ export * from './alert_instance_summary';
export * from './builtin_action_groups';
export * from './disabled_action_groups';
export * from './alert_notify_when_type';
export * from './parse_duration';
export interface AlertingFrameworkHealth {
isSufficientlySecure: boolean;

View file

@ -0,0 +1,398 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { buildSortedEventsQuery, BuildSortedEventsQuery } from './build_sorted_events_query';
import type { Writable } from '@kbn/utility-types';
const DefaultQuery: Writable<Partial<BuildSortedEventsQuery>> = {
index: ['index-name'],
from: '2021-01-01T00:00:10.123Z',
to: '2021-01-23T12:00:50.321Z',
filter: {},
size: 100,
timeField: 'timefield',
};
describe('buildSortedEventsQuery', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let query: any;
beforeEach(() => {
query = { ...DefaultQuery };
});
test('it builds a filter with given date range', () => {
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
},
});
});
test('it does not include searchAfterSortId if it is an empty string', () => {
query.searchAfterSortId = '';
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
},
});
});
test('it includes searchAfterSortId if it is a valid string', () => {
const sortId = '123456789012';
query.searchAfterSortId = sortId;
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
search_after: [sortId],
},
});
});
test('it includes searchAfterSortId if it is a valid number', () => {
const sortId = 123456789012;
query.searchAfterSortId = sortId;
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
search_after: [sortId],
},
});
});
test('it includes aggregations if provided', () => {
query.aggs = {
tags: {
terms: {
field: 'tag',
},
},
};
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
aggs: {
tags: {
terms: {
field: 'tag',
},
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
},
});
});
test('it uses sortOrder if specified', () => {
query.sortOrder = 'desc';
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: false,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'desc',
},
},
],
},
});
});
test('it uses track_total_hits if specified', () => {
query.track_total_hits = true;
expect(buildSortedEventsQuery(query)).toEqual({
allowNoIndices: true,
index: ['index-name'],
size: 100,
ignoreUnavailable: true,
track_total_hits: true,
body: {
docvalue_fields: [
{
field: 'timefield',
format: 'strict_date_optional_time',
},
],
query: {
bool: {
filter: [
{},
{
bool: {
filter: [
{
range: {
timefield: {
gte: '2021-01-01T00:00:10.123Z',
lte: '2021-01-23T12:00:50.321Z',
format: 'strict_date_optional_time',
},
},
},
],
},
},
{
match_all: {},
},
],
},
},
sort: [
{
timefield: {
order: 'asc',
},
},
],
},
});
});
});

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ESSearchBody, ESSearchRequest } from '../../../typings/elasticsearch';
import { SortOrder } from '../../../typings/elasticsearch/aggregations';
type BuildSortedEventsQueryOpts = Pick<ESSearchBody, 'aggs' | 'track_total_hits'> &
Pick<Required<ESSearchRequest>, 'index' | 'size'>;
export interface BuildSortedEventsQuery extends BuildSortedEventsQueryOpts {
filter: unknown;
from: string;
to: string;
sortOrder?: SortOrder | undefined;
searchAfterSortId: string | number | undefined;
timeField: string;
}
export const buildSortedEventsQuery = ({
aggs,
index,
from,
to,
filter,
size,
searchAfterSortId,
sortOrder,
timeField,
// eslint-disable-next-line @typescript-eslint/naming-convention
track_total_hits,
}: BuildSortedEventsQuery): ESSearchRequest => {
const sortField = timeField;
const docFields = [timeField].map((tstamp) => ({
field: tstamp,
format: 'strict_date_optional_time',
}));
const rangeFilter: unknown[] = [
{
range: {
[timeField]: {
lte: to,
gte: from,
format: 'strict_date_optional_time',
},
},
},
];
const filterWithTime = [filter, { bool: { filter: rangeFilter } }];
const searchQuery = {
allowNoIndices: true,
index,
size,
ignoreUnavailable: true,
track_total_hits: track_total_hits ?? false,
body: {
docvalue_fields: docFields,
query: {
bool: {
filter: [
...filterWithTime,
{
match_all: {},
},
],
},
},
...(aggs ? { aggs } : {}),
sort: [
{
[sortField]: {
order: sortOrder ?? 'asc',
},
},
],
},
};
if (searchAfterSortId) {
return {
...searchQuery,
body: {
...searchQuery.body,
search_after: [searchAfterSortId],
},
};
}
return searchQuery;
};

View file

@ -5,5 +5,6 @@
"kibanaVersion": "kibana",
"requiredPlugins": ["alerts", "features", "triggersActionsUi", "kibanaReact", "savedObjects", "data"],
"configPath": ["xpack", "stack_alerts"],
"requiredBundles": ["esUiShared"],
"ui": true
}

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mountWithIntl, nextTick } from '@kbn/test/jest';
import { IndexSelectPopover } from './index_select_popover';
jest.mock('../../../../triggers_actions_ui/public', () => ({
getIndexPatterns: () => {
return ['index1', 'index2'];
},
firstFieldOption: () => {
return { text: 'Select a field', value: '' };
},
getTimeFieldOptions: () => {
return [
{
text: '@timestamp',
value: '@timestamp',
},
];
},
getFields: () => {
return Promise.resolve([
{
name: '@timestamp',
type: 'date',
},
{
name: 'field',
type: 'text',
},
]);
},
getIndexOptions: () => {
return Promise.resolve([
{
label: 'indexOption',
options: [
{
label: 'index1',
value: 'index1',
},
{
label: 'index2',
value: 'index2',
},
],
},
]);
},
}));
describe('IndexSelectPopover', () => {
const props = {
index: [],
esFields: [],
timeField: undefined,
errors: {
index: [],
timeField: [],
},
onIndexChange: jest.fn(),
onTimeFieldChange: jest.fn(),
};
beforeEach(() => {
jest.resetAllMocks();
});
test('renders closed popover initially and opens on click', async () => {
const wrapper = mountWithIntl(<IndexSelectPopover {...props} />);
expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeFalsy();
wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdAlertTimeFieldSelect"]').exists()).toBeTruthy();
});
test('renders search input', async () => {
const wrapper = mountWithIntl(<IndexSelectPopover {...props} />);
expect(wrapper.find('[data-test-subj="selectIndexExpression"]').exists()).toBeTruthy();
wrapper.find('[data-test-subj="selectIndexExpression"]').first().simulate('click');
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="thresholdIndexesComboBox"]').exists()).toBeTruthy();
const indexSearchBoxValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]');
expect(indexSearchBoxValue.first().props().value).toEqual('');
const indexComboBox = wrapper.find('#indexSelectSearchBox');
indexComboBox.first().simulate('click');
const event = { target: { value: 'indexPattern1' } };
indexComboBox.find('input').first().simulate('change', event);
const updatedIndexSearchValue = wrapper.find('[data-test-subj="comboBoxSearchInput"]');
expect(updatedIndexSearchValue.first().props().value).toEqual('indexPattern1');
});
});

View file

@ -0,0 +1,239 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isString } from 'lodash';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonIcon,
EuiComboBox,
EuiComboBoxOptionOption,
EuiExpression,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPopover,
EuiPopoverTitle,
EuiSelect,
} from '@elastic/eui';
import { HttpSetup } from 'kibana/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import {
firstFieldOption,
getFields,
getIndexOptions,
getIndexPatterns,
getTimeFieldOptions,
IErrorObject,
} from '../../../../triggers_actions_ui/public';
interface KibanaDeps {
http: HttpSetup;
}
interface Props {
index: string[];
esFields: Array<{
name: string;
type: string;
normalizedType: string;
searchable: boolean;
aggregatable: boolean;
}>;
timeField: string | undefined;
errors: IErrorObject;
onIndexChange: (indices: string[]) => void;
onTimeFieldChange: (timeField: string) => void;
}
export const IndexSelectPopover: React.FunctionComponent<Props> = ({
index,
esFields,
timeField,
errors,
onIndexChange,
onTimeFieldChange,
}) => {
const { http } = useKibana<KibanaDeps>().services;
const [indexPopoverOpen, setIndexPopoverOpen] = useState(false);
const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]);
const [indexPatterns, setIndexPatterns] = useState([]);
const [areIndicesLoading, setAreIndicesLoading] = useState<boolean>(false);
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
useEffect(() => {
const indexPatternsFunction = async () => {
setIndexPatterns(await getIndexPatterns());
};
indexPatternsFunction();
}, []);
useEffect(() => {
const timeFields = getTimeFieldOptions(esFields);
setTimeFieldOptions([firstFieldOption, ...timeFields]);
}, [esFields]);
const renderIndices = (indices: string[]) => {
const rows = indices.map((indexName: string, idx: number) => {
return (
<p key={idx}>
{indexName}
{idx < indices.length - 1 ? ',' : null}
</p>
);
});
return <div>{rows}</div>;
};
const closeIndexPopover = () => {
setIndexPopoverOpen(false);
if (timeField === undefined) {
onTimeFieldChange('');
}
};
return (
<EuiPopover
id="indexPopover"
button={
<EuiExpression
display="columns"
data-test-subj="selectIndexExpression"
description={i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexLabel', {
defaultMessage: 'index',
})}
value={index && index.length > 0 ? renderIndices(index) : firstFieldOption.text}
isActive={indexPopoverOpen}
onClick={() => {
setIndexPopoverOpen(true);
}}
isInvalid={!(index && index.length > 0 && timeField !== '')}
/>
}
isOpen={indexPopoverOpen}
closePopover={closeIndexPopover}
ownFocus
anchorPosition="downLeft"
zIndex={8000}
display="block"
>
<div style={{ width: '450px' }}>
<EuiPopoverTitle>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
{i18n.translate('xpack.stackAlerts.components.ui.alertParams.indexButtonLabel', {
defaultMessage: 'index',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="closePopover"
iconType="cross"
color="danger"
aria-label={i18n.translate(
'xpack.stackAlerts.components.ui.alertParams.closeIndexPopoverLabel',
{
defaultMessage: 'Close',
}
)}
onClick={closeIndexPopover}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<EuiFormRow
id="indexSelectSearchBox"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.components.ui.alertParams.indicesToQueryLabel"
defaultMessage="Indices to query"
/>
}
isInvalid={errors.index.length > 0 && index != null && index.length > 0}
error={errors.index}
helpText={
<FormattedMessage
id="xpack.stackAlerts.components.ui.alertParams.howToBroadenSearchQueryDescription"
defaultMessage="Use * to broaden your query."
/>
}
>
<EuiComboBox
fullWidth
async
isLoading={areIndicesLoading}
isInvalid={errors.index.length > 0 && index != null && index.length > 0}
noSuggestions={!indexOptions.length}
options={indexOptions}
data-test-subj="thresholdIndexesComboBox"
selectedOptions={(index || []).map((anIndex: string) => {
return {
label: anIndex,
value: anIndex,
};
})}
onChange={async (selected: EuiComboBoxOptionOption[]) => {
const selectedIndices = selected
.map((aSelected) => aSelected.value)
.filter<string>(isString);
onIndexChange(selectedIndices);
// reset time field if indices have been reset
if (selectedIndices.length === 0) {
setTimeFieldOptions([firstFieldOption]);
} else {
const currentEsFields = await getFields(http!, selectedIndices);
const timeFields = getTimeFieldOptions(currentEsFields);
setTimeFieldOptions([firstFieldOption, ...timeFields]);
}
}}
onSearchChange={async (search) => {
setAreIndicesLoading(true);
setIndexOptions(await getIndexOptions(http!, search, indexPatterns));
setAreIndicesLoading(false);
}}
onBlur={() => {
if (!index) {
onIndexChange([]);
}
}}
/>
</EuiFormRow>
<EuiFormRow
id="thresholdTimeField"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.components.ui.alertParams.timeFieldLabel"
defaultMessage="Time field"
/>
}
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
error={errors.timeField}
>
<EuiSelect
options={timeFieldOptions}
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
fullWidth
name="thresholdTimeField"
data-test-subj="thresholdAlertTimeFieldSelect"
value={timeField || ''}
onChange={(e) => {
onTimeFieldChange(e.target.value);
}}
onBlur={() => {
if (timeField === undefined) {
onTimeFieldChange('');
}
}}
/>
</EuiFormRow>
</div>
</EuiPopover>
);
};

View file

@ -0,0 +1,235 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import 'brace';
import { of } from 'rxjs';
import { mountWithIntl, nextTick } from '@kbn/test/jest';
import { act } from 'react-dom/test-utils';
import EsQueryAlertTypeExpression from './expression';
import { dataPluginMock } from 'src/plugins/data/public/mocks';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import {
DataPublicPluginStart,
IKibanaSearchResponse,
ISearchStart,
} from 'src/plugins/data/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { EsQueryAlertParams } from './types';
jest.mock('../../../../../../src/plugins/kibana_react/public');
jest.mock('../../../../../../src/plugins/es_ui_shared/public');
jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
XJson: {
useXJsonMode: jest.fn().mockReturnValue({
convertToJson: jest.fn(),
setXJson: jest.fn(),
xJson: jest.fn(),
}),
},
}));
jest.mock('');
jest.mock('@elastic/eui', () => {
const original = jest.requireActual('@elastic/eui');
return {
...original,
// Mocking EuiCodeEditor, which uses React Ace under the hood
// eslint-disable-next-line @typescript-eslint/no-explicit-any
EuiCodeEditor: (props: any) => (
<input
data-test-subj="mockCodeEditor"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange={(syntheticEvent: any) => {
props.onChange(syntheticEvent.jsonString);
}}
/>
),
};
});
jest.mock('../../../../triggers_actions_ui/public', () => {
const original = jest.requireActual('../../../../triggers_actions_ui/public');
return {
...original,
getIndexPatterns: () => {
return ['index1', 'index2'];
},
firstFieldOption: () => {
return { text: 'Select a field', value: '' };
},
getTimeFieldOptions: () => {
return [
{
text: '@timestamp',
value: '@timestamp',
},
];
},
getFields: () => {
return Promise.resolve([
{
name: '@timestamp',
type: 'date',
},
{
name: 'field',
type: 'text',
},
]);
},
getIndexOptions: () => {
return Promise.resolve([
{
label: 'indexOption',
options: [
{
label: 'index1',
value: 'index1',
},
{
label: 'index2',
value: 'index2',
},
],
},
]);
},
};
});
const createDataPluginMock = () => {
const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
search: ISearchStart & { search: jest.MockedFunction<any> };
};
return dataMock;
};
const dataMock = createDataPluginMock();
const chartsStartMock = chartPluginMock.createStartContract();
describe('EsQueryAlertTypeExpression', () => {
beforeAll(() => {
(useKibana as jest.Mock).mockReturnValue({
services: {
docLinks: {
ELASTIC_WEBSITE_URL: '',
DOC_LINK_VERSION: '',
},
},
});
});
function getAlertParams(overrides = {}) {
return {
index: ['test-index'],
timeField: '@timestamp',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
...overrides,
};
}
async function setup(alertParams: EsQueryAlertParams) {
const errors = {
index: [],
esQuery: [],
timeField: [],
timeWindowSize: [],
};
const wrapper = mountWithIntl(
<EsQueryAlertTypeExpression
alertInterval="1m"
alertThrottle="1m"
alertParams={alertParams}
setAlertParams={() => {}}
setAlertProperty={() => {}}
errors={errors}
data={dataMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
/>
);
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
return wrapper;
}
test('should render EsQueryAlertTypeExpression with expected components', async () => {
const wrapper = await setup(getAlertParams());
expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy();
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
expect(testQueryButton.exists()).toBeTruthy();
expect(testQueryButton.prop('disabled')).toBe(false);
});
test('should render Test Query button disabled if alert params are invalid', async () => {
const wrapper = await setup(getAlertParams({ timeField: null }));
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
expect(testQueryButton.exists()).toBeTruthy();
expect(testQueryButton.prop('disabled')).toBe(true);
});
test('should show success message if Test Query is successful', async () => {
const searchResponseMock$ = of<IKibanaSearchResponse>({
rawResponse: {
hits: {
total: 1234,
},
},
});
dataMock.search.search.mockImplementation(() => searchResponseMock$);
const wrapper = await setup(getAlertParams());
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
testQueryButton.simulate('click');
expect(dataMock.search.search).toHaveBeenCalled();
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeFalsy();
expect(wrapper.find('EuiText[data-test-subj="testQuerySuccess"]').text()).toEqual(
`Query matched 1234 documents in the last 15s.`
);
});
test('should show error message if Test Query is throws error', async () => {
dataMock.search.search.mockImplementation(() => {
throw new Error('What is this query');
});
const wrapper = await setup(getAlertParams());
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
testQueryButton.simulate('click');
expect(dataMock.search.search).toHaveBeenCalled();
await act(async () => {
await nextTick();
wrapper.update();
});
expect(wrapper.find('[data-test-subj="testQuerySuccess"]').exists()).toBeFalsy();
expect(wrapper.find('[data-test-subj="testQueryError"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,371 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useState, Fragment, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import 'brace/theme/github';
import { XJsonMode } from '@kbn/ace';
import {
EuiButtonEmpty,
EuiCodeEditor,
EuiSpacer,
EuiFormRow,
EuiCallOut,
EuiText,
EuiTitle,
EuiLink,
} from '@elastic/eui';
import { DocLinksStart, HttpSetup } from 'kibana/public';
import { XJson } from '../../../../../../src/plugins/es_ui_shared/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import {
getFields,
COMPARATORS,
ThresholdExpression,
ForLastExpression,
AlertTypeParamsExpressionProps,
} from '../../../../triggers_actions_ui/public';
import { validateExpression } from './validation';
import { parseDuration } from '../../../../alerts/common';
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
import { EsQueryAlertParams } from './types';
import { IndexSelectPopover } from '../components/index_select_popover';
const DEFAULT_VALUES = {
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
QUERY: `{
"query":{
"match_all" : {}
}
}`,
TIME_WINDOW_SIZE: 5,
TIME_WINDOW_UNIT: 'm',
THRESHOLD: [1000],
};
const expressionFieldsWithValidation = [
'index',
'esQuery',
'timeField',
'threshold0',
'threshold1',
'timeWindowSize',
];
const { useXJsonMode } = XJson;
const xJsonMode = new XJsonMode();
interface KibanaDeps {
http: HttpSetup;
docLinks: DocLinksStart;
}
export const EsQueryAlertTypeExpression: React.FunctionComponent<
AlertTypeParamsExpressionProps<EsQueryAlertParams>
> = ({ alertParams, setAlertParams, setAlertProperty, errors, data }) => {
const {
index,
timeField,
esQuery,
thresholdComparator,
threshold,
timeWindowSize,
timeWindowUnit,
} = alertParams;
const getDefaultParams = () => ({
...alertParams,
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
});
const { http, docLinks } = useKibana<KibanaDeps>().services;
const [esFields, setEsFields] = useState<
Array<{
name: string;
type: string;
normalizedType: string;
searchable: boolean;
aggregatable: boolean;
}>
>([]);
const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY);
const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>(
getDefaultParams()
);
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
const [testQueryError, setTestQueryError] = useState<string | null>(null);
const hasExpressionErrors = !!Object.keys(errors).find(
(errorKey) =>
expressionFieldsWithValidation.includes(errorKey) &&
errors[errorKey].length >= 1 &&
alertParams[errorKey as keyof EsQueryAlertParams] !== undefined
);
const expressionErrorMessage = i18n.translate(
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
{
defaultMessage: 'Expression contains errors.',
}
);
const setDefaultExpressionValues = async () => {
setAlertProperty('params', getDefaultParams());
setXJson(esQuery ?? DEFAULT_VALUES.QUERY);
if (index && index.length > 0) {
await refreshEsFields();
}
};
const setParam = (paramField: string, paramValue: unknown) => {
setCurrentAlertParams({
...currentAlertParams,
[paramField]: paramValue,
});
setAlertParams(paramField, paramValue);
};
useEffect(() => {
setDefaultExpressionValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const refreshEsFields = async () => {
if (index) {
const currentEsFields = await getFields(http, index);
setEsFields(currentEsFields);
}
};
const hasValidationErrors = () => {
const { errors: validationErrors } = validateExpression(currentAlertParams);
return Object.keys(validationErrors).some(
(key) => validationErrors[key] && validationErrors[key].length
);
};
const onTestQuery = async () => {
if (!hasValidationErrors()) {
setTestQueryError(null);
setTestQueryResult(null);
try {
const window = `${timeWindowSize}${timeWindowUnit}`;
const timeWindow = parseDuration(window);
const parsedQuery = JSON.parse(esQuery);
const now = Date.now();
const { rawResponse } = await data.search
.search({
params: buildSortedEventsQuery({
index,
from: new Date(now - timeWindow).toISOString(),
to: new Date(now).toISOString(),
filter: parsedQuery.query,
size: 0,
searchAfterSortId: undefined,
timeField: timeField ? timeField : '',
track_total_hits: true,
}),
})
.toPromise();
const hits = rawResponse.hits;
setTestQueryResult(
i18n.translate('xpack.stackAlerts.esQuery.ui.numQueryMatchesText', {
defaultMessage: 'Query matched {count} documents in the last {window}.',
values: { count: hits.total, window },
})
);
} catch (err) {
const message = err?.body?.attributes?.error?.root_cause[0]?.reason || err?.body?.message;
setTestQueryError(
i18n.translate('xpack.stackAlerts.esQuery.ui.queryError', {
defaultMessage: 'Error testing query: {message}',
values: { message: message ? `${err.message}: ${message}` : err.message },
})
);
}
}
};
return (
<Fragment>
{hasExpressionErrors ? (
<Fragment>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
<EuiSpacer />
</Fragment>
) : null}
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.selectIndex"
defaultMessage="Select an index"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<IndexSelectPopover
index={index}
data-test-subj="indexSelectPopover"
esFields={esFields}
timeField={timeField}
errors={errors}
onIndexChange={async (indices: string[]) => {
setParam('index', indices);
// reset expression fields if indices are deleted
if (indices.length === 0) {
setAlertProperty('params', {
...alertParams,
index: indices,
esQuery: DEFAULT_VALUES.QUERY,
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
threshold: DEFAULT_VALUES.THRESHOLD,
timeField: '',
});
} else {
await refreshEsFields();
}
}}
onTimeFieldChange={(updatedTimeField: string) => setParam('timeField', updatedTimeField)}
/>
<EuiSpacer />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.queryPrompt"
defaultMessage="Define the ES query"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFormRow
id="queryEditor"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.queryPrompt.label"
defaultMessage="ES query"
/>
}
isInvalid={errors.esQuery.length > 0}
error={errors.esQuery}
helpText={
<EuiLink
href={`${docLinks.ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${docLinks.DOC_LINK_VERSION}/query-dsl.html`}
target="_blank"
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.queryPrompt.help"
defaultMessage="ES Query DSL documentation"
/>
</EuiLink>
}
>
<EuiCodeEditor
mode={xJsonMode}
width="100%"
height="200px"
theme="github"
data-test-subj="queryJsonEditor"
aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.queryEditor', {
defaultMessage: 'ES query editor',
})}
value={xJson}
onChange={(xjson: string) => {
setXJson(xjson);
setParam('esQuery', convertToJson(xjson));
}}
/>
</EuiFormRow>
<EuiFormRow>
<EuiButtonEmpty
data-test-subj="testQuery"
color={'primary'}
iconSide={'left'}
flush={'left'}
iconType={'play'}
disabled={hasValidationErrors()}
onClick={onTestQuery}
>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.testQuery"
defaultMessage="Test query"
/>
</EuiButtonEmpty>
</EuiFormRow>
{testQueryResult && (
<EuiFormRow>
<EuiText data-test-subj="testQuerySuccess" color="subdued" size="s">
<p>{testQueryResult}</p>
</EuiText>
</EuiFormRow>
)}
{testQueryError && (
<EuiFormRow>
<EuiText data-test-subj="testQueryError" color="danger" size="s">
<p>{testQueryError}</p>
</EuiText>
</EuiFormRow>
)}
<EuiSpacer />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.conditionPrompt"
defaultMessage="When number of matches"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<ThresholdExpression
data-test-subj="thresholdExpression"
thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR}
threshold={threshold ?? DEFAULT_VALUES.THRESHOLD}
errors={errors}
display="fullWidth"
popupPosition={'upLeft'}
onChangeSelectedThreshold={(selectedThresholds) =>
setParam('threshold', selectedThresholds)
}
onChangeSelectedThresholdComparator={(selectedThresholdComparator) =>
setParam('thresholdComparator', selectedThresholdComparator)
}
/>
<ForLastExpression
data-test-subj="forLastExpression"
popupPosition={'upLeft'}
timeWindowSize={timeWindowSize}
timeWindowUnit={timeWindowUnit}
display="fullWidth"
errors={errors}
onChangeWindowSize={(selectedWindowSize: number | undefined) =>
setParam('timeWindowSize', selectedWindowSize)
}
onChangeWindowUnit={(selectedWindowUnit: string) =>
setParam('timeWindowUnit', selectedWindowUnit)
}
/>
<EuiSpacer />
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { EsQueryAlertTypeExpression as default };

View file

@ -0,0 +1,36 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { validateExpression } from './validation';
import { EsQueryAlertParams } from './types';
import { AlertTypeModel } from '../../../../triggers_actions_ui/public';
export function getAlertType(): AlertTypeModel<EsQueryAlertParams> {
return {
id: '.es-query',
description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', {
defaultMessage: 'Alert on matches against an ES query.',
}),
iconClass: 'logoElastic',
documentationUrl(docLinks) {
return `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/alert-types.html#alert-type-es-query`;
},
alertParamsExpression: lazy(() => import('./expression')),
validate: validateExpression,
defaultActionMessage: i18n.translate(
'xpack.stackAlerts.esQuery.ui.alertType.defaultActionMessage',
{
defaultMessage: `ES query alert '\\{\\{alertName\\}\\}' is active:
- Value: \\{\\{context.value\\}\\}
- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}
- Timestamp: \\{\\{context.date\\}\\}`,
}
),
requiresAppContext: false,
};
}

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { AlertTypeParams } from '../../../../alerts/common';
export interface Comparator {
text: string;
value: string;
requiredValues: number;
}
export interface EsQueryAlertParams extends AlertTypeParams {
index: string[];
timeField?: string;
esQuery: string;
thresholdComparator?: string;
threshold: number[];
timeWindowSize: number;
timeWindowUnit: string;
}

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EsQueryAlertParams } from './types';
import { validateExpression } from './validation';
describe('expression params validation', () => {
test('if index property is invalid should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: [],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
};
expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.');
});
test('if timeField property is not defined should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
};
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
});
test('if esQuery property is invalid JSON should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
};
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.');
});
test('if esQuery property is invalid should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
};
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`);
});
test('if threshold0 property is not set should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
threshold: [],
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: '<',
};
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.');
});
test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
threshold: [1],
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: 'between',
};
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.');
});
test('if threshold0 property greater than threshold1 property should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
threshold: [10, 1],
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: 'between',
};
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold1[0]).toBe(
'Threshold 1 must be > Threshold 0.'
);
});
});

View file

@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { EsQueryAlertParams } from './types';
import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public';
export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => {
const { index, timeField, esQuery, threshold, timeWindowSize, thresholdComparator } = alertParams;
const validationResult = { errors: {} };
const errors = {
index: new Array<string>(),
timeField: new Array<string>(),
esQuery: new Array<string>(),
threshold0: new Array<string>(),
threshold1: new Array<string>(),
thresholdComparator: new Array<string>(),
timeWindowSize: new Array<string>(),
};
validationResult.errors = errors;
if (!index || index.length === 0) {
errors.index.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
defaultMessage: 'Index is required.',
})
);
}
if (!timeField) {
errors.timeField.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
defaultMessage: 'Time field is required.',
})
);
}
if (!esQuery) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
defaultMessage: 'ES query is required.',
})
);
} else {
try {
const parsedQuery = JSON.parse(esQuery);
if (!parsedQuery.query) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
defaultMessage: `Query field is required.`,
})
);
}
} catch (err) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
defaultMessage: 'Query must be valid JSON.',
})
);
}
}
if (!threshold || threshold.length === 0 || threshold[0] === undefined) {
errors.threshold0.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', {
defaultMessage: 'Threshold 0 is required.',
})
);
}
if (
thresholdComparator &&
builtInComparators[thresholdComparator].requiredValues > 1 &&
(!threshold ||
threshold[1] === undefined ||
(threshold && threshold.length < builtInComparators[thresholdComparator!].requiredValues))
) {
errors.threshold1.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold1Text', {
defaultMessage: 'Threshold 1 is required.',
})
);
}
if (threshold && threshold.length === 2 && threshold[0] > threshold[1]) {
errors.threshold1.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.greaterThenThreshold0Text', {
defaultMessage: 'Threshold 1 must be > Threshold 0.',
})
);
}
if (!timeWindowSize) {
errors.timeWindowSize.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeWindowSizeText', {
defaultMessage: 'Time window size is required.',
})
);
}
return validationResult;
};

View file

@ -7,6 +7,7 @@
import { getAlertType as getGeoThresholdAlertType } from './geo_threshold';
import { getAlertType as getGeoContainmentAlertType } from './geo_containment';
import { getAlertType as getThresholdAlertType } from './threshold';
import { getAlertType as getEsQueryAlertType } from './es_query';
import { Config } from '../../common';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
@ -22,4 +23,5 @@ export function registerAlertTypes({
alertTypeRegistry.register(getGeoContainmentAlertType());
}
alertTypeRegistry.register(getThresholdAlertType());
alertTypeRegistry.register(getEsQueryAlertType());
}

View file

@ -7,33 +7,13 @@
import React, { useState, Fragment, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiFlexItem,
EuiFlexGroup,
EuiExpression,
EuiPopover,
EuiPopoverTitle,
EuiSelect,
EuiSpacer,
EuiComboBox,
EuiComboBoxOptionOption,
EuiFormRow,
EuiCallOut,
EuiEmptyPrompt,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { EuiButtonIcon } from '@elastic/eui';
import { EuiSpacer, EuiCallOut, EuiEmptyPrompt, EuiText, EuiTitle } from '@elastic/eui';
import { HttpSetup } from 'kibana/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import {
firstFieldOption,
getIndexPatterns,
getIndexOptions,
getFields,
COMPARATORS,
builtInComparators,
getTimeFieldOptions,
OfExpression,
ThresholdExpression,
ForLastExpression,
@ -45,6 +25,7 @@ import {
import { ThresholdVisualization } from './visualization';
import { IndexThresholdAlertParams } from './types';
import './expression.scss';
import { IndexSelectPopover } from '../components/index_select_popover';
const DEFAULT_VALUES = {
AGGREGATION_TYPE: 'count',
@ -101,12 +82,15 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
const indexArray = indexParamToArray(index);
const { http } = useKibana<KibanaDeps>().services;
const [indexPopoverOpen, setIndexPopoverOpen] = useState(false);
const [indexPatterns, setIndexPatterns] = useState([]);
const [esFields, setEsFields] = useState<unknown[]>([]);
const [indexOptions, setIndexOptions] = useState<EuiComboBoxOptionOption[]>([]);
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
const [isIndiciesLoading, setIsIndiciesLoading] = useState<boolean>(false);
const [esFields, setEsFields] = useState<
Array<{
name: string;
type: string;
normalizedType: string;
searchable: boolean;
aggregatable: boolean;
}>
>([]);
const hasExpressionErrors = !!Object.keys(errors).find(
(errorKey) =>
@ -138,154 +122,23 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
});
if (indexArray.length > 0) {
await refreshEsFields();
}
};
const refreshEsFields = async () => {
if (indexArray.length > 0) {
const currentEsFields = await getFields(http, indexArray);
const timeFields = getTimeFieldOptions(currentEsFields);
setEsFields(currentEsFields);
setTimeFieldOptions([firstFieldOption, ...timeFields]);
}
};
const closeIndexPopover = () => {
setIndexPopoverOpen(false);
if (timeField === undefined) {
setAlertParams('timeField', '');
}
};
useEffect(() => {
const indexPatternsFunction = async () => {
setIndexPatterns(await getIndexPatterns());
};
indexPatternsFunction();
}, []);
useEffect(() => {
setDefaultExpressionValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const indexPopover = (
<Fragment>
<EuiFormRow
id="indexSelectSearchBox"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel"
defaultMessage="Indices to query"
/>
}
isInvalid={errors.index.length > 0 && indexArray.length > 0}
error={errors.index}
helpText={
<FormattedMessage
id="xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription"
defaultMessage="Use * to broaden your query."
/>
}
>
<EuiComboBox
fullWidth
async
isLoading={isIndiciesLoading}
isInvalid={errors.index.length > 0 && indexArray.length > 0}
noSuggestions={!indexOptions.length}
options={indexOptions}
data-test-subj="thresholdIndexesComboBox"
selectedOptions={indexArray.map((anIndex: string) => {
return {
label: anIndex,
value: anIndex,
};
})}
onChange={async (selected: EuiComboBoxOptionOption[]) => {
const indicies: string[] = selected
.map((aSelected) => aSelected.value)
.filter<string>(isString);
setAlertParams('index', indicies);
const indices = selected.map((s) => s.value as string);
// reset time field and expression fields if indices are deleted
if (indices.length === 0) {
setTimeFieldOptions([firstFieldOption]);
setAlertProperty('params', {
...alertParams,
index: indices,
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
termSize: DEFAULT_VALUES.TERM_SIZE,
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
groupBy: DEFAULT_VALUES.GROUP_BY,
threshold: DEFAULT_VALUES.THRESHOLD,
timeField: '',
});
return;
}
const currentEsFields = await getFields(http!, indices);
const timeFields = getTimeFieldOptions(currentEsFields);
setEsFields(currentEsFields);
setTimeFieldOptions([firstFieldOption, ...timeFields]);
}}
onSearchChange={async (search) => {
setIsIndiciesLoading(true);
setIndexOptions(await getIndexOptions(http!, search, indexPatterns));
setIsIndiciesLoading(false);
}}
onBlur={() => {
if (!index) {
setAlertParams('index', []);
}
}}
/>
</EuiFormRow>
<EuiFormRow
id="thresholdTimeField"
fullWidth
label={
<FormattedMessage
id="xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel"
defaultMessage="Time field"
/>
}
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
error={errors.timeField}
>
<EuiSelect
options={timeFieldOptions}
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
fullWidth
name="thresholdTimeField"
data-test-subj="thresholdAlertTimeFieldSelect"
value={timeField || ''}
onChange={(e) => {
setAlertParams('timeField', e.target.value);
}}
onBlur={() => {
if (timeField === undefined) {
setAlertParams('timeField', '');
}
}}
/>
</EuiFormRow>
</Fragment>
);
const renderIndices = (indices: string[]) => {
const rows = indices.map((s: string, i: number) => {
return (
<p key={i}>
{s}
{i < indices.length - 1 ? ',' : null}
</p>
);
});
return <div>{rows}</div>;
};
return (
<Fragment>
{hasExpressionErrors ? (
@ -304,58 +157,36 @@ export const IndexThresholdAlertTypeExpression: React.FunctionComponent<
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiPopover
id="indexPopover"
button={
<EuiExpression
display="columns"
data-test-subj="selectIndexExpression"
description={i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexLabel', {
defaultMessage: 'index',
})}
value={indexArray.length > 0 ? renderIndices(indexArray) : firstFieldOption.text}
isActive={indexPopoverOpen}
onClick={() => {
setIndexPopoverOpen(true);
}}
isInvalid={!(indexArray.length > 0 && timeField !== '')}
/>
}
isOpen={indexPopoverOpen}
closePopover={closeIndexPopover}
ownFocus
anchorPosition="downLeft"
zIndex={8000}
display="block"
>
<div style={{ width: '450px' }}>
<EuiPopoverTitle>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
{i18n.translate('xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel', {
defaultMessage: 'index',
})}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonIcon
data-test-subj="closePopover"
iconType="cross"
color="danger"
aria-label={i18n.translate(
'xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel',
{
defaultMessage: 'Close',
}
)}
onClick={closeIndexPopover}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPopoverTitle>
<IndexSelectPopover
index={indexArray}
esFields={esFields}
timeField={timeField}
errors={errors}
onIndexChange={async (indices: string[]) => {
setAlertParams('index', indices);
{indexPopover}
</div>
</EuiPopover>
// reset expression fields if indices are deleted
if (indices.length === 0) {
setAlertProperty('params', {
...alertParams,
index: indices,
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
termSize: DEFAULT_VALUES.TERM_SIZE,
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT,
groupBy: DEFAULT_VALUES.GROUP_BY,
threshold: DEFAULT_VALUES.THRESHOLD,
timeField: '',
});
} else {
await refreshEsFields();
}
}}
onTimeFieldChange={(updatedTimeField: string) =>
setAlertParams('timeField', updatedTimeField)
}
/>
<WhenExpression
display="fullWidth"
aggType={aggType ?? DEFAULT_VALUES.AGGREGATION_TYPE}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EsQueryAlertActionContext, addMessages } from './action_context';
import { EsQueryAlertParamsSchema } from './alert_type_params';
describe('ActionContext', () => {
it('generates expected properties', async () => {
const params = EsQueryAlertParamsSchema.validate({
index: ['[index]'],
timeField: '[timeField]',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '>',
threshold: [4],
});
const base: EsQueryAlertActionContext = {
date: '2020-01-01T00:00:00.000Z',
value: 42,
conditions: 'count greater than 4',
hits: [],
};
const context = addMessages({ name: '[alert-name]' }, base, params);
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
expect(context.message).toEqual(
`alert '[alert-name]' is active:
- Value: 42
- Conditions Met: count greater than 4 over 5m
- Timestamp: 2020-01-01T00:00:00.000Z`
);
});
it('generates expected properties if comparator is between', async () => {
const params = EsQueryAlertParamsSchema.validate({
index: ['[index]'],
timeField: '[timeField]',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: 'between',
threshold: [4, 5],
});
const base: EsQueryAlertActionContext = {
date: '2020-01-01T00:00:00.000Z',
value: 4,
conditions: 'count between 4 and 5',
hits: [],
};
const context = addMessages({ name: '[alert-name]' }, base, params);
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
expect(context.message).toEqual(
`alert '[alert-name]' is active:
- Value: 4
- Conditions Met: count between 4 and 5 over 5m
- Timestamp: 2020-01-01T00:00:00.000Z`
);
});
});

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerts/server';
import { EsQueryAlertParams } from './alert_type_params';
import { ESSearchHit } from '../../../../../typings/elasticsearch';
// alert type context provided to actions
type AlertInfo = Pick<AlertExecutorOptions, 'name'>;
export interface ActionContext extends EsQueryAlertActionContext {
// a short pre-constructed message which may be used in an action field
title: string;
// a longer pre-constructed message which may be used in an action field
message: string;
}
export interface EsQueryAlertActionContext extends AlertInstanceContext {
// the date the alert was run as an ISO date
date: string;
// the value that met the threshold
value: number;
// threshold conditions
conditions: string;
// query matches
hits: ESSearchHit[];
}
export function addMessages(
alertInfo: AlertInfo,
baseContext: EsQueryAlertActionContext,
params: EsQueryAlertParams
): ActionContext {
const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', {
defaultMessage: `alert '{name}' matched query`,
values: {
name: alertInfo.name,
},
});
const window = `${params.timeWindowSize}${params.timeWindowUnit}`;
const message = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextMessageDescription', {
defaultMessage: `alert '{name}' is active:
- Value: {value}
- Conditions Met: {conditions} over {window}
- Timestamp: {date}`,
values: {
name: alertInfo.name,
value: baseContext.value,
conditions: baseContext.conditions,
window,
date: baseContext.date,
},
});
return { ...baseContext, title, message };
}

View file

@ -0,0 +1,103 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import type { Writable } from '@kbn/utility-types';
import { loggingSystemMock } from '../../../../../../src/core/server/mocks';
import { getAlertType } from './alert_type';
import { EsQueryAlertParams } from './alert_type_params';
describe('alertType', () => {
const logger = loggingSystemMock.create().get();
const alertType = getAlertType(logger);
it('alert type creation structure is the expected value', async () => {
expect(alertType.id).toBe('.es-query');
expect(alertType.name).toBe('ES query');
expect(alertType.actionGroups).toEqual([{ id: 'query matched', name: 'Query matched' }]);
expect(alertType.actionVariables).toMatchInlineSnapshot(`
Object {
"context": Array [
Object {
"description": "A message for the alert.",
"name": "message",
},
Object {
"description": "A title for the alert.",
"name": "title",
},
Object {
"description": "The date that the alert met the threshold condition.",
"name": "date",
},
Object {
"description": "The value that met the threshold condition.",
"name": "value",
},
Object {
"description": "The documents that met the threshold condition.",
"name": "hits",
},
Object {
"description": "A string that describes the threshold condition.",
"name": "conditions",
},
],
"params": Array [
Object {
"description": "The index the query was run against.",
"name": "index",
},
Object {
"description": "The string representation of the ES query.",
"name": "esQuery",
},
Object {
"description": "An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
"name": "threshold",
},
Object {
"description": "A function to determine if the threshold has been met.",
"name": "thresholdComparator",
},
],
}
`);
});
it('validator succeeds with valid params', async () => {
const params: Partial<Writable<EsQueryAlertParams>> = {
index: ['index-name'],
timeField: 'time-field',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '<',
threshold: [0],
};
expect(alertType.validate?.params?.validate(params)).toBeTruthy();
});
it('validator fails with invalid params - threshold', async () => {
const paramsSchema = alertType.validate?.params;
if (!paramsSchema) throw new Error('params validator not set');
const params: Partial<Writable<EsQueryAlertParams>> = {
index: ['index-name'],
timeField: 'time-field',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: 'between',
threshold: [0],
};
expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: must have two elements for the \\"between\\" comparator"`
);
});
});

View file

@ -0,0 +1,307 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { Logger } from 'src/core/server';
import { ESSearchResponse } from '../../../../../typings/elasticsearch';
import { AlertType, AlertExecutorOptions } from '../../types';
import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context';
import {
EsQueryAlertParams,
EsQueryAlertParamsSchema,
EsQueryAlertState,
} from './alert_type_params';
import { STACK_ALERTS_FEATURE_ID } from '../../../common';
import { ComparatorFns, getHumanReadableComparator } from '../lib';
import { parseDuration } from '../../../../alerts/server';
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
import { ESSearchHit } from '../../../../../typings/elasticsearch';
export const ES_QUERY_ID = '.es-query';
const DEFAULT_MAX_HITS_PER_EXECUTION = 1000;
const ActionGroupId = 'query matched';
const ConditionMetAlertInstanceId = 'query matched';
export function getAlertType(
logger: Logger
): AlertType<EsQueryAlertParams, EsQueryAlertState, {}, ActionContext, typeof ActionGroupId> {
const alertTypeName = i18n.translate('xpack.stackAlerts.esQuery.alertTypeTitle', {
defaultMessage: 'ES query',
});
const actionGroupName = i18n.translate('xpack.stackAlerts.esQuery.actionGroupThresholdMetTitle', {
defaultMessage: 'Query matched',
});
const actionVariableContextDateLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextDateLabel',
{
defaultMessage: 'The date that the alert met the threshold condition.',
}
);
const actionVariableContextValueLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextValueLabel',
{
defaultMessage: 'The value that met the threshold condition.',
}
);
const actionVariableContextHitsLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextHitsLabel',
{
defaultMessage: 'The documents that met the threshold condition.',
}
);
const actionVariableContextMessageLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextMessageLabel',
{
defaultMessage: 'A message for the alert.',
}
);
const actionVariableContextTitleLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextTitleLabel',
{
defaultMessage: 'A title for the alert.',
}
);
const actionVariableContextIndexLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextIndexLabel',
{
defaultMessage: 'The index the query was run against.',
}
);
const actionVariableContextQueryLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextQueryLabel',
{
defaultMessage: 'The string representation of the ES query.',
}
);
const actionVariableContextThresholdLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel',
{
defaultMessage:
"An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
}
);
const actionVariableContextThresholdComparatorLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel',
{
defaultMessage: 'A function to determine if the threshold has been met.',
}
);
const actionVariableContextConditionsLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextConditionsLabel',
{
defaultMessage: 'A string that describes the threshold condition.',
}
);
return {
id: ES_QUERY_ID,
name: alertTypeName,
actionGroups: [{ id: ActionGroupId, name: actionGroupName }],
defaultActionGroupId: ActionGroupId,
validate: {
params: EsQueryAlertParamsSchema,
},
actionVariables: {
context: [
{ name: 'message', description: actionVariableContextMessageLabel },
{ name: 'title', description: actionVariableContextTitleLabel },
{ name: 'date', description: actionVariableContextDateLabel },
{ name: 'value', description: actionVariableContextValueLabel },
{ name: 'hits', description: actionVariableContextHitsLabel },
{ name: 'conditions', description: actionVariableContextConditionsLabel },
],
params: [
{ name: 'index', description: actionVariableContextIndexLabel },
{ name: 'esQuery', description: actionVariableContextQueryLabel },
{ name: 'threshold', description: actionVariableContextThresholdLabel },
{ name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel },
],
},
minimumLicenseRequired: 'basic',
executor,
producer: STACK_ALERTS_FEATURE_ID,
};
async function executor(
options: AlertExecutorOptions<
EsQueryAlertParams,
EsQueryAlertState,
{},
ActionContext,
typeof ActionGroupId
>
) {
const { alertId, name, services, params, state } = options;
const previousTimestamp = state.latestTimestamp;
const callCluster = services.callCluster;
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
const compareFn = ComparatorFns.get(params.thresholdComparator);
if (compareFn == null) {
throw new Error(getInvalidComparatorError(params.thresholdComparator));
}
// During each alert execution, we run the configured query, get a hit count
// (hits.total) and retrieve up to DEFAULT_MAX_HITS_PER_EXECUTION hits. We
// evaluate the threshold condition using the value of hits.total. If the threshold
// condition is met, the hits are counted toward the query match and we update
// the alert state with the timestamp of the latest hit. In the next execution
// of the alert, the latestTimestamp will be used to gate the query in order to
// avoid counting a document multiple times.
let timestamp: string | undefined = previousTimestamp;
const filter = timestamp
? {
bool: {
filter: [
parsedQuery.query,
{
bool: {
must_not: [
{ bool: { filter: [{ range: { [params.timeField]: { lte: timestamp } } }] } },
],
},
},
],
},
}
: parsedQuery.query;
const query = buildSortedEventsQuery({
index: params.index,
from: dateStart,
to: dateEnd,
filter,
size: DEFAULT_MAX_HITS_PER_EXECUTION,
sortOrder: 'desc',
searchAfterSortId: undefined,
timeField: params.timeField,
track_total_hits: true,
});
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`);
const searchResult: ESSearchResponse<unknown, {}> = await callCluster('search', query);
if (searchResult.hits.hits.length > 0) {
const numMatches = searchResult.hits.total.value;
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query has ${numMatches} matches`);
// apply the alert condition
const conditionMet = compareFn(numMatches, params.threshold);
if (conditionMet) {
const humanFn = i18n.translate(
'xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription',
{
defaultMessage: `Number of matching documents is {thresholdComparator} {threshold}`,
values: {
thresholdComparator: getHumanReadableComparator(params.thresholdComparator),
threshold: params.threshold.join(' and '),
},
}
);
const baseContext: EsQueryAlertActionContext = {
date: new Date().toISOString(),
value: numMatches,
conditions: humanFn,
hits: searchResult.hits.hits,
};
const actionContext = addMessages(options, baseContext, params);
const alertInstance = options.services.alertInstanceFactory(ConditionMetAlertInstanceId);
alertInstance
// store the params we would need to recreate the query that led to this alert instance
.replaceState({ latestTimestamp: timestamp, dateStart, dateEnd })
.scheduleActions(ActionGroupId, actionContext);
// update the timestamp based on the current search results
const firstHitWithSort = searchResult.hits.hits.find(
(hit: ESSearchHit) => hit.sort != null
);
const lastTimestamp = firstHitWithSort?.sort;
if (lastTimestamp != null && lastTimestamp.length > 0) {
timestamp = lastTimestamp[0];
}
}
}
return {
latestTimestamp: timestamp,
};
}
}
function getInvalidComparatorError(comparator: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}
function getSearchParams(queryParams: EsQueryAlertParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}

View file

@ -0,0 +1,190 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { TypeOf } from '@kbn/config-schema';
import type { Writable } from '@kbn/utility-types';
import { EsQueryAlertParamsSchema, EsQueryAlertParams } from './alert_type_params';
const DefaultParams: Writable<Partial<EsQueryAlertParams>> = {
index: ['index-name'],
timeField: 'time-field',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '>',
threshold: [0],
};
describe('alertType Params validate()', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let params: any;
beforeEach(() => {
params = { ...DefaultParams };
});
it('passes for valid input', async () => {
expect(validate()).toBeTruthy();
});
it('fails for invalid index', async () => {
delete params.index;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[index]: expected value of type [array] but got [undefined]"`
);
params.index = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[index]: expected value of type [array] but got [number]"`
);
params.index = 'index-name';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[index]: could not parse array value from json input"`
);
params.index = [];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[index]: array size is [0], but cannot be smaller than [1]"`
);
params.index = ['', 'a'];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[index.0]: value has length [0] but it must have a minimum length of [1]."`
);
});
it('fails for invalid timeField', async () => {
delete params.timeField;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeField]: expected value of type [string] but got [undefined]"`
);
params.timeField = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeField]: expected value of type [string] but got [number]"`
);
params.timeField = '';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeField]: value has length [0] but it must have a minimum length of [1]."`
);
});
it('fails for invalid esQuery', async () => {
delete params.esQuery;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[esQuery]: expected value of type [string] but got [undefined]"`
);
params.esQuery = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[esQuery]: expected value of type [string] but got [number]"`
);
params.esQuery = '';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[esQuery]: value has length [0] but it must have a minimum length of [1]."`
);
params.esQuery = '{\n "query":{\n "match_all" : {}\n }\n';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(`"[esQuery]: must be valid JSON"`);
params.esQuery = '{\n "aggs":{\n "match_all" : {}\n }\n}';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[esQuery]: must contain \\"query\\""`
);
});
it('fails for invalid timeWindowSize', async () => {
delete params.timeWindowSize;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowSize]: expected value of type [number] but got [undefined]"`
);
params.timeWindowSize = 'foo';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowSize]: expected value of type [number] but got [string]"`
);
params.timeWindowSize = 0;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowSize]: Value must be equal to or greater than [1]."`
);
});
it('fails for invalid timeWindowUnit', async () => {
delete params.timeWindowUnit;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowUnit]: expected value of type [string] but got [undefined]"`
);
params.timeWindowUnit = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowUnit]: expected value of type [string] but got [number]"`
);
params.timeWindowUnit = 'x';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[timeWindowUnit]: invalid timeWindowUnit: \\"x\\""`
);
});
it('fails for invalid threshold', async () => {
params.threshold = 42;
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: expected value of type [array] but got [number]"`
);
params.threshold = 'x';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: could not parse array value from json input"`
);
params.threshold = [];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: array size is [0], but cannot be smaller than [1]"`
);
params.threshold = [1, 2, 3];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: array size is [3], but cannot be greater than [2]"`
);
params.threshold = ['foo'];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold.0]: expected value of type [number] but got [string]"`
);
});
it('fails for invalid thresholdComparator', async () => {
params.thresholdComparator = '[invalid-comparator]';
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[thresholdComparator]: invalid thresholdComparator specified: [invalid-comparator]"`
);
});
it('fails for invalid threshold length', async () => {
params.thresholdComparator = '<';
params.threshold = [0, 1, 2];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: array size is [3], but cannot be greater than [2]"`
);
params.thresholdComparator = 'between';
params.threshold = [0];
expect(onValidate()).toThrowErrorMatchingInlineSnapshot(
`"[threshold]: must have two elements for the \\"between\\" comparator"`
);
});
function onValidate(): () => void {
return () => validate();
}
function validate(): TypeOf<typeof EsQueryAlertParamsSchema> {
return EsQueryAlertParamsSchema.validate(params);
}
});

View file

@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { ComparatorFnNames } from '../lib';
import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server';
import { AlertTypeState } from '../../../../alerts/server';
// alert type parameters
export type EsQueryAlertParams = TypeOf<typeof EsQueryAlertParamsSchema>;
export interface EsQueryAlertState extends AlertTypeState {
latestTimestamp: string | undefined;
}
export const EsQueryAlertParamsSchemaProperties = {
index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
timeField: schema.string({ minLength: 1 }),
esQuery: schema.string({ minLength: 1 }),
timeWindowSize: schema.number({ min: 1 }),
timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }),
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
thresholdComparator: schema.string({ validate: validateComparator }),
};
export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, {
validate: validateParams,
});
const betweenComparators = new Set(['between', 'notBetween']);
// using direct type not allowed, circular reference, so body is typed to any
function validateParams(anyParams: unknown): string | undefined {
const {
esQuery,
thresholdComparator,
threshold,
}: EsQueryAlertParams = anyParams as EsQueryAlertParams;
if (betweenComparators.has(thresholdComparator) && threshold.length === 1) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', {
defaultMessage:
'[threshold]: must have two elements for the "{thresholdComparator}" comparator',
values: {
thresholdComparator,
},
});
}
try {
const parsedQuery = JSON.parse(esQuery);
if (parsedQuery && !parsedQuery.query) {
return i18n.translate('xpack.stackAlerts.esQuery.missingEsQueryErrorMessage', {
defaultMessage: '[esQuery]: must contain "query"',
});
}
} catch (err) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidEsQueryErrorMessage', {
defaultMessage: '[esQuery]: must be valid JSON',
});
}
}
export function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator)) return;
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Logger } from 'src/core/server';
import { AlertingSetup } from '../../types';
import { getAlertType } from './alert_type';
interface RegisterParams {
logger: Logger;
alerts: AlertingSetup;
}
export function register(params: RegisterParams) {
const { logger, alerts } = params;
alerts.registerType(getAlertType(logger));
}

View file

@ -9,7 +9,7 @@ import { AlertingSetup, StackAlertsStartDeps } from '../types';
import { register as registerIndexThreshold } from './index_threshold';
import { register as registerGeoThreshold } from './geo_threshold';
import { register as registerGeoContainment } from './geo_containment';
import { register as registerEsQuery } from './es_query';
interface RegisterAlertTypesParams {
logger: Logger;
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
@ -20,4 +20,5 @@ export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) {
registerIndexThreshold(params);
registerGeoThreshold(params);
registerGeoContainment(params);
registerEsQuery(params);
}

View file

@ -6,7 +6,7 @@ The index threshold alert type is designed to run an ES query over indices,
aggregating field values from documents, comparing them to threshold values,
and scheduling actions to run when the thresholds are met.
And example would be checking a monitoring index for percent cpu usage field
An example would be checking a monitoring index for percent cpu usage field
values that are greater than some threshold, which could then be used to invoke
an action (email, slack, etc) to notify interested parties when the threshold
is exceeded.

View file

@ -14,30 +14,10 @@ import {
CoreQueryParamsSchemaProperties,
TimeSeriesQuery,
} from '../../../../triggers_actions_ui/server';
import { ComparatorFns, getHumanReadableComparator } from '../lib';
export const ID = '.index-threshold';
enum Comparator {
GT = '>',
LT = '<',
GT_OR_EQ = '>=',
LT_OR_EQ = '<=',
BETWEEN = 'between',
NOT_BETWEEN = 'notBetween',
}
const humanReadableComparators = new Map<string, string>([
[Comparator.LT, 'less than'],
[Comparator.LT_OR_EQ, 'less than or equal to'],
[Comparator.GT_OR_EQ, 'greater than or equal to'],
[Comparator.GT, 'greater than'],
[Comparator.BETWEEN, 'between'],
[Comparator.NOT_BETWEEN, 'not between'],
]);
const ActionGroupId = 'threshold met';
const ComparatorFns = getComparatorFns();
export const ComparatorFnNames = new Set(ComparatorFns.keys());
export function getAlertType(
logger: Logger,
@ -155,7 +135,14 @@ export function getAlertType(
const compareFn = ComparatorFns.get(params.thresholdComparator);
if (compareFn == null) {
throw new Error(getInvalidComparatorMessage(params.thresholdComparator));
throw new Error(
i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator: params.thresholdComparator,
},
})
);
}
const callCluster = services.callCluster;
@ -210,40 +197,3 @@ export function getAlertType(
}
}
}
export function getInvalidComparatorMessage(comparator: string) {
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}
type ComparatorFn = (value: number, threshold: number[]) => boolean;
function getComparatorFns(): Map<string, ComparatorFn> {
const fns: Record<string, ComparatorFn> = {
[Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0],
[Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0],
[Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0],
[Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0],
[Comparator.BETWEEN]: (value: number, threshold: number[]) =>
value >= threshold[0] && value <= threshold[1],
[Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) =>
value < threshold[0] || value > threshold[1],
};
const result = new Map<string, ComparatorFn>();
for (const key of Object.keys(fns)) {
result.set(key, fns[key]);
}
return result;
}
function getHumanReadableComparator(comparator: string) {
return humanReadableComparators.has(comparator)
? humanReadableComparators.get(comparator)
: comparator;
}

View file

@ -6,7 +6,7 @@
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { ComparatorFnNames, getInvalidComparatorMessage } from './alert_type';
import { ComparatorFnNames } from '../lib';
import {
CoreQueryParamsSchemaProperties,
validateCoreQueryBody,
@ -54,5 +54,10 @@ function validateParams(anyParams: unknown): string | undefined {
export function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator)) return;
return getInvalidComparatorMessage(comparator);
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}

View file

@ -0,0 +1,54 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
enum Comparator {
GT = '>',
LT = '<',
GT_OR_EQ = '>=',
LT_OR_EQ = '<=',
BETWEEN = 'between',
NOT_BETWEEN = 'notBetween',
}
const humanReadableComparators = new Map<string, string>([
[Comparator.LT, 'less than'],
[Comparator.LT_OR_EQ, 'less than or equal to'],
[Comparator.GT_OR_EQ, 'greater than or equal to'],
[Comparator.GT, 'greater than'],
[Comparator.BETWEEN, 'between'],
[Comparator.NOT_BETWEEN, 'not between'],
]);
export const ComparatorFns = getComparatorFns();
export const ComparatorFnNames = new Set(ComparatorFns.keys());
type ComparatorFn = (value: number, threshold: number[]) => boolean;
function getComparatorFns(): Map<string, ComparatorFn> {
const fns: Record<string, ComparatorFn> = {
[Comparator.LT]: (value: number, threshold: number[]) => value < threshold[0],
[Comparator.LT_OR_EQ]: (value: number, threshold: number[]) => value <= threshold[0],
[Comparator.GT_OR_EQ]: (value: number, threshold: number[]) => value >= threshold[0],
[Comparator.GT]: (value: number, threshold: number[]) => value > threshold[0],
[Comparator.BETWEEN]: (value: number, threshold: number[]) =>
value >= threshold[0] && value <= threshold[1],
[Comparator.NOT_BETWEEN]: (value: number, threshold: number[]) =>
value < threshold[0] || value > threshold[1],
};
const result = new Map<string, ComparatorFn>();
for (const key of Object.keys(fns)) {
result.set(key, fns[key]);
}
return result;
}
export function getHumanReadableComparator(comparator: string) {
return humanReadableComparators.has(comparator)
? humanReadableComparators.get(comparator)
: comparator;
}

View file

@ -0,0 +1,7 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types';

View file

@ -27,7 +27,7 @@ describe('AlertingBuiltins Plugin', () => {
const featuresSetup = featuresPluginMock.createSetup();
await plugin.setup(coreSetup, { alerts: alertingSetup, features: featuresSetup });
expect(alertingSetup.registerType).toHaveBeenCalledTimes(3);
expect(alertingSetup.registerType).toHaveBeenCalledTimes(4);
const indexThresholdArgs = alertingSetup.registerType.mock.calls[0][0];
const testedIndexThresholdArgs = {
@ -67,6 +67,25 @@ describe('AlertingBuiltins Plugin', () => {
}
`);
const esQueryArgs = alertingSetup.registerType.mock.calls[3][0];
const testedEsQueryArgs = {
id: esQueryArgs.id,
name: esQueryArgs.name,
actionGroups: esQueryArgs.actionGroups,
};
expect(testedEsQueryArgs).toMatchInlineSnapshot(`
Object {
"actionGroups": Array [
Object {
"id": "query matched",
"name": "Query matched",
},
],
"id": ".es-query",
"name": "ES query",
}
`);
expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith(BUILT_IN_ALERTS_FEATURE);
});
});

View file

@ -20872,13 +20872,7 @@
"xpack.stackAlerts.indexThreshold.alertTypeTitle": "インデックスしきい値",
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "無効な thresholdComparator が指定されました:{comparator}",
"xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]: 「{thresholdComparator}」比較子の場合には2つの要素が必要です",
"xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "閉じる",
"xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "下の表現のエラーを修正してください。",
"xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "* で検索クエリの範囲を広げます。",
"xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "インデックス",
"xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "インデックス",
"xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "クエリを実行するインデックス",
"xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "時間フィールド",
"xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "アラート '\\{\\{alertName\\}\\}' はグループ '\\{\\{context.group\\}\\}' でアクティブです:\n\n- 値:\\{\\{context.value\\}\\}\n- 満たされた条件:\\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- タイムスタンプ:\\{\\{context.date\\}\\}",
"xpack.stackAlerts.threshold.ui.alertType.descriptionText": "アグリゲーションされたクエリがしきい値に達したときにアラートを発行します。",
"xpack.stackAlerts.threshold.ui.conditionPrompt": "条件を定義してください",

View file

@ -20920,13 +20920,7 @@
"xpack.stackAlerts.indexThreshold.alertTypeTitle": "索引阈值",
"xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}",
"xpack.stackAlerts.indexThreshold.invalidThreshold2ErrorMessage": "[threshold]:对于“{thresholdComparator}”比较运算符,必须包含两个元素",
"xpack.stackAlerts.threshold.ui.alertParams.closeIndexPopoverLabel": "关闭",
"xpack.stackAlerts.threshold.ui.alertParams.fixErrorInExpressionBelowValidationMessage": "表达式包含错误。",
"xpack.stackAlerts.threshold.ui.alertParams.howToBroadenSearchQueryDescription": "使用 * 可扩大您的查询范围。",
"xpack.stackAlerts.threshold.ui.alertParams.indexButtonLabel": "索引",
"xpack.stackAlerts.threshold.ui.alertParams.indexLabel": "索引",
"xpack.stackAlerts.threshold.ui.alertParams.indicesToQueryLabel": "要查询的索引",
"xpack.stackAlerts.threshold.ui.alertParams.timeFieldLabel": "时间字段",
"xpack.stackAlerts.threshold.ui.alertType.defaultActionMessage": "组“\\{\\{context.group\\}\\}”的告警“\\{\\{alertName\\}\\}”处于活动状态:\n\n- 值:\\{\\{context.value\\}\\}\n- 满足的条件:\\{\\{context.conditions\\}\\} 超过 \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}\n- 时间戳:\\{\\{context.date\\}\\}",
"xpack.stackAlerts.threshold.ui.alertType.descriptionText": "聚合查询达到阈值时告警。",
"xpack.stackAlerts.threshold.ui.conditionPrompt": "定义条件",

View file

@ -13,6 +13,7 @@ export {
CoreQueryParams,
CoreQueryParamsSchemaProperties,
validateCoreQueryBody,
validateTimeWindowUnits,
} from './lib';
// future enhancement: make these configurable?

View file

@ -9,4 +9,5 @@ export {
CoreQueryParams,
CoreQueryParamsSchemaProperties,
validateCoreQueryBody,
validateTimeWindowUnits,
} from './core_query_types';

View file

@ -14,6 +14,7 @@ export {
CoreQueryParams,
CoreQueryParamsSchemaProperties,
validateCoreQueryBody,
validateTimeWindowUnits,
MAX_INTERVALS,
MAX_GROUPS,
DEFAULT_GROUPS,

View file

@ -0,0 +1,251 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import expect from '@kbn/expect';
import { Spaces } from '../../../../scenarios';
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
import {
ESTestIndexTool,
ES_TEST_INDEX_NAME,
getUrlPrefix,
ObjectRemover,
} from '../../../../../common/lib';
import { createEsDocuments } from './create_test_data';
const ALERT_TYPE_ID = '.es-query';
const ACTION_TYPE_ID = '.index';
const ES_TEST_INDEX_SOURCE = 'builtin-alert:es-query';
const ES_TEST_INDEX_REFERENCE = '-na-';
const ES_TEST_OUTPUT_INDEX_NAME = `${ES_TEST_INDEX_NAME}-output`;
const ALERT_INTERVALS_TO_WRITE = 5;
const ALERT_INTERVAL_SECONDS = 3;
const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000;
const ES_GROUPS_TO_WRITE = 3;
// eslint-disable-next-line import/no-default-export
export default function alertTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
const es = getService('legacyEs');
const esTestIndexTool = new ESTestIndexTool(es, retry);
const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME);
describe('alert', async () => {
let endDate: string;
let actionId: string;
const objectRemover = new ObjectRemover(supertest);
beforeEach(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
await esTestIndexToolOutput.destroy();
await esTestIndexToolOutput.setup();
actionId = await createAction(supertest, objectRemover);
// write documents in the future, figure out the end date
const endDateMillis = Date.now() + (ALERT_INTERVALS_TO_WRITE - 1) * ALERT_INTERVAL_MILLIS;
endDate = new Date(endDateMillis).toISOString();
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
});
afterEach(async () => {
await objectRemover.removeAll();
await esTestIndexTool.destroy();
await esTestIndexToolOutput.destroy();
});
it('runs correctly: threshold on hit count < >', async () => {
await createAlert({
name: 'never fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
thresholdComparator: '<',
threshold: [0],
});
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
thresholdComparator: '>',
threshold: [-1],
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern = /alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
});
it('runs correctly with query: threshold on hit count < >', async () => {
const rangeQuery = (rangeThreshold: number) => {
return {
query: {
bool: {
filter: [
{
range: {
testedValue: {
gte: rangeThreshold,
},
},
},
],
},
},
};
};
await createAlert({
name: 'never fire',
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
thresholdComparator: '>=',
threshold: [0],
});
await createAlert({
name: 'fires once',
esQuery: JSON.stringify(
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
),
thresholdComparator: '>=',
threshold: [0],
});
const docs = await waitForDocs(1);
for (const doc of docs) {
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('fires once');
expect(title).to.be(`alert 'fires once' matched query`);
const messagePattern = /alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
expect(previousTimestamp).to.be.empty();
}
});
async function createEsDocumentsInGroups(groups: number) {
await createEsDocuments(
es,
esTestIndexTool,
endDate,
ALERT_INTERVALS_TO_WRITE,
ALERT_INTERVAL_MILLIS,
groups
);
}
async function waitForDocs(count: number): Promise<any[]> {
return await esTestIndexToolOutput.waitForDocs(
ES_TEST_INDEX_SOURCE,
ES_TEST_INDEX_REFERENCE,
count
);
}
interface CreateAlertParams {
name: string;
timeField?: string;
esQuery: string;
thresholdComparator: string;
threshold: number[];
timeWindowSize?: number;
}
async function createAlert(params: CreateAlertParams): Promise<string> {
const action = {
id: actionId,
group: 'query matched',
params: {
documents: [
{
source: ES_TEST_INDEX_SOURCE,
reference: ES_TEST_INDEX_REFERENCE,
params: {
name: '{{{alertName}}}',
value: '{{{context.value}}}',
title: '{{{context.title}}}',
message: '{{{context.message}}}',
},
hits: '{{context.hits}}',
date: '{{{context.date}}}',
previousTimestamp: '{{{state.latestTimestamp}}}',
},
],
},
};
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`)
.set('kbn-xsrf', 'foo')
.send({
name: params.name,
consumer: 'alerts',
enabled: true,
alertTypeId: ALERT_TYPE_ID,
schedule: { interval: `${ALERT_INTERVAL_SECONDS}s` },
actions: [action],
params: {
index: [ES_TEST_INDEX_NAME],
timeField: params.timeField || 'date',
esQuery: params.esQuery,
timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5,
timeWindowUnit: 's',
thresholdComparator: params.thresholdComparator,
threshold: params.threshold,
},
})
.expect(200);
const alertId = createdAlert.id;
objectRemover.add(Spaces.space1.id, alertId, 'alert', 'alerts');
return alertId;
}
});
}
async function createAction(supertest: any, objectRemover: ObjectRemover): Promise<string> {
const { body: createdAction } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/actions/action`)
.set('kbn-xsrf', 'foo')
.send({
name: 'index action for es query FT',
actionTypeId: ACTION_TYPE_ID,
config: {
index: ES_TEST_OUTPUT_INDEX_NAME,
},
secrets: {},
})
.expect(200);
const actionId = createdAction.id;
objectRemover.add(Spaces.space1.id, actionId, 'action', 'actions');
return actionId;
}

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { times } from 'lodash';
import { v4 as uuid } from 'uuid';
import { ESTestIndexTool, ES_TEST_INDEX_NAME } from '../../../../../common/lib';
// default end date
export const END_DATE = '2020-01-01T00:00:00Z';
export const DOCUMENT_SOURCE = 'queryDataEndpointTests';
export const DOCUMENT_REFERENCE = '-na-';
export async function createEsDocuments(
es: any,
esTestIndexTool: ESTestIndexTool,
endDate: string = END_DATE,
intervals: number = 1,
intervalMillis: number = 1000,
groups: number = 2
) {
const endDateMillis = Date.parse(endDate) - intervalMillis / 2;
let testedValue = 0;
times(intervals, (interval) => {
const date = endDateMillis - interval * intervalMillis;
// don't need await on these, wait at the end of the function
times(groups, () => {
createEsDocument(es, date, testedValue++);
});
});
const totalDocuments = intervals * groups;
await esTestIndexTool.waitForDocs(DOCUMENT_SOURCE, DOCUMENT_REFERENCE, totalDocuments);
}
async function createEsDocument(es: any, epochMillis: number, testedValue: number) {
const document = {
source: DOCUMENT_SOURCE,
reference: DOCUMENT_REFERENCE,
date: new Date(epochMillis).toISOString(),
date_epoch_millis: epochMillis,
testedValue,
};
const response = await es.index({
id: uuid(),
index: ES_TEST_INDEX_NAME,
body: document,
});
if (response.result !== 'created') {
throw new Error(`document not created: ${JSON.stringify(response)}`);
}
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FtrProviderContext } from '../../../../../common/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
describe('es_query', () => {
loadTestFile(require.resolve('./alert'));
});
}

View file

@ -10,5 +10,6 @@ import { FtrProviderContext } from '../../../../common/ftr_provider_context';
export default function alertingTests({ loadTestFile }: FtrProviderContext) {
describe('builtin alertTypes', () => {
loadTestFile(require.resolve('./index_threshold'));
loadTestFile(require.resolve('./es_query'));
});
}

View file

@ -29,10 +29,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
});
}
async function defineAlert(alertName: string) {
async function defineAlert(alertName: string, alertType?: string) {
alertType = alertType || '.index-threshold';
await pageObjects.triggersActionsUI.clickCreateAlertButton();
await testSubjects.setValue('alertNameInput', alertName);
await testSubjects.click('.index-threshold-SelectOption');
await testSubjects.click(`${alertType}-SelectOption`);
await testSubjects.click('selectIndexExpression');
const comboBox = await find.byCssSelector('#indexSelectSearchBox');
await comboBox.click();
@ -217,5 +218,26 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
await testSubjects.click('confirmAlertCloseModal > confirmModalCancelButton');
await testSubjects.missingOrFail('confirmAlertCloseModal');
});
it('should successfully test valid es_query alert', async () => {
const alertName = generateUniqueKey();
await defineAlert(alertName, '.es-query');
// Valid query
await testSubjects.setValue('queryJsonEditor', '{"query":{"match_all":{}}}', {
clearWithKeyboard: true,
});
await testSubjects.click('testQuery');
await testSubjects.existOrFail('testQuerySuccess');
await testSubjects.missingOrFail('testQueryError');
// Invalid query
await testSubjects.setValue('queryJsonEditor', '{"query":{"foo":{}}}', {
clearWithKeyboard: true,
});
await testSubjects.click('testQuery');
await testSubjects.missingOrFail('testQuerySuccess');
await testSubjects.existOrFail('testQueryError');
});
});
};

View file

@ -7,7 +7,7 @@
import { Unionize, UnionToIntersection } from 'utility-types';
import { ESSearchHit, MaybeReadonlyArray, ESSourceOptions, ESHitsOf } from '.';
type SortOrder = 'asc' | 'desc';
export type SortOrder = 'asc' | 'desc';
type SortInstruction = Record<string, SortOrder | { order: SortOrder }>;
export type SortOptions = SortOrder | SortInstruction | SortInstruction[];

View file

@ -70,6 +70,7 @@ export interface ESSearchBody {
aggs?: AggregationInputMap;
track_total_hits?: boolean | number;
collapse?: CollapseQuery;
search_after?: Array<string | number>;
_source?: ESSourceOptions;
}