[ResponseOps][Alerting] ESQL alerting rule type ( pointed at main) (#165480)

Resolves https://github.com/elastic/kibana/issues/153448

## Summary

The same as this [pr](https://github.com/elastic/kibana/pull/164073),
but just points at main

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
Co-authored-by: Alexey Antonov <alexwizp@gmail.com>
Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Abdon Pijpelink <abdon.pijpelink@elastic.co>
Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: Peter Pisljar <peter.pisljar@elastic.co>
Co-authored-by: Marco Liberati <dej611@users.noreply.github.com>
This commit is contained in:
Alexi Doak 2023-09-05 06:59:52 -07:00 committed by GitHub
parent 756599f7c2
commit 5f00ae97dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 2241 additions and 129 deletions

View file

@ -7,6 +7,7 @@
*/ */
export type { TextBasedLanguagesEditorProps } from './src/text_based_languages_editor'; export type { TextBasedLanguagesEditorProps } from './src/text_based_languages_editor';
export { fetchFieldsFromESQL } from './src/fetch_fields_from_esql';
import { TextBasedLanguagesEditor } from './src/text_based_languages_editor'; import { TextBasedLanguagesEditor } from './src/text_based_languages_editor';
// React.lazy support // React.lazy support

View file

@ -155,6 +155,7 @@ interface EditorFooterProps {
detectTimestamp: boolean; detectTimestamp: boolean;
onErrorClick: (error: MonacoError) => void; onErrorClick: (error: MonacoError) => void;
refreshErrors: () => void; refreshErrors: () => void;
hideRunQueryText?: boolean;
} }
export const EditorFooter = memo(function EditorFooter({ export const EditorFooter = memo(function EditorFooter({
@ -165,6 +166,7 @@ export const EditorFooter = memo(function EditorFooter({
detectTimestamp, detectTimestamp,
onErrorClick, onErrorClick,
refreshErrors, refreshErrors,
hideRunQueryText,
}: EditorFooterProps) { }: EditorFooterProps) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return ( return (
@ -235,27 +237,29 @@ export const EditorFooter = memo(function EditorFooter({
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
</EuiFlexItem> </EuiFlexItem>
<EuiFlexItem grow={false}> {!hideRunQueryText && (
<EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center"> <EuiFlexItem grow={false}>
<EuiFlexItem grow={false}> <EuiFlexGroup gutterSize="xs" responsive={false} alignItems="center">
<EuiText size="xs" color="subdued"> <EuiFlexItem grow={false}>
<p> <EuiText size="xs" color="subdued" data-test-subj="TextBasedLangEditor-run-query">
{i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', { <p>
defaultMessage: 'Run query', {i18n.translate('textBasedEditor.query.textBasedLanguagesEditor.runQuery', {
})} defaultMessage: 'Run query',
</p> })}
</EuiText> </p>
</EuiFlexItem> </EuiText>
<EuiFlexItem grow={false}> </EuiFlexItem>
<EuiCode <EuiFlexItem grow={false}>
transparentBackground <EuiCode
css={css` transparentBackground
font-size: 12px; css={css`
`} font-size: 12px;
>{`${COMMAND_KEY} + Enter`}</EuiCode> `}
</EuiFlexItem> >{`${COMMAND_KEY} + Enter`}</EuiCode>
</EuiFlexGroup> </EuiFlexItem>
</EuiFlexItem> </EuiFlexGroup>
</EuiFlexItem>
)}
</EuiFlexGroup> </EuiFlexGroup>
); );
}); });

View file

@ -8,7 +8,7 @@
import { pluck } from 'rxjs/operators'; import { pluck } from 'rxjs/operators';
import { lastValueFrom } from 'rxjs'; import { lastValueFrom } from 'rxjs';
import { Query, AggregateQuery } from '@kbn/es-query'; import { Query, AggregateQuery, TimeRange } from '@kbn/es-query';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public'; import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { Datatable } from '@kbn/expressions-plugin/public'; import type { Datatable } from '@kbn/expressions-plugin/public';
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common'; import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
@ -20,9 +20,14 @@ interface TextBasedLanguagesErrorResponse {
type: 'error'; type: 'error';
} }
export function fetchFieldsFromESQL(query: Query | AggregateQuery, expressions: ExpressionsStart) { export function fetchFieldsFromESQL(
query: Query | AggregateQuery,
expressions: ExpressionsStart,
time?: TimeRange
) {
return textBasedQueryStateToAstWithValidation({ return textBasedQueryStateToAstWithValidation({
query, query,
time,
}) })
.then((ast) => { .then((ast) => {
if (ast) { if (ast) {

View file

@ -242,4 +242,27 @@ describe('TextBasedLanguagesEditor', () => {
).toBe('1 line'); ).toBe('1 line');
}); });
}); });
it('should render the run query text', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
};
await act(async () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).not.toBe(0);
});
});
it('should not render the run query text if the hideRunQueryText prop is set to true', async () => {
const newProps = {
...props,
isCodeEditorExpanded: true,
hideRunQueryText: true,
};
await act(async () => {
const component = mount(renderTextBasedLanguagesEditorComponent({ ...newProps }));
expect(component.find('[data-test-subj="TextBasedLangEditor-run-query"]').length).toBe(0);
});
});
}); });

View file

@ -75,6 +75,7 @@ export interface TextBasedLanguagesEditorProps {
isDarkMode?: boolean; isDarkMode?: boolean;
dataTestSubj?: string; dataTestSubj?: string;
hideMinimizeButton?: boolean; hideMinimizeButton?: boolean;
hideRunQueryText?: boolean;
} }
interface TextBasedEditorDeps { interface TextBasedEditorDeps {
@ -120,6 +121,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
isDisabled, isDisabled,
isDarkMode, isDarkMode,
hideMinimizeButton, hideMinimizeButton,
hideRunQueryText,
dataTestSubj, dataTestSubj,
}: TextBasedLanguagesEditorProps) { }: TextBasedLanguagesEditorProps) {
const { euiTheme } = useEuiTheme(); const { euiTheme } = useEuiTheme();
@ -781,6 +783,7 @@ export const TextBasedLanguagesEditor = memo(function TextBasedLanguagesEditor({
onErrorClick={onErrorClick} onErrorClick={onErrorClick}
refreshErrors={onTextLangQuerySubmit} refreshErrors={onTextLangQuerySubmit}
detectTimestamp={detectTimestamp} detectTimestamp={detectTimestamp}
hideRunQueryText={hideRunQueryText}
/> />
)} )}
{isCodeEditorExpanded && ( {isCodeEditorExpanded && (

View file

@ -0,0 +1,95 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from './esql_query_utils';
describe('ESQL query utils', () => {
describe('rowToDocument', () => {
it('correctly converts ESQL row to document', () => {
expect(
rowToDocument(
[
{ name: '@timestamp', type: 'date' },
{ name: 'ecs.version', type: 'keyword' },
{ name: 'error.code', type: 'keyword' },
],
['2023-07-12T13:32:04.174Z', '1.8.0', null]
)
).toEqual({
'@timestamp': '2023-07-12T13:32:04.174Z',
'ecs.version': '1.8.0',
'error.code': null,
});
});
});
describe('toEsQueryHits', () => {
it('correctly converts ESQL table to ES query hits', () => {
expect(
toEsQueryHits({
columns: [
{ name: '@timestamp', type: 'date' },
{ name: 'ecs.version', type: 'keyword' },
{ name: 'error.code', type: 'keyword' },
],
values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]],
})
).toEqual({
hits: [
{
_id: 'esql_query_document',
_index: '',
_source: {
'@timestamp': '2023-07-12T13:32:04.174Z',
'ecs.version': '1.8.0',
'error.code': null,
},
},
],
total: 1,
});
});
});
describe('transformDatatableToEsqlTable', () => {
it('correctly converts data table to ESQL table', () => {
expect(
transformDatatableToEsqlTable({
type: 'datatable',
columns: [
{ id: '@timestamp', name: '@timestamp', meta: { type: 'date' } },
{ id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } },
{ id: 'error.code', name: 'error.code', meta: { type: 'string' } },
],
rows: [
{
'@timestamp': '2023-07-12T13:32:04.174Z',
'ecs.version': '1.8.0',
'error.code': null,
},
],
})
).toEqual({
columns: [
{
name: '@timestamp',
type: 'date',
},
{
name: 'ecs.version',
type: 'string',
},
{
name: 'error.code',
type: 'string',
},
],
values: [['2023-07-12T13:32:04.174Z', '1.8.0', null]],
});
});
});
});

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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Datatable } from '@kbn/expressions-plugin/common';
type EsqlDocument = Record<string, string | null>;
interface EsqlHit {
_id: string;
_index: string;
_source: EsqlDocument;
}
interface EsqlResultColumn {
name: string;
type: string;
}
type EsqlResultRow = Array<string | null>;
export interface EsqlTable {
columns: EsqlResultColumn[];
values: EsqlResultRow[];
}
const ESQL_DOCUMENT_ID = 'esql_query_document';
export const rowToDocument = (columns: EsqlResultColumn[], row: EsqlResultRow): EsqlDocument => {
return columns.reduce<Record<string, string | null>>((acc, column, i) => {
acc[column.name] = row[i];
return acc;
}, {});
};
export const toEsQueryHits = (results: EsqlTable) => {
const hits: EsqlHit[] = results.values.map((row) => {
const document = rowToDocument(results.columns, row);
return {
_id: ESQL_DOCUMENT_ID,
_index: '',
_source: document,
};
});
return {
hits,
total: hits.length,
};
};
export const transformDatatableToEsqlTable = (results: Datatable): EsqlTable => {
const columns: EsqlResultColumn[] = results.columns.map((c) => ({
name: c.id,
type: c.meta.type,
}));
const values: EsqlResultRow[] = results.rows.map((r) => Object.values(r));
return { columns, values };
};

View file

@ -12,3 +12,6 @@ export {
getHumanReadableComparator, getHumanReadableComparator,
} from './comparator'; } from './comparator';
export { STACK_ALERTS_FEATURE_ID } from './constants'; export { STACK_ALERTS_FEATURE_ID } from './constants';
export type { EsqlTable } from './esql_query_utils';
export { rowToDocument, transformDatatableToEsqlTable, toEsQueryHits } from './esql_query_utils';

View file

@ -22,7 +22,8 @@
"kibanaUtils" "kibanaUtils"
], ],
"requiredBundles": [ "requiredBundles": [
"esUiShared" "esUiShared",
"textBasedLanguages"
] ]
} }
} }

View file

