[Discover] Extend Elasticsearch query rule with search source based data fetching (#124534)

* [Discover] introduce .index-threshold rule

* [Discover] change filters in alert expression

* [Discover] fix cursor issue

* [Discover] add loading

* [Discover] separate validation params

* [Discover] add view alert route

* [Discover] enable "view in app" for alert created from discover

* [Discover] fix filter popover

* [Discover] fix linting, unit tests

* [Discover] fix remaining tests

* [Discover] add unit tests, add link back to stack management for es query

* Update src/plugins/discover/public/application/view_alert/view_alert_route.tsx

* [Discover] add tool tip for data view without time field

* [Discover] add info alert about possible document difference that triggered alert and displayed documents

* [Discover] update unit test

* [Discover] fix unit tests

* Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update src/plugins/discover/public/application/main/components/top_nav/open_alerts_popover.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* [Discover] fix unit tests

* [Discover] fix security solution alerts

* [Discover] fix eslint errors

* [Discover] fix unit tests

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* [Discover] apply suggestions

* [Discover] fix tests

* Update x-pack/plugins/stack_alerts/server/alert_types/es_query/alert_type/alert_type.ts

* [Discover] remove close button in filters

* Improve code structure

* Fix missing name in fetchEsQuery

* Fix messages

* Fix messages, again

* Refactor

* Refactor, add tests + a bit more of documentation

* Move size field, change text

* Implement readonly callout

* change icon in callout

* add padding to popover

* Hide query and filter UI if there are no values to display

* [Discover] add unit test, improve comparator types

* [Discover] fix linting and unit test

* [Discover] add es query alert integration tests

* [Discover] fix linting

* [Discover] uncomment one expect

* [Discover] fix latesTimestamp for searchSource type, unify test logic

* Update x-pack/plugins/stack_alerts/public/alert_types/es_query/expression/search_source_expression.tsx

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>

* [Discover] apply suggestions

* [Discover] make searchType optional, adjust tests

* [Discover] remove updated translations

* [Discover] apply suggestions

* [Discover] fix unit test

* [Discover] close popover on alert rule creation

* [Discover] apply suggestions

* [Discover] add first functional test

* [Discover] implement tests

* Move functionals x-pack since ssl is needed

* Fix potential flakiness in functional test

* [Discover] remove timeout waiter

* Fix functional test

- adding permissions to fix the functional

* [Discover] add logger

* [Discover] add more log points

* [Discover] wait for indices creation finished

* Try to fix the functional flakiness
- by creating data views in a serial way
- lets see if that work

Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com>
Co-authored-by: Matthias Wilhelm <matthias.wilhelm@elastic.co>
Co-authored-by: andreadelrio <andrea.delrio@elastic.co>
This commit is contained in:
Dmitry Tomashevich 2022-04-01 14:57:57 +05:00 committed by GitHub
parent f9d83f9b8b
commit 0427952e76
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
84 changed files with 3578 additions and 1305 deletions

View file

@ -20,7 +20,12 @@ export * from './deprecated';
export { getEsQueryConfig, FilterStateStore } from '../common';
export { FilterLabel, FilterItem } from './ui';
export { getDisplayValueFromFilter, generateFilters, extractTimeRange } from './query';
export {
getDisplayValueFromFilter,
generateFilters,
extractTimeRange,
getIndexPatternFromFilter,
} from './query';
/**
* Exporters (CSV)

View file

@ -88,3 +88,8 @@
.globalFilterItem__popoverAnchor {
display: block;
}
.globalFilterItem__readonlyPanel {
min-width: auto;
padding: $euiSizeM;
}

View file

@ -30,7 +30,7 @@ import { IDataPluginServices, IIndexPattern } from '../..';
import { UI_SETTINGS } from '../../../common';
interface Props {
export interface Props {
filters: Filter[];
onFiltersUpdated?: (filters: Filter[]) => void;
className: string;

View file

@ -17,11 +17,17 @@ export interface FilterLabelProps {
filter: Filter;
valueLabel?: string;
filterLabelStatus?: FilterLabelStatus;
hideAlias?: boolean;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: FilterLabelProps) {
export default function FilterLabel({
filter,
valueLabel,
filterLabelStatus,
hideAlias,
}: FilterLabelProps) {
const prefixText = filter.meta.negate
? ` ${i18n.translate('data.filter.filterBar.negatedFilterPrefix', {
defaultMessage: 'NOT ',
@ -38,7 +44,7 @@ export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: F
return <span className="globalFilterLabel__value">{text}</span>;
};
if (filter.meta.alias !== null) {
if (!hideAlias && filter.meta.alias !== null) {
return (
<Fragment>
{prefix}

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { EuiContextMenu, EuiPopover } from '@elastic/eui';
import { EuiContextMenu, EuiPopover, EuiPopoverProps } from '@elastic/eui';
import { InjectedIntl } from '@kbn/i18n-react';
import {
Filter,
@ -16,7 +16,7 @@ import {
toggleFilterDisabled,
} from '@kbn/es-query';
import classNames from 'classnames';
import React, { MouseEvent, useState, useEffect } from 'react';
import React, { MouseEvent, useState, useEffect, HTMLAttributes } from 'react';
import { IUiSettingsClient } from 'src/core/public';
import { FilterEditor } from './filter_editor';
import { FilterView } from './filter_view';
@ -37,8 +37,11 @@ export interface FilterItemProps {
uiSettings: IUiSettingsClient;
hiddenPanelOptions?: PanelOptions[];
timeRangeForSuggestionsOverride?: boolean;
readonly?: boolean;
}
type FilterPopoverProps = HTMLAttributes<HTMLDivElement> & EuiPopoverProps;
interface LabelOptions {
title: string;
status: FilterLabelStatus;
@ -349,32 +352,44 @@ export function FilterItem(props: FilterItemProps) {
return null;
}
const badge = (
<FilterView
filter={filter}
valueLabel={valueLabelConfig.title}
filterLabelStatus={valueLabelConfig.status}
errorMessage={valueLabelConfig.message}
className={getClasses(filter.meta.negate ?? false, valueLabelConfig)}
iconOnClick={() => props.onRemove()}
onClick={handleBadgeClick}
data-test-subj={getDataTestSubj(valueLabelConfig)}
/>
);
const filterViewProps = {
filter,
valueLabel: valueLabelConfig.title,
filterLabelStatus: valueLabelConfig.status,
errorMessage: valueLabelConfig.message,
className: getClasses(!!filter.meta.negate, valueLabelConfig),
iconOnClick: props.onRemove,
onClick: handleBadgeClick,
'data-test-subj': getDataTestSubj(valueLabelConfig),
readonly: props.readonly,
};
const popoverProps: FilterPopoverProps = {
id: `popoverFor_filter${id}`,
className: `globalFilterItem__popover`,
anchorClassName: `globalFilterItem__popoverAnchor`,
isOpen: isPopoverOpen,
closePopover: () => {
setIsPopoverOpen(false);
},
button: <FilterView {...filterViewProps} />,
panelPaddingSize: 'none',
};
if (props.readonly) {
return (
<EuiPopover
panelClassName="globalFilterItem__readonlyPanel"
anchorPosition="upCenter"
{...popoverProps}
>
<FilterView {...filterViewProps} hideAlias />
</EuiPopover>
);
}
return (
<EuiPopover
id={`popoverFor_filter${id}`}
className={`globalFilterItem__popover`}
anchorClassName={`globalFilterItem__popoverAnchor`}
isOpen={isPopoverOpen}
closePopover={() => {
setIsPopoverOpen(false);
}}
button={badge}
anchorPosition="downLeft"
panelPaddingSize="none"
>
<EuiPopover anchorPosition="downLeft" {...popoverProps}>
<EuiContextMenu initialPanelId={0} panels={getPanels()} />
</EuiPopover>
);

View file

@ -6,7 +6,7 @@
* Side Public License, v 1.
*/
import { EuiBadge, useInnerText } from '@elastic/eui';
import { EuiBadge, EuiBadgeProps, useInnerText } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { FC } from 'react';
import { Filter, isFilterPinned } from '@kbn/es-query';
@ -18,6 +18,8 @@ interface Props {
valueLabel: string;
filterLabelStatus: FilterLabelStatus;
errorMessage?: string;
readonly?: boolean;
hideAlias?: boolean;
[propName: string]: any;
}
@ -28,6 +30,8 @@ export const FilterView: FC<Props> = ({
valueLabel,
errorMessage,
filterLabelStatus,
readonly,
hideAlias,
...rest
}: Props) => {
const [ref, innerText] = useInnerText();
@ -50,33 +54,45 @@ export const FilterView: FC<Props> = ({
})} ${title}`;
}
const badgeProps: EuiBadgeProps = readonly
? {
title,
color: 'hollow',
onClick,
onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemReadOnlyBadgeAriaLabel', {
defaultMessage: 'Filter entry',
}),
iconOnClick,
}
: {
title,
color: 'hollow',
iconType: 'cross',
iconSide: 'right',
closeButtonProps: {
// Removing tab focus on close button because the same option can be obtained through the context menu
// Also, we may want to add a `DEL` keyboard press functionality
tabIndex: -1,
},
iconOnClick,
iconOnClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeIconAriaLabel', {
defaultMessage: 'Delete {filter}',
values: { filter: innerText },
}),
onClick,
onClickAriaLabel: i18n.translate('data.filter.filterBar.filterItemBadgeAriaLabel', {
defaultMessage: 'Filter actions',
}),
};
return (
<EuiBadge
title={title}
color="hollow"
iconType="cross"
iconSide="right"
closeButtonProps={{
// Removing tab focus on close button because the same option can be obtained through the context menu
// Also, we may want to add a `DEL` keyboard press functionality
tabIndex: -1,
}}
iconOnClick={iconOnClick}
iconOnClickAriaLabel={i18n.translate('data.filter.filterBar.filterItemBadgeIconAriaLabel', {
defaultMessage: 'Delete {filter}',
values: { filter: innerText },
})}
onClick={onClick}
onClickAriaLabel={i18n.translate('data.filter.filterBar.filterItemBadgeAriaLabel', {
defaultMessage: 'Filter actions',
})}
{...rest}
>
<EuiBadge {...badgeProps} {...rest}>
<span ref={ref}>
<FilterLabel
filter={filter}
valueLabel={valueLabel}
filterLabelStatus={filterLabelStatus}
hideAlias={hideAlias}
/>
</span>
</EuiBadge>

View file

@ -16,7 +16,7 @@
"dataViewFieldEditor",
"dataViewEditor"
],
"optionalPlugins": ["home", "share", "usageCollection", "spaces"],
"optionalPlugins": ["home", "share", "usageCollection", "spaces", "triggersActionsUi"],
"requiredBundles": ["kibanaUtils", "home", "kibanaReact", "dataViews"],
"extraPublicDirs": ["common"],
"owner": {

View file

@ -16,6 +16,7 @@ import { SingleDocRoute } from './doc';
import { DiscoverMainRoute } from './main';
import { NotFoundRoute } from './not_found';
import { DiscoverServices } from '../build_services';
import { ViewAlertRoute } from './view_alert';
export const discoverRouter = (services: DiscoverServices, history: History) => (
<KibanaContextProvider services={services}>
@ -36,6 +37,9 @@ export const discoverRouter = (services: DiscoverServices, history: History) =>
<Route path="/doc/:indexPatternId/:index">
<SingleDocRoute />
</Route>
<Route path="/viewAlert/:id">
<ViewAlertRoute />
</Route>
<Route path="/view/:id">
<DiscoverMainRoute />
</Route>

View file

@ -18,6 +18,7 @@ import { onSaveSearch } from './on_save_search';
import { GetStateReturn } from '../../services/discover_state';
import { openOptionsPopover } from './open_options_popover';
import type { TopNavMenuData } from '../../../../../../navigation/public';
import { openAlertsPopover } from './open_alerts_popover';
/**
* Helper function to build the top nav links
@ -59,6 +60,25 @@ export const getTopNavLinks = ({
testId: 'discoverOptionsButton',
};
const alerts = {
id: 'alerts',
label: i18n.translate('discover.localMenu.localMenu.alertsTitle', {
defaultMessage: 'Alerts',
}),
description: i18n.translate('discover.localMenu.alertsDescription', {
defaultMessage: 'Alerts',
}),
run: (anchorElement: HTMLElement) => {
openAlertsPopover({
I18nContext: services.core.i18n.Context,
anchorElement,
searchSource: savedSearch.searchSource,
services,
});
},
testId: 'discoverAlertsButton',
};
const newSearch = {
id: 'new',
label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', {
@ -162,6 +182,7 @@ export const getTopNavLinks = ({
...(services.capabilities.advancedSettings.save ? [options] : []),
newSearch,
openSearch,
...(services.triggersActionsUi ? [alerts] : []),
shareSearch,
inspectSearch,
...(services.capabilities.discover.save ? [saveSearch] : []),

View file

@ -0,0 +1,185 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback, useState, useMemo } from 'react';
import ReactDOM from 'react-dom';
import { I18nStart } from 'kibana/public';
import { EuiWrappingPopover, EuiLink, EuiContextMenu, EuiToolTip } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { ISearchSource } from '../../../../../../data/common';
import { KibanaContextProvider } from '../../../../../../kibana_react/public';
import { DiscoverServices } from '../../../../build_services';
import { updateSearchSource } from '../../utils/update_search_source';
import { useDiscoverServices } from '../../../../utils/use_discover_services';
const container = document.createElement('div');
let isOpen = false;
const ALERT_TYPE_ID = '.es-query';
interface AlertsPopoverProps {
onClose: () => void;
anchorElement: HTMLElement;
searchSource: ISearchSource;
}
export function AlertsPopover({ searchSource, anchorElement, onClose }: AlertsPopoverProps) {
const dataView = searchSource.getField('index')!;
const services = useDiscoverServices();
const { triggersActionsUi } = services;
const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false);
/**
* Provides the default parameters used to initialize the new rule
*/
const getParams = useCallback(() => {
const nextSearchSource = searchSource.createCopy();
updateSearchSource(nextSearchSource, true, {
indexPattern: searchSource.getField('index')!,
services,
sort: [],
useNewFieldsApi: true,
});
return {
searchType: 'searchSource',
searchConfiguration: nextSearchSource.getSerializedFields(),
};
}, [searchSource, services]);
const SearchThresholdAlertFlyout = useMemo(() => {
if (!alertFlyoutVisible) {
return;
}
return triggersActionsUi?.getAddAlertFlyout({
consumer: 'discover',
onClose,
canChangeTrigger: false,
ruleTypeId: ALERT_TYPE_ID,
initialValues: {
params: getParams(),
},
});
}, [getParams, onClose, triggersActionsUi, alertFlyoutVisible]);
const hasTimeFieldName = dataView.timeFieldName;
let createSearchThresholdRuleLink = (
<EuiLink
data-test-subj="discoverCreateAlertButton"
onClick={() => setAlertFlyoutVisibility(true)}
disabled={!hasTimeFieldName}
>
<FormattedMessage
id="discover.alerts.createSearchThreshold"
defaultMessage="Create search threshold rule"
/>
</EuiLink>
);
if (!hasTimeFieldName) {
const toolTipContent = (
<FormattedMessage
id="discover.alerts.missedTimeFieldToolTip"
defaultMessage="Data view does not have a time field."
/>
);
createSearchThresholdRuleLink = (
<EuiToolTip position="top" content={toolTipContent}>
{createSearchThresholdRuleLink}
</EuiToolTip>
);
}
const panels = [
{
id: 'mainPanel',
name: 'Alerting',
items: [
{
name: (
<>
{SearchThresholdAlertFlyout}
{createSearchThresholdRuleLink}
</>
),
icon: 'bell',
disabled: !hasTimeFieldName,
},
{
name: (
<EuiLink
color="text"
href={services?.application?.getUrlForApp(
'management/insightsAndAlerting/triggersActions/alerts'
)}
>
<FormattedMessage
id="discover.alerts.manageRulesAndConnectors"
defaultMessage="Manage rules and connectors"
/>
</EuiLink>
),
icon: 'tableOfContents',
},
],
},
];
return (
<>
{SearchThresholdAlertFlyout}
<EuiWrappingPopover
ownFocus
button={anchorElement}
closePopover={onClose}
isOpen={!alertFlyoutVisible}
>
<EuiContextMenu initialPanelId="mainPanel" panels={panels} />
</EuiWrappingPopover>
</>
);
}
function closeAlertsPopover() {
ReactDOM.unmountComponentAtNode(container);
document.body.removeChild(container);
isOpen = false;
}
export function openAlertsPopover({
I18nContext,
anchorElement,
searchSource,
services,
}: {
I18nContext: I18nStart['Context'];
anchorElement: HTMLElement;
searchSource: ISearchSource;
services: DiscoverServices;
}) {
if (isOpen) {
closeAlertsPopover();
return;
}
isOpen = true;
document.body.appendChild(container);
const element = (
<I18nContext>
<KibanaContextProvider services={services}>
<AlertsPopover
onClose={closeAlertsPopover}
anchorElement={anchorElement}
searchSource={searchSource}
/>
</KibanaContextProvider>
</I18nContext>
);
ReactDOM.render(element, container);
}

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { ViewAlertRoute } from './view_alert_route';

View file

@ -0,0 +1,134 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { useEffect, useMemo } from 'react';
import { useHistory, useLocation, useParams } from 'react-router-dom';
import { sha256 } from 'js-sha256';
import type { Alert } from '../../../../../../x-pack/plugins/alerting/common';
import { getTime, IndexPattern } from '../../../../data/common';
import type { Filter } from '../../../../data/public';
import { DiscoverAppLocatorParams } from '../../locator';
import { useDiscoverServices } from '../../utils/use_discover_services';
import { getAlertUtils, QueryParams, SearchThresholdAlertParams } from './view_alert_utils';
type NonNullableEntry<T> = { [K in keyof T]: NonNullable<T[keyof T]> };
const getCurrentChecksum = (params: SearchThresholdAlertParams) =>
sha256.create().update(JSON.stringify(params)).hex();
const isActualAlert = (queryParams: QueryParams): queryParams is NonNullableEntry<QueryParams> => {
return Boolean(queryParams.from && queryParams.to && queryParams.checksum);
};
const buildTimeRangeFilter = (
dataView: IndexPattern,
fetchedAlert: Alert<SearchThresholdAlertParams>,
timeFieldName: string
) => {
const filter = getTime(dataView, {
from: `now-${fetchedAlert.params.timeWindowSize}${fetchedAlert.params.timeWindowUnit}`,
to: 'now',
});
return {
from: filter?.query.range[timeFieldName].gte,
to: filter?.query.range[timeFieldName].lte,
};
};
const DISCOVER_MAIN_ROUTE = '/';
export function ViewAlertRoute() {
const { core, data, locator, toastNotifications } = useDiscoverServices();
const { id } = useParams<{ id: string }>();
const history = useHistory();
const { search } = useLocation();
const query = useMemo(() => new URLSearchParams(search), [search]);
const queryParams: QueryParams = useMemo(
() => ({
from: query.get('from'),
to: query.get('to'),
checksum: query.get('checksum'),
}),
[query]
);
const openActualAlert = useMemo(() => isActualAlert(queryParams), [queryParams]);
useEffect(() => {
const {
fetchAlert,
fetchSearchSource,
displayRuleChangedWarn,
displayPossibleDocsDiffInfoAlert,
showDataViewFetchError,
} = getAlertUtils(toastNotifications, core, data);
const navigateToResults = async () => {
const fetchedAlert = await fetchAlert(id);
if (!fetchedAlert) {
history.push(DISCOVER_MAIN_ROUTE);
return;
}
const calculatedChecksum = getCurrentChecksum(fetchedAlert.params);
if (openActualAlert && calculatedChecksum !== queryParams.checksum) {
displayRuleChangedWarn();
} else if (openActualAlert && calculatedChecksum === queryParams.checksum) {
displayPossibleDocsDiffInfoAlert();
}
const fetchedSearchSource = await fetchSearchSource(fetchedAlert);
if (!fetchedSearchSource) {
history.push(DISCOVER_MAIN_ROUTE);
return;
}
const dataView = fetchedSearchSource.getField('index');
const timeFieldName = dataView?.timeFieldName;
if (!dataView || !timeFieldName) {
showDataViewFetchError(fetchedAlert.id);
history.push(DISCOVER_MAIN_ROUTE);
return;
}
const timeRange = openActualAlert
? { from: queryParams.from, to: queryParams.to }
: buildTimeRangeFilter(dataView, fetchedAlert, timeFieldName);
const state: DiscoverAppLocatorParams = {
query: fetchedSearchSource.getField('query') || data.query.queryString.getDefaultQuery(),
indexPatternId: dataView.id,
timeRange,
};
const filters = fetchedSearchSource.getField('filter');
if (filters) {
state.filters = filters as Filter[];
}
await locator.navigate(state);
};
navigateToResults();
}, [
toastNotifications,
data.query.queryString,
data.search.searchSource,
core.http,
locator,
id,
queryParams,
history,
openActualAlert,
core,
data,
]);
return null;
}

View file

@ -0,0 +1,116 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { i18n } from '@kbn/i18n';
import { CoreStart, ToastsStart } from 'kibana/public';
import type { Alert } from '../../../../../../x-pack/plugins/alerting/common';
import type { AlertTypeParams } from '../../../../../../x-pack/plugins/alerting/common';
import { SerializedSearchSourceFields } from '../../../../data/common';
import type { DataPublicPluginStart } from '../../../../data/public';
import { MarkdownSimple, toMountPoint } from '../../../../kibana_react/public';
export interface SearchThresholdAlertParams extends AlertTypeParams {
searchConfiguration: SerializedSearchSourceFields;
}
export interface QueryParams {
from: string | null;
to: string | null;
checksum: string | null;
}
const LEGACY_BASE_ALERT_API_PATH = '/api/alerts';
export const getAlertUtils = (
toastNotifications: ToastsStart,
core: CoreStart,
data: DataPublicPluginStart
) => {
const showDataViewFetchError = (alertId: string) => {
const errorTitle = i18n.translate('discover.viewAlert.dataViewErrorTitle', {
defaultMessage: 'Error fetching data view',
});
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(
<MarkdownSimple>
{new Error(`Data view failure of the alert rule with id ${alertId}.`).message}
</MarkdownSimple>
),
});
};
const displayRuleChangedWarn = () => {
const warnTitle = i18n.translate('discover.viewAlert.alertRuleChangedWarnTitle', {
defaultMessage: 'Alert rule has changed',
});
const warnDescription = i18n.translate('discover.viewAlert.alertRuleChangedWarnDescription', {
defaultMessage: `The displayed documents might not match the documents that triggered the alert
because the rule configuration changed.`,
});
toastNotifications.addWarning({
title: warnTitle,
text: toMountPoint(<MarkdownSimple>{warnDescription}</MarkdownSimple>),
});
};
const displayPossibleDocsDiffInfoAlert = () => {
const infoTitle = i18n.translate('discover.viewAlert.documentsMayVaryInfoTitle', {
defaultMessage: 'Displayed documents may vary',
});
const infoDescription = i18n.translate('discover.viewAlert.documentsMayVaryInfoDescription', {
defaultMessage: `The displayed documents might differ from the documents that triggered the alert.
Some documents might have been added or deleted.`,
});
toastNotifications.addInfo({
title: infoTitle,
text: toMountPoint(<MarkdownSimple>{infoDescription}</MarkdownSimple>),
});
};
const fetchAlert = async (id: string) => {
try {
return await core.http.get<Alert<SearchThresholdAlertParams>>(
`${LEGACY_BASE_ALERT_API_PATH}/alert/${id}`
);
} catch (error) {
const errorTitle = i18n.translate('discover.viewAlert.alertRuleFetchErrorTitle', {
defaultMessage: 'Error fetching alert rule',
});
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
}
};
const fetchSearchSource = async (fetchedAlert: Alert<SearchThresholdAlertParams>) => {
try {
return await data.search.searchSource.create(fetchedAlert.params.searchConfiguration);
} catch (error) {
const errorTitle = i18n.translate('discover.viewAlert.searchSourceErrorTitle', {
defaultMessage: 'Error fetching search source',
});
toastNotifications.addDanger({
title: errorTitle,
text: toMountPoint(<MarkdownSimple>{error.message}</MarkdownSimple>),
});
}
};
return {
displayRuleChangedWarn,
displayPossibleDocsDiffInfoAlert,
showDataViewFetchError,
fetchAlert,
fetchSearchSource,
};
};

View file

@ -18,6 +18,8 @@ import {
IUiSettingsClient,
PluginInitializerContext,
HttpStart,
NotificationsStart,
ApplicationStart,
} from 'kibana/public';
import {
FilterManager,
@ -38,15 +40,18 @@ import { NavigationPublicPluginStart } from '../../navigation/public';
import { IndexPatternFieldEditorStart } from '../../data_view_field_editor/public';
import { FieldFormatsStart } from '../../field_formats/public';
import { EmbeddableStart } from '../../embeddable/public';
import { DiscoverAppLocator } from './locator';
import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public';
import { DataViewEditorStart } from '../../../plugins/data_view_editor/public';
import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public';
export interface HistoryLocationState {
referrer: string;
}
export interface DiscoverServices {
application: ApplicationStart;
addBasePath: (path: string) => string;
capabilities: Capabilities;
chrome: ChromeStart;
@ -66,6 +71,7 @@ export interface DiscoverServices {
urlForwarding: UrlForwardingStart;
timefilter: TimefilterContract;
toastNotifications: ToastsStart;
notifications: NotificationsStart;
uiSettings: IUiSettingsClient;
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
dataViewFieldEditor: IndexPatternFieldEditorStart;
@ -73,17 +79,21 @@ export interface DiscoverServices {
http: HttpStart;
storage: Storage;
spaces?: SpacesApi;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
locator: DiscoverAppLocator;
}
export const buildServices = memoize(function (
core: CoreStart,
plugins: DiscoverStartPlugins,
context: PluginInitializerContext
context: PluginInitializerContext,
locator: DiscoverAppLocator
): DiscoverServices {
const { usageCollection } = plugins;
const storage = new Storage(localStorage);
return {
application: core.application,
addBasePath: core.http.basePath.prepend,
capabilities: core.application.capabilities,
chrome: core.chrome,
@ -105,6 +115,7 @@ export const buildServices = memoize(function (
urlForwarding: plugins.urlForwarding,
timefilter: plugins.data.query.timefilter.timefilter,
toastNotifications: core.notifications.toasts,
notifications: core.notifications,
uiSettings: core.uiSettings,
storage,
trackUiMetric: usageCollection?.reportUiCounter.bind(usageCollection, 'discover'),
@ -112,5 +123,7 @@ export const buildServices = memoize(function (
http: core.http,
spaces: plugins.spaces,
dataViewEditor: plugins.dataViewEditor,
triggersActionsUi: plugins.triggersActionsUi,
locator,
};
});

View file

@ -53,6 +53,7 @@ import { injectTruncateStyles } from './utils/truncate_styles';
import { DOC_TABLE_LEGACY, TRUNCATE_MAX_HEIGHT } from '../common';
import { DataViewEditorStart } from '../../../plugins/data_view_editor/public';
import { useDiscoverServices } from './utils/use_discover_services';
import type { TriggersAndActionsUIPublicPluginStart } from '../../../../x-pack/plugins/triggers_actions_ui/public';
import { initializeKbnUrlTracking } from './utils/initialize_kbn_url_tracking';
const DocViewerLegacyTable = React.lazy(
@ -170,6 +171,7 @@ export interface DiscoverStartPlugins {
usageCollection?: UsageCollectionSetup;
dataViewFieldEditor: IndexPatternFieldEditorStart;
spaces?: SpacesPluginStart;
triggersActionsUi: TriggersAndActionsUIPublicPluginStart;
}
/**
@ -274,7 +276,12 @@ export class DiscoverPlugin
window.dispatchEvent(new HashChangeEvent('hashchange'));
});
const services = buildServices(coreStart, discoverStartPlugins, this.initializerContext);
const services = buildServices(
coreStart,
discoverStartPlugins,
this.initializerContext,
this.locator!
);
// make sure the index pattern list is up to date
await discoverStartPlugins.data.indexPatterns.clearCache();
@ -364,7 +371,7 @@ export class DiscoverPlugin
const getDiscoverServices = async () => {
const [coreStart, discoverStartPlugins] = await core.getStartServices();
return buildServices(coreStart, discoverStartPlugins, this.initializerContext);
return buildServices(coreStart, discoverStartPlugins, this.initializerContext, this.locator!);
};
const factory = new SearchEmbeddableFactory(getStartServices, getDiscoverServices);

View file

@ -26,6 +26,7 @@
{ "path": "../field_formats/tsconfig.json" },
{ "path": "../data_views/tsconfig.json" },
{ "path": "../../../x-pack/plugins/spaces/tsconfig.json" },
{ "path": "../data_view_editor/tsconfig.json" }
{ "path": "../data_view_editor/tsconfig.json" },
{ "path": "../../../x-pack/plugins/triggers_actions_ui/tsconfig.json" }
]
}

View file

@ -16,13 +16,14 @@ export class IndexPatternsService extends FtrService {
* Create a new index pattern
*/
async create(
indexPattern: { title: string },
{ override = false }: { override: boolean } = { override: false }
indexPattern: { title: string; timeFieldName?: string },
{ override = false }: { override: boolean },
spaceId = ''
): Promise<DataViewSpec> {
const response = await this.kibanaServer.request<{
index_pattern: DataViewSpec;
}>({
path: '/api/index_patterns/index_pattern',
path: `${spaceId}/api/index_patterns/index_pattern`,
method: 'POST',
body: {
override,

View file

@ -11,6 +11,7 @@
"configPath": ["xpack", "alerting"],
"requiredPlugins": [
"actions",
"data",
"encryptedSavedObjects",
"eventLog",
"features",

View file

@ -12,7 +12,9 @@ import {
elasticsearchServiceMock,
savedObjectsClientMock,
uiSettingsServiceMock,
httpServerMock,
} from '../../../../src/core/server/mocks';
import { dataPluginMock } from '../../../../src/plugins/data/server/mocks';
import { AlertInstanceContext, AlertInstanceState } from './types';
export { rulesClientMock };
@ -111,6 +113,11 @@ const createAlertServicesMock = <
shouldWriteAlerts: () => true,
shouldStopExecution: () => true,
search: createAbortableSearchServiceMock(),
searchSourceClient: Promise.resolve(
dataPluginMock
.createStartContract()
.search.searchSource.asScoped(httpServerMock.createKibanaRequest())
),
};
};
export type AlertServicesMock = ReturnType<typeof createAlertServicesMock>;

View file

@ -19,6 +19,7 @@ import { AlertingConfig } from './config';
import { RuleType } from './types';
import { eventLogMock } from '../../event_log/server/mocks';
import { actionsMock } from '../../actions/server/mocks';
import { dataPluginMock } from '../../../../src/plugins/data/server/mocks';
import { monitoringCollectionMock } from '../../monitoring_collection/server/mocks';
const generateAlertingConfig = (): AlertingConfig => ({
@ -276,6 +277,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createStart(),
eventLog: eventLogMock.createStart(),
taskManager: taskManagerMock.createStart(),
data: dataPluginMock.createStartContract(),
});
expect(encryptedSavedObjectsSetup.canEncrypt).toEqual(false);
@ -313,6 +315,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createStart(),
eventLog: eventLogMock.createStart(),
taskManager: taskManagerMock.createStart(),
data: dataPluginMock.createStartContract(),
});
const fakeRequest = {
@ -361,6 +364,7 @@ describe('Alerting Plugin', () => {
licensing: licensingMock.createStart(),
eventLog: eventLogMock.createStart(),
taskManager: taskManagerMock.createStart(),
data: dataPluginMock.createStartContract(),
});
const fakeRequest = {

View file

@ -63,6 +63,7 @@ import { getHealth } from './health/get_health';
import { AlertingAuthorizationClientFactory } from './alerting_authorization_client_factory';
import { AlertingAuthorization } from './authorization';
import { getSecurityHealth, SecurityHealth } from './lib/get_security_health';
import { PluginStart as DataPluginStart } from '../../../../src/plugins/data/server';
import { MonitoringCollectionSetup } from '../../monitoring_collection/server';
import { registerNodeCollector, registerClusterCollector, InMemoryMetrics } from './monitoring';
import { getExecutionConfigForRuleType } from './lib/get_rules_config';
@ -139,6 +140,7 @@ export interface AlertingPluginsStart {
licensing: LicensingPluginStart;
spaces?: SpacesPluginStart;
security?: SecurityPluginStart;
data: DataPluginStart;
}
export class AlertingPlugin {
@ -407,6 +409,7 @@ export class AlertingPlugin {
taskRunnerFactory.initialize({
logger,
data: plugins.data,
savedObjects: core.savedObjects,
uiSettings: core.uiSettings,
elasticsearch: core.elasticsearch,

View file

@ -70,6 +70,7 @@ import {
import { EVENT_LOG_ACTIONS } from '../plugin';
import { IN_MEMORY_METRICS } from '../monitoring';
import { translations } from '../constants/translations';
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
jest.mock('uuid', () => ({
v4: () => '5f6aa57d-3e22-484e-bae8-cbed868f4d28',
@ -101,6 +102,7 @@ describe('Task Runner', () => {
const ruleTypeRegistry = ruleTypeRegistryMock.create();
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
const dataPlugin = dataPluginMock.createStartContract();
const uiSettingsService = uiSettingsServiceMock.createStartContract();
const inMemoryMetrics = inMemoryMetricsMock.create();
@ -113,6 +115,7 @@ describe('Task Runner', () => {
type EnqueueFunction = (options: ExecuteOptions) => Promise<void | RunNowResult>;
const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = {
data: dataPlugin,
savedObjects: savedObjectsService,
uiSettings: uiSettingsService,
elasticsearch: elasticsearchService,

View file

@ -391,6 +391,7 @@ export class TaskRunner<
savedObjectsClient,
uiSettingsClient: this.context.uiSettings.asScopedToClient(savedObjectsClient),
scopedClusterClient: wrappedScopedClusterClient.client(),
searchSourceClient: this.context.data.search.searchSource.asScoped(fakeRequest),
alertFactory: createAlertFactory<
InstanceState,
InstanceContext,

View file

@ -35,6 +35,7 @@ import { IEventLogger } from '../../../event_log/server';
import { Alert, RecoveredActionGroup } from '../../common';
import { UntypedNormalizedRuleType } from '../rule_type_registry';
import { ruleTypeRegistryMock } from '../rule_type_registry.mock';
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
jest.mock('uuid', () => ({
@ -104,6 +105,7 @@ describe('Task Runner Cancel', () => {
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
const uiSettingsService = uiSettingsServiceMock.createStartContract();
const dataPlugin = dataPluginMock.createStartContract();
const inMemoryMetrics = inMemoryMetricsMock.create();
type TaskRunnerFactoryInitializerParamsType = jest.Mocked<TaskRunnerContext> & {
@ -113,6 +115,7 @@ describe('Task Runner Cancel', () => {
};
const taskRunnerFactoryInitializerParams: TaskRunnerFactoryInitializerParamsType = {
data: dataPlugin,
savedObjects: savedObjectsService,
uiSettings: uiSettingsService,
elasticsearch: elasticsearchService,

View file

@ -24,6 +24,7 @@ import { eventLoggerMock } from '../../../event_log/server/event_logger.mock';
import { UntypedNormalizedRuleType } from '../rule_type_registry';
import { ruleTypeRegistryMock } from '../rule_type_registry.mock';
import { executionContextServiceMock } from '../../../../../src/core/server/mocks';
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
import { inMemoryMetricsMock } from '../monitoring/in_memory_metrics.mock';
const inMemoryMetrics = inMemoryMetricsMock.create();
@ -33,6 +34,7 @@ const mockUsageCounter = mockUsageCountersSetup.createUsageCounter('test');
const savedObjectsService = savedObjectsServiceMock.createInternalStartContract();
const uiSettingsService = uiSettingsServiceMock.createStartContract();
const elasticsearchService = elasticsearchServiceMock.createInternalStart();
const dataPlugin = dataPluginMock.createStartContract();
const ruleType: UntypedNormalizedRuleType = {
id: 'test',
name: 'My test alert',
@ -80,6 +82,7 @@ describe('Task Runner Factory', () => {
const rulesClient = rulesClientMock.create();
const taskRunnerFactoryInitializerParams: jest.Mocked<TaskRunnerContext> = {
data: dataPlugin,
savedObjects: savedObjectsService,
uiSettings: uiSettingsService,
elasticsearch: elasticsearchService,

View file

@ -32,10 +32,12 @@ import { TaskRunner } from './task_runner';
import { IEventLogger } from '../../../event_log/server';
import { RulesClient } from '../rules_client';
import { NormalizedRuleType } from '../rule_type_registry';
import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/server';
import { InMemoryMetrics } from '../monitoring';
export interface TaskRunnerContext {
logger: Logger;
data: DataPluginStart;
savedObjects: SavedObjectsServiceStart;
uiSettings: UiSettingsServiceStart;
elasticsearch: ElasticsearchServiceStart;

View file

@ -42,6 +42,7 @@ import {
AlertExecutionStatusWarningReasons,
} from '../common';
import { LicenseType } from '../../licensing/server';
import { ISearchStartSearchSource } from '../../../../src/plugins/data/common';
import { RuleTypeConfig } from './config';
export type WithoutQueryAndParams<T> = Pick<T, Exclude<keyof T, 'query' | 'params'>>;
export type SpaceIdToNamespaceFunction = (spaceId?: string) => string | undefined;
@ -73,6 +74,7 @@ export interface AlertServices<
InstanceContext extends AlertInstanceContext = AlertInstanceContext,
ActionGroupIds extends string = never
> {
searchSourceClient: Promise<ISearchStartSearchSource>;
savedObjectsClient: SavedObjectsClientContract;
uiSettingsClient: IUiSettingsClient;
scopedClusterClient: IScopedClusterClient;

View file

@ -660,11 +660,15 @@ Array [
"privilege": Object {
"alerting": Object {
"alert": Object {
"all": Array [],
"all": Array [
".es-query",
],
"read": Array [],
},
"rule": Object {
"all": Array [],
"all": Array [
".es-query",
],
"read": Array [],
},
},
@ -711,6 +715,18 @@ Array [
},
Object {
"privilege": Object {
"alerting": Object {
"alert": Object {
"all": Array [
".es-query",
],
},
"rule": Object {
"all": Array [
".es-query",
],
},
},
"app": Array [
"discover",
"kibana",
@ -1140,11 +1156,15 @@ Array [
"privilege": Object {
"alerting": Object {
"alert": Object {
"all": Array [],
"all": Array [
".es-query",
],
"read": Array [],
},
"rule": Object {
"all": Array [],
"all": Array [
".es-query",
],
"read": Array [],
},
},
@ -1191,6 +1211,18 @@ Array [
},
Object {
"privilege": Object {
"alerting": Object {
"alert": Object {
"all": Array [
".es-query",
],
},
"rule": Object {
"all": Array [
".es-query",
],
},
},
"app": Array [
"discover",
"kibana",

View file

@ -32,6 +32,7 @@ export const buildOSSFeatures = ({
category: DEFAULT_APP_CATEGORIES.kibana,
app: ['discover', 'kibana'],
catalogue: ['discover'],
alerting: ['.es-query'],
privileges: {
all: {
app: ['discover', 'kibana'],
@ -42,6 +43,14 @@ export const buildOSSFeatures = ({
read: ['index-pattern'],
},
ui: ['show', 'save', 'saveQuery'],
alerting: {
rule: {
all: ['.es-query'],
},
alert: {
all: ['.es-query'],
},
},
},
read: {
app: ['discover', 'kibana'],
@ -51,6 +60,14 @@ export const buildOSSFeatures = ({
read: ['index-pattern', 'search', 'query'],
},
ui: ['show'],
alerting: {
rule: {
all: ['.es-query'],
},
alert: {
all: ['.es-query'],
},
},
},
},
subFeatures: [

View file

@ -18,6 +18,7 @@ import { castArray, omit } from 'lodash';
import { RuleDataClient } from '../rule_data_client';
import { createRuleDataClientMock } from '../rule_data_client/rule_data_client.mock';
import { createLifecycleRuleTypeFactory } from './create_lifecycle_rule_type_factory';
import { ISearchStartSearchSource } from '../../../../../src/plugins/data/common';
type RuleTestHelpers = ReturnType<typeof createRule>;
@ -117,6 +118,7 @@ function createRule(shouldWriteAlerts: boolean = true) {
shouldWriteAlerts: () => shouldWriteAlerts,
shouldStopExecution: () => false,
search: {} as any,
searchSourceClient: Promise.resolve({} as ISearchStartSearchSource),
},
spaceId: 'spaceId',
state,

View file

@ -7,6 +7,7 @@
import {
elasticsearchServiceMock,
savedObjectsClientMock,
httpServerMock,
uiSettingsServiceMock,
} from '../../../../../src/core/server/mocks';
import {
@ -17,6 +18,7 @@ import {
AlertTypeState,
} from '../../../alerting/server';
import { alertsMock } from '../../../alerting/server/mocks';
import { dataPluginMock } from '../../../../../src/plugins/data/server/mocks';
export const createDefaultAlertExecutorOptions = <
Params extends AlertTypeParams = never,
@ -74,6 +76,11 @@ export const createDefaultAlertExecutorOptions = <
scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
shouldWriteAlerts: () => shouldWriteAlerts,
shouldStopExecution: () => false,
searchSourceClient: Promise.resolve(
dataPluginMock
.createStartContract()
.search.searchSource.asScoped(httpServerMock.createKibanaRequest())
),
},
state,
updatedBy: null,

View file

@ -7,7 +7,9 @@
import moment from 'moment';
import uuid from 'uuid';
import { transformError } from '@kbn/securitysolution-es-utils';
import type { StartServicesAccessor } from 'kibana/server';
import { IRuleDataClient } from '../../../../../../rule_registry/server';
import type { StartPlugins } from '../../../../plugin';
import { buildSiemResponse } from '../utils';
import { convertCreateAPIToInternalSchema } from '../../schemas/rule_converters';
import { RuleParams } from '../../schemas/rule_schemas';
@ -59,7 +61,8 @@ export const previewRulesRoute = async (
security: SetupPlugins['security'],
ruleOptions: CreateRuleOptions,
securityRuleTypeOptions: CreateSecurityRuleTypeWrapperProps,
previewRuleDataClient: IRuleDataClient
previewRuleDataClient: IRuleDataClient,
getStartServices: StartServicesAccessor<StartPlugins>
) => {
router.post(
{
@ -78,6 +81,8 @@ export const previewRulesRoute = async (
return siemResponse.error({ statusCode: 400, body: validationErrors });
}
try {
const [, { data }] = await getStartServices();
const searchSourceClient = data.search.searchSource.asScoped(request);
const savedObjectsClient = context.core.savedObjects.client;
const siemClient = context.securitySolution.getAppClient();
@ -203,6 +208,7 @@ export const previewRulesRoute = async (
abortController,
scopedClusterClient: context.core.elasticsearch.client,
}),
searchSourceClient,
uiSettingsClient: context.core.uiSettings.client,
},
spaceId,

View file

@ -103,7 +103,8 @@ export const initRoutes = (
security,
ruleOptions,
securityRuleTypeOptions,
previewRuleDataClient
previewRuleDataClient,
getStartServices
);
// Once we no longer have the legacy notifications system/"side car actions" this should be removed.

View file

@ -0,0 +1,15 @@
/*
* 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.
*/
export enum Comparator {
GT = '>',
LT = '<',
GT_OR_EQ = '>=',
LT_OR_EQ = '<=',
BETWEEN = 'between',
NOT_BETWEEN = 'notBetween',
}

View file

@ -0,0 +1,21 @@
/*
* 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 { COMPARATORS } from '../../../../triggers_actions_ui/public';
export const DEFAULT_VALUES = {
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
QUERY: `{
"query":{
"match_all" : {}
}
}`,
SIZE: 100,
TIME_WINDOW_SIZE: 5,
TIME_WINDOW_UNIT: 'm',
THRESHOLD: [1000],
};

View file

@ -10,7 +10,6 @@ import 'brace';
import { of } from 'rxjs';
import { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import { act } from 'react-dom/test-utils';
import EsQueryAlertTypeExpression from './expression';
import { dataPluginMock } from 'src/plugins/data/public/mocks';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import {
@ -18,11 +17,12 @@ import {
IKibanaSearchResponse,
ISearchStart,
} from 'src/plugins/data/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { EsQueryAlertParams } from './types';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import { EsQueryAlertParams, SearchType } from '../types';
import { EsQueryExpression } from './es_query_expression';
jest.mock('../../../../../../src/plugins/kibana_react/public');
jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
jest.mock('../../../../../../../src/plugins/kibana_react/public');
jest.mock('../../../../../../../src/plugins/es_ui_shared/public', () => ({
XJson: {
useXJsonMode: jest.fn().mockReturnValue({
convertToJson: jest.fn(),
@ -42,8 +42,8 @@ jest.mock('../../../../../../src/plugins/es_ui_shared/public', () => ({
/>
),
}));
jest.mock('../../../../triggers_actions_ui/public', () => {
const original = jest.requireActual('../../../../triggers_actions_ui/public');
jest.mock('../../../../../triggers_actions_ui/public', () => {
const original = jest.requireActual('../../../../../triggers_actions_ui/public');
return {
...original,
getIndexPatterns: () => {
@ -100,6 +100,17 @@ const createDataPluginMock = () => {
const dataMock = createDataPluginMock();
const chartsStartMock = chartPluginMock.createStartContract();
const defaultEsQueryExpressionParams: EsQueryAlertParams<SearchType.esQuery> = {
size: 100,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
index: ['test-index'],
timeField: '@timestamp',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
};
describe('EsQueryAlertTypeExpression', () => {
beforeAll(() => {
(useKibana as jest.Mock).mockReturnValue({
@ -117,20 +128,7 @@ describe('EsQueryAlertTypeExpression', () => {
});
});
function getAlertParams(overrides = {}) {
return {
index: ['test-index'],
timeField: '@timestamp',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
...overrides,
};
}
async function setup(alertParams: EsQueryAlertParams) {
async function setup(alertParams: EsQueryAlertParams<SearchType.esQuery>) {
const errors = {
index: [],
esQuery: [],
@ -140,7 +138,7 @@ describe('EsQueryAlertTypeExpression', () => {
};
const wrapper = mountWithIntl(
<EsQueryAlertTypeExpression
<EsQueryExpression
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
@ -166,7 +164,7 @@ describe('EsQueryAlertTypeExpression', () => {
}
test('should render EsQueryAlertTypeExpression with expected components', async () => {
const wrapper = await setup(getAlertParams());
const wrapper = await setup(defaultEsQueryExpressionParams);
expect(wrapper.find('[data-test-subj="indexSelectPopover"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="queryJsonEditor"]').exists()).toBeTruthy();
@ -181,7 +179,10 @@ describe('EsQueryAlertTypeExpression', () => {
});
test('should render Test Query button disabled if alert params are invalid', async () => {
const wrapper = await setup(getAlertParams({ timeField: null }));
const wrapper = await setup({
...defaultEsQueryExpressionParams,
timeField: null,
} as unknown as EsQueryAlertParams<SearchType.esQuery>);
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
expect(testQueryButton.exists()).toBeTruthy();
expect(testQueryButton.prop('disabled')).toBe(true);
@ -196,7 +197,7 @@ describe('EsQueryAlertTypeExpression', () => {
},
});
dataMock.search.search.mockImplementation(() => searchResponseMock$);
const wrapper = await setup(getAlertParams());
const wrapper = await setup(defaultEsQueryExpressionParams);
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
testQueryButton.simulate('click');
@ -217,7 +218,7 @@ describe('EsQueryAlertTypeExpression', () => {
dataMock.search.search.mockImplementation(() => {
throw new Error('What is this query');
});
const wrapper = await setup(getAlertParams());
const wrapper = await setup(defaultEsQueryExpressionParams);
const testQueryButton = wrapper.find('EuiButtonEmpty[data-test-subj="testQuery"]');
testQueryButton.simulate('click');

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import React, { useState, Fragment, useEffect } from 'react';
import React, { useState, Fragment, useEffect, useCallback } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
@ -18,7 +18,6 @@ import {
EuiButtonEmpty,
EuiSpacer,
EuiFormRow,
EuiCallOut,
EuiText,
EuiTitle,
EuiLink,
@ -27,49 +26,26 @@ import {
import { DocLinksStart, HttpSetup } from 'kibana/public';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { XJson, EuiCodeEditor } from '../../../../../../src/plugins/es_ui_shared/public';
import { useKibana } from '../../../../../../src/plugins/kibana_react/public';
import { XJson, EuiCodeEditor } from '../../../../../../../src/plugins/es_ui_shared/public';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import {
getFields,
COMPARATORS,
ThresholdExpression,
ForLastExpression,
ValueExpression,
RuleTypeParamsExpressionProps,
} from '../../../../triggers_actions_ui/public';
import { validateExpression } from './validation';
import { parseDuration } from '../../../../alerting/common';
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
import { EsQueryAlertParams } from './types';
import { IndexSelectPopover } from '../components/index_select_popover';
ForLastExpression,
ThresholdExpression,
} from '../../../../../triggers_actions_ui/public';
import { validateExpression } from '../validation';
import { parseDuration } from '../../../../../alerting/common';
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
import { EsQueryAlertParams, SearchType } from '../types';
import { IndexSelectPopover } from '../../components/index_select_popover';
import { DEFAULT_VALUES } from '../constants';
function totalHitsToNumber(total: estypes.SearchHitsMetadata['total']): number {
return typeof total === 'number' ? total : total?.value ?? 0;
}
const DEFAULT_VALUES = {
THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN,
QUERY: `{
"query":{
"match_all" : {}
}
}`,
SIZE: 100,
TIME_WINDOW_SIZE: 5,
TIME_WINDOW_UNIT: 'm',
THRESHOLD: [1000],
};
const expressionFieldsWithValidation = [
'index',
'esQuery',
'size',
'timeField',
'threshold0',
'threshold1',
'timeWindowSize',
];
const { useXJsonMode } = XJson;
const xJsonMode = new XJsonMode();
@ -78,9 +54,13 @@ interface KibanaDeps {
docLinks: DocLinksStart;
}
export const EsQueryAlertTypeExpression: React.FunctionComponent<
RuleTypeParamsExpressionProps<EsQueryAlertParams>
> = ({ ruleParams, setRuleParams, setRuleProperty, errors, data }) => {
export const EsQueryExpression = ({
ruleParams,
setRuleParams,
setRuleProperty,
errors,
data,
}: RuleTypeParamsExpressionProps<EsQueryAlertParams<SearchType.esQuery>>) => {
const {
index,
timeField,
@ -92,16 +72,29 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
timeWindowUnit,
} = ruleParams;
const getDefaultParams = () => ({
const [currentAlertParams, setCurrentAlertParams] = useState<
EsQueryAlertParams<SearchType.esQuery>
>({
...ruleParams,
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
size: size ?? DEFAULT_VALUES.SIZE,
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
size: size ?? DEFAULT_VALUES.SIZE,
esQuery: esQuery ?? DEFAULT_VALUES.QUERY,
});
const setParam = useCallback(
(paramField: string, paramValue: unknown) => {
setCurrentAlertParams((currentParams) => ({
...currentParams,
[paramField]: paramValue,
}));
setRuleParams(paramField, paramValue);
},
[setRuleParams]
);
const { http, docLinks } = useKibana<KibanaDeps>().services;
const [esFields, setEsFields] = useState<
@ -114,29 +107,11 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
}>
>([]);
const { convertToJson, setXJson, xJson } = useXJsonMode(DEFAULT_VALUES.QUERY);
const [currentAlertParams, setCurrentAlertParams] = useState<EsQueryAlertParams>(
getDefaultParams()
);
const [testQueryResult, setTestQueryResult] = useState<string | null>(null);
const [testQueryError, setTestQueryError] = useState<string | null>(null);
const hasExpressionErrors = !!Object.keys(errors).find(
(errorKey) =>
expressionFieldsWithValidation.includes(errorKey) &&
errors[errorKey].length >= 1 &&
ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined
);
const expressionErrorMessage = i18n.translate(
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
{
defaultMessage: 'Expression contains errors.',
}
);
const setDefaultExpressionValues = async () => {
setRuleProperty('params', getDefaultParams());
setRuleProperty('params', currentAlertParams);
setXJson(esQuery ?? DEFAULT_VALUES.QUERY);
if (index && index.length > 0) {
@ -144,14 +119,6 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
}
};
const setParam = (paramField: string, paramValue: unknown) => {
setCurrentAlertParams({
...currentAlertParams,
[paramField]: paramValue,
});
setRuleParams(paramField, paramValue);
};
useEffect(() => {
setDefaultExpressionValues();
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -216,13 +183,6 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
return (
<Fragment>
{hasExpressionErrors ? (
<Fragment>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
<EuiSpacer />
</Fragment>
) : null}
<EuiTitle size="xs">
<h5>
<FormattedMessage
@ -408,6 +368,3 @@ export const EsQueryAlertTypeExpression: React.FunctionComponent<
</Fragment>
);
};
// eslint-disable-next-line import/no-default-export
export { EsQueryAlertTypeExpression as default };

View file

@ -0,0 +1,70 @@
/*
* 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 { i18n } from '@kbn/i18n';
import 'brace/theme/github';
import { EuiSpacer, EuiCallOut } from '@elastic/eui';
import { RuleTypeParamsExpressionProps } from '../../../../../triggers_actions_ui/public';
import { EsQueryAlertParams } from '../types';
import { SearchSourceExpression } from './search_source_expression';
import { EsQueryExpression } from './es_query_expression';
import { isSearchSourceAlert } from '../util';
const expressionFieldsWithValidation = [
'index',
'size',
'timeField',
'threshold0',
'threshold1',
'timeWindowSize',
'searchType',
'esQuery',
'searchConfiguration',
];
export const EsQueryAlertTypeExpression: React.FunctionComponent<
RuleTypeParamsExpressionProps<EsQueryAlertParams>
> = (props) => {
const { ruleParams, errors } = props;
const isSearchSource = isSearchSourceAlert(ruleParams);
const hasExpressionErrors = !!Object.keys(errors).find((errorKey) => {
return (
expressionFieldsWithValidation.includes(errorKey) &&
errors[errorKey].length >= 1 &&
ruleParams[errorKey as keyof EsQueryAlertParams] !== undefined
);
});
const expressionErrorMessage = i18n.translate(
'xpack.stackAlerts.esQuery.ui.alertParams.fixErrorInExpressionBelowValidationMessage',
{
defaultMessage: 'Expression contains errors.',
}
);
return (
<>
{hasExpressionErrors && (
<>
<EuiSpacer />
<EuiCallOut color="danger" size="s" title={expressionErrorMessage} />
<EuiSpacer />
</>
)}
{isSearchSource ? (
<SearchSourceExpression {...props} ruleParams={ruleParams} />
) : (
<EsQueryExpression {...props} ruleParams={ruleParams} />
)}
</>
);
};

View file

@ -0,0 +1,11 @@
/*
* 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 { EsQueryAlertTypeExpression } from './expression';
// eslint-disable-next-line import/no-default-export
export default EsQueryAlertTypeExpression;

View file

@ -0,0 +1,61 @@
/*
* 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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { injectI18n } from '@kbn/i18n-react';
import { useKibana } from '../../../../../../../src/plugins/kibana_react/public';
import {
FilterItem,
getDisplayValueFromFilter,
} from '../../../../../../../src/plugins/data/public';
import { Filter, IIndexPattern } from '../../../../../../../src/plugins/data/common';
const FilterItemComponent = injectI18n(FilterItem);
interface ReadOnlyFilterItemsProps {
filters: Filter[];
indexPatterns: IIndexPattern[];
}
const noOp = () => {};
export const ReadOnlyFilterItems = ({ filters, indexPatterns }: ReadOnlyFilterItemsProps) => {
const { uiSettings } = useKibana().services;
const filterList = filters.map((filter, index) => {
const filterValue = getDisplayValueFromFilter(filter, indexPatterns);
return (
<EuiFlexItem grow={false} className="globalFilterBar__flexItem">
<FilterItemComponent
key={`${filter.meta.key}${filterValue}`}
id={`${index}`}
filter={filter}
onUpdate={noOp}
onRemove={noOp}
indexPatterns={indexPatterns}
uiSettings={uiSettings!}
readonly
/>
</EuiFlexItem>
);
});
return (
<EuiFlexGroup
className="globalFilterBar"
wrap={true}
responsive={false}
gutterSize="xs"
alignItems="center"
tabIndex={-1}
>
{filterList}
</EuiFlexGroup>
);
};

View file

@ -0,0 +1,9 @@
.searchSourceAlertFilters {
.euiExpression__value {
width: 80%;
}
}
.dscExpressionParam.euiExpression {
margin-left: 0;
}

View file

@ -0,0 +1,116 @@
/*
* 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 { mountWithIntl, nextTick } from '@kbn/test-jest-helpers';
import React from 'react';
import { dataPluginMock } from 'src/plugins/data/public/mocks';
import { DataPublicPluginStart, ISearchStart } from 'src/plugins/data/public';
import { EsQueryAlertParams, SearchType } from '../types';
import { SearchSourceExpression } from './search_source_expression';
import { chartPluginMock } from 'src/plugins/charts/public/mocks';
import { act } from 'react-dom/test-utils';
import { EuiCallOut, EuiLoadingSpinner } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
const dataMock = dataPluginMock.createStartContract() as DataPublicPluginStart & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
search: ISearchStart & { searchSource: { create: jest.MockedFunction<any> } };
};
const chartsStartMock = chartPluginMock.createStartContract();
const defaultSearchSourceExpressionParams: EsQueryAlertParams<SearchType.searchSource> = {
size: 100,
thresholdComparator: '>',
threshold: [0],
timeWindowSize: 15,
timeWindowUnit: 's',
index: ['test-index'],
timeField: '@timestamp',
searchType: SearchType.searchSource,
searchConfiguration: {},
};
const searchSourceMock = {
getField: (name: string) => {
if (name === 'filter') {
return [];
}
return '';
},
};
const setup = async (alertParams: EsQueryAlertParams<SearchType.searchSource>) => {
const errors = {
size: [],
timeField: [],
timeWindowSize: [],
searchConfiguration: [],
};
const wrapper = mountWithIntl(
<SearchSourceExpression
ruleInterval="1m"
ruleThrottle="1m"
alertNotifyWhen="onThrottleInterval"
ruleParams={alertParams}
setRuleParams={() => {}}
setRuleProperty={() => {}}
errors={errors}
data={dataMock}
defaultActionGroupId=""
actionGroups={[]}
charts={chartsStartMock}
/>
);
return wrapper;
};
const rerender = async (wrapper: ReactWrapper) => {
const update = async () =>
await act(async () => {
await nextTick();
wrapper.update();
});
await update();
};
describe('SearchSourceAlertTypeExpression', () => {
test('should render loading prompt', async () => {
dataMock.search.searchSource.create.mockImplementation(() =>
Promise.resolve(() => searchSourceMock)
);
const wrapper = await setup(defaultSearchSourceExpressionParams);
expect(wrapper.find(EuiLoadingSpinner).exists()).toBeTruthy();
});
test('should render error prompt', async () => {
dataMock.search.searchSource.create.mockImplementation(() =>
Promise.reject(() => 'test error')
);
const wrapper = await setup(defaultSearchSourceExpressionParams);
await rerender(wrapper);
expect(wrapper.find(EuiCallOut).exists()).toBeTruthy();
});
test('should render SearchSourceAlertTypeExpression with expected components', async () => {
dataMock.search.searchSource.create.mockImplementation(() =>
Promise.resolve(() => searchSourceMock)
);
const wrapper = await setup(defaultSearchSourceExpressionParams);
await rerender(wrapper);
expect(wrapper.find('[data-test-subj="sizeValueExpression"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="thresholdExpression"]').exists()).toBeTruthy();
expect(wrapper.find('[data-test-subj="forLastExpression"]').exists()).toBeTruthy();
});
});

View file

@ -0,0 +1,220 @@
/*
* 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, { Fragment, useCallback, useEffect, useState } from 'react';
import './search_source_expression.scss';
import { FormattedMessage } from '@kbn/i18n-react';
import {
EuiSpacer,
EuiTitle,
EuiExpression,
EuiLoadingSpinner,
EuiEmptyPrompt,
EuiCallOut,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Filter, ISearchSource } from '../../../../../../../src/plugins/data/common';
import { EsQueryAlertParams, SearchType } from '../types';
import {
ForLastExpression,
RuleTypeParamsExpressionProps,
ThresholdExpression,
ValueExpression,
} from '../../../../../triggers_actions_ui/public';
import { DEFAULT_VALUES } from '../constants';
import { ReadOnlyFilterItems } from './read_only_filter_items';
export const SearchSourceExpression = ({
ruleParams,
setRuleParams,
setRuleProperty,
data,
errors,
}: RuleTypeParamsExpressionProps<EsQueryAlertParams<SearchType.searchSource>>) => {
const {
searchConfiguration,
thresholdComparator,
threshold,
timeWindowSize,
timeWindowUnit,
size,
} = ruleParams;
const [usedSearchSource, setUsedSearchSource] = useState<ISearchSource | undefined>();
const [paramsError, setParamsError] = useState<Error | undefined>();
const [currentAlertParams, setCurrentAlertParams] = useState<
EsQueryAlertParams<SearchType.searchSource>
>({
searchConfiguration,
searchType: SearchType.searchSource,
timeWindowSize: timeWindowSize ?? DEFAULT_VALUES.TIME_WINDOW_SIZE,
timeWindowUnit: timeWindowUnit ?? DEFAULT_VALUES.TIME_WINDOW_UNIT,
threshold: threshold ?? DEFAULT_VALUES.THRESHOLD,
thresholdComparator: thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR,
size: size ?? DEFAULT_VALUES.SIZE,
});
const setParam = useCallback(
(paramField: string, paramValue: unknown) => {
setCurrentAlertParams((currentParams) => ({
...currentParams,
[paramField]: paramValue,
}));
setRuleParams(paramField, paramValue);
},
[setRuleParams]
);
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => setRuleProperty('params', currentAlertParams), []);
useEffect(() => {
async function initSearchSource() {
try {
const loadedSearchSource = await data.search.searchSource.create(searchConfiguration);
setUsedSearchSource(loadedSearchSource);
} catch (error) {
setParamsError(error);
}
}
if (searchConfiguration) {
initSearchSource();
}
}, [data.search.searchSource, searchConfiguration]);
if (paramsError) {
return (
<>
<EuiCallOut color="danger" iconType="alert">
<p>{paramsError.message}</p>
</EuiCallOut>
<EuiSpacer size="s" />
</>
);
}
if (!usedSearchSource) {
return <EuiEmptyPrompt title={<EuiLoadingSpinner size="xl" />} />;
}
const dataView = usedSearchSource.getField('index')!;
const query = usedSearchSource.getField('query')!;
const filters = (usedSearchSource.getField('filter') as Filter[]).filter(
({ meta }) => !meta.disabled
);
const dataViews = [dataView];
return (
<Fragment>
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.searchThreshold.ui.conditionPrompt"
defaultMessage="When the number of documents match"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<EuiCallOut
size="s"
title={
<FormattedMessage
id="xpack.stackAlerts.searchThreshold.ui.notEditable"
defaultMessage="The data view, query, and filter are initialized in Discover and cannot be edited."
/>
}
iconType="iInCircle"
/>
<EuiSpacer size="s" />
<EuiExpression
className="dscExpressionParam"
description={'Data view'}
value={dataView.title}
display="columns"
/>
{query.query !== '' && (
<EuiExpression
className="dscExpressionParam"
description={'Query'}
value={query.query}
display="columns"
/>
)}
{filters.length > 0 && (
<EuiExpression
className="dscExpressionParam searchSourceAlertFilters"
title={'sas'}
description={'Filter'}
value={<ReadOnlyFilterItems filters={filters} indexPatterns={dataViews} />}
display="columns"
/>
)}
<EuiSpacer size="s" />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.searchSource.ui.conditionPrompt"
defaultMessage="When the number of matches"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<ThresholdExpression
data-test-subj="thresholdExpression"
thresholdComparator={thresholdComparator ?? DEFAULT_VALUES.THRESHOLD_COMPARATOR}
threshold={threshold ?? DEFAULT_VALUES.THRESHOLD}
errors={errors}
display="fullWidth"
popupPosition={'upLeft'}
onChangeSelectedThreshold={(selectedThresholds) =>
setParam('threshold', selectedThresholds)
}
onChangeSelectedThresholdComparator={(selectedThresholdComparator) =>
setParam('thresholdComparator', selectedThresholdComparator)
}
/>
<ForLastExpression
data-test-subj="forLastExpression"
popupPosition={'upLeft'}
timeWindowSize={timeWindowSize}
timeWindowUnit={timeWindowUnit}
display="fullWidth"
errors={errors}
onChangeWindowSize={(selectedWindowSize: number | undefined) =>
setParam('timeWindowSize', selectedWindowSize)
}
onChangeWindowUnit={(selectedWindowUnit: string) =>
setParam('timeWindowUnit', selectedWindowUnit)
}
/>
<EuiSpacer size="s" />
<EuiTitle size="xs">
<h5>
<FormattedMessage
id="xpack.stackAlerts.searchSource.ui.selectSizePrompt"
defaultMessage="Select a size"
/>
</h5>
</EuiTitle>
<EuiSpacer size="s" />
<ValueExpression
description={i18n.translate('xpack.stackAlerts.searchSource.ui.sizeExpression', {
defaultMessage: 'Size',
})}
data-test-subj="sizeValueExpression"
value={size}
errors={errors.size}
display="fullWidth"
popupPosition={'upLeft'}
onChangeSelectedValue={(updatedValue) => {
setParam('size', updatedValue);
}}
/>
<EuiSpacer size="s" />
</Fragment>
);
};

View file

@ -8,12 +8,19 @@
import { lazy } from 'react';
import { i18n } from '@kbn/i18n';
import { validateExpression } from './validation';
import { EsQueryAlertParams } from './types';
import { EsQueryAlertParams, SearchType } from './types';
import { RuleTypeModel } from '../../../../triggers_actions_ui/public';
import { PluginSetupContract as AlertingSetup } from '../../../../alerting/public';
import { SanitizedAlert } from '../../../../alerting/common';
const PLUGIN_ID = 'discover';
const ES_QUERY_ALERT_TYPE = '.es-query';
export function getAlertType(alerting: AlertingSetup): RuleTypeModel<EsQueryAlertParams> {
registerNavigation(alerting);
export function getAlertType(): RuleTypeModel<EsQueryAlertParams> {
return {
id: '.es-query',
id: ES_QUERY_ALERT_TYPE,
description: i18n.translate('xpack.stackAlerts.esQuery.ui.alertType.descriptionText', {
defaultMessage: 'Alert when matches are found during the latest query run.',
}),
@ -28,9 +35,20 @@ export function getAlertType(): RuleTypeModel<EsQueryAlertParams> {
- Value: \\{\\{context.value\\}\\}
- Conditions Met: \\{\\{context.conditions\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\}
- Timestamp: \\{\\{context.date\\}\\}`,
- Timestamp: \\{\\{context.date\\}\\}
- Link: \\{\\{context.link\\}\\}`,
}
),
requiresAppContext: false,
};
}
function registerNavigation(alerting: AlertingSetup) {
alerting.registerNavigation(
PLUGIN_ID,
ES_QUERY_ALERT_TYPE,
(alert: SanitizedAlert<EsQueryAlertParams<SearchType.searchSource>>) => {
return `#/viewAlert/${alert.id}`;
}
);
}

View file

@ -6,6 +6,7 @@
*/
import { AlertTypeParams } from '../../../../alerting/common';
import { SerializedSearchSourceFields } from '../../../../../../src/plugins/data/common';
export interface Comparator {
text: string;
@ -13,13 +14,29 @@ export interface Comparator {
requiredValues: number;
}
export interface EsQueryAlertParams extends AlertTypeParams {
index: string[];
timeField?: string;
esQuery: string;
export enum SearchType {
esQuery = 'esQuery',
searchSource = 'searchSource',
}
export interface CommonAlertParams<T extends SearchType> extends AlertTypeParams {
size: number;
thresholdComparator?: string;
threshold: number[];
timeWindowSize: number;
timeWindowUnit: string;
}
export type EsQueryAlertParams<T = SearchType> = T extends SearchType.searchSource
? CommonAlertParams<SearchType.searchSource> & OnlySearchSourceAlertParams
: CommonAlertParams<SearchType.esQuery> & OnlyEsQueryAlertParams;
export interface OnlyEsQueryAlertParams {
esQuery: string;
index: string[];
timeField: string;
}
export interface OnlySearchSourceAlertParams {
searchType: 'searchSource';
searchConfiguration: SerializedSearchSourceFields;
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EsQueryAlertParams, SearchType } from './types';
export const isSearchSourceAlert = (
ruleParams: EsQueryAlertParams
): ruleParams is EsQueryAlertParams<SearchType.searchSource> => {
return ruleParams.searchType === 'searchSource';
};

View file

@ -5,64 +5,82 @@
* 2.0.
*/
import { EsQueryAlertParams } from './types';
import { EsQueryAlertParams, SearchType } from './types';
import { validateExpression } from './validation';
describe('expression params validation', () => {
test('if index property is invalid should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: [],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.index.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.index[0]).toBe('Index is required.');
});
test('if timeField property is not defined should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.timeField.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.timeField[0]).toBe('Time field is required.');
});
test('if esQuery property is invalid JSON should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.esQuery[0]).toBe('Query must be valid JSON.');
});
test('if esQuery property is invalid should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"aggs\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.esQuery.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.esQuery[0]).toBe(`Query field is required.`);
});
test('if searchConfiguration property is not set should return proper error message', () => {
const initialParams = {
size: 100,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
searchType: SearchType.searchSource,
} as EsQueryAlertParams<SearchType.searchSource>;
expect(validateExpression(initialParams).errors.searchConfiguration.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.searchConfiguration[0]).toBe(
`Search source configuration is required.`
);
});
test('if threshold0 property is not set should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
@ -70,13 +88,14 @@ describe('expression params validation', () => {
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: '<',
timeField: '',
};
expect(validateExpression(initialParams).errors.threshold0.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold0[0]).toBe('Threshold 0 is required.');
});
test('if threshold1 property is needed by thresholdComparator but not set should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
@ -84,13 +103,14 @@ describe('expression params validation', () => {
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: 'between',
timeField: '',
};
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold1[0]).toBe('Threshold 1 is required.');
});
test('if threshold0 property greater than threshold1 property should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
@ -98,6 +118,7 @@ describe('expression params validation', () => {
timeWindowSize: 1,
timeWindowUnit: 's',
thresholdComparator: 'between',
timeField: '',
};
expect(validateExpression(initialParams).errors.threshold1.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.threshold1[0]).toBe(
@ -106,13 +127,14 @@ describe('expression params validation', () => {
});
test('if size property is < 0 should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
size: -1,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.size.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.size[0]).toBe(
@ -121,13 +143,14 @@ describe('expression params validation', () => {
});
test('if size property is > 10000 should return proper error message', () => {
const initialParams: EsQueryAlertParams = {
const initialParams: EsQueryAlertParams<SearchType.esQuery> = {
index: ['test'],
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n`,
size: 25000,
timeWindowSize: 1,
timeWindowUnit: 's',
threshold: [0],
timeField: '',
};
expect(validateExpression(initialParams).errors.size.length).toBeGreaterThan(0);
expect(validateExpression(initialParams).errors.size[0]).toBe(

View file

@ -8,10 +8,10 @@
import { i18n } from '@kbn/i18n';
import { EsQueryAlertParams } from './types';
import { ValidationResult, builtInComparators } from '../../../../triggers_actions_ui/public';
import { isSearchSourceAlert } from './util';
export const validateExpression = (alertParams: EsQueryAlertParams): ValidationResult => {
const { index, timeField, esQuery, size, threshold, timeWindowSize, thresholdComparator } =
alertParams;
const { size, threshold, timeWindowSize, thresholdComparator } = alertParams;
const validationResult = { errors: {} };
const errors = {
index: new Array<string>(),
@ -22,46 +22,9 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
threshold1: new Array<string>(),
thresholdComparator: new Array<string>(),
timeWindowSize: new Array<string>(),
searchConfiguration: new Array<string>(),
};
validationResult.errors = errors;
if (!index || index.length === 0) {
errors.index.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
defaultMessage: 'Index is required.',
})
);
}
if (!timeField) {
errors.timeField.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
defaultMessage: 'Time field is required.',
})
);
}
if (!esQuery) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
defaultMessage: 'Elasticsearch query is required.',
})
);
} else {
try {
const parsedQuery = JSON.parse(esQuery);
if (!parsedQuery.query) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
defaultMessage: `Query field is required.`,
})
);
}
} catch (err) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
defaultMessage: 'Query must be valid JSON.',
})
);
}
}
if (!threshold || threshold.length === 0 || threshold[0] === undefined) {
errors.threshold0.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredThreshold0Text', {
@ -96,6 +59,7 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
})
);
}
if (!size) {
errors.size.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredSizeText', {
@ -111,5 +75,66 @@ export const validateExpression = (alertParams: EsQueryAlertParams): ValidationR
})
);
}
/**
* Skip esQuery and index params check if it is search source alert,
* since it should contain searchConfiguration instead of esQuery and index.
*/
const isSearchSource = isSearchSourceAlert(alertParams);
if (isSearchSource) {
if (!alertParams.searchConfiguration) {
errors.searchConfiguration.push(
i18n.translate(
'xpack.stackAlerts.esQuery.ui.validation.error.requiredSearchConfiguration',
{
defaultMessage: 'Search source configuration is required.',
}
)
);
}
return validationResult;
}
if (!alertParams.index || alertParams.index.length === 0) {
errors.index.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredIndexText', {
defaultMessage: 'Index is required.',
})
);
}
if (!alertParams.timeField) {
errors.timeField.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredTimeFieldText', {
defaultMessage: 'Time field is required.',
})
);
}
if (!alertParams.esQuery) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredQueryText', {
defaultMessage: 'Elasticsearch query is required.',
})
);
} else {
try {
const parsedQuery = JSON.parse(alertParams.esQuery);
if (!parsedQuery.query) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.requiredEsQueryText', {
defaultMessage: `Query field is required.`,
})
);
}
} catch (err) {
errors.esQuery.push(
i18n.translate('xpack.stackAlerts.esQuery.ui.validation.error.jsonQueryText', {
defaultMessage: 'Query must be valid JSON.',
})
);
}
}
return validationResult;
};

View file

@ -10,15 +10,18 @@ import { getAlertType as getThresholdAlertType } from './threshold';
import { getAlertType as getEsQueryAlertType } from './es_query';
import { Config } from '../../common';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../triggers_actions_ui/public';
import { PluginSetupContract as AlertingSetup } from '../../../alerting/public';
export function registerAlertTypes({
ruleTypeRegistry,
config,
alerting,
}: {
ruleTypeRegistry: TriggersAndActionsUIPublicPluginSetup['ruleTypeRegistry'];
config: Config;
alerting: AlertingSetup;
}) {
ruleTypeRegistry.register(getGeoContainmentAlertType());
ruleTypeRegistry.register(getThresholdAlertType());
ruleTypeRegistry.register(getEsQueryAlertType());
ruleTypeRegistry.register(getEsQueryAlertType(alerting));
}

View file

@ -9,12 +9,14 @@ import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public';
import { TriggersAndActionsUIPublicPluginSetup } from '../../../plugins/triggers_actions_ui/public';
import { registerAlertTypes } from './alert_types';
import { Config } from '../common';
import { PluginSetupContract as AlertingSetup } from '../../alerting/public';
export type Setup = void;
export type Start = void;
export interface StackAlertsPublicSetupDeps {
triggersActionsUi: TriggersAndActionsUIPublicPluginSetup;
alerting: AlertingSetup;
}
export class StackAlertsPublicPlugin implements Plugin<Setup, Start, StackAlertsPublicSetupDeps> {
@ -24,10 +26,11 @@ export class StackAlertsPublicPlugin implements Plugin<Setup, Start, StackAlerts
this.initializerContext = initializerContext;
}
public setup(core: CoreSetup, { triggersActionsUi }: StackAlertsPublicSetupDeps) {
public setup(core: CoreSetup, { triggersActionsUi, alerting }: StackAlertsPublicSetupDeps) {
registerAlertTypes({
ruleTypeRegistry: triggersActionsUi.ruleTypeRegistry,
config: this.initializerContext.config.get<Config>(),
alerting,
});
}

View file

@ -7,6 +7,7 @@
import { EsQueryAlertActionContext, addMessages } from './action_context';
import { EsQueryAlertParamsSchema } from './alert_type_params';
import { OnlyEsQueryAlertParams } from './types';
describe('ActionContext', () => {
it('generates expected properties', async () => {
@ -19,12 +20,13 @@ describe('ActionContext', () => {
timeWindowUnit: 'm',
thresholdComparator: '>',
threshold: [4],
});
}) as OnlyEsQueryAlertParams;
const base: EsQueryAlertActionContext = {
date: '2020-01-01T00:00:00.000Z',
value: 42,
conditions: 'count greater than 4',
hits: [],
link: 'link-mock',
};
const context = addMessages({ name: '[alert-name]' }, base, params);
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
@ -33,7 +35,8 @@ describe('ActionContext', () => {
- Value: 42
- Conditions Met: count greater than 4 over 5m
- Timestamp: 2020-01-01T00:00:00.000Z`
- Timestamp: 2020-01-01T00:00:00.000Z
- Link: link-mock`
);
});
@ -47,12 +50,13 @@ describe('ActionContext', () => {
timeWindowUnit: 'm',
thresholdComparator: 'between',
threshold: [4, 5],
});
}) as OnlyEsQueryAlertParams;
const base: EsQueryAlertActionContext = {
date: '2020-01-01T00:00:00.000Z',
value: 4,
conditions: 'count between 4 and 5',
hits: [],
link: 'link-mock',
};
const context = addMessages({ name: '[alert-name]' }, base, params);
expect(context.title).toMatchInlineSnapshot(`"alert '[alert-name]' matched query"`);
@ -61,7 +65,8 @@ describe('ActionContext', () => {
- Value: 4
- Conditions Met: count between 4 and 5 over 5m
- Timestamp: 2020-01-01T00:00:00.000Z`
- Timestamp: 2020-01-01T00:00:00.000Z
- Link: link-mock`
);
});
});

View file

@ -8,7 +8,7 @@
import { i18n } from '@kbn/i18n';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { AlertExecutorOptions, AlertInstanceContext } from '../../../../alerting/server';
import { EsQueryAlertParams } from './alert_type_params';
import { OnlyEsQueryAlertParams, OnlySearchSourceAlertParams } from './types';
// alert type context provided to actions
@ -30,12 +30,15 @@ export interface EsQueryAlertActionContext extends AlertInstanceContext {
conditions: string;
// query matches
hits: estypes.SearchHit[];
// a link to see records that triggered the alert for Discover alert
// a link which navigates to stack management in case of Elastic query alert
link: string;
}
export function addMessages(
alertInfo: AlertInfo,
baseContext: EsQueryAlertActionContext,
params: EsQueryAlertParams
params: OnlyEsQueryAlertParams | OnlySearchSourceAlertParams
): ActionContext {
const title = i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle', {
defaultMessage: `alert '{name}' matched query`,
@ -50,13 +53,15 @@ export function addMessages(
- Value: {value}
- Conditions Met: {conditions} over {window}
- Timestamp: {date}`,
- Timestamp: {date}
- Link: {link}`,
values: {
name: alertInfo.name,
value: baseContext.value,
conditions: baseContext.conditions,
window,
date: baseContext.date,
link: baseContext.link,
},
});

View file

@ -6,26 +6,23 @@
*/
import { i18n } from '@kbn/i18n';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { Logger } from 'src/core/server';
import { RuleType, AlertExecutorOptions } from '../../types';
import { ActionContext, EsQueryAlertActionContext, addMessages } from './action_context';
import { CoreSetup, Logger } from 'kibana/server';
import { RuleType } from '../../types';
import { ActionContext } from './action_context';
import {
EsQueryAlertParams,
EsQueryAlertParamsSchema,
EsQueryAlertState,
} from './alert_type_params';
import { STACK_ALERTS_FEATURE_ID } from '../../../common';
import { ComparatorFns, getHumanReadableComparator } from '../lib';
import { parseDuration } from '../../../../alerting/server';
import { buildSortedEventsQuery } from '../../../common/build_sorted_events_query';
import { ExecutorOptions } from './types';
import { ActionGroupId, ES_QUERY_ID } from './constants';
import { executor } from './executor';
export const ES_QUERY_ID = '.es-query';
export const ActionGroupId = 'query matched';
export const ConditionMetAlertInstanceId = 'query matched';
export function getAlertType(logger: Logger): RuleType<
export function getAlertType(
logger: Logger,
core: CoreSetup
): RuleType<
EsQueryAlertParams,
never, // Only use if defining useSavedObjectReferences hook
EsQueryAlertState,
@ -101,14 +98,14 @@ export function getAlertType(logger: Logger): RuleType<
'xpack.stackAlerts.esQuery.actionVariableContextThresholdLabel',
{
defaultMessage:
"An array of values to use as the threshold; 'between' and 'notBetween' require two values, the others require one.",
"An array of values to use as the threshold. 'between' and 'notBetween' require two values.",
}
);
const actionVariableContextThresholdComparatorLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextThresholdComparatorLabel',
{
defaultMessage: 'A function to determine if the threshold has been met.',
defaultMessage: 'A function to determine if the threshold was met.',
}
);
@ -119,6 +116,22 @@ export function getAlertType(logger: Logger): RuleType<
}
);
const actionVariableSearchConfigurationLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextSearchConfigurationLabel',
{
defaultMessage:
'Serialized search source fields used to fetch the documents from Elasticsearch.',
}
);
const actionVariableContextLinkLabel = i18n.translate(
'xpack.stackAlerts.esQuery.actionVariableContextLinkLabel',
{
defaultMessage: `Navigate to Discover and show the records that triggered
the alert when the rule is created in Discover. Otherwise, navigate to the status page for the rule.`,
}
);
return {
id: ES_QUERY_ID,
name: alertTypeName,
@ -135,214 +148,22 @@ export function getAlertType(logger: Logger): RuleType<
{ name: 'value', description: actionVariableContextValueLabel },
{ name: 'hits', description: actionVariableContextHitsLabel },
{ name: 'conditions', description: actionVariableContextConditionsLabel },
{ name: 'link', description: actionVariableContextLinkLabel },
],
params: [
{ name: 'index', description: actionVariableContextIndexLabel },
{ name: 'esQuery', description: actionVariableContextQueryLabel },
{ name: 'size', description: actionVariableContextSizeLabel },
{ name: 'threshold', description: actionVariableContextThresholdLabel },
{ name: 'thresholdComparator', description: actionVariableContextThresholdComparatorLabel },
{ name: 'searchConfiguration', description: actionVariableSearchConfigurationLabel },
{ name: 'esQuery', description: actionVariableContextQueryLabel },
{ name: 'index', description: actionVariableContextIndexLabel },
],
},
minimumLicenseRequired: 'basic',
isExportable: true,
executor,
executor: async (options: ExecutorOptions<EsQueryAlertParams>) => {
return await executor(logger, core, options);
},
producer: STACK_ALERTS_FEATURE_ID,
};
async function executor(
options: AlertExecutorOptions<
EsQueryAlertParams,
EsQueryAlertState,
{},
ActionContext,
typeof ActionGroupId
>
) {
const { alertId, name, services, params, state } = options;
const { alertFactory, scopedClusterClient } = services;
const previousTimestamp = state.latestTimestamp;
const esClient = scopedClusterClient.asCurrentUser;
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
const compareFn = ComparatorFns.get(params.thresholdComparator);
if (compareFn == null) {
throw new Error(getInvalidComparatorError(params.thresholdComparator));
}
// During each alert execution, we run the configured query, get a hit count
// (hits.total) and retrieve up to params.size hits. We
// evaluate the threshold condition using the value of hits.total. If the threshold
// condition is met, the hits are counted toward the query match and we update
// the alert state with the timestamp of the latest hit. In the next execution
// of the alert, the latestTimestamp will be used to gate the query in order to
// avoid counting a document multiple times.
let timestamp: string | undefined = tryToParseAsDate(previousTimestamp);
const filter = timestamp
? {
bool: {
filter: [
parsedQuery.query,
{
bool: {
must_not: [
{
bool: {
filter: [
{
range: {
[params.timeField]: {
lte: timestamp,
format: 'strict_date_optional_time',
},
},
},
],
},
},
],
},
},
],
},
}
: parsedQuery.query;
const query = buildSortedEventsQuery({
index: params.index,
from: dateStart,
to: dateEnd,
filter,
size: params.size,
sortOrder: 'desc',
searchAfterSortId: undefined,
timeField: params.timeField,
track_total_hits: true,
});
logger.debug(`alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`);
const { body: searchResult } = await esClient.search(query, { meta: true });
logger.debug(
`alert ${ES_QUERY_ID}:${alertId} "${name}" result - ${JSON.stringify(searchResult)}`
);
const numMatches = (searchResult.hits.total as estypes.SearchTotalHits).value;
// apply the alert condition
const conditionMet = compareFn(numMatches, params.threshold);
if (conditionMet) {
const humanFn = i18n.translate(
'xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription',
{
defaultMessage: `Number of matching documents is {thresholdComparator} {threshold}`,
values: {
thresholdComparator: getHumanReadableComparator(params.thresholdComparator),
threshold: params.threshold.join(' and '),
},
}
);
const baseContext: EsQueryAlertActionContext = {
date: new Date().toISOString(),
value: numMatches,
conditions: humanFn,
hits: searchResult.hits.hits,
};
const actionContext = addMessages(options, baseContext, params);
const alertInstance = alertFactory.create(ConditionMetAlertInstanceId);
alertInstance
// store the params we would need to recreate the query that led to this alert instance
.replaceState({ latestTimestamp: timestamp, dateStart, dateEnd })
.scheduleActions(ActionGroupId, actionContext);
// update the timestamp based on the current search results
const firstValidTimefieldSort = getValidTimefieldSort(
searchResult.hits.hits.find((hit) => getValidTimefieldSort(hit.sort))?.sort
);
if (firstValidTimefieldSort) {
timestamp = firstValidTimefieldSort;
}
}
return {
latestTimestamp: timestamp,
};
}
}
function getValidTimefieldSort(sortValues: Array<string | number | null> = []): undefined | string {
for (const sortValue of sortValues) {
const sortDate = tryToParseAsDate(sortValue);
if (sortDate) {
return sortDate;
}
}
}
function tryToParseAsDate(sortValue?: string | number | null): undefined | string {
const sortDate = typeof sortValue === 'string' ? Date.parse(sortValue) : sortValue;
if (sortDate && !isNaN(sortDate)) {
return new Date(sortDate).toISOString();
}
}
function getInvalidComparatorError(comparator: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}
function getSearchParams(queryParams: EsQueryAlertParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}

View file

@ -7,6 +7,7 @@
import { TypeOf } from '@kbn/config-schema';
import type { Writable } from '@kbn/utility-types';
import { Comparator } from '../../../common/comparator_types';
import {
EsQueryAlertParamsSchema,
EsQueryAlertParams,
@ -20,7 +21,7 @@ const DefaultParams: Writable<Partial<EsQueryAlertParams>> = {
size: 100,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '>',
thresholdComparator: Comparator.GT,
threshold: [0],
};

View file

@ -6,10 +6,11 @@
*/
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { ComparatorFnNames } from '../lib';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import { validateTimeWindowUnits } from '../../../../triggers_actions_ui/server';
import { AlertTypeState } from '../../../../alerting/server';
import { Comparator } from '../../../common/comparator_types';
import { ComparatorFnNames } from '../lib';
export const ES_QUERY_MAX_HITS_PER_EXECUTION = 10000;
@ -19,15 +20,39 @@ export interface EsQueryAlertState extends AlertTypeState {
latestTimestamp: string | undefined;
}
export const EsQueryAlertParamsSchemaProperties = {
index: schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 }),
timeField: schema.string({ minLength: 1 }),
esQuery: schema.string({ minLength: 1 }),
const EsQueryAlertParamsSchemaProperties = {
size: schema.number({ min: 0, max: ES_QUERY_MAX_HITS_PER_EXECUTION }),
timeWindowSize: schema.number({ min: 1 }),
timeWindowUnit: schema.string({ validate: validateTimeWindowUnits }),
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
thresholdComparator: schema.string({ validate: validateComparator }),
thresholdComparator: schema.string({ validate: validateComparator }) as Type<Comparator>,
searchType: schema.nullable(schema.literal('searchSource')),
// searchSource alert param only
searchConfiguration: schema.conditional(
schema.siblingRef('searchType'),
schema.literal('searchSource'),
schema.object({}, { unknowns: 'allow' }),
schema.never()
),
// esQuery alert params only
esQuery: schema.conditional(
schema.siblingRef('searchType'),
schema.literal('searchSource'),
schema.never(),
schema.string({ minLength: 1 })
),
index: schema.conditional(
schema.siblingRef('searchType'),
schema.literal('searchSource'),
schema.never(),
schema.arrayOf(schema.string({ minLength: 1 }), { minSize: 1 })
),
timeField: schema.conditional(
schema.siblingRef('searchType'),
schema.literal('searchSource'),
schema.never(),
schema.string({ minLength: 1 })
),
};
export const EsQueryAlertParamsSchema = schema.object(EsQueryAlertParamsSchemaProperties, {
@ -38,8 +63,7 @@ const betweenComparators = new Set(['between', 'notBetween']);
// using direct type not allowed, circular reference, so body is typed to any
function validateParams(anyParams: unknown): string | undefined {
const { esQuery, thresholdComparator, threshold }: EsQueryAlertParams =
anyParams as EsQueryAlertParams;
const { esQuery, thresholdComparator, threshold, searchType } = anyParams as EsQueryAlertParams;
if (betweenComparators.has(thresholdComparator) && threshold.length === 1) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidThreshold2ErrorMessage', {
@ -51,6 +75,10 @@ function validateParams(anyParams: unknown): string | undefined {
});
}
if (searchType === 'searchSource') {
return;
}
try {
const parsedQuery = JSON.parse(esQuery);
@ -66,8 +94,8 @@ function validateParams(anyParams: unknown): string | undefined {
}
}
export function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator)) return;
function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator as Comparator)) return;
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',

View file

@ -0,0 +1,10 @@
/*
* 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.
*/
export const ES_QUERY_ID = '.es-query';
export const ActionGroupId = 'query matched';
export const ConditionMetAlertInstanceId = 'query matched';

View file

@ -0,0 +1,80 @@
/*
* 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 { getSearchParams, getValidTimefieldSort, tryToParseAsDate } from './executor';
import { OnlyEsQueryAlertParams } from './types';
describe('es_query executor', () => {
const defaultProps = {
size: 3,
timeWindowSize: 5,
timeWindowUnit: 'm',
threshold: [],
thresholdComparator: '>=',
esQuery: '{ "query": "test-query" }',
index: ['test-index'],
timeField: '',
};
describe('tryToParseAsDate', () => {
it.each<[string | number]>([['2019-01-01T00:00:00.000Z'], [1546300800000]])(
'should parse as date correctly',
(value) => {
expect(tryToParseAsDate(value)).toBe('2019-01-01T00:00:00.000Z');
}
);
it.each<[string | null | undefined]>([[null], ['invalid date'], [undefined]])(
'should not parse as date',
(value) => {
expect(tryToParseAsDate(value)).toBe(undefined);
}
);
});
describe('getValidTimefieldSort', () => {
it('should return valid time field', () => {
const result = getValidTimefieldSort([
null,
'invalid date',
'2018-12-31T19:00:00.000Z',
1546282800000,
]);
expect(result).toEqual('2018-12-31T19:00:00.000Z');
});
});
describe('getSearchParams', () => {
it('should return search params correctly', () => {
const result = getSearchParams(defaultProps as OnlyEsQueryAlertParams);
expect(result.parsedQuery.query).toBe('test-query');
});
it('should throw invalid query error', () => {
expect(() =>
getSearchParams({ ...defaultProps, esQuery: '' } as OnlyEsQueryAlertParams)
).toThrow('invalid query specified: "" - query must be JSON');
});
it('should throw invalid query error due to missing query property', () => {
expect(() =>
getSearchParams({
...defaultProps,
esQuery: '{ "someProperty": "test-query" }',
} as OnlyEsQueryAlertParams)
).toThrow('invalid query specified: "{ "someProperty": "test-query" }" - query must be JSON');
});
it('should throw invalid window size error', () => {
expect(() =>
getSearchParams({
...defaultProps,
timeWindowSize: 5,
timeWindowUnit: 'r',
} as OnlyEsQueryAlertParams)
).toThrow('invalid format for windowSize: "5r"');
});
});
});

View file

@ -0,0 +1,193 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sha256 } from 'js-sha256';
import { i18n } from '@kbn/i18n';
import { CoreSetup, Logger } from 'kibana/server';
import { addMessages, EsQueryAlertActionContext } from './action_context';
import { ComparatorFns, getHumanReadableComparator } from '../lib';
import { parseDuration } from '../../../../alerting/server';
import { ExecutorOptions, OnlyEsQueryAlertParams, OnlySearchSourceAlertParams } from './types';
import { ActionGroupId, ConditionMetAlertInstanceId } from './constants';
import { fetchEsQuery } from './lib/fetch_es_query';
import { EsQueryAlertParams } from './alert_type_params';
import { fetchSearchSourceQuery } from './lib/fetch_search_source_query';
import { Comparator } from '../../../common/comparator_types';
export async function executor(
logger: Logger,
core: CoreSetup,
options: ExecutorOptions<EsQueryAlertParams>
) {
const esQueryAlert = isEsQueryAlert(options);
const { alertId, name, services, params, state } = options;
const { alertFactory, scopedClusterClient, searchSourceClient } = services;
const currentTimestamp = new Date().toISOString();
const publicBaseUrl = core.http.basePath.publicBaseUrl ?? '';
const compareFn = ComparatorFns.get(params.thresholdComparator);
if (compareFn == null) {
throw new Error(getInvalidComparatorError(params.thresholdComparator));
}
let latestTimestamp: string | undefined = tryToParseAsDate(state.latestTimestamp);
// During each alert execution, we run the configured query, get a hit count
// (hits.total) and retrieve up to params.size hits. We
// evaluate the threshold condition using the value of hits.total. If the threshold
// condition is met, the hits are counted toward the query match and we update
// the alert state with the timestamp of the latest hit. In the next execution
// of the alert, the latestTimestamp will be used to gate the query in order to
// avoid counting a document multiple times.
const { numMatches, searchResult, dateStart, dateEnd } = esQueryAlert
? await fetchEsQuery(alertId, name, params as OnlyEsQueryAlertParams, latestTimestamp, {
scopedClusterClient,
logger,
})
: await fetchSearchSourceQuery(
alertId,
params as OnlySearchSourceAlertParams,
latestTimestamp,
{
searchSourceClient,
logger,
}
);
// apply the alert condition
const conditionMet = compareFn(numMatches, params.threshold);
if (conditionMet) {
const base = publicBaseUrl;
const link = esQueryAlert
? `${base}/app/management/insightsAndAlerting/triggersActions/rule/${alertId}`
: `${base}/app/discover#/viewAlert/${alertId}?from=${dateStart}&to=${dateEnd}&checksum=${getChecksum(
params
)}`;
const conditions = getContextConditionsDescription(
params.thresholdComparator,
params.threshold
);
const baseContext: EsQueryAlertActionContext = {
title: name,
date: currentTimestamp,
value: numMatches,
conditions,
hits: searchResult.hits.hits,
link,
};
const actionContext = addMessages(options, baseContext, params);
const alertInstance = alertFactory.create(ConditionMetAlertInstanceId);
alertInstance
// store the params we would need to recreate the query that led to this alert instance
.replaceState({ latestTimestamp, dateStart, dateEnd })
.scheduleActions(ActionGroupId, actionContext);
// update the timestamp based on the current search results
const firstValidTimefieldSort = getValidTimefieldSort(
searchResult.hits.hits.find((hit) => getValidTimefieldSort(hit.sort))?.sort
);
if (firstValidTimefieldSort) {
latestTimestamp = firstValidTimefieldSort;
}
}
return { latestTimestamp };
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}
export function getSearchParams(queryParams: OnlyEsQueryAlertParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}
export function getValidTimefieldSort(
sortValues: Array<string | number | null> = []
): undefined | string {
for (const sortValue of sortValues) {
const sortDate = tryToParseAsDate(sortValue);
if (sortDate) {
return sortDate;
}
}
}
export function tryToParseAsDate(sortValue?: string | number | null): undefined | string {
const sortDate = typeof sortValue === 'string' ? Date.parse(sortValue) : sortValue;
if (sortDate && !isNaN(sortDate)) {
return new Date(sortDate).toISOString();
}
}
export function isEsQueryAlert(options: ExecutorOptions<EsQueryAlertParams>) {
return options.params.searchType !== 'searchSource';
}
export function getChecksum(params: EsQueryAlertParams) {
return sha256.create().update(JSON.stringify(params));
}
export function getInvalidComparatorError(comparator: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',
values: {
comparator,
},
});
}
export function getContextConditionsDescription(comparator: Comparator, threshold: number[]) {
return i18n.translate('xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription', {
defaultMessage: 'Number of matching documents is {thresholdComparator} {threshold}',
values: {
thresholdComparator: getHumanReadableComparator(comparator),
threshold: threshold.join(' and '),
},
});
}

View file

@ -5,16 +5,17 @@
* 2.0.
*/
import { Logger } from 'src/core/server';
import { CoreSetup, Logger } from 'src/core/server';
import { AlertingSetup } from '../../types';
import { getAlertType } from './alert_type';
interface RegisterParams {
logger: Logger;
alerting: AlertingSetup;
core: CoreSetup;
}
export function register(params: RegisterParams) {
const { logger, alerting } = params;
alerting.registerType(getAlertType(logger));
const { logger, alerting, core } = params;
alerting.registerType(getAlertType(logger, core));
}

View file

@ -0,0 +1,88 @@
/*
* 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 { IScopedClusterClient, Logger } from 'kibana/server';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { OnlyEsQueryAlertParams } from '../types';
import { buildSortedEventsQuery } from '../../../../common/build_sorted_events_query';
import { ES_QUERY_ID } from '../constants';
import { getSearchParams } from './get_search_params';
/**
* Fetching matching documents for a given alert from elasticsearch by a given index and query
*/
export async function fetchEsQuery(
alertId: string,
name: string,
params: OnlyEsQueryAlertParams,
timestamp: string | undefined,
services: {
scopedClusterClient: IScopedClusterClient;
logger: Logger;
}
) {
const { scopedClusterClient, logger } = services;
const esClient = scopedClusterClient.asCurrentUser;
const { parsedQuery, dateStart, dateEnd } = getSearchParams(params);
const filter = timestamp
? {
bool: {
filter: [
parsedQuery.query,
{
bool: {
must_not: [
{
bool: {
filter: [
{
range: {
[params.timeField]: {
lte: timestamp,
format: 'strict_date_optional_time',
},
},
},
],
},
},
],
},
},
],
},
}
: parsedQuery.query;
const query = buildSortedEventsQuery({
index: params.index,
from: dateStart,
to: dateEnd,
filter,
size: params.size,
sortOrder: 'desc',
searchAfterSortId: undefined,
timeField: params.timeField,
track_total_hits: true,
});
logger.debug(
`es query alert ${ES_QUERY_ID}:${alertId} "${name}" query - ${JSON.stringify(query)}`
);
const { body: searchResult } = await esClient.search(query, { meta: true });
logger.debug(
` es query alert ${ES_QUERY_ID}:${alertId} "${name}" result - ${JSON.stringify(searchResult)}`
);
return {
numMatches: (searchResult.hits.total as estypes.SearchTotalHits).value,
searchResult,
dateStart,
dateEnd,
};
}

View file

@ -0,0 +1,164 @@
/*
* 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 { OnlySearchSourceAlertParams } from '../types';
import { createSearchSourceMock } from 'src/plugins/data/common/search/search_source/mocks';
import { updateSearchSource } from './fetch_search_source_query';
import { stubbedSavedObjectIndexPattern } from '../../../../../../../src/plugins/data_views/common/data_view.stub';
import { DataView } from '../../../../../../../src/plugins/data_views/common';
import { fieldFormatsMock } from '../../../../../../../src/plugins/field_formats/common/mocks';
import { Comparator } from '../../../../common/comparator_types';
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: OnlySearchSourceAlertParams = {
size: 100,
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: Comparator.LT,
threshold: [0],
searchConfiguration: {},
searchType: 'searchSource',
};
describe('fetchSearchSourceQuery', () => {
describe('updateSearchSource', () => {
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('without latest timestamp', async () => {
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource, dateStart, dateEnd } = updateSearchSource(
searchSourceInstance,
params,
undefined
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"time": Object {
"format": "strict_date_optional_time",
"gte": "2020-02-09T23:10:41.941Z",
"lte": "2020-02-09T23:15:41.941Z",
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
}
`);
expect(dateStart).toMatch('2020-02-09T23:10:41.941Z');
expect(dateEnd).toMatch('2020-02-09T23:15:41.941Z');
});
it('with latest timestamp in between the given time range ', async () => {
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
searchSourceInstance,
params,
'2020-02-09T23:12:41.941Z'
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"time": Object {
"format": "strict_date_optional_time",
"gte": "2020-02-09T23:10:41.941Z",
"lte": "2020-02-09T23:15:41.941Z",
},
},
},
Object {
"range": Object {
"time": Object {
"gt": "2020-02-09T23:12:41.941Z",
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
}
`);
});
it('with latest timestamp in before the given time range ', async () => {
const params = { ...defaultParams, thresholdComparator: Comparator.GT_OR_EQ, threshold: [3] };
const searchSourceInstance = createSearchSourceMock({ index: dataViewMock });
const { searchSource } = updateSearchSource(
searchSourceInstance,
params,
'2020-01-09T22:12:41.941Z'
);
const searchRequest = searchSource.getSearchRequestBody();
expect(searchRequest.query).toMatchInlineSnapshot(`
Object {
"bool": Object {
"filter": Array [
Object {
"range": Object {
"time": Object {
"format": "strict_date_optional_time",
"gte": "2020-02-09T23:10:41.941Z",
"lte": "2020-02-09T23:15:41.941Z",
},
},
},
],
"must": Array [],
"must_not": Array [],
"should": Array [],
},
}
`);
});
});
});

View file

@ -0,0 +1,89 @@
/*
* 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 { buildRangeFilter, Filter } from '@kbn/es-query';
import { Logger } from 'kibana/server';
import { OnlySearchSourceAlertParams } from '../types';
import {
getTime,
ISearchSource,
ISearchStartSearchSource,
SortDirection,
} from '../../../../../../../src/plugins/data/common';
export async function fetchSearchSourceQuery(
alertId: string,
params: OnlySearchSourceAlertParams,
latestTimestamp: string | undefined,
services: {
logger: Logger;
searchSourceClient: Promise<ISearchStartSearchSource>;
}
) {
const { logger, searchSourceClient } = services;
const client = await searchSourceClient;
const initialSearchSource = await client.create(params.searchConfiguration);
const { searchSource, dateStart, dateEnd } = updateSearchSource(
initialSearchSource,
params,
latestTimestamp
);
logger.debug(
`search source query alert (${alertId}) query: ${JSON.stringify(
searchSource.getSearchRequestBody()
)}`
);
const searchResult = await searchSource.fetch();
return {
numMatches: Number(searchResult.hits.total),
searchResult,
dateStart,
dateEnd,
};
}
export function updateSearchSource(
searchSource: ISearchSource,
params: OnlySearchSourceAlertParams,
latestTimestamp: string | undefined
) {
const index = searchSource.getField('index');
const timeFieldName = index?.timeFieldName;
if (!timeFieldName) {
throw new Error('Invalid data view without timeFieldName.');
}
searchSource.setField('size', params.size);
const timerangeFilter = getTime(index, {
from: `now-${params.timeWindowSize}${params.timeWindowUnit}`,
to: 'now',
});
const dateStart = timerangeFilter?.query.range[timeFieldName].gte;
const dateEnd = timerangeFilter?.query.range[timeFieldName].lte;
const filters = [timerangeFilter];
if (latestTimestamp && latestTimestamp > dateStart) {
// add additional filter for documents with a timestamp greater then
// the timestamp of the previous run, so that those documents are not counted twice
const field = index.fields.find((f) => f.name === timeFieldName);
const addTimeRangeField = buildRangeFilter(field!, { gt: latestTimestamp }, index);
filters.push(addTimeRangeField);
}
const searchSourceChild = searchSource.createChild();
searchSourceChild.setField('filter', filters as Filter[]);
searchSourceChild.setField('sort', [{ [timeFieldName]: SortDirection.desc }]);
return {
searchSource: searchSourceChild,
dateStart,
dateEnd,
};
}

View file

@ -0,0 +1,56 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { OnlyEsQueryAlertParams } from '../types';
import { parseDuration } from '../../../../../alerting/common';
export function getSearchParams(queryParams: OnlyEsQueryAlertParams) {
const date = Date.now();
const { esQuery, timeWindowSize, timeWindowUnit } = queryParams;
let parsedQuery;
try {
parsedQuery = JSON.parse(esQuery);
} catch (err) {
throw new Error(getInvalidQueryError(esQuery));
}
if (parsedQuery && !parsedQuery.query) {
throw new Error(getInvalidQueryError(esQuery));
}
const window = `${timeWindowSize}${timeWindowUnit}`;
let timeWindow: number;
try {
timeWindow = parseDuration(window);
} catch (err) {
throw new Error(getInvalidWindowSizeError(window));
}
const dateStart = new Date(date - timeWindow).toISOString();
const dateEnd = new Date(date).toISOString();
return { parsedQuery, dateStart, dateEnd };
}
function getInvalidWindowSizeError(windowValue: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidWindowSizeErrorMessage', {
defaultMessage: 'invalid format for windowSize: "{windowValue}"',
values: {
windowValue,
},
});
}
function getInvalidQueryError(query: string) {
return i18n.translate('xpack.stackAlerts.esQuery.invalidQueryErrorMessage', {
defaultMessage: 'invalid query specified: "{query}" - query must be JSON',
values: {
query,
},
});
}

View file

@ -0,0 +1,28 @@
/*
* 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 { AlertExecutorOptions, AlertTypeParams } from '../../types';
import { ActionContext } from './action_context';
import { EsQueryAlertParams, EsQueryAlertState } from './alert_type_params';
import { ActionGroupId } from './constants';
export type OnlyEsQueryAlertParams = Omit<EsQueryAlertParams, 'searchConfiguration' | 'searchType'>;
export type OnlySearchSourceAlertParams = Omit<
EsQueryAlertParams,
'esQuery' | 'index' | 'timeField'
> & {
searchType: 'searchSource';
};
export type ExecutorOptions<P extends AlertTypeParams> = AlertExecutorOptions<
P,
EsQueryAlertState,
{},
ActionContext,
typeof ActionGroupId
>;

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { Logger } from 'src/core/server';
import { CoreSetup, Logger } from 'src/core/server';
import { AlertingSetup, StackAlertsStartDeps } from '../types';
import { register as registerIndexThreshold } from './index_threshold';
import { register as registerGeoContainment } from './geo_containment';
@ -14,6 +14,7 @@ interface RegisterAlertTypesParams {
logger: Logger;
data: Promise<StackAlertsStartDeps['triggersActionsUi']['data']>;
alerting: AlertingSetup;
core: CoreSetup;
}
export function registerBuiltInAlertTypes(params: RegisterAlertTypesParams) {

View file

@ -13,6 +13,7 @@ import { getAlertType, ActionGroupId } from './alert_type';
import { ActionContext } from './action_context';
import { Params } from './alert_type_params';
import { AlertServicesMock, alertsMock } from '../../../../alerting/server/mocks';
import { Comparator } from '../../../common/comparator_types';
describe('alertType', () => {
const logger = loggingSystemMock.create().get();
@ -118,7 +119,7 @@ describe('alertType', () => {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '<',
thresholdComparator: Comparator.LT,
threshold: [0],
};
@ -136,7 +137,7 @@ describe('alertType', () => {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '>',
thresholdComparator: Comparator.GT,
threshold: [0],
};
@ -163,7 +164,7 @@ describe('alertType', () => {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '<',
thresholdComparator: Comparator.LT,
threshold: [1],
};
@ -225,7 +226,7 @@ describe('alertType', () => {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '<',
thresholdComparator: Comparator.LT,
threshold: [1],
};
@ -291,7 +292,7 @@ describe('alertType', () => {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '<',
thresholdComparator: Comparator.LT,
threshold: [1],
};

View file

@ -9,6 +9,7 @@ import { ParamsSchema, Params } from './alert_type_params';
import { ObjectType, TypeOf } from '@kbn/config-schema';
import type { Writable } from '@kbn/utility-types';
import { CoreQueryParams, MAX_GROUPS } from '../../../../triggers_actions_ui/server';
import { Comparator } from '../../../common/comparator_types';
const DefaultParams: Writable<Partial<Params>> = {
index: 'index-name',
@ -17,7 +18,7 @@ const DefaultParams: Writable<Partial<Params>> = {
groupBy: 'all',
timeWindowSize: 5,
timeWindowUnit: 'm',
thresholdComparator: '>',
thresholdComparator: Comparator.GT,
threshold: [0],
};

View file

@ -6,12 +6,13 @@
*/
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
import { ComparatorFnNames } from '../lib';
import { schema, Type, TypeOf } from '@kbn/config-schema';
import {
CoreQueryParamsSchemaProperties,
validateCoreQueryBody,
} from '../../../../triggers_actions_ui/server';
import { ComparatorFnNames } from '../lib';
import { Comparator } from '../../../common/comparator_types';
// alert type parameters
@ -21,7 +22,7 @@ export const ParamsSchema = schema.object(
{
...CoreQueryParamsSchemaProperties,
// the comparison function to use to determine if the threshold as been met
thresholdComparator: schema.string({ validate: validateComparator }),
thresholdComparator: schema.string({ validate: validateComparator }) as Type<Comparator>,
// the values to use as the threshold; `between` and `notBetween` require
// two values, the others require one.
threshold: schema.arrayOf(schema.number(), { minSize: 1, maxSize: 2 }),
@ -52,8 +53,8 @@ function validateParams(anyParams: unknown): string | undefined {
}
}
export function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator)) return;
function validateComparator(comparator: string): string | undefined {
if (ComparatorFnNames.has(comparator as Comparator)) return;
return i18n.translate('xpack.stackAlerts.indexThreshold.invalidComparatorErrorMessage', {
defaultMessage: 'invalid thresholdComparator specified: {comparator}',

View file

@ -0,0 +1,42 @@
/*
* 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 { Comparator } from '../../../common/comparator_types';
export type ComparatorFn = (value: number, threshold: number[]) => boolean;
const humanReadableComparators = new Map<Comparator, string>([
[Comparator.LT, 'less than'],
[Comparator.LT_OR_EQ, 'less than or equal to'],
[Comparator.GT_OR_EQ, 'greater than or equal to'],
[Comparator.GT, 'greater than'],
[Comparator.BETWEEN, 'between'],
[Comparator.NOT_BETWEEN, 'not between'],
]);
export const ComparatorFns = new Map<Comparator, ComparatorFn>([
[Comparator.LT, (value: number, threshold: number[]) => value < threshold[0]],
[Comparator.LT_OR_EQ, (value: number, threshold: number[]) => value <= threshold[0]],
[Comparator.GT_OR_EQ, (value: number, threshold: number[]) => value >= threshold[0]],
[Comparator.GT, (value: number, threshold: number[]) => value > threshold[0]],
[
Comparator.BETWEEN,
(value: number, threshold: number[]) => value >= threshold[0] && value <= threshold[1],
],
[
Comparator.NOT_BETWEEN,
(value: number, threshold: number[]) => value < threshold[0] || value > threshold[1],
],
]);
export const ComparatorFnNames = new Set(ComparatorFns.keys());
export function getHumanReadableComparator(comparator: Comparator) {
return humanReadableComparators.has(comparator)
? humanReadableComparators.get(comparator)
: comparator;
}

View file

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

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator_types';
export { ComparatorFns, ComparatorFnNames, getHumanReadableComparator } from './comparator';

View file

@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n';
import { KibanaFeatureConfig } from '../../../plugins/features/common';
import { ID as IndexThreshold } from './alert_types/index_threshold/alert_type';
import { GEO_CONTAINMENT_ID as GeoContainment } from './alert_types/geo_containment/alert_type';
import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/alert_type';
import { ES_QUERY_ID as ElasticsearchQuery } from './alert_types/es_query/constants';
import { STACK_ALERTS_FEATURE_ID } from '../common';
import { DEFAULT_APP_CATEGORIES } from '../../../../src/core/server';
import { TRANSFORM_RULE_TYPE } from '../../transform/common';

View file

@ -29,6 +29,7 @@ export class AlertingBuiltinsPlugin
.getStartServices()
.then(async ([, { triggersActionsUi }]) => triggersActionsUi.data),
alerting,
core,
});
}

View file

@ -13,6 +13,7 @@ export type {
RuleType,
RuleParamsAndRefs,
AlertExecutorOptions,
AlertTypeParams,
} from '../../alerting/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';

View file

@ -23329,7 +23329,6 @@
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "Titre pour l'alerte.",
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "Valeur ayant rempli la condition de seuil.",
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "Le nombre de documents correspondants est {thresholdComparator} {threshold}",
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "l'alerte \"{name}\" est active :\n\n- Valeur : {value}\n- Conditions remplies : {conditions} sur {window}\n- Horodatage : {date}",
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "l'alerte \"{name}\" correspond à la recherche",
"xpack.stackAlerts.esQuery.alertTypeTitle": "Recherche Elasticsearch",
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "thresholdComparator spécifié non valide : {comparator}",

View file

@ -26653,7 +26653,6 @@
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "アラートのタイトル。",
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "しきい値条件を満たした値。",
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "一致するドキュメント数は{thresholdComparator} {threshold}です",
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "アラート'{name}'は有効です。\n\n- 値:{value}\n- 条件が満たされました:{window} の {conditions}\n- タイムスタンプ:{date}",
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "アラート'{name}'はクエリと一致しました",
"xpack.stackAlerts.esQuery.alertTypeTitle": "Elasticsearch クエリ",
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "無効な thresholdComparator が指定されました:{comparator}",

View file

@ -26682,7 +26682,6 @@
"xpack.stackAlerts.esQuery.actionVariableContextTitleLabel": "告警的标题。",
"xpack.stackAlerts.esQuery.actionVariableContextValueLabel": "满足阈值条件的值。",
"xpack.stackAlerts.esQuery.alertTypeContextConditionsDescription": "匹配文档的数目{thresholdComparator} {threshold}",
"xpack.stackAlerts.esQuery.alertTypeContextMessageDescription": "告警“{name}”处于活动状态:\n\n- 值:{value}\n- 满足的条件:{conditions} 超过 {window}\n- 时间戳:{date}",
"xpack.stackAlerts.esQuery.alertTypeContextSubjectTitle": "告警“{name}”已匹配查询",
"xpack.stackAlerts.esQuery.alertTypeTitle": "Elasticsearch 查询",
"xpack.stackAlerts.esQuery.invalidComparatorErrorMessage": "指定的 thresholdComparator 无效:{comparator}",

View file

@ -107,7 +107,8 @@ export class ESTestIndexTool {
return await this.retry.try(async () => {
const searchResult = await this.search(source, reference);
// @ts-expect-error doesn't handle total: number
if (searchResult.body.hits.total.value < numDocs) {
const value = searchResult.body.hits.total.value?.value || searchResult.body.hits.total.value;
if (value < numDocs) {
// @ts-expect-error doesn't handle total: number
throw new Error(`Expected ${numDocs} but received ${searchResult.body.hits.total.value}.`);
}

View file

@ -32,6 +32,7 @@ const ES_GROUPS_TO_WRITE = 3;
export default function alertTests({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const retry = getService('retry');
const indexPatterns = getService('indexPatterns');
const es = getService('es');
const esTestIndexTool = new ESTestIndexTool(es, retry);
const esTestIndexToolOutput = new ESTestIndexTool(es, retry, ES_TEST_OUTPUT_INDEX_NAME);
@ -61,180 +62,357 @@ export default function alertTests({ getService }: FtrProviderContext) {
await esTestIndexToolOutput.destroy();
});
it('runs correctly: threshold on hit count < >', async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
await createAlert({
name: 'never fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '<',
threshold: [0],
});
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [-1],
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
});
it('runs correctly: use epoch millis - threshold on hit count < >', async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
await createAlert({
name: 'never fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '<',
threshold: [0],
timeField: 'date_epoch_millis',
});
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [-1],
timeField: 'date_epoch_millis',
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
});
it('runs correctly with query: threshold on hit count < >', async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
const rangeQuery = (rangeThreshold: number) => {
return {
query: {
bool: {
filter: [
{
range: {
testedValue: {
gte: rangeThreshold,
},
},
},
],
[
[
'esQuery',
async () => {
await createAlert({
name: 'never fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '<',
threshold: [0],
});
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [-1],
});
},
] as const,
[
'searchSource',
async () => {
const esTestDataView = await indexPatterns.create(
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
{ override: true },
getUrlPrefix(Spaces.space1.id)
);
await createAlert({
name: 'never fire',
size: 100,
thresholdComparator: '<',
threshold: [0],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
},
};
};
});
await createAlert({
name: 'always fire',
size: 100,
thresholdComparator: '>',
threshold: [-1],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
},
] as const,
].forEach(([searchType, initData]) =>
it(`runs correctly: threshold on hit count < > for ${searchType} search type`, async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
await initData();
await createAlert({
name: 'never fire',
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
size: 100,
thresholdComparator: '<',
threshold: [-1],
});
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
await createAlert({
name: 'fires once',
esQuery: JSON.stringify(
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
),
size: 100,
thresholdComparator: '>=',
threshold: [0],
});
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
const docs = await waitForDocs(1);
for (const doc of docs) {
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('fires once');
expect(title).to.be(`alert 'fires once' matched query`);
const messagePattern =
/alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
expect(previousTimestamp).to.be.empty();
}
});
it('runs correctly: no matches', async () => {
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '<',
threshold: [1],
});
const docs = await waitForDocs(1);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: 0+\n- Conditions Met: Number of matching documents is less than 1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
}
});
})
);
[
[
'esQuery',
async () => {
await createAlert({
name: 'never fire',
size: 100,
thresholdComparator: '<',
threshold: [0],
timeField: 'date_epoch_millis',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
});
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '>',
threshold: [-1],
timeField: 'date_epoch_millis',
});
},
] as const,
[
'searchSource',
async () => {
const esTestDataView = await indexPatterns.create(
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date_epoch_millis' },
{ override: true },
getUrlPrefix(Spaces.space1.id)
);
await createAlert({
name: 'never fire',
size: 100,
thresholdComparator: '<',
threshold: [0],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
await createAlert({
name: 'always fire',
size: 100,
thresholdComparator: '>',
threshold: [-1],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
},
] as const,
].forEach(([searchType, initData]) =>
it(`runs correctly: use epoch millis - threshold on hit count < > for ${searchType} search type`, async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
await initData();
const docs = await waitForDocs(2);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than -1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
})
);
[
[
'esQuery',
async () => {
const rangeQuery = (rangeThreshold: number) => {
return {
query: {
bool: {
filter: [
{
range: {
testedValue: {
gte: rangeThreshold,
},
},
},
],
},
},
};
};
await createAlert({
name: 'never fire',
esQuery: JSON.stringify(rangeQuery(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1)),
size: 100,
thresholdComparator: '<',
threshold: [-1],
});
await createAlert({
name: 'fires once',
esQuery: JSON.stringify(
rangeQuery(Math.floor((ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2))
),
size: 100,
thresholdComparator: '>=',
threshold: [0],
});
},
] as const,
[
'searchSource',
async () => {
const esTestDataView = await indexPatterns.create(
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
{ override: true },
getUrlPrefix(Spaces.space1.id)
);
await createAlert({
name: 'never fire',
size: 100,
thresholdComparator: '<',
threshold: [-1],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: `testedValue > ${ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE + 1}`,
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
await createAlert({
name: 'fires once',
size: 100,
thresholdComparator: '>=',
threshold: [0],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: `testedValue > ${Math.floor(
(ES_GROUPS_TO_WRITE * ALERT_INTERVALS_TO_WRITE) / 2
)}`,
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
},
] as const,
].forEach(([searchType, initData]) =>
it(`runs correctly with query: threshold on hit count < > for ${searchType}`, async () => {
// write documents from now to the future end date in groups
createEsDocumentsInGroups(ES_GROUPS_TO_WRITE);
await initData();
const docs = await waitForDocs(1);
for (const doc of docs) {
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('fires once');
expect(title).to.be(`alert 'fires once' matched query`);
const messagePattern =
/alert 'fires once' is active:\n\n- Value: \d+\n- Conditions Met: Number of matching documents is greater than or equal to 0 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).not.to.be.empty();
expect(previousTimestamp).to.be.empty();
}
})
);
[
[
'esQuery',
async () => {
await createAlert({
name: 'always fire',
esQuery: `{\n \"query\":{\n \"match_all\" : {}\n }\n}`,
size: 100,
thresholdComparator: '<',
threshold: [1],
});
},
] as const,
[
'searchSource',
async () => {
const esTestDataView = await indexPatterns.create(
{ title: ES_TEST_INDEX_NAME, timeFieldName: 'date' },
{ override: true },
getUrlPrefix(Spaces.space1.id)
);
await createAlert({
name: 'always fire',
size: 100,
thresholdComparator: '<',
threshold: [1],
searchType: 'searchSource',
searchConfiguration: {
query: {
query: '',
language: 'kuery',
},
index: esTestDataView.id,
filter: [],
},
});
},
] as const,
].forEach(([searchType, initData]) =>
it(`runs correctly: no matches for ${searchType} search type`, async () => {
await initData();
const docs = await waitForDocs(1);
for (let i = 0; i < docs.length; i++) {
const doc = docs[i];
const { previousTimestamp, hits } = doc._source;
const { name, title, message } = doc._source.params;
expect(name).to.be('always fire');
expect(title).to.be(`alert 'always fire' matched query`);
const messagePattern =
/alert 'always fire' is active:\n\n- Value: 0+\n- Conditions Met: Number of matching documents is less than 1 over 15s\n- Timestamp: \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/;
expect(message).to.match(messagePattern);
expect(hits).to.be.empty();
// during the first execution, the latestTimestamp value should be empty
// since this alert always fires, the latestTimestamp value should be updated each execution
if (!i) {
expect(previousTimestamp).to.be.empty();
} else {
expect(previousTimestamp).not.to.be.empty();
}
}
})
);
async function createEsDocumentsInGroups(groups: number) {
await createEsDocuments(
@ -257,12 +435,14 @@ export default function alertTests({ getService }: FtrProviderContext) {
interface CreateAlertParams {
name: string;
timeField?: string;
esQuery: string;
size: number;
thresholdComparator: string;
threshold: number[];
timeWindowSize?: number;
esQuery?: string;
timeField?: string;
searchConfiguration?: unknown;
searchType?: 'searchSource';
}
async function createAlert(params: CreateAlertParams): Promise<string> {
@ -288,6 +468,17 @@ export default function alertTests({ getService }: FtrProviderContext) {
},
};
const alertParams =
params.searchType === 'searchSource'
? {
searchConfiguration: params.searchConfiguration,
}
: {
index: [ES_TEST_INDEX_NAME],
timeField: params.timeField || 'date',
esQuery: params.esQuery,
};
const { body: createdAlert } = await supertest
.post(`${getUrlPrefix(Spaces.space1.id)}/api/alerting/rule`)
.set('kbn-xsrf', 'foo')
@ -300,14 +491,13 @@ export default function alertTests({ getService }: FtrProviderContext) {
actions: [action],
notify_when: 'onActiveAlert',
params: {
index: [ES_TEST_INDEX_NAME],
timeField: params.timeField || 'date',
esQuery: params.esQuery,
size: params.size,
timeWindowSize: params.timeWindowSize || ALERT_INTERVAL_SECONDS * 5,
timeWindowUnit: 's',
thresholdComparator: params.thresholdComparator,
threshold: params.threshold,
searchType: params.searchType,
...alertParams,
},
})
.expect(200);

View file

@ -11,8 +11,8 @@ import { buildUp, tearDown } from '..';
// eslint-disable-next-line import/no-default-export
export default function alertingTests({ loadTestFile, getService }: FtrProviderContext) {
describe('Alerting', () => {
before(async () => buildUp(getService));
after(async () => tearDown(getService));
before(async () => await buildUp(getService));
after(async () => await tearDown(getService));
loadTestFile(require.resolve('./aggregate'));
loadTestFile(require.resolve('./create'));

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ loadTestFile, getService }: FtrProviderContext) => {
describe('Discover alerting', function () {
this.tags('ciGroup6');
loadTestFile(require.resolve('./search_source_alert'));
});
};

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 { asyncForEach } from '@kbn/std';
import { last } from 'lodash';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const log = getService('log');
const es = getService('es');
const monacoEditor = getService('monacoEditor');
const PageObjects = getPageObjects([
'settings',
'common',
'header',
'discover',
'timePicker',
'dashboard',
]);
const deployment = getService('deployment');
const dataGrid = getService('dataGrid');
const browser = getService('browser');
const retry = getService('retry');
const testSubjects = getService('testSubjects');
const supertest = getService('supertest');
const queryBar = getService('queryBar');
const security = getService('security');
const SOURCE_DATA_INDEX = 'search-source-alert';
const OUTPUT_DATA_INDEX = 'search-source-alert-output';
const ACTION_TYPE_ID = '.index';
const RULE_NAME = 'test-search-source-alert';
let sourceDataViewId: string;
let outputDataViewId: string;
let connectorId: string;
const createSourceIndex = () =>
es.index({
index: SOURCE_DATA_INDEX,
body: {
settings: { number_of_shards: 1 },
mappings: {
properties: {
'@timestamp': { type: 'date' },
message: { type: 'text' },
},
},
},
});
const generateNewDocs = async (docsNumber: number) => {
const mockMessages = new Array(docsNumber).map((current) => `msg-${current}`);
const dateNow = new Date().toISOString();
for (const message of mockMessages) {
await es.transport.request({
path: `/${SOURCE_DATA_INDEX}/_doc`,
method: 'POST',
body: {
'@timestamp': dateNow,
message,
},
});
}
};
const createOutputDataIndex = () =>
es.index({
index: OUTPUT_DATA_INDEX,
body: {
settings: {
number_of_shards: 1,
},
mappings: {
properties: {
rule_id: { type: 'text' },
rule_name: { type: 'text' },
alert_id: { type: 'text' },
context_message: { type: 'text' },
},
},
},
});
const deleteAlerts = (alertIds: string[]) =>
asyncForEach(alertIds, async (alertId: string) => {
await supertest
.delete(`/api/alerting/rule/${alertId}`)
.set('kbn-xsrf', 'foo')
.expect(204, '');
});
const getAlertsByName = async (name: string) => {
const {
body: { data: alerts },
} = await supertest
.get(`/api/alerting/rules/_find?search=${name}&search_fields=name`)
.expect(200);
return alerts;
};
const createDataView = async (dataView: string) => {
log.debug(`create data view ${dataView}`);
return await supertest
.post(`/api/data_views/data_view`)
.set('kbn-xsrf', 'foo')
.send({ data_view: { title: dataView, timeFieldName: '@timestamp' } })
.expect(200);
};
const createConnector = async (): Promise<string> => {
const { body: createdAction } = await supertest
.post(`/api/actions/connector`)
.set('kbn-xsrf', 'foo')
.send({
name: 'search-source-alert-test-connector',
connector_type_id: ACTION_TYPE_ID,
config: { index: OUTPUT_DATA_INDEX },
secrets: {},
})
.expect(200);
return createdAction.id;
};
const deleteConnector = (id: string) =>
supertest.delete(`/api/actions/connector/${id}`).set('kbn-xsrf', 'foo').expect(204, '');
const deleteDataViews = (dataViews: string[]) =>
asyncForEach(
dataViews,
async (dataView: string) =>
await supertest
.delete(`/api/data_views/data_view/${dataView}`)
.set('kbn-xsrf', 'foo')
.expect(200)
);
const defineSearchSourceAlert = async (alertName: string) => {
await testSubjects.click('discoverAlertsButton');
await testSubjects.click('discoverCreateAlertButton');
await testSubjects.setValue('ruleNameInput', alertName);
await testSubjects.click('thresholdPopover');
await testSubjects.setValue('alertThresholdInput', '3');
await testSubjects.click('.index-ActionTypeSelectOption');
await monacoEditor.setCodeEditorValue(`{
"rule_id": "{{ruleId}}",
"rule_name": "{{ruleName}}",
"alert_id": "{{alertId}}",
"context_message": "{{context.message}}"
}`);
await testSubjects.click('saveRuleButton');
};
const getLastToast = async () => {
const toastList = await testSubjects.find('globalToastList');
const titles = await toastList.findAllByCssSelector('.euiToastHeader');
const lastTitleElement = last(titles)!;
const title = await lastTitleElement.getVisibleText();
const messages = await toastList.findAllByCssSelector('.euiToastBody');
const lastMessageElement = last(messages)!;
const message = await lastMessageElement.getVisibleText();
return { message, title };
};
const openOutputIndex = async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.header.waitUntilLoadingHasFinished();
await PageObjects.discover.selectIndexPattern(OUTPUT_DATA_INDEX);
const [{ id: alertId }] = await getAlertsByName(RULE_NAME);
await queryBar.setQuery(`alert_id:${alertId}`);
await retry.waitFor('document explorer contains alert', async () => {
await queryBar.submitQuery();
await PageObjects.discover.waitUntilSearchingHasFinished();
return (await dataGrid.getDocCount()) > 0;
});
};
const getResultsLink = async () => {
// getting the link
await dataGrid.clickRowToggle();
await testSubjects.click('collapseBtn');
const contextMessageElement = await testSubjects.find('tableDocViewRow-context_message-value');
const contextMessage = await contextMessageElement.getVisibleText();
const [, link] = contextMessage.split(`Link\: `);
return link;
};
const navigateToDiscover = async (link: string) => {
// following ling provided by alert to see documents triggered the alert
const baseUrl = deployment.getHostPort();
await browser.navigateTo(baseUrl + link);
await PageObjects.discover.waitUntilSearchingHasFinished();
await retry.waitFor('navigate to discover', async () => {
const currentUrl = await browser.getCurrentUrl();
return currentUrl.includes(sourceDataViewId);
});
};
const navigateToResults = async () => {
const link = await getResultsLink();
await navigateToDiscover(link);
};
const openAlertRule = async () => {
await PageObjects.common.navigateToApp('management');
await PageObjects.header.waitUntilLoadingHasFinished();
await testSubjects.click('triggersActions');
await PageObjects.header.waitUntilLoadingHasFinished();
const rulesList = await testSubjects.find('rulesList');
const alertRule = await rulesList.findByCssSelector('[title="test-search-source-alert"]');
await alertRule.click();
await PageObjects.header.waitUntilLoadingHasFinished();
};
describe('Search source Alert', () => {
before(async () => {
await security.testUser.setRoles(['discover_alert']);
log.debug('create source index');
await createSourceIndex();
log.debug('generate documents');
await generateNewDocs(5);
log.debug('create output index');
await createOutputDataIndex();
log.debug('create data views');
const sourceDataViewResponse = await createDataView(SOURCE_DATA_INDEX);
const outputDataViewResponse = await createDataView(OUTPUT_DATA_INDEX);
log.debug('create connector');
connectorId = await createConnector();
sourceDataViewId = sourceDataViewResponse.body.data_view.id;
outputDataViewId = outputDataViewResponse.body.data_view.id;
});
after(async () => {
// delete only remaining output index
await es.transport.request({
path: `/${OUTPUT_DATA_INDEX}`,
method: 'DELETE',
});
await deleteDataViews([sourceDataViewId, outputDataViewId]);
await deleteConnector(connectorId);
const alertsToDelete = await getAlertsByName(RULE_NAME);
await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id));
await security.testUser.restoreDefaults();
});
it('should navigate to discover via view in app link', async () => {
await PageObjects.common.navigateToApp('discover');
await PageObjects.discover.waitUntilSearchingHasFinished();
await PageObjects.discover.selectIndexPattern(SOURCE_DATA_INDEX);
await PageObjects.timePicker.setCommonlyUsedTime('Last_15 minutes');
// create an alert
await defineSearchSourceAlert(RULE_NAME);
await PageObjects.header.waitUntilLoadingHasFinished();
await openAlertRule();
await testSubjects.click('ruleDetails-viewInApp');
await PageObjects.header.waitUntilLoadingHasFinished();
await retry.waitFor('navigate to discover', async () => {
const currentUrl = await browser.getCurrentUrl();
return currentUrl.includes(sourceDataViewId);
});
expect(await dataGrid.getDocCount()).to.be(5);
});
it('should open documents triggered the alert', async () => {
await openOutputIndex();
await navigateToResults();
const { message, title } = await getLastToast();
expect(await dataGrid.getDocCount()).to.be(5);
expect(title).to.be.equal('Displayed documents may vary');
expect(message).to.be.equal(
'The displayed documents might differ from the documents that triggered the alert. Some documents might have been added or deleted.'
);
});
it('should display warning about updated alert rule', async () => {
await openAlertRule();
// change rule configuration
await testSubjects.click('openEditRuleFlyoutButton');
await testSubjects.click('thresholdPopover');
await testSubjects.setValue('alertThresholdInput', '1');
await testSubjects.click('saveEditedRuleButton');
await PageObjects.header.waitUntilLoadingHasFinished();
await openOutputIndex();
await navigateToResults();
const { message, title } = await getLastToast();
expect(await dataGrid.getDocCount()).to.be(5);
expect(title).to.be.equal('Alert rule has changed');
expect(message).to.be.equal(
'The displayed documents might not match the documents that triggered the alert because the rule configuration changed.'
);
});
it('should display not found index error', async () => {
await openOutputIndex();
const link = await getResultsLink();
await navigateToDiscover(link);
await es.transport.request({
path: `/${SOURCE_DATA_INDEX}`,
method: 'DELETE',
});
await browser.refresh();
await navigateToDiscover(link);
const { title } = await getLastToast();
expect(title).to.be.equal(
'No matching indices found: No indices match "search-source-alert"'
);
});
});
}

View file

@ -48,6 +48,7 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
pageObjects,
// list paths to the files that contain your plugins tests
testFiles: [
resolve(__dirname, './apps/discover'),
resolve(__dirname, './apps/triggers_actions_ui'),
resolve(__dirname, './apps/uptime'),
resolve(__dirname, './apps/ml'),
@ -111,6 +112,30 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
},
],
},
discover_alert: {
kibana: [
{
feature: {
actions: ['all'],
stackAlerts: ['all'],
discover: ['all'],
advancedSettings: ['all'],
},
spaces: ['*'],
},
],
elasticsearch: {
cluster: [],
indices: [
{
names: ['search-source-alert', 'search-source-alert-output'],
privileges: ['read', 'view_index_metadata', 'manage', 'create_index', 'index'],
field_security: { grant: ['*'], except: [] },
},
],
run_as: [],
},
},
},
defaultRoles: ['superuser'],
},