@ -48,6 +48,13 @@ export const ONLY_ES_QUERY_EXPRESSION_ERRORS = {
timeField: new Array<string>(), timeField: new Array<string>(),
}; };
export const ONLY_ESQL_QUERY_EXPRESSION_ERRORS = {
esqlQuery: new Array<string>(),
timeField: new Array<string>(),
thresholdComparator: new Array<string>(),
threshold0: new Array<string>(),
};
const ALL_EXPRESSION_ERROR_ENTRIES = { const ALL_EXPRESSION_ERROR_ENTRIES = {
...COMMON_EXPRESSION_ERRORS, ...COMMON_EXPRESSION_ERRORS,
...SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS, ...SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,

View file

@ -0,0 +1,196 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n-react';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks';
import { unifiedSearchPluginMock } from '@kbn/unified-search-plugin/public/mocks';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { EsqlQueryExpression } from './esql_query_expression';
import { EsQueryRuleParams, SearchType } from '../types';
jest.mock('../validation', () => ({
hasExpressionValidationErrors: jest.fn(),
}));
const { hasExpressionValidationErrors } = jest.requireMock('../validation');
jest.mock('@kbn/text-based-editor', () => ({
fetchFieldsFromESQL: jest.fn(),
}));
const { fetchFieldsFromESQL } = jest.requireMock('@kbn/text-based-editor');
const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => (
<I18nProvider>{children}</I18nProvider>
));
const dataMock = dataPluginMock.createStartContract();
const dataViewMock = dataViewPluginMocks.createStartContract();
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
const chartsStartMock = chartPluginMock.createStartContract();
const defaultEsqlQueryExpressionParams: EsQueryRuleParams<SearchType.esqlQuery> = {
size: 100,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
index: ['test-index'],
timeField: '@timestamp',
aggType: 'count',
groupBy: 'all',
searchType: SearchType.esqlQuery,
esqlQuery: { esql: '' },
excludeHitsFromPreviousRun: false,
};
describe('EsqlQueryRuleTypeExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
hasExpressionValidationErrors.mockReturnValue(false);
});
it('should render EsqlQueryRuleTypeExpression with expected components', () => {
const result = render(
<EsqlQueryExpression
unifiedSearch={unifiedSearchMock}
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={defaultEsqlQueryExpressionParams}
setRuleParams={() => {}}
setRuleProperty={() => {}}
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
data={dataMock}
dataViews={dataViewMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>,
{
wrapper: AppWrapper,
}
);
expect(result.getByTestId('queryEsqlEditor')).toBeInTheDocument();
expect(result.getByTestId('timeFieldSelect')).toBeInTheDocument();
expect(result.getByTestId('timeWindowSizeNumber')).toBeInTheDocument();
expect(result.getByTestId('timeWindowUnitSelect')).toBeInTheDocument();
expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument();
expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument();
});
test('should render Test Query button disabled if alert params are invalid', async () => {
hasExpressionValidationErrors.mockReturnValue(true);
const result = render(
<EsqlQueryExpression
unifiedSearch={unifiedSearchMock}
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={defaultEsqlQueryExpressionParams}
setRuleParams={() => {}}
setRuleProperty={() => {}}
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
data={dataMock}
dataViews={dataViewMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>,
{
wrapper: AppWrapper,
}
);
const button = result.getByTestId('testQuery');
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
test('should show success message if Test Query is successful', async () => {
fetchFieldsFromESQL.mockResolvedValue({
type: 'datatable',
columns: [
{ id: '@timestamp', name: '@timestamp', meta: { type: 'date' } },
{ id: 'ecs.version', name: 'ecs.version', meta: { type: 'string' } },
{ id: 'error.code', name: 'error.code', meta: { type: 'string' } },
],
rows: [
{
'@timestamp': '2023-07-12T13:32:04.174Z',
'ecs.version': '1.8.0',
'error.code': null,
},
],
});
const result = render(
<EsqlQueryExpression
unifiedSearch={unifiedSearchMock}
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={defaultEsqlQueryExpressionParams}
setRuleParams={() => {}}
setRuleProperty={() => {}}
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
data={dataMock}
dataViews={dataViewMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>,
{
wrapper: AppWrapper,
}
);
fireEvent.click(result.getByTestId('testQuery'));
await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled());
expect(result.getByTestId('testQuerySuccess')).toBeInTheDocument();
expect(result.getByText('Query matched 1 documents in the last 15s.')).toBeInTheDocument();
expect(result.queryByTestId('testQueryError')).not.toBeInTheDocument();
});
test('should show error message if Test Query is throws error', async () => {
fetchFieldsFromESQL.mockRejectedValue('Error getting test results.!');
const result = render(
<EsqlQueryExpression
unifiedSearch={unifiedSearchMock}
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={defaultEsqlQueryExpressionParams}
setRuleParams={() => {}}
setRuleProperty={() => {}}
errors={{ esqlQuery: [], timeField: [], timeWindowSize: [] }}
data={dataMock}
dataViews={dataViewMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
onChangeMetaData={() => {}}
/>,
{
wrapper: AppWrapper,
}
);
fireEvent.click(result.getByTestId('testQuery'));
await waitFor(() => expect(fetchFieldsFromESQL).toBeCalled());
expect(result.queryByTestId('testQuerySuccess')).not.toBeInTheDocument();
expect(result.getByTestId('testQueryError')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,255 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useState, Fragment, useEffect, useCallback } from 'react';
import { get } from 'lodash';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiFieldNumber,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiSelect,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { getFields, RuleTypeParamsExpressionProps } from '@kbn/triggers-actions-ui-plugin/public';
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
import { fetchFieldsFromESQL } from '@kbn/text-based-editor';
import { AggregateQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query';
import { parseDuration } from '@kbn/alerting-plugin/common';
import {
firstFieldOption,
getTimeFieldOptions,
getTimeOptions,
parseAggregationResults,
} from '@kbn/triggers-actions-ui-plugin/public/common';
import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
import { DEFAULT_VALUES } from '../constants';
import { useTriggerUiActionServices } from '../util';
import { hasExpressionValidationErrors } from '../validation';
import { TestQueryRow } from '../test_query_row';
import { rowToDocument, toEsQueryHits, transformDatatableToEsqlTable } from '../../../../common';
export const EsqlQueryExpression: React.FC<
RuleTypeParamsExpressionProps<EsQueryRuleParams<SearchType.esqlQuery>, EsQueryRuleMetaData>
> = ({ ruleParams, setRuleParams, setRuleProperty, errors }) => {
const { expressions, http } = useTriggerUiActionServices();
const { esqlQuery, timeWindowSize, timeWindowUnit, timeField } = ruleParams;
const [currentRuleParams, setCurrentRuleParams] = useState<
EsQueryRuleParams<SearchType.esqlQuery>
>({
...ruleParams,
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
// ESQL queries compare conditions within the ES query
// so only 'met' results are returned, therefore the threshold should always be 0
threshold: [0],
thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR,
size: DEFAULT_VALUES.SIZE,
esqlQuery: esqlQuery ?? { esql: '' },
aggType: DEFAULT_VALUES.AGGREGATION_TYPE,
groupBy: DEFAULT_VALUES.GROUP_BY,
termSize: DEFAULT_VALUES.TERM_SIZE,
searchType: SearchType.esqlQuery,
});
const [query, setQuery] = useState<AggregateQuery>({ esql: '' });
const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]);
const [detectTimestamp, setDetectTimestamp] = useState<boolean>(false);
const setParam = useCallback(
(paramField: string, paramValue: unknown) => {
setCurrentRuleParams((currentParams) => ({
...currentParams,
[paramField]: paramValue,
}));
setRuleParams(paramField, paramValue);
},
[setRuleParams]
);
const setDefaultExpressionValues = async () => {
setRuleProperty('params', currentRuleParams);
setQuery(esqlQuery ?? { esql: '' });
if (timeField) {
setTimeFieldOptions([firstFieldOption, { text: timeField, value: timeField }]);
}
};
useEffect(() => {
setDefaultExpressionValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onTestQuery = useCallback(async () => {
const window = `${timeWindowSize}${timeWindowUnit}`;
const emptyResult = {
testResults: { results: [], truncated: false },
isGrouped: true,
timeWindow: window,
};
if (hasExpressionValidationErrors(currentRuleParams)) {
return emptyResult;
}
const timeWindow = parseDuration(window);
const now = Date.now();
const table = await fetchFieldsFromESQL(esqlQuery, expressions, {
from: new Date(now - timeWindow).toISOString(),
to: new Date(now).toISOString(),
});
if (table) {
const esqlTable = transformDatatableToEsqlTable(table);
const hits = toEsQueryHits(esqlTable);
return {
testResults: parseAggregationResults({
isCountAgg: true,
isGroupAgg: false,
esResult: {
took: 0,
timed_out: false,
_shards: { failed: 0, successful: 0, total: 0 },
hits,
},
}),
isGrouped: false,
timeWindow: window,
rawResults: {
cols: esqlTable.columns.map((col) => ({
id: col.name,
})),
rows: esqlTable.values.slice(0, 5).map((row) => rowToDocument(esqlTable.columns, row)),
},
};
}
return emptyResult;
}, [timeWindowSize, timeWindowUnit, currentRuleParams, esqlQuery, expressions]);
const refreshTimeFields = async (q: AggregateQuery) => {
let hasTimestamp = false;
const indexPattern: string = getIndexPatternFromESQLQuery(get(q, 'esql'));
const currentEsFields = await getFields(http, [indexPattern]);
const timeFields = getTimeFieldOptions(currentEsFields);
setTimeFieldOptions([firstFieldOption, ...timeFields]);
const timestampField = timeFields.find((field) => field.value === '@timestamp');
if (timestampField) {
setParam('timeField', timestampField.value);
hasTimestamp = true;
}
setDetectTimestamp(hasTimestamp);
};
return (
<Fragment>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.defineEsqlQueryPrompt"
defaultMessage="Define your query using ESQL"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFormRow id="queryEditor" data-test-subj="queryEsqlEditor" fullWidth>
<TextBasedLangEditor
query={query}
onTextLangQueryChange={(q: AggregateQuery) => {
setQuery(q);
setParam('esqlQuery', q);
refreshTimeFields(q);
}}
expandCodeEditor={() => true}
isCodeEditorExpanded={true}
onTextLangQuerySubmit={() => {}}
detectTimestamp={detectTimestamp}
hideMinimizeButton={true}
hideRunQueryText={true}
/>
</EuiFormRow>
<EuiSpacer />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.selectEsqlQueryTimeFieldPrompt"
defaultMessage="Select a time field"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFormRow
id="timeField"
fullWidth
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
error={errors.timeField}
>
<EuiSelect
options={timeFieldOptions}
isInvalid={errors.timeField.length > 0 && timeField !== undefined}
fullWidth
name="timeField"
data-test-subj="timeFieldSelect"
value={timeField || ''}
onChange={(e) => {
setParam('timeField', e.target.value);
}}
/>
</EuiFormRow>
<EuiSpacer />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.esQuery.ui.setEsqlQueryTimeWindowPrompt"
defaultMessage="Set the time window"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiFormRow
id="timeWindowSize"
isInvalid={errors.timeWindowSize.length > 0}
error={errors.timeWindowSize}
>
<EuiFieldNumber
name="timeWindowSize"
data-test-subj="timeWindowSizeNumber"
isInvalid={errors.timeWindowSize.length > 0}
min={0}
value={timeWindowSize || ''}
onChange={(e) => {
const { value } = e.target;
const timeWindowSizeVal = value !== '' ? parseInt(value, 10) : undefined;
setParam('timeWindowSize', timeWindowSizeVal);
}}
/>
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFormRow id="timeWindowUnit">
<EuiSelect
name="timeWindowUnit"
data-test-subj="timeWindowUnitSelect"
value={timeWindowUnit}
onChange={(e) => {
setParam('timeWindowUnit', e.target.value);
}}
options={getTimeOptions(timeWindowSize ?? 1)}
/>
</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<TestQueryRow
fetch={onTestQuery}
hasValidationErrors={hasExpressionValidationErrors(currentRuleParams)}
showTable
/>
</Fragment>
);
};

View file

@ -23,7 +23,6 @@ import { EsQueryRuleTypeExpression } from './expression';
import { chartPluginMock } from '@kbn/charts-plugin/public/mocks'; import { chartPluginMock } from '@kbn/charts-plugin/public/mocks';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { ISearchSource } from '@kbn/data-plugin/common'; import { ISearchSource } from '@kbn/data-plugin/common';
import { IUiSettingsClient } from '@kbn/core/public';
import { findTestSubject } from '@elastic/eui/lib/test'; import { findTestSubject } from '@elastic/eui/lib/test';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
@ -75,6 +74,20 @@ const defaultSearchSourceRuleParams: EsQueryRuleParams<SearchType.searchSource>
aggType: 'count', aggType: 'count',
}; };
const defaultEsqlRuleParams: EsQueryRuleParams<SearchType.esqlQuery> = {
size: 100,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
index: ['test-index'],
timeField: '@timestamp',
searchType: SearchType.esqlQuery,
esqlQuery: { esql: 'test' },
excludeHitsFromPreviousRun: true,
aggType: 'count',
};
const dataViewPluginMock = dataViewPluginMocks.createStartContract(); const dataViewPluginMock = dataViewPluginMocks.createStartContract();
const chartsStartMock = chartPluginMock.createStartContract(); const chartsStartMock = chartPluginMock.createStartContract();
const unifiedSearchMock = unifiedSearchPluginMock.createStartContract(); const unifiedSearchMock = unifiedSearchPluginMock.createStartContract();
@ -82,7 +95,7 @@ const httpMock = httpServiceMock.createStartContract();
const docLinksMock = docLinksServiceMock.createStartContract(); const docLinksMock = docLinksServiceMock.createStartContract();
export const uiSettingsMock = { export const uiSettingsMock = {
get: jest.fn(), get: jest.fn(),
} as unknown as IUiSettingsClient; };
const mockSearchResult = new Subject(); const mockSearchResult = new Subject();
const searchSourceFieldsMock = { const searchSourceFieldsMock = {
@ -150,7 +163,10 @@ dataMock.query.savedQueries.findSavedQueries = jest.fn(() =>
(httpMock.post as jest.Mock).mockImplementation(() => Promise.resolve({ fields: [] })); (httpMock.post as jest.Mock).mockImplementation(() => Promise.resolve({ fields: [] }));
const Wrapper: React.FC<{ const Wrapper: React.FC<{
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>; ruleParams:
| EsQueryRuleParams<SearchType.searchSource>
| EsQueryRuleParams<SearchType.esQuery>
| EsQueryRuleParams<SearchType.esqlQuery>;
metadata?: EsQueryRuleMetaData; metadata?: EsQueryRuleMetaData;
}> = ({ ruleParams, metadata }) => { }> = ({ ruleParams, metadata }) => {
const [currentRuleParams, setCurrentRuleParams] = useState<CommonEsQueryRuleParams>(ruleParams); const [currentRuleParams, setCurrentRuleParams] = useState<CommonEsQueryRuleParams>(ruleParams);
@ -192,7 +208,10 @@ const Wrapper: React.FC<{
}; };
const setup = ( const setup = (
ruleParams: EsQueryRuleParams<SearchType.searchSource> | EsQueryRuleParams<SearchType.esQuery>, ruleParams:
| EsQueryRuleParams<SearchType.searchSource>
| EsQueryRuleParams<SearchType.esQuery>
| EsQueryRuleParams<SearchType.esqlQuery>,
metadata?: EsQueryRuleMetaData metadata?: EsQueryRuleMetaData
) => { ) => {
return mountWithIntl( return mountWithIntl(
@ -213,11 +232,28 @@ const setup = (
}; };
describe('EsQueryRuleTypeExpression', () => { describe('EsQueryRuleTypeExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
uiSettingsMock.get.mockReturnValue(true);
});
test('should render options by default', async () => { test('should render options by default', async () => {
const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>); const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>);
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
});
test('should hide ESQL option when not enabled', async () => {
uiSettingsMock.get.mockReturnValueOnce(false);
const wrapper = setup({} as EsQueryRuleParams<SearchType.esQuery>);
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_searchSource').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_esQuery').exists()).toBeTruthy();
expect(findTestSubject(wrapper, 'queryFormType_esqlQuery').exists()).toBeFalsy();
expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); expect(findTestSubject(wrapper, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
}); });
@ -257,6 +293,23 @@ describe('EsQueryRuleTypeExpression', () => {
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy(); expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
}); });
test('should switch to ESQL form type on selection and return back on cancel', async () => {
let wrapper = setup({} as EsQueryRuleParams<SearchType.searchSource>);
await act(async () => {
findTestSubject(wrapper, 'queryFormType_esqlQuery').simulate('click');
});
wrapper = await wrapper.update();
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeFalsy();
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy();
await act(async () => {
findTestSubject(wrapper, 'queryFormTypeChooserCancel').simulate('click');
});
wrapper = await wrapper.update();
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeFalsy();
expect(findTestSubject(wrapper, 'queryFormTypeChooserTitle').exists()).toBeTruthy();
});
test('should render QueryDSL view without the form type chooser', async () => { test('should render QueryDSL view without the form type chooser', async () => {
let wrapper: ReactWrapper; let wrapper: ReactWrapper;
await act(async () => { await act(async () => {
@ -282,4 +335,19 @@ describe('EsQueryRuleTypeExpression', () => {
expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy(); expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
expect(findTestSubject(wrapper!, 'selectDataViewExpression').exists()).toBeTruthy(); expect(findTestSubject(wrapper!, 'selectDataViewExpression').exists()).toBeTruthy();
}); });
test('should render ESQL view without the form type chooser', async () => {
let wrapper: ReactWrapper;
await act(async () => {
wrapper = setup(defaultEsqlRuleParams, {
adHocDataViewList: [],
isManagementPage: false,
});
wrapper = await wrapper.update();
});
wrapper = await wrapper!.update();
expect(findTestSubject(wrapper!, 'queryFormTypeChooserTitle').exists()).toBeFalsy();
expect(findTestSubject(wrapper!, 'queryFormTypeChooserCancel').exists()).toBeFalsy();
expect(wrapper.exists('[data-test-subj="queryEsqlEditor"]')).toBeTruthy();
});
}); });

View file

@ -15,8 +15,9 @@ import { EsQueryRuleParams, EsQueryRuleMetaData, SearchType } from '../types';
import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression'; import { SearchSourceExpression, SearchSourceExpressionProps } from './search_source_expression';
import { EsQueryExpression } from './es_query_expression'; import { EsQueryExpression } from './es_query_expression';
import { QueryFormTypeChooser } from './query_form_type_chooser'; import { QueryFormTypeChooser } from './query_form_type_chooser';
import { isSearchSourceRule } from '../util'; import { isEsqlQueryRule, isSearchSourceRule } from '../util';
import { ALL_EXPRESSION_ERROR_KEYS } from '../constants'; import { ALL_EXPRESSION_ERROR_KEYS } from '../constants';
import { EsqlQueryExpression } from './esql_query_expression';
function areSearchSourceExpressionPropsEqual( function areSearchSourceExpressionPropsEqual(
prevProps: Readonly<PropsWithChildren<SearchSourceExpressionProps>>, prevProps: Readonly<PropsWithChildren<SearchSourceExpressionProps>>,
@ -37,6 +38,7 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent<
> = (props) => { > = (props) => {
const { ruleParams, errors, setRuleProperty, setRuleParams } = props; const { ruleParams, errors, setRuleProperty, setRuleParams } = props;
const isSearchSource = isSearchSourceRule(ruleParams); const isSearchSource = isSearchSourceRule(ruleParams);
const isEsqlQuery = isEsqlQueryRule(ruleParams);
// metadata provided only when open alert from Discover page // metadata provided only when open alert from Discover page
const isManagementPage = props.metadata?.isManagementPage ?? true; const isManagementPage = props.metadata?.isManagementPage ?? true;
@ -95,10 +97,14 @@ export const EsQueryRuleTypeExpression: React.FunctionComponent<
<SearchSourceExpressionMemoized {...props} ruleParams={ruleParams} /> <SearchSourceExpressionMemoized {...props} ruleParams={ruleParams} />
)} )}
{ruleParams.searchType && !isSearchSource && ( {ruleParams.searchType && !isSearchSource && !isEsqlQuery && (
<EsQueryExpression {...props} ruleParams={ruleParams} /> <EsQueryExpression {...props} ruleParams={ruleParams} />
)} )}
{ruleParams.searchType && isEsqlQuery && (
<EsqlQueryExpression {...props} ruleParams={ruleParams} />
)}
<EuiHorizontalRule /> <EuiHorizontalRule />
</> </>
); );

View file

@ -5,7 +5,7 @@
* 2.0. * 2.0.
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
EuiButtonIcon, EuiButtonIcon,
EuiFlexGroup, EuiFlexGroup,
@ -19,39 +19,7 @@ import {
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import { SearchType } from '../types'; import { SearchType } from '../types';
import { useTriggerUiActionServices } from '../util';
const FORM_TYPE_ITEMS: Array<{ formType: SearchType; label: string; description: string }> = [
{
formType: SearchType.searchSource,
label: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel',
{
defaultMessage: 'KQL or Lucene',
}
),
description: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription',
{
defaultMessage: 'Use KQL or Lucene to define a text-based query.',
}
),
},
{
formType: SearchType.esQuery,
label: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel',
{
defaultMessage: 'Query DSL',
}
),
description: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription',
{
defaultMessage: 'Use the Elasticsearch Query DSL to define a query.',
}
),
},
];
export interface QueryFormTypeProps { export interface QueryFormTypeProps {
searchType: SearchType | null; searchType: SearchType | null;
@ -62,8 +30,65 @@ export const QueryFormTypeChooser: React.FC<QueryFormTypeProps> = ({
searchType, searchType,
onFormTypeSelect, onFormTypeSelect,
}) => { }) => {
const { uiSettings } = useTriggerUiActionServices();
const isEsqlEnabled = uiSettings?.get('discover:enableESQL');
const formTypeItems = useMemo(() => {
const items: Array<{ formType: SearchType; label: string; description: string }> = [
{
formType: SearchType.searchSource,
label: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeLabel',
{
defaultMessage: 'KQL or Lucene',
}
),
description: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.kqlOrLuceneFormTypeDescription',
{
defaultMessage: 'Use KQL or Lucene to define a text-based query.',
}
),
},
{
formType: SearchType.esQuery,
label: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeLabel',
{
defaultMessage: 'Query DSL',
}
),
description: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.queryDslFormTypeDescription',
{
defaultMessage: 'Use the Elasticsearch Query DSL to define a query.',
}
),
},
];
if (isEsqlEnabled) {
items.push({
formType: SearchType.esqlQuery,
label: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeLabel',
{
defaultMessage: 'ESQL',
}
),
description: i18n.translate(
'xpack.stackAlerts.esQuery.ui.selectQueryFormType.esqlFormTypeDescription',
{
defaultMessage: 'Use ESQL to define a text-based query.',
}
),
});
}
return items;
}, [isEsqlEnabled]);
if (searchType) { if (searchType) {
const activeFormTypeItem = FORM_TYPE_ITEMS.find((item) => item.formType === searchType); const activeFormTypeItem = formTypeItems.find((item) => item.formType === searchType);
return ( return (
<> <>
@ -107,7 +132,7 @@ export const QueryFormTypeChooser: React.FC<QueryFormTypeProps> = ({
</h5> </h5>
</EuiTitle> </EuiTitle>
<EuiListGroup flush gutterSize="m" size="m" maxWidth={false}> <EuiListGroup flush gutterSize="m" size="m" maxWidth={false}>
{FORM_TYPE_ITEMS.map((item) => ( {formTypeItems.map((item) => (
<EuiListGroupItem <EuiListGroupItem
wrapText wrapText
key={`form-type-${item.formType}`} key={`form-type-${item.formType}`}

View file

@ -12,12 +12,14 @@ import {
EuiFlexGroup, EuiFlexGroup,
EuiFlexItem, EuiFlexItem,
EuiFormRow, EuiFormRow,
EuiSpacer,
EuiText, EuiText,
EuiToolTip, EuiToolTip,
} from '@elastic/eui'; } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react'; import { FormattedMessage } from '@kbn/i18n-react';
import { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common'; import { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
import { useTestQuery } from './use_test_query'; import { useTestQuery } from './use_test_query';
import { TestQueryRowTable } from './test_query_row_table';
export interface TestQueryRowProps { export interface TestQueryRowProps {
fetch: () => Promise<{ fetch: () => Promise<{
@ -27,14 +29,24 @@ export interface TestQueryRowProps {
}>; }>;
copyQuery?: () => string; copyQuery?: () => string;
hasValidationErrors: boolean; hasValidationErrors: boolean;
showTable?: boolean;
} }
export const TestQueryRow: React.FC<TestQueryRowProps> = ({ export const TestQueryRow: React.FC<TestQueryRowProps> = ({
fetch, fetch,
copyQuery, copyQuery,
hasValidationErrors, hasValidationErrors,
showTable,
}) => { }) => {
const { onTestQuery, testQueryResult, testQueryError, testQueryLoading } = useTestQuery(fetch); const {
onTestQuery,
testQueryResult,
testQueryError,
testQueryLoading,
testQueryRawResults,
testQueryAlerts,
} = useTestQuery(fetch);
const [copiedMessage, setCopiedMessage] = useState<ReactNode | null>(null); const [copiedMessage, setCopiedMessage] = useState<ReactNode | null>(null);
return ( return (
@ -124,6 +136,12 @@ export const TestQueryRow: React.FC<TestQueryRowProps> = ({
</EuiText> </EuiText>
</EuiFormRow> </EuiFormRow>
)} )}
{showTable && testQueryRawResults && (
<>
<EuiSpacer size="s" />
<TestQueryRowTable rawResults={testQueryRawResults} alerts={testQueryAlerts} />
</>
)}
</> </>
); );
}; };

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { render } from '@testing-library/react';
import { I18nProvider } from '@kbn/i18n-react';
import { TestQueryRowTable } from './test_query_row_table';
const AppWrapper: React.FC<{ children: React.ReactElement }> = React.memo(({ children }) => (
<I18nProvider>{children}</I18nProvider>
));
describe('TestQueryRow', () => {
it('should render the datagrid', () => {
const result = render(
<TestQueryRowTable
rawResults={{
cols: [
{
id: 'test',
},
],
rows: [
{
test: 'esql query 1',
},
{
test: 'esql query 2',
},
],
}}
alerts={null}
/>,
{
wrapper: AppWrapper,
}
);
expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument();
expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2);
expect(result.queryByText('Alerts generated')).not.toBeInTheDocument();
expect(result.queryAllByTestId('alert-badge')).toHaveLength(0);
});
it('should render the datagrid and alerts if provided', () => {
const result = render(
<TestQueryRowTable
rawResults={{
cols: [
{
id: 'test',
},
],
rows: [
{
test: 'esql query 1',
},
{
test: 'esql query 2',
},
],
}}
alerts={['alert1', 'alert2']}
/>,
{
wrapper: AppWrapper,
}
);
expect(result.getByTestId('test-query-row-datagrid')).toBeInTheDocument();
expect(result.getAllByTestId('dataGridRowCell')).toHaveLength(2);
expect(result.getByText('Alerts generated')).toBeInTheDocument();
expect(result.getAllByTestId('alert-badge')).toHaveLength(2);
});
});

View file

@ -0,0 +1,90 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { css } from '@emotion/react';
import {
EuiDataGrid,
EuiPanel,
EuiDataGridColumn,
EuiFlexGroup,
EuiFlexItem,
EuiText,
EuiBadge,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
const styles = {
grid: css`
.euiDataGridHeaderCell {
background: none;
}
.euiDataGridHeader .euiDataGridHeaderCell {
border-top: none;
}
`,
};
export interface TestQueryRowTableProps {
rawResults: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> };
alerts: string[] | null;
}
export const TestQueryRowTable: React.FC<TestQueryRowTableProps> = ({ rawResults, alerts }) => {
return (
<EuiPanel style={{ overflow: 'hidden' }} hasShadow={false} hasBorder={true}>
<EuiDataGrid
css={styles.grid}
aria-label={i18n.translate('xpack.stackAlerts.esQuery.ui.testQueryTableAriaLabel', {
defaultMessage: 'Test query grid',
})}
data-test-subj="test-query-row-datagrid"
columns={rawResults.cols}
columnVisibility={{
visibleColumns: rawResults.cols.map((c) => c.id),
setVisibleColumns: () => {},
}}
rowCount={rawResults.rows.length}
gridStyle={{
border: 'horizontal',
rowHover: 'none',
}}
renderCellValue={({ rowIndex, columnId }) => rawResults.rows[rowIndex][columnId]}
pagination={{
pageIndex: 0,
pageSize: 10,
onChangeItemsPerPage: () => {},
onChangePage: () => {},
}}
toolbarVisibility={false}
/>
<EuiSpacer size="m" />
{alerts && (
<EuiFlexGroup gutterSize="m">
<EuiFlexItem grow={false}>
<EuiText>
<h5>
{i18n.translate('xpack.stackAlerts.esQuery.ui.testQueryAlerts', {
defaultMessage: 'Alerts generated',
})}
</h5>
</EuiText>
</EuiFlexItem>
{alerts.map((alert, index) => {
return (
<EuiFlexItem key={index} grow={false}>
<EuiBadge data-test-subj="alert-badge" color="primary">
{alert}
</EuiBadge>
</EuiFlexItem>
);
})}
</EuiFlexGroup>
)}
</EuiPanel>
);
};

View file

@ -20,6 +20,10 @@ describe('useTestQuery', () => {
}, },
isGrouped: false, isGrouped: false,
timeWindow: '1s', timeWindow: '1s',
rawResults: {
cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }],
rows: [{ ungrouped: 'test' }],
},
}), }),
}); });
await act(async () => { await act(async () => {
@ -29,6 +33,11 @@ describe('useTestQuery', () => {
expect(result.current.testQueryError).toBe(null); expect(result.current.testQueryError).toBe(null);
expect(result.current.testQueryResult).toContain('1s'); expect(result.current.testQueryResult).toContain('1s');
expect(result.current.testQueryResult).toContain('1 document'); expect(result.current.testQueryResult).toContain('1 document');
expect(result.current.testQueryRawResults).toEqual({
cols: [{ id: 'ungrouped', name: 'ungrouped', field: 'ungrouped', actions: false }],
rows: [{ ungrouped: 'test' }],
});
expect(result.current.testQueryAlerts).toEqual(['query matched']);
}); });
test('returning a valid result for grouped result', async () => { test('returning a valid result for grouped result', async () => {
@ -44,6 +53,10 @@ describe('useTestQuery', () => {
}, },
isGrouped: true, isGrouped: true,
timeWindow: '1s', timeWindow: '1s',
rawResults: {
cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }],
rows: [{ grouped: 'test' }],
},
}), }),
}); });
await act(async () => { await act(async () => {
@ -55,6 +68,11 @@ describe('useTestQuery', () => {
expect(result.current.testQueryResult).toContain( expect(result.current.testQueryResult).toContain(
'Grouped query matched 2 groups in the last 1s.' 'Grouped query matched 2 groups in the last 1s.'
); );
expect(result.current.testQueryRawResults).toEqual({
cols: [{ id: 'grouped', name: 'grouped', field: 'grouped', actions: false }],
rows: [{ grouped: 'test' }],
});
expect(result.current.testQueryAlerts).toEqual(['a', 'b']);
}); });
test('returning an error', async () => { test('returning an error', async () => {
@ -68,5 +86,7 @@ describe('useTestQuery', () => {
expect(result.current.testQueryLoading).toBe(false); expect(result.current.testQueryLoading).toBe(false);
expect(result.current.testQueryError).toContain(errorMsg); expect(result.current.testQueryError).toContain(errorMsg);
expect(result.current.testQueryResult).toBe(null); expect(result.current.testQueryResult).toBe(null);
expect(result.current.testQueryRawResults).toBe(null);
expect(result.current.testQueryAlerts).toBe(null);
}); });
}); });

View file

@ -8,17 +8,22 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { i18n } from '@kbn/i18n'; import { i18n } from '@kbn/i18n';
import type { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common'; import type { ParsedAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
import { EuiDataGridColumn } from '@elastic/eui';
interface TestQueryResponse { interface TestQueryResponse {
result: string | null; result: string | null;
error: string | null; error: string | null;
isLoading: boolean; isLoading: boolean;
rawResults: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> } | null;
alerts: string[] | null;
} }
const TEST_QUERY_INITIAL_RESPONSE: TestQueryResponse = { const TEST_QUERY_INITIAL_RESPONSE: TestQueryResponse = {
result: null, result: null,
error: null, error: null,
isLoading: false, isLoading: false,
rawResults: null,
alerts: null,
}; };
/** /**
@ -30,6 +35,7 @@ export function useTestQuery(
testResults: ParsedAggregationResults; testResults: ParsedAggregationResults;
isGrouped: boolean; isGrouped: boolean;
timeWindow: string; timeWindow: string;
rawResults?: { cols: EuiDataGridColumn[]; rows: Array<Record<string, string | null>> };
}> }>
) { ) {
const [testQueryResponse, setTestQueryResponse] = useState<TestQueryResponse>( const [testQueryResponse, setTestQueryResponse] = useState<TestQueryResponse>(
@ -46,10 +52,12 @@ export function useTestQuery(
result: null, result: null,
error: null, error: null,
isLoading: true, isLoading: true,
rawResults: null,
alerts: null,
}); });
try { try {
const { testResults, isGrouped, timeWindow } = await fetch(); const { testResults, isGrouped, timeWindow, rawResults } = await fetch();
if (isGrouped) { if (isGrouped) {
setTestQueryResponse({ setTestQueryResponse({
@ -62,6 +70,11 @@ export function useTestQuery(
}), }),
error: null, error: null,
isLoading: false, isLoading: false,
rawResults: rawResults ?? null,
alerts:
testResults.results.length > 0
? testResults.results.map((result) => result.group)
: null,
}); });
} else { } else {
const ungroupedQueryResponse = const ungroupedQueryResponse =
@ -73,6 +86,8 @@ export function useTestQuery(
}), }),
error: null, error: null,
isLoading: false, isLoading: false,
rawResults: rawResults ?? null,
alerts: ungroupedQueryResponse.count > 0 ? ['query matched'] : null,
}); });
} }
} catch (err) { } catch (err) {
@ -85,6 +100,8 @@ export function useTestQuery(
values: { message: message ? `${err.message}: ${message}` : err.message }, values: { message: message ? `${err.message}: ${message}` : err.message },
}), }),
isLoading: false, isLoading: false,
rawResults: null,
alerts: null,
}); });
} }
}, [fetch]); }, [fetch]);
@ -94,5 +111,7 @@ export function useTestQuery(
testQueryResult: testQueryResponse.result, testQueryResult: testQueryResponse.result,
testQueryError: testQueryResponse.error, testQueryError: testQueryResponse.error,
testQueryLoading: testQueryResponse.isLoading, testQueryLoading: testQueryResponse.isLoading,
testQueryRawResults: testQueryResponse.rawResults,
testQueryAlerts: testQueryResponse.alerts,
}; };
} }

View file

@ -9,10 +9,12 @@ import { RuleTypeParams } from '@kbn/alerting-plugin/common';
import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { EuiComboBoxOptionOption } from '@elastic/eui'; import { EuiComboBoxOptionOption } from '@elastic/eui';
import type { DataView } from '@kbn/data-views-plugin/public'; import type { DataView } from '@kbn/data-views-plugin/public';
import { AggregateQuery } from '@kbn/es-query';
export enum SearchType { export enum SearchType {
esQuery = 'esQuery', esQuery = 'esQuery',
searchSource = 'searchSource', searchSource = 'searchSource',
esqlQuery = 'esqlQuery',
} }
export interface CommonRuleParams { export interface CommonRuleParams {
@ -38,6 +40,8 @@ export interface EsQueryRuleMetaData {
export type EsQueryRuleParams<T = SearchType> = T extends SearchType.searchSource export type EsQueryRuleParams<T = SearchType> = T extends SearchType.searchSource
? CommonEsQueryRuleParams & OnlySearchSourceRuleParams ? CommonEsQueryRuleParams & OnlySearchSourceRuleParams
: T extends SearchType.esqlQuery
? CommonEsQueryRuleParams & OnlyEsqlQueryRuleParams
: CommonEsQueryRuleParams & OnlyEsQueryRuleParams; : CommonEsQueryRuleParams & OnlyEsQueryRuleParams;
export interface OnlyEsQueryRuleParams { export interface OnlyEsQueryRuleParams {
@ -53,4 +57,10 @@ export interface OnlySearchSourceRuleParams {
savedQueryId?: string; savedQueryId?: string;
} }
export interface OnlyEsqlQueryRuleParams {
searchType?: 'esqlQuery';
esqlQuery: AggregateQuery;
timeField: string;
}
export type DataViewOption = EuiComboBoxOptionOption<string>; export type DataViewOption = EuiComboBoxOptionOption<string>;

View file

@ -17,6 +17,12 @@ export const isSearchSourceRule = (
return ruleParams.searchType === 'searchSource'; return ruleParams.searchType === 'searchSource';
}; };
export const isEsqlQueryRule = (
ruleParams: EsQueryRuleParams
): ruleParams is EsQueryRuleParams<SearchType.esqlQuery> => {
return ruleParams.searchType === 'esqlQuery';
};
export const convertFieldSpecToFieldOption = (fieldSpec: FieldSpec[]): FieldOption[] => { export const convertFieldSpecToFieldOption = (fieldSpec: FieldSpec[]): FieldOption[] => {
return (fieldSpec ?? []) return (fieldSpec ?? [])
.filter((spec: FieldSpec) => spec.isMapped || spec.runtimeField) .filter((spec: FieldSpec) => spec.isMapped || spec.runtimeField)

View file

@ -278,4 +278,63 @@ describe('expression params validation', () => {
expect(validateExpression(initialParams).errors.size.length).toBe(0); expect(validateExpression(initialParams).errors.size.length).toBe(0);
expect(hasExpressionValidationErrors(initialParams)).toBe(false); expect(hasExpressionValidationErrors(initialParams)).toBe(false);
}); });
test('if esqlQuery property is not set should return proper error message', () => {
const initialParams = {
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '@timestamp',
searchType: SearchType.esqlQuery,
} as EsQueryRuleParams<SearchType.esqlQuery>;
expect(validateExpression(initialParams).errors.esqlQuery.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.esqlQuery[0]).toBe(`ESQL query is required.`);
});
test('if esqlQuery timeField property is not defined should return proper error message', () => {
const initialParams = {
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
esqlQuery: { esql: 'test' },
searchType: SearchType.esqlQuery,
} as EsQueryRuleParams<SearchType.esqlQuery>;
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
});
test('if esqlQuery thresholdComparator property is not gt should return proper error message', () => {
const initialParams = {
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
esqlQuery: { esql: 'test' },
searchType: SearchType.esqlQuery,
thresholdComparator: '<',
timeField: '@timestamp',
} as EsQueryRuleParams<SearchType.esqlQuery>;
expect(validateExpression(initialParams).errors.thresholdComparator.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.thresholdComparator[0]).toBe(
'Threshold comparator is required to be greater than.'
);
});
test('if esqlQuery threshold property is not 0 should return proper error message', () => {
const initialParams = {
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [8],
esqlQuery: { esql: 'test' },
searchType: SearchType.esqlQuery,
timeField: '@timestamp',
} as EsQueryRuleParams<SearchType.esqlQuery>;
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold0[0]).toBe(
'Threshold is required to be 0.'
);
});
}); });

View file

@ -12,11 +12,13 @@ import {
builtInComparators, builtInComparators,
builtInAggregationTypes, builtInAggregationTypes,
builtInGroupByTypes, builtInGroupByTypes,
COMPARATORS,
} from '@kbn/triggers-actions-ui-plugin/public'; } from '@kbn/triggers-actions-ui-plugin/public';
import { EsQueryRuleParams, SearchType } from './types'; import { EsQueryRuleParams, SearchType } from './types';
import { isSearchSourceRule } from './util'; import { isEsqlQueryRule, isSearchSourceRule } from './util';
import { import {
COMMON_EXPRESSION_ERRORS, COMMON_EXPRESSION_ERRORS,
ONLY_ESQL_QUERY_EXPRESSION_ERRORS,
ONLY_ES_QUERY_EXPRESSION_ERRORS, ONLY_ES_QUERY_EXPRESSION_ERRORS,
SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS, SEARCH_SOURCE_ONLY_EXPRESSION_ERRORS,
} from './constants'; } from './constants';
@ -221,6 +223,46 @@ const validateEsQueryParams = (ruleParams: EsQueryRuleParams<SearchType.esQuery>
return errors; return errors;
}; };
const validateEsqlQueryParams = (ruleParams: EsQueryRuleParams<SearchType.esqlQuery>) => {
const errors: typeof ONLY_ESQL_QUERY_EXPRESSION_ERRORS = defaultsDeep(
{},
ONLY_ESQL_QUERY_EXPRESSION_ERRORS
);
if (!ruleParams.esqlQuery) {
errors.esqlQuery.push(
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredQueryText', {
defaultMessage: 'ESQL query is required.',
})
);
}
if (!ruleParams.timeField) {
errors.timeField.push(
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredTimeFieldText', {
defaultMessage: 'Time field is required.',
})
);
}
if (ruleParams.thresholdComparator !== COMPARATORS.GREATER_THAN) {
errors.thresholdComparator.push(
i18n.translate(
'xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThresholdComparatorText',
{
defaultMessage: 'Threshold comparator is required to be greater than.',
}
)
);
}
if (ruleParams.threshold && ruleParams.threshold[0] !== 0) {
errors.threshold0.push(
i18n.translate('xpack.stackAlerts.esqlQuery.ui.validation.error.requiredThreshold0Text', {
defaultMessage: 'Threshold is required to be 0.',
})
);
}
return errors;
};
export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationResult => { export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationResult => {
const validationResult = { errors: {} }; const validationResult = { errors: {} };
@ -234,8 +276,7 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes
* It's important to report searchSource rule related errors only into errors.searchConfiguration prop. * It's important to report searchSource rule related errors only into errors.searchConfiguration prop.
* For example errors.index is a mistake to report searchSource rule related errors. It will lead to issues. * For example errors.index is a mistake to report searchSource rule related errors. It will lead to issues.
*/ */
const isSearchSource = isSearchSourceRule(ruleParams); if (isSearchSourceRule(ruleParams)) {
if (isSearchSource) {
validationResult.errors = { validationResult.errors = {
...validationResult.errors, ...validationResult.errors,
...validateSearchSourceParams(ruleParams), ...validateSearchSourceParams(ruleParams),
@ -243,6 +284,14 @@ export const validateExpression = (ruleParams: EsQueryRuleParams): ValidationRes
return validationResult; return validationResult;
} }
if (isEsqlQueryRule(ruleParams)) {
validationResult.errors = {
...validationResult.errors,
...validateEsqlQueryParams(ruleParams),
};
return validationResult;
}
const esQueryErrors = validateEsQueryParams(ruleParams as EsQueryRuleParams<SearchType.esQuery>); const esQueryErrors = validateEsQueryParams(ruleParams as EsQueryRuleParams<SearchType.esQuery>);
validationResult.errors = { ...validationResult.errors, ...esQueryErrors }; validationResult.errors = { ...validationResult.errors, ...esQueryErrors };
return validationResult; return validationResult;

View file

@ -167,6 +167,7 @@ describe('getContextConditionsDescription', () => {
comparator: Comparator.GT, comparator: Comparator.GT,
threshold: [10], threshold: [10],
aggType: 'count', aggType: 'count',
searchType: 'esQuery',
}); });
expect(result).toBe(`Number of matching documents is greater than 10`); expect(result).toBe(`Number of matching documents is greater than 10`);
}); });
@ -177,6 +178,7 @@ describe('getContextConditionsDescription', () => {
threshold: [10], threshold: [10],
aggType: 'count', aggType: 'count',
isRecovered: true, isRecovered: true,
searchType: 'esQuery',
}); });
expect(result).toBe(`Number of matching documents is NOT greater than 10`); expect(result).toBe(`Number of matching documents is NOT greater than 10`);
}); });
@ -187,6 +189,7 @@ describe('getContextConditionsDescription', () => {
threshold: [10, 20], threshold: [10, 20],
aggType: 'count', aggType: 'count',
isRecovered: true, isRecovered: true,
searchType: 'esQuery',
}); });
expect(result).toBe(`Number of matching documents is NOT between 10 and 20`); expect(result).toBe(`Number of matching documents is NOT between 10 and 20`);
}); });
@ -197,6 +200,7 @@ describe('getContextConditionsDescription', () => {
threshold: [10], threshold: [10],
aggType: 'count', aggType: 'count',
group: 'host-1', group: 'host-1',
searchType: 'esQuery',
}); });
expect(result).toBe(`Number of matching documents for group "host-1" is greater than 10`); expect(result).toBe(`Number of matching documents for group "host-1" is greater than 10`);
}); });
@ -208,6 +212,7 @@ describe('getContextConditionsDescription', () => {
aggType: 'count', aggType: 'count',
isRecovered: true, isRecovered: true,
group: 'host-1', group: 'host-1',
searchType: 'esQuery',
}); });
expect(result).toBe(`Number of matching documents for group "host-1" is NOT greater than 10`); expect(result).toBe(`Number of matching documents for group "host-1" is NOT greater than 10`);
}); });
@ -218,6 +223,7 @@ describe('getContextConditionsDescription', () => {
threshold: [10], threshold: [10],
aggType: 'min', aggType: 'min',
aggField: 'numericField', aggField: 'numericField',
searchType: 'esQuery',
}); });
expect(result).toBe( expect(result).toBe(
`Number of matching documents where min of numericField is greater than 10` `Number of matching documents where min of numericField is greater than 10`
@ -231,6 +237,7 @@ describe('getContextConditionsDescription', () => {
aggType: 'min', aggType: 'min',
aggField: 'numericField', aggField: 'numericField',
isRecovered: true, isRecovered: true,
searchType: 'esQuery',
}); });
expect(result).toBe( expect(result).toBe(
`Number of matching documents where min of numericField is NOT greater than 10` `Number of matching documents where min of numericField is NOT greater than 10`
@ -244,6 +251,7 @@ describe('getContextConditionsDescription', () => {
group: 'host-1', group: 'host-1',
aggType: 'max', aggType: 'max',
aggField: 'numericField', aggField: 'numericField',
searchType: 'esQuery',
}); });
expect(result).toBe( expect(result).toBe(
`Number of matching documents for group "host-1" where max of numericField is greater than 10` `Number of matching documents for group "host-1" where max of numericField is greater than 10`
@ -258,9 +266,31 @@ describe('getContextConditionsDescription', () => {
group: 'host-1', group: 'host-1',
aggType: 'max', aggType: 'max',
aggField: 'numericField', aggField: 'numericField',
searchType: 'esQuery',
}); });
expect(result).toBe( expect(result).toBe(
`Number of matching documents for group "host-1" where max of numericField is NOT greater than 10` `Number of matching documents for group "host-1" where max of numericField is NOT greater than 10`
); );
}); });
it('should return conditions correctly for ESQL search type', () => {
const result = getContextConditionsDescription({
comparator: Comparator.GT,
threshold: [0],
aggType: 'count',
searchType: 'esqlQuery',
});
expect(result).toBe(`Query matched documents`);
});
it('should return conditions correctly ESQL search type when isRecovered is true', () => {
const result = getContextConditionsDescription({
comparator: Comparator.GT,
threshold: [0],
aggType: 'count',
isRecovered: true,
searchType: 'esqlQuery',
});
expect(result).toBe(`Query did NOT match documents`);
});
}); });

View file

@ -11,6 +11,7 @@ import { AlertInstanceContext } from '@kbn/alerting-plugin/server';
import { EsQueryRuleParams } from './rule_type_params'; import { EsQueryRuleParams } from './rule_type_params';
import { Comparator } from '../../../common/comparator_types'; import { Comparator } from '../../../common/comparator_types';
import { getHumanReadableComparator } from '../../../common'; import { getHumanReadableComparator } from '../../../common';
import { isEsqlQueryRule } from './util';
// rule type context provided to actions // rule type context provided to actions
export interface ActionContext extends EsQueryRuleActionContext { export interface ActionContext extends EsQueryRuleActionContext {
@ -79,6 +80,7 @@ export function addMessages({
} }
interface GetContextConditionsDescriptionOpts { interface GetContextConditionsDescriptionOpts {
searchType: 'searchSource' | 'esQuery' | 'esqlQuery';
comparator: Comparator; comparator: Comparator;
threshold: number[]; threshold: number[];
aggType: string; aggType: string;
@ -88,6 +90,7 @@ interface GetContextConditionsDescriptionOpts {
} }
export function getContextConditionsDescription({ export function getContextConditionsDescription({
searchType,
comparator, comparator,
threshold, threshold,
aggType, aggType,
@ -95,15 +98,23 @@ export function getContextConditionsDescription({
isRecovered = false, isRecovered = false,
group, group,
}: GetContextConditionsDescriptionOpts) { }: GetContextConditionsDescriptionOpts) {
return i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', { return isEsqlQueryRule(searchType)
defaultMessage: ? i18n.translate('xpack.stackAlerts.esQuery.esqlAlertTypeContextConditionsDescription', {
'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}', defaultMessage: 'Query{negation} documents{groupCondition}',
values: { values: {
aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`, groupCondition: group ? ` for group "${group}"` : '',
groupCondition: group ? ` for group "${group}"` : '', negation: isRecovered ? ' did NOT match' : ' matched',
thresholdComparator: getHumanReadableComparator(comparator), },
threshold: threshold.join(' and '), })
negation: isRecovered ? 'NOT ' : '', : i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', {
}, defaultMessage:
}); 'Number of matching documents{groupCondition}{aggCondition} is {negation}{thresholdComparator} {threshold}',
values: {
aggCondition: aggType === 'count' ? '' : ` where ${aggType} of ${aggField}`,
groupCondition: group ? ` for group "${group}"` : '',
thresholdComparator: getHumanReadableComparator(comparator),
threshold: threshold.join(' and '),
negation: isRecovered ? 'NOT ' : '',
},
});
} }

View file

@ -17,6 +17,7 @@ import { ISearchStartSearchSource } from '@kbn/data-plugin/common';
import { EsQueryRuleParams } from './rule_type_params'; import { EsQueryRuleParams } from './rule_type_params';
import { FetchEsQueryOpts } from './lib/fetch_es_query'; import { FetchEsQueryOpts } from './lib/fetch_es_query';
import { FetchSearchSourceQueryOpts } from './lib/fetch_search_source_query'; import { FetchSearchSourceQueryOpts } from './lib/fetch_search_source_query';
import { FetchEsqlQueryOpts } from './lib/fetch_esql_query';
const logger = loggerMock.create(); const logger = loggerMock.create();
const scopedClusterClientMock = elasticsearchServiceMock.createScopedClusterClient(); const scopedClusterClientMock = elasticsearchServiceMock.createScopedClusterClient();
@ -44,6 +45,10 @@ jest.mock('./lib/fetch_search_source_query', () => ({
fetchSearchSourceQuery: (...args: [FetchSearchSourceQueryOpts]) => fetchSearchSourceQuery: (...args: [FetchSearchSourceQueryOpts]) =>
mockFetchSearchSourceQuery(...args), mockFetchSearchSourceQuery(...args),
})); }));
const mockFetchEsqlQuery = jest.fn();
jest.mock('./lib/fetch_esql_query', () => ({
fetchEsqlQuery: (...args: [FetchEsqlQueryOpts]) => mockFetchEsqlQuery(...args),
}));
const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]); const mockGetRecoveredAlerts = jest.fn().mockReturnValue([]);
const mockSetLimitReached = jest.fn(); const mockSetLimitReached = jest.fn();
@ -86,6 +91,8 @@ describe('es_query executor', () => {
excludeHitsFromPreviousRun: true, excludeHitsFromPreviousRun: true,
aggType: 'count', aggType: 'count',
groupBy: 'all', groupBy: 'all',
searchConfiguration: {},
esqlQuery: { esql: 'test-query' },
}; };
describe('executor', () => { describe('executor', () => {
@ -171,12 +178,12 @@ describe('es_query executor', () => {
}); });
await executor(coreMock, { await executor(coreMock, {
...defaultExecutorOptions, ...defaultExecutorOptions,
params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' }, params: { ...defaultProps, searchType: 'searchSource' },
}); });
expect(mockFetchSearchSourceQuery).toHaveBeenCalledWith({ expect(mockFetchSearchSourceQuery).toHaveBeenCalledWith({
ruleId: 'test-rule-id', ruleId: 'test-rule-id',
alertLimit: 1000, alertLimit: 1000,
params: { ...defaultProps, searchConfiguration: {}, searchType: 'searchSource' }, params: { ...defaultProps, searchType: 'searchSource' },
latestTimestamp: undefined, latestTimestamp: undefined,
services: { services: {
searchSourceClient: searchSourceClientMock, searchSourceClient: searchSourceClientMock,
@ -188,6 +195,42 @@ describe('es_query executor', () => {
expect(mockFetchEsQuery).not.toHaveBeenCalled(); expect(mockFetchEsQuery).not.toHaveBeenCalled();
}); });
it('should call fetchEsqlQuery if searchType is esqlQuery', async () => {
mockFetchEsqlQuery.mockResolvedValueOnce({
parsedResults: {
results: [
{
group: 'all documents',
count: 491,
hits: [],
},
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
});
await executor(coreMock, {
...defaultExecutorOptions,
params: { ...defaultProps, searchType: 'esqlQuery' },
});
expect(mockFetchEsqlQuery).toHaveBeenCalledWith({
ruleId: 'test-rule-id',
alertLimit: 1000,
params: { ...defaultProps, searchType: 'esqlQuery' },
services: {
scopedClusterClient: scopedClusterClientMock,
logger,
share: undefined,
dataViews: undefined,
},
spacePrefix: '',
publicBaseUrl: 'https://localhost:5601',
});
expect(mockFetchEsQuery).not.toHaveBeenCalled();
expect(mockFetchSearchSourceQuery).not.toHaveBeenCalled();
});
it('should not create alert if compare function returns false for ungrouped alert', async () => { it('should not create alert if compare function returns false for ungrouped alert', async () => {
mockFetchEsQuery.mockResolvedValueOnce({ mockFetchEsQuery.mockResolvedValueOnce({
parsedResults: { parsedResults: {
@ -455,6 +498,78 @@ describe('es_query executor', () => {
expect(mockSetLimitReached).toHaveBeenCalledWith(false); expect(mockSetLimitReached).toHaveBeenCalledWith(false);
}); });
it('should create alert if there are hits for ESQL alert', async () => {
mockFetchEsqlQuery.mockResolvedValueOnce({
parsedResults: {
results: [
{
group: 'all documents',
count: 198,
hits: [],
},
],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
...defaultExecutorOptions,
params: {
...defaultProps,
searchType: 'esqlQuery',
threshold: [0],
thresholdComparator: '>=' as Comparator,
},
});
expect(mockReport).toHaveBeenCalledTimes(1);
expect(mockReport).toHaveBeenNthCalledWith(1, {
actionGroup: 'query matched',
context: {
conditions: 'Query matched documents',
date: new Date(mockNow).toISOString(),
hits: [],
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
message: `rule 'test-rule-name' is active:
- Value: 198
- Conditions Met: Query matched documents over 5m
- Timestamp: ${new Date(mockNow).toISOString()}
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
title: "rule 'test-rule-name' matched query",
value: 198,
},
id: 'query matched',
payload: {
kibana: {
alert: {
evaluation: {
conditions: 'Query matched documents',
value: 198,
},
reason: `rule 'test-rule-name' is active:
- Value: 198
- Conditions Met: Query matched documents over 5m
- Timestamp: ${new Date(mockNow).toISOString()}
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
title: "rule 'test-rule-name' matched query",
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
},
},
},
state: {
dateEnd: new Date(mockNow).toISOString(),
dateStart: new Date(mockNow).toISOString(),
latestTimestamp: undefined,
},
});
expect(mockSetLimitReached).toHaveBeenCalledTimes(1);
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
});
it('should set limit as reached if results are truncated', async () => { it('should set limit as reached if results are truncated', async () => {
mockFetchEsQuery.mockResolvedValueOnce({ mockFetchEsQuery.mockResolvedValueOnce({
parsedResults: { parsedResults: {
@ -670,6 +785,74 @@ describe('es_query executor', () => {
- Value: 0 - Value: 0
- Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m - Conditions Met: Number of matching documents for group \"host-2\" is NOT greater than or equal to 200 over 5m
- Timestamp: ${new Date(mockNow).toISOString()} - Timestamp: ${new Date(mockNow).toISOString()}
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
title: "rule 'test-rule-name' recovered",
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
},
},
},
});
expect(mockSetLimitReached).toHaveBeenCalledTimes(1);
expect(mockSetLimitReached).toHaveBeenCalledWith(false);
});
it('should correctly handle recovered alerts for ESQL alert', async () => {
mockGetRecoveredAlerts.mockReturnValueOnce([
{
alert: {
getId: () => 'query matched',
},
},
]);
mockFetchEsqlQuery.mockResolvedValueOnce({
parsedResults: {
results: [],
truncated: false,
},
dateStart: new Date().toISOString(),
dateEnd: new Date().toISOString(),
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
});
await executor(coreMock, {
...defaultExecutorOptions,
params: {
...defaultProps,
searchType: 'esqlQuery',
threshold: [0],
thresholdComparator: '>=' as Comparator,
},
});
expect(mockReport).not.toHaveBeenCalled();
expect(mockSetAlertData).toHaveBeenCalledTimes(1);
expect(mockSetAlertData).toHaveBeenNthCalledWith(1, {
id: 'query matched',
context: {
conditions: 'Query did NOT match documents',
date: new Date(mockNow).toISOString(),
hits: [],
link: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',
message: `rule 'test-rule-name' is recovered:
- Value: 0
- Conditions Met: Query did NOT match documents over 5m
- Timestamp: ${new Date(mockNow).toISOString()}
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
title: "rule 'test-rule-name' recovered",
value: 0,
},
payload: {
kibana: {
alert: {
evaluation: {
conditions: 'Query did NOT match documents',
value: 0,
},
reason: `rule 'test-rule-name' is recovered:
- Value: 0
- Conditions Met: Query did NOT match documents over 5m
- Timestamp: ${new Date(mockNow).toISOString()}
- Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`, - Link: https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id`,
title: "rule 'test-rule-name' recovered", title: "rule 'test-rule-name' recovered",
url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id', url: 'https://localhost:5601/app/management/insightsAndAlerting/triggersActions/rule/test-rule-id',

View file

@ -19,15 +19,22 @@ import {
EsQueryRuleActionContext, EsQueryRuleActionContext,
getContextConditionsDescription, getContextConditionsDescription,
} from './action_context'; } from './action_context';
import { ExecutorOptions, OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types'; import {
ExecutorOptions,
OnlyEsQueryRuleParams,
OnlySearchSourceRuleParams,
OnlyEsqlQueryRuleParams,
} from './types';
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants'; import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
import { fetchEsQuery } from './lib/fetch_es_query'; import { fetchEsQuery } from './lib/fetch_es_query';
import { EsQueryRuleParams } from './rule_type_params'; import { EsQueryRuleParams } from './rule_type_params';
import { fetchSearchSourceQuery } from './lib/fetch_search_source_query'; import { fetchSearchSourceQuery } from './lib/fetch_search_source_query';
import { isEsQueryRule } from './util'; import { isEsqlQueryRule, isSearchSourceRule } from './util';
import { fetchEsqlQuery } from './lib/fetch_esql_query';
export async function executor(core: CoreSetup, options: ExecutorOptions<EsQueryRuleParams>) { export async function executor(core: CoreSetup, options: ExecutorOptions<EsQueryRuleParams>) {
const esQueryRule = isEsQueryRule(options.params.searchType); const searchSourceRule = isSearchSourceRule(options.params.searchType);
const esqlQueryRule = isEsqlQueryRule(options.params.searchType);
const { const {
rule: { id: ruleId, name }, rule: { id: ruleId, name },
services, services,
@ -54,21 +61,8 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
// avoid counting a document multiple times. // avoid counting a document multiple times.
// latestTimestamp will be ignored if set for grouped queries // latestTimestamp will be ignored if set for grouped queries
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp); let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
const { parsedResults, dateStart, dateEnd, link } = esQueryRule const { parsedResults, dateStart, dateEnd, link } = searchSourceRule
? await fetchEsQuery({ ? await fetchSearchSourceQuery({
ruleId,
name,
alertLimit,
params: params as OnlyEsQueryRuleParams,
timestamp: latestTimestamp,
publicBaseUrl,
spacePrefix,
services: {
scopedClusterClient,
logger,
},
})
: await fetchSearchSourceQuery({
ruleId, ruleId,
alertLimit, alertLimit,
params: params as OnlySearchSourceRuleParams, params: params as OnlySearchSourceRuleParams,
@ -80,8 +74,34 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
logger, logger,
dataViews, dataViews,
}, },
})
: esqlQueryRule
? await fetchEsqlQuery({
ruleId,
alertLimit,
params: params as OnlyEsqlQueryRuleParams,
spacePrefix,
publicBaseUrl,
services: {
share,
scopedClusterClient,
logger,
dataViews,
},
})
: await fetchEsQuery({
ruleId,
name,
alertLimit,
params: params as OnlyEsQueryRuleParams,
timestamp: latestTimestamp,
publicBaseUrl,
spacePrefix,
services: {
scopedClusterClient,
logger,
},
}); });
const unmetGroupValues: Record<string, number> = {}; const unmetGroupValues: Record<string, number> = {};
for (const result of parsedResults.results) { for (const result of parsedResults.results) {
const alertId = result.group; const alertId = result.group;
@ -105,6 +125,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
const baseActiveContext: EsQueryRuleActionContext = { const baseActiveContext: EsQueryRuleActionContext = {
...baseContext, ...baseContext,
conditions: getContextConditionsDescription({ conditions: getContextConditionsDescription({
searchType: params.searchType,
comparator: params.thresholdComparator, comparator: params.thresholdComparator,
threshold: params.threshold, threshold: params.threshold,
aggType: params.aggType, aggType: params.aggType,
@ -157,6 +178,7 @@ export async function executor(core: CoreSetup, options: ExecutorOptions<EsQuery
hits: [], hits: [],
link, link,
conditions: getContextConditionsDescription({ conditions: getContextConditionsDescription({
searchType: params.searchType,
comparator: params.thresholdComparator, comparator: params.thresholdComparator,
threshold: params.threshold, threshold: params.threshold,
isRecovered: true, isRecovered: true,

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { OnlyEsqlQueryRuleParams } from '../types';
import { stubbedSavedObjectIndexPattern } from '@kbn/data-views-plugin/common/data_view.stub';
import { DataView } from '@kbn/data-views-plugin/common';
import { fieldFormatsMock } from '@kbn/field-formats-plugin/common/mocks';
import { Comparator } from '../../../../common/comparator_types';
import { getEsqlQuery } from './fetch_esql_query';
const createDataView = () => {
const id = 'test-id';
const {
type,
version,
attributes: { timeFieldName, fields, title },
} = stubbedSavedObjectIndexPattern(id);
return new DataView({
spec: { id, type, version, timeFieldName, fields: JSON.parse(fields), title },
fieldFormats: fieldFormatsMock,
shortDotsEnable: false,
metaFields: ['_id', '_type', '_score'],
});
};
const defaultParams: OnlyEsqlQueryRuleParams = {
size: 100,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: Comparator.GT,
threshold: [0],
esqlQuery: { esql: 'from test' },
excludeHitsFromPreviousRun: false,
searchType: 'esqlQuery',
aggType: 'count',
groupBy: 'all',
timeField: 'time',
};
describe('fetchEsqlQuery', () => {
describe('getEsqlQuery', () => {
const dataViewMock = createDataView();
afterAll(() => {
jest.resetAllMocks();
});
const fakeNow = new Date('2020-02-09T23:15:41.941Z');
beforeAll(() => {
jest.resetAllMocks();
global.Date.now = jest.fn(() => fakeNow.getTime());
});
it('should generate the correct query', async () => {
const params = defaultParams;
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, undefined);
expect(query).toMatchInlineSnapshot(`
Object {
"filter": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"time": Object {
"format": "strict_date_optional_time",
"gt": "2020-02-09T23:10:41.941Z",
"lte": "2020-02-09T23:15:41.941Z",
},
},
},
],
},
},
"query": "from test",
}
`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
it('should generate the correct query with the alertLimit', async () => {
const params = defaultParams;
const { query, dateStart, dateEnd } = getEsqlQuery(dataViewMock, params, 100);
expect(query).toMatchInlineSnapshot(`
Object {
"filter": Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"time": Object {
"format": "strict_date_optional_time",
"gt": "2020-02-09T23:10:41.941Z",
"lte": "2020-02-09T23:15:41.941Z",
},
},
},
],
},
},
"query": "from test | limit 100",
}
`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
});
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { DataView, DataViewsContract, getTime } from '@kbn/data-plugin/common';
import { parseAggregationResults } from '@kbn/triggers-actions-ui-plugin/common';
import { SharePluginStart } from '@kbn/share-plugin/server';
import { IScopedClusterClient, Logger } from '@kbn/core/server';
import { OnlyEsqlQueryRuleParams } from '../types';
import { EsqlTable, toEsQueryHits } from '../../../../common';
export interface FetchEsqlQueryOpts {
ruleId: string;
alertLimit: number | undefined;
params: OnlyEsqlQueryRuleParams;
spacePrefix: string;
publicBaseUrl: string;
services: {
logger: Logger;
scopedClusterClient: IScopedClusterClient;
share: SharePluginStart;
dataViews: DataViewsContract;
};
}
export async function fetchEsqlQuery({
ruleId,
alertLimit,
params,
services,
spacePrefix,
publicBaseUrl,
}: FetchEsqlQueryOpts) {
const { logger, scopedClusterClient, dataViews } = services;
const esClient = scopedClusterClient.asCurrentUser;
const dataView = await dataViews.create({
timeFieldName: params.timeField,
});
const { query, dateStart, dateEnd } = getEsqlQuery(dataView, params, alertLimit);
logger.debug(`ESQL query rule (${ruleId}) query: ${JSON.stringify(query)}`);
const response = await esClient.transport.request<EsqlTable>({
method: 'POST',
path: '/_esql',
body: query,
});
const link = `${publicBaseUrl}${spacePrefix}/app/management/insightsAndAlerting/triggersActions/rule/${ruleId}`;
return {
link,
numMatches: Number(response.values.length),
parsedResults: parseAggregationResults({
isCountAgg: true,
isGroupAgg: false,
esResult: {
took: 0,
timed_out: false,
_shards: { failed: 0, successful: 0, total: 0 },
hits: toEsQueryHits(response),
},
resultLimit: alertLimit,
}),
dateStart,
dateEnd,
};
}
export const getEsqlQuery = (
dataView: DataView,
params: OnlyEsqlQueryRuleParams,
alertLimit: number | undefined
) => {
const timeRange = {
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
to: 'now',
};
const timerangeFilter = getTime(dataView, timeRange);
const dateStart = timerangeFilter?.query.range[params.timeField].gte;
const dateEnd = timerangeFilter?.query.range[params.timeField].lte;
const rangeFilter: unknown[] = [
{
range: {
[params.timeField]: {
lte: dateEnd,
gt: dateStart,
format: 'strict_date_optional_time',
},
},
},
];
const query = {
query: alertLimit ? `${params.esqlQuery.esql} | limit ${alertLimit}` : params.esqlQuery.esql,
filter: {
bool: {
filter: rangeFilter,
},
},
};
return {
query,
dateStart,
dateEnd,
};
};

View file

@ -19,7 +19,11 @@ import type { ESSearchResponse, ESSearchRequest } from '@kbn/es-types';
import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks'; import { elasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { coreMock } from '@kbn/core/server/mocks'; import { coreMock } from '@kbn/core/server/mocks';
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants'; import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
import { OnlyEsQueryRuleParams, OnlySearchSourceRuleParams } from './types'; import {
OnlyEsqlQueryRuleParams,
OnlyEsQueryRuleParams,
OnlySearchSourceRuleParams,
} from './types';
import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks'; import { searchSourceInstanceMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { Comparator } from '../../../common/comparator_types'; import { Comparator } from '../../../common/comparator_types';
import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings'; import { DEFAULT_FLAPPING_SETTINGS } from '@kbn/alerting-plugin/common/rules_settings';
@ -108,6 +112,10 @@ describe('ruleType', () => {
"description": "The indices the rule queries.", "description": "The indices the rule queries.",
"name": "index", "name": "index",
}, },
Object {
"description": "ESQL query field used to fetch data from Elasticsearch.",
"name": "esqlQuery",
},
], ],
} }
`); `);
@ -596,10 +604,6 @@ describe('ruleType', () => {
groupBy: 'all', groupBy: 'all',
}; };
afterAll(() => {
jest.resetAllMocks();
});
it('validator succeeds with valid search source params', async () => { it('validator succeeds with valid search source params', async () => {
expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy(); expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy();
}); });
@ -710,6 +714,144 @@ describe('ruleType', () => {
); );
}); });
}); });
describe('ESQL query', () => {
const dataViewMock = {
id: 'test-id',
title: 'test-title',
timeFieldName: 'time-field',
fields: [
{
name: 'message',
type: 'string',
displayName: 'message',
scripted: false,
filterable: false,
aggregatable: false,
},
{
name: 'timestamp',
type: 'date',
displayName: 'timestamp',
scripted: false,
filterable: false,
aggregatable: false,
},
],
toSpec: () => {
return { id: 'test-id', title: 'test-title', timeFieldName: 'timestamp', fields: [] };
},
};
const defaultParams: OnlyEsqlQueryRuleParams = {
size: 100,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: Comparator.GT,
threshold: [0],
esqlQuery: { esql: 'test' },
timeField: 'timestamp',
searchType: 'esqlQuery',
excludeHitsFromPreviousRun: true,
aggType: 'count',
groupBy: 'all',
};
it('validator succeeds with valid ESQL query params', async () => {
expect(ruleType.validate.params.validate(defaultParams)).toBeTruthy();
});
it('validator fails with invalid ESQL query params - esQuery provided', async () => {
const paramsSchema = ruleType.validate.params;
const params: Partial<Writable<EsQueryRuleParams>> = {
size: 100,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: Comparator.LT,
threshold: [0],
esQuery: '',
searchType: 'esqlQuery',
aggType: 'count',
groupBy: 'all',
};
expect(() => paramsSchema.validate(params)).toThrowErrorMatchingInlineSnapshot(
`"[esQuery]: a value wasn't expected to be present"`
);
});
it('rule executor handles no documents returned by ES', async () => {
const params = defaultParams;
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
});
const searchResult = {
columns: [
{ name: 'timestamp', type: 'date' },
{ name: 'message', type: 'keyword' },
],
values: [],
};
ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce(
searchResult
);
await invokeExecutor({ params, ruleServices });
expect(ruleServices.alertsClient.report).not.toHaveBeenCalled();
});
it('rule executor schedule actions when condition met', async () => {
const params = defaultParams;
const ruleServices: RuleExecutorServicesMock = alertsMock.createRuleExecutorServices();
(ruleServices.dataViews.create as jest.Mock).mockResolvedValueOnce({
...dataViewMock.toSpec(),
toSpec: () => dataViewMock.toSpec(),
});
const searchResult = {
columns: [
{ name: 'timestamp', type: 'date' },
{ name: 'message', type: 'keyword' },
],
values: [
['timestamp', 'message'],
['timestamp', 'message'],
['timestamp', 'message'],
],
};
ruleServices.scopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce(
searchResult
);
await invokeExecutor({ params, ruleServices });
expect(ruleServices.alertsClient.report).toHaveBeenCalledTimes(1);
expect(ruleServices.alertsClient.report).toHaveBeenCalledWith(
expect.objectContaining({
actionGroup: 'query matched',
id: 'query matched',
payload: expect.objectContaining({
kibana: {
alert: {
url: expect.any(String),
reason: expect.any(String),
title: "rule 'rule-name' matched query",
evaluation: {
conditions: 'Query matched documents',
value: 3,
},
},
},
}),
})
);
});
});
}); });
function generateResults( function generateResults(
@ -756,7 +898,7 @@ async function invokeExecutor({
ruleServices, ruleServices,
state, state,
}: { }: {
params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams; params: OnlySearchSourceRuleParams | OnlyEsQueryRuleParams | OnlyEsqlQueryRuleParams;
ruleServices: RuleExecutorServicesMock; ruleServices: RuleExecutorServicesMock;
state?: EsQueryRuleState; state?: EsQueryRuleState;
}) { }) {

View file

@ -25,7 +25,7 @@ import { STACK_ALERTS_FEATURE_ID } from '../../../common';
import { ExecutorOptions } from './types'; import { ExecutorOptions } from './types';
import { ActionGroupId, ES_QUERY_ID } from './constants'; import { ActionGroupId, ES_QUERY_ID } from './constants';
import { executor } from './executor'; import { executor } from './executor';
import { isEsQueryRule } from './util'; import { isSearchSourceRule } from './util';
export function getRuleType( export function getRuleType(
core: CoreSetup core: CoreSetup
@ -134,6 +134,13 @@ export function getRuleType(
} }
); );
const actionVariableEsqlQueryLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextEsqlQueryLabel',
{
defaultMessage: 'ESQL query field used to fetch data from Elasticsearch.',
}
);
const actionVariableContextLinkLabel = i18n.translate( const actionVariableContextLinkLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel', 'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel',
{ {
@ -179,25 +186,27 @@ export function getRuleType(
{ name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel }, { name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel },
{ name: 'esQuery', description: actionVariableContextQueryLabel }, { name: 'esQuery', description: actionVariableContextQueryLabel },
{ name: 'index', description: actionVariableContextIndexLabel }, { name: 'index', description: actionVariableContextIndexLabel },
{ name: 'esqlQuery', description: actionVariableEsqlQueryLabel },
], ],
}, },
useSavedObjectReferences: { useSavedObjectReferences: {
extractReferences: (params) => { extractReferences: (params) => {
if (isEsQueryRule(params.searchType)) { if (isSearchSourceRule(params.searchType)) {
return { params: params as EsQueryRuleParamsExtractedParams, references: [] }; const [searchConfiguration, references] = extractReferences(params.searchConfiguration);
const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams;
return { params: newParams, references };
} }
const [searchConfiguration, references] = extractReferences(params.searchConfiguration);
const newParams = { ...params, searchConfiguration } as EsQueryRuleParamsExtractedParams; return { params: params as EsQueryRuleParamsExtractedParams, references: [] };
return { params: newParams, references };
}, },
injectReferences: (params, references) => { injectReferences: (params, references) => {
if (isEsQueryRule(params.searchType)) { if (isSearchSourceRule(params.searchType)) {
return params; return {
...params,
searchConfiguration: injectReferences(params.searchConfiguration, references),
};
} }
return { return params;
...params,
searchConfiguration: injectReferences(params.searchConfiguration, references),
};
}, },
}, },
minimumLicenseRequired: 'basic', minimumLicenseRequired: 'basic',

View file

@ -18,6 +18,7 @@ import { SerializedSearchSourceFields } from '@kbn/data-plugin/common';
import { ComparatorFnNames } from '../../../common'; import { ComparatorFnNames } from '../../../common';
import { Comparator } from '../../../common/comparator_types'; import { Comparator } from '../../../common/comparator_types';
import { getComparatorSchemaType } from '../lib/comparator'; import { getComparatorSchemaType } from '../lib/comparator';
import { isEsqlQueryRule, isSearchSourceRule } from './util';
export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000; export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000;
@ -50,9 +51,12 @@ const EsQueryRuleParamsSchemaProperties = {
termField: schema.maybe(schema.string({ minLength: 1 })), termField: schema.maybe(schema.string({ minLength: 1 })),
// limit on number of groups returned // limit on number of groups returned
termSize: schema.maybe(schema.number({ min: 1 })), termSize: schema.maybe(schema.number({ min: 1 })),
searchType: schema.oneOf([schema.literal('searchSource'), schema.literal('esQuery')], { searchType: schema.oneOf(
defaultValue: 'esQuery', [schema.literal('searchSource'), schema.literal('esQuery'), schema.literal('esqlQuery')],
}), {
defaultValue: 'esQuery',
}
),
timeField: schema.conditional( timeField: schema.conditional(
schema.siblingRef('searchType'), schema.siblingRef('searchType'),
schema.literal('esQuery'), schema.literal('esQuery'),
@ -79,6 +83,13 @@ const EsQueryRuleParamsSchemaProperties = {
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }), schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
schema.never() schema.never()
), ),
// esqlQuery rule params only
esqlQuery: schema.conditional(
schema.siblingRef('searchType'),
schema.literal('esqlQuery'),
schema.object({ esql: schema.string({ minLength: 1 }) }),
schema.never()
),
}; };
export const EsQueryRuleParamsSchema = schema.object(EsQueryRuleParamsSchemaProperties, { export const EsQueryRuleParamsSchema = schema.object(EsQueryRuleParamsSchemaProperties, {
@ -142,7 +153,7 @@ function validateParams(anyParams: unknown): string | undefined {
} }
} }
if (searchType === 'searchSource') { if (isSearchSourceRule(searchType) || isEsqlQueryRule(searchType)) {
return; return;
} }

View file

@ -11,15 +11,26 @@ import { ActionContext } from './action_context';
import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params'; import { EsQueryRuleParams, EsQueryRuleState } from './rule_type_params';
import { ActionGroupId } from './constants'; import { ActionGroupId } from './constants';
export type OnlyEsQueryRuleParams = Omit<EsQueryRuleParams, 'searchConfiguration'> & { export type OnlyEsQueryRuleParams = Omit<EsQueryRuleParams, 'searchConfiguration' | 'esqlQuery'> & {
searchType: 'esQuery'; searchType: 'esQuery';
timeField: string; timeField: string;
}; };
export type OnlySearchSourceRuleParams = Omit<EsQueryRuleParams, 'esQuery' | 'index'> & { export type OnlySearchSourceRuleParams = Omit<
EsQueryRuleParams,
'esQuery' | 'index' | 'esqlQuery'
> & {
searchType: 'searchSource'; searchType: 'searchSource';
}; };
export type OnlyEsqlQueryRuleParams = Omit<
EsQueryRuleParams,
'esQuery' | 'index' | 'searchConfiguration'
> & {
searchType: 'esqlQuery';
timeField: string;
};
export type ExecutorOptions<P extends RuleTypeParams> = RuleExecutorOptions< export type ExecutorOptions<P extends RuleTypeParams> = RuleExecutorOptions<
P, P,
EsQueryRuleState, EsQueryRuleState,

View file

@ -8,5 +8,13 @@
import { EsQueryRuleParams } from './rule_type_params'; import { EsQueryRuleParams } from './rule_type_params';
export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) { export function isEsQueryRule(searchType: EsQueryRuleParams['searchType']) {
return searchType !== 'searchSource'; return searchType === 'esQuery';
}
export function isSearchSourceRule(searchType: EsQueryRuleParams['searchType']) {
return searchType === 'searchSource';
}
export function isEsqlQueryRule(searchType: EsQueryRuleParams['searchType']) {
return searchType === 'esqlQuery';
} }

View file

@ -44,6 +44,9 @@
"@kbn/discover-plugin", "@kbn/discover-plugin",
"@kbn/rule-data-utils", "@kbn/rule-data-utils",
"@kbn/alerts-as-data-utils", "@kbn/alerts-as-data-utils",
"@kbn/text-based-languages",
"@kbn/text-based-editor",
"@kbn/expressions-plugin",
], ],
"exclude": [ "exclude": [
"target/**/*", "target/**/*",

View file

@ -24,6 +24,7 @@
"actions", "actions",
"dashboard", "dashboard",
"licensing", "licensing",
"expressions"
], ],
"optionalPlugins": [ "optionalPlugins": [
"cloud", "cloud",

View file

@ -30,6 +30,7 @@ import { ActionsPublicPluginSetup } from '@kbn/actions-plugin/public';
import { ruleDetailsRoute } from '@kbn/rule-data-utils'; import { ruleDetailsRoute } from '@kbn/rule-data-utils';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public';
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
import { suspendedComponentWithProps } from './lib/suspended_component_with_props'; import { suspendedComponentWithProps } from './lib/suspended_component_with_props';
import { import {
ActionTypeRegistryContract, ActionTypeRegistryContract,
@ -70,6 +71,7 @@ export interface TriggersAndActionsUiServices extends CoreStart {
theme$: Observable<CoreTheme>; theme$: Observable<CoreTheme>;
unifiedSearch: UnifiedSearchPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart;
licensing: LicensingPluginStart; licensing: LicensingPluginStart;
expressions: ExpressionsStart;
} }
export const renderApp = (deps: TriggersAndActionsUiServices) => { export const renderApp = (deps: TriggersAndActionsUiServices) => {

View file

@ -24,7 +24,7 @@ export {
export { connectorDeprecatedMessage, deprecatedMessage } from './connectors_selection'; export { connectorDeprecatedMessage, deprecatedMessage } from './connectors_selection';
export type { IOption } from './index_controls'; export type { IOption } from './index_controls';
export { getFields, getIndexOptions, firstFieldOption } from './index_controls'; export { getFields, getIndexOptions, firstFieldOption } from './index_controls';
export { getTimeFieldOptions, useKibana } from './lib'; export { getTimeFieldOptions, getTimeOptions, useKibana } from './lib';
export type { export type {
Comparator, Comparator,
AggregationType, AggregationType,

View file

@ -21,6 +21,7 @@ import {
AlertsTableConfigurationRegistryContract, AlertsTableConfigurationRegistryContract,
} from '../../../types'; } from '../../../types';
import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public'; import { DataViewEditorStart } from '@kbn/data-view-editor-plugin/public';
import { expressionsPluginMock } from '@kbn/expressions-plugin/public/mocks';
export const createStartServicesMock = (): TriggersAndActionsUiServices => { export const createStartServicesMock = (): TriggersAndActionsUiServices => {
const core = coreMock.createStart(); const core = coreMock.createStart();
@ -70,6 +71,7 @@ export const createStartServicesMock = (): TriggersAndActionsUiServices => {
} as unknown as HTMLElement, } as unknown as HTMLElement,
theme$: themeServiceMock.createTheme$(), theme$: themeServiceMock.createTheme$(),
licensing: licensingPluginMock, licensing: licensingPluginMock,
expressions: expressionsPluginMock.createStartContract(),
} as TriggersAndActionsUiServices; } as TriggersAndActionsUiServices;
}; };

View file

@ -116,6 +116,7 @@ export {
getIndexOptions, getIndexOptions,
firstFieldOption, firstFieldOption,
getTimeFieldOptions, getTimeFieldOptions,
getTimeOptions,
GroupByExpression, GroupByExpression,
COMPARATORS, COMPARATORS,
connectorDeprecatedMessage, connectorDeprecatedMessage,

View file

@ -26,6 +26,7 @@ import type { UnifiedSearchPublicPluginStart } from '@kbn/unified-search-plugin/
import { triggersActionsRoute } from '@kbn/rule-data-utils'; import { triggersActionsRoute } from '@kbn/rule-data-utils';
import { DashboardStart } from '@kbn/dashboard-plugin/public'; import { DashboardStart } from '@kbn/dashboard-plugin/public';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar'; import type { AlertsSearchBarProps } from './application/sections/alerts_search_bar';
import { TypeRegistry } from './application/type_registry'; import { TypeRegistry } from './application/type_registry';
@ -161,6 +162,7 @@ interface PluginsStart {
spaces?: SpacesPluginStart; spaces?: SpacesPluginStart;
navigateToApp: CoreStart['application']['navigateToApp']; navigateToApp: CoreStart['application']['navigateToApp'];
features: FeaturesPluginStart; features: FeaturesPluginStart;
expressions: ExpressionsStart;
unifiedSearch: UnifiedSearchPublicPluginStart; unifiedSearch: UnifiedSearchPublicPluginStart;
licensing: LicensingPluginStart; licensing: LicensingPluginStart;
} }
@ -287,6 +289,7 @@ export class Plugin
alertsTableConfigurationRegistry, alertsTableConfigurationRegistry,
kibanaFeatures, kibanaFeatures,
licensing: pluginsStart.licensing, licensing: pluginsStart.licensing,
expressions: pluginsStart.expressions,
}); });
}, },
}); });

View file

@ -54,6 +54,7 @@
"@kbn/core-ui-settings-common", "@kbn/core-ui-settings-common",
"@kbn/dashboard-plugin", "@kbn/dashboard-plugin",
"@kbn/licensing-plugin", "@kbn/licensing-plugin",
"@kbn/expressions-plugin",
], ],
"exclude": ["target/**/*"] "exclude": ["target/**/*"]
} }

View file

@ -0,0 +1,340 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { Spaces } from '../../../../../scenarios';
import { FtrProviderContext } from '../../../../../../common/ftr_provider_context';
import { getUrlPrefix, ObjectRemover } from '../../../../../../common/lib';
import {
createConnector,
ES_GROUPS_TO_WRITE,
ES_TEST_DATA_STREAM_NAME,
ES_TEST_INDEX_REFERENCE,
ES_TEST_INDEX_SOURCE,
ES_TEST_OUTPUT_INDEX_NAME,
getRuleServices,
RULE_INTERVALS_TO_WRITE,
RULE_INTERVAL_MILLIS,
RULE_INTERVAL_SECONDS,
RULE_TYPE_ID,
} from './common';
import { createDataStream, deleteDataStream } from '../../../create_test_data';
// eslint-disable-next-line import/no-default-export
export default function ruleTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const {
es,
esTestIndexTool,
esTestIndexToolOutput,
esTestIndexToolDataStream,
createEsDocumentsInGroups,
removeAllAADDocs,
getAllAADDocs,
} = getRuleServices(getService);
describe('rule', async () => {
let endDate: string;
let connectorId: string;
const objectRemover = new ObjectRemover(supertest);
beforeEach(async () => {
await esTestIndexTool.destroy();
await esTestIndexTool.setup();
await esTestIndexToolOutput.destroy();
await esTestIndexToolOutput.setup();
connectorId = await createConnector(supertest, objectRemover, ES_TEST_OUTPUT_INDEX_NAME);
// write documents in the future, figure out the end date
const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS;
endDate = new Date(endDateMillis).toISOString();
await createDataStream(es, ES_TEST_DATA_STREAM_NAME);
});
afterEach(async () => {
await objectRemover.removeAll();
await esTestIndexTool.destroy();
await esTestIndexToolOutput.destroy();
await deleteDataStream(es, ES_TEST_DATA_STREAM_NAME);
await removeAllAADDocs();
});
it('runs correctly: threshold on ungrouped hit count < >', async () => {
// write documents from now to the future end date in groups
await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate);
await createRule({
name: 'never fire',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0',
size: 100,
});
await createRule({
name: 'always fire',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1',
size: 100,
});
const docs = await waitForDocs(2);
const messagePattern =
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`rule 'always fire' matched query`);
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
}
const aadDocs = await getAllAADDocs(1);
const alertDoc = aadDocs.body.hits.hits[0]._source.kibana.alert;
expect(alertDoc.reason).to.match(messagePattern);
expect(alertDoc.title).to.be("rule 'always fire' matched query");
expect(alertDoc.evaluation.conditions).to.be('Query matched documents');
expect(alertDoc.evaluation.value).greaterThan(0);
expect(alertDoc.url).to.contain('/s/space1/app/');
});
it('runs correctly: use epoch millis - threshold on hit count < >', async () => {
// write documents from now to the future end date in groups
const endDateMillis = Date.now() + (RULE_INTERVALS_TO_WRITE - 1) * RULE_INTERVAL_MILLIS;
endDate = new Date(endDateMillis).toISOString();
await createEsDocumentsInGroups(ES_GROUPS_TO_WRITE, endDate);
await createRule({
name: 'never fire',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 0',
size: 100,
timeField: 'date_epoch_millis',
});
await createRule({
name: 'always fire',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c > -1',
size: 100,
timeField: 'date_epoch_millis',
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`rule 'always fire' matched query`);
const messagePattern =
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
}
});
it('runs correctly: no matches', async () => {
await createRule({
name: 'always fire',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1',
size: 100,
});
const docs = await waitForDocs(1);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`rule 'always fire' matched query`);
const messagePattern =
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
}
});
it('runs correctly and populates recovery context', async () => {
// This rule should be active initially when the number of documents is below the threshold
// and then recover when we add more documents.
await createRule({
name: 'fire then recovers',
esqlQuery: 'from .kibana-alerting-test-data | stats c = count(date) | where c < 1',
size: 100,
notifyWhen: 'onActionGroupChange',
timeWindowSize: RULE_INTERVAL_SECONDS,
});
let docs = await waitForDocs(1);
const activeDoc = docs[0];
const {
name: activeName,
title: activeTitle,
value: activeValue,
message: activeMessage,
} = activeDoc._source.params;
expect(activeName).to.be('fire then recovers');
expect(activeTitle).to.be(`rule 'fire then recovers' matched query`);
expect(activeValue).to.be('1');
expect(activeMessage).to.match(
/rule 'fire then recovers' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/
);
await createEsDocumentsInGroups(1, endDate);
docs = await waitForDocs(2);
const recoveredDoc = docs[1];
const {
name: recoveredName,
title: recoveredTitle,
message: recoveredMessage,
} = recoveredDoc._source.params;
expect(recoveredName).to.be('fire then recovers');
expect(recoveredTitle).to.be(`rule 'fire then recovers' recovered`);
expect(recoveredMessage).to.match(
/rule 'fire then recovers' is recovered:\n\n- Value: \d+\n- Conditions Met: Query did NOT match documents over 4s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/
);
});
it('runs correctly over a data stream: threshold on hit count < >', async () => {
// write documents from now to the future end date in groups
await createEsDocumentsInGroups(
ES_GROUPS_TO_WRITE,
endDate,
esTestIndexToolDataStream,
ES_TEST_DATA_STREAM_NAME
);
await createRule({
name: 'never fire',
esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c < 0',
size: 100,
});
await createRule({
name: 'always fire',
esqlQuery: 'from test-data-stream | stats c = count(@timestamp) | where c > -1',
size: 100,
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`rule 'always fire' matched query`);
const messagePattern =
/rule 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Query matched documents over 20s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z\n- Link:/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
}
});
async function waitForDocs(count: number): Promise<any[]> {
return await esTestIndexToolOutput.waitForDocs(
ES_TEST_INDEX_SOURCE,
ES_TEST_INDEX_REFERENCE,
count
);
}
interface CreateRuleParams {
name: string;
size: number;
esqlQuery: string;
timeWindowSize?: number;
timeField?: string;
notifyWhen?: string;
aggType?: string;
aggField?: string;
groupBy?: string;
termField?: string;
termSize?: number;
}
async function createRule(params: CreateRuleParams): Promise<string> {
const action = {
id: connectorId,
group: 'query matched',
params: {
documents: [
{
source: ES_TEST_INDEX_SOURCE,
reference: ES_TEST_INDEX_REFERENCE,
params: {
name: '{{{rule.name}}}',
value: '{{{context.value}}}',
title: '{{{context.title}}}',
message: '{{{context.message}}}',
},
hits: '{{context.hits}}',
date: '{{{context.date}}}',
previousTimestamp: '{{{state.latestTimestamp}}}',
},
],
},
};
const recoveryAction = {
id: connectorId,
group: 'recovered',
params: {
documents: [
{
source: ES_TEST_INDEX_SOURCE,
reference: ES_TEST_INDEX_REFERENCE,
params: {
name: '{{{rule.name}}}',
value: '{{{context.value}}}',
title: '{{{context.title}}}',
message: '{{{context.message}}}',
},
hits: '{{context.hits}}',
date: '{{{context.date}}}',
},
],
},
};
const { body: createdRule } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
.send({
name: params.name,
consumer: 'alerts',
enabled: true,
rule_type_id: RULE_TYPE_ID,
schedule: { interval: `${RULE_INTERVAL_SECONDS}s` },
actions: [action, recoveryAction],
notify_when: params.notifyWhen || 'onActiveAlert',
params: {
size: params.size,
timeWindowSize: params.timeWindowSize || RULE_INTERVAL_SECONDS * 5,
timeWindowUnit: 's',
thresholdComparator: '>',
threshold: [0],
searchType: 'esqlQuery',
aggType: params.aggType,
groupBy: params.groupBy,
aggField: params.aggField,
termField: params.termField,
termSize: params.termSize,
timeField: params.timeField || 'date',
esqlQuery: { esql: params.esqlQuery },
},
})
.expect(200);
const ruleId = createdRule.id;
objectRemover.add(Spaces.space1.id, ruleId, 'rule', 'alerting');
return ruleId;
}
});
}

View file

@ -12,5 +12,6 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) {
describe('es_query', () => { describe('es_query', () => {
loadTestFile(require.resolve('./rule')); loadTestFile(require.resolve('./rule'));
loadTestFile(require.resolve('./query_dsl_only')); loadTestFile(require.resolve('./query_dsl_only'));
loadTestFile(require.resolve('./esql_only'));
}); });
} }