(false);
const setChart: SetChart = useCallback(
(paramName, value) => {
setParamByIndex('seriesParams', index, paramName, value);
@@ -68,6 +69,20 @@ function ChartOptions({
[valueAxes]
);
+ useEffect(() => {
+ const valueAxisToMetric = valueAxes.find((valueAxis) => valueAxis.id === chart.valueAxis);
+ if (valueAxisToMetric) {
+ if (valueAxisToMetric.scale.mode === AxisMode.Percentage) {
+ setDisabledMode(true);
+ if (chart.mode !== ChartMode.Stacked) {
+ setChart('mode', ChartMode.Stacked);
+ }
+ } else if (disabledMode) {
+ setDisabledMode(false);
+ }
+ }
+ }, [valueAxes, chart, disabledMode, setChart, setDisabledMode]);
+
return (
<>
diff --git a/src/plugins/visualizations/public/components/visualization_container.tsx b/src/plugins/visualizations/public/components/visualization_container.tsx
index 3081c39530d75..063715b6438eb 100644
--- a/src/plugins/visualizations/public/components/visualization_container.tsx
+++ b/src/plugins/visualizations/public/components/visualization_container.tsx
@@ -10,6 +10,7 @@ import React, { ReactNode, Suspense } from 'react';
import { EuiLoadingChart } from '@elastic/eui';
import classNames from 'classnames';
import { VisualizationNoResults } from './visualization_noresults';
+import { VisualizationError } from './visualization_error';
import { IInterpreterRenderHandlers } from '../../../expressions/common';
interface VisualizationContainerProps {
@@ -18,6 +19,7 @@ interface VisualizationContainerProps {
children: ReactNode;
handlers: IInterpreterRenderHandlers;
showNoResult?: boolean;
+ error?: string;
}
export const VisualizationContainer = ({
@@ -26,6 +28,7 @@ export const VisualizationContainer = ({
children,
handlers,
showNoResult = false,
+ error,
}: VisualizationContainerProps) => {
const classes = classNames('visualization', className);
@@ -38,7 +41,13 @@ export const VisualizationContainer = ({
return (
- {showNoResult ? handlers.done()} /> : children}
+ {error ? (
+ handlers.done()} error={error} />
+ ) : showNoResult ? (
+ handlers.done()} />
+ ) : (
+ children
+ )}
);
diff --git a/src/plugins/visualizations/public/components/visualization_error.tsx b/src/plugins/visualizations/public/components/visualization_error.tsx
new file mode 100644
index 0000000000000..81600a4e3601c
--- /dev/null
+++ b/src/plugins/visualizations/public/components/visualization_error.tsx
@@ -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 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 { EuiEmptyPrompt } from '@elastic/eui';
+import React from 'react';
+
+interface VisualizationNoResultsProps {
+ onInit?: () => void;
+ error: string;
+}
+
+export class VisualizationError extends React.Component {
+ public render() {
+ return (
+ {this.props.error}}
+ />
+ );
+ }
+
+ public componentDidMount() {
+ this.afterRender();
+ }
+
+ public componentDidUpdate() {
+ this.afterRender();
+ }
+
+ private afterRender() {
+ if (this.props.onInit) {
+ this.props.onInit();
+ }
+ }
+}
diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts
index 429dabeeef042..3bb52eb15758a 100644
--- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts
+++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts
@@ -149,8 +149,9 @@ export class VisualizeEmbeddable
}
this.subscriptions.push(
- this.getUpdated$().subscribe(() => {
+ this.getUpdated$().subscribe((value) => {
const isDirty = this.handleChanges();
+
if (isDirty && this.handler) {
this.updateHandler();
}
@@ -367,8 +368,8 @@ export class VisualizeEmbeddable
}
}
- public reload = () => {
- this.handleVisUpdate();
+ public reload = async () => {
+ await this.handleVisUpdate();
};
private async updateHandler() {
@@ -395,13 +396,13 @@ export class VisualizeEmbeddable
});
if (this.handler && !abortController.signal.aborted) {
- this.handler.update(this.expression, expressionParams);
+ await this.handler.update(this.expression, expressionParams);
}
}
private handleVisUpdate = async () => {
this.handleChanges();
- this.updateHandler();
+ await this.updateHandler();
};
private uiStateChangeHandler = () => {
diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx
index 256e634ac6c40..f6ef1caf9c9e0 100644
--- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx
+++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx
@@ -183,8 +183,12 @@ const TopNav = ({
useEffect(() => {
const autoRefreshFetchSub = services.data.query.timefilter.timefilter
.getAutoRefreshFetch$()
- .subscribe(() => {
- visInstance.embeddableHandler.reload();
+ .subscribe(async (done) => {
+ try {
+ await visInstance.embeddableHandler.reload();
+ } finally {
+ done();
+ }
});
return () => {
autoRefreshFetchSub.unsubscribe();
diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts
index cc0f3ce2afae5..9eda709e58c3e 100644
--- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts
+++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts
@@ -18,8 +18,17 @@ import { SavedObject } from 'src/plugins/saved_objects/public';
import { cloneDeep } from 'lodash';
import { ExpressionValueError } from 'src/plugins/expressions/public';
import { createSavedSearchesLoader } from '../../../../discover/public';
+import { SavedFieldNotFound, SavedFieldTypeInvalidForAgg } from '../../../../kibana_utils/common';
import { VisualizeServices } from '../types';
+function isErrorRelatedToRuntimeFields(error: ExpressionValueError['error']) {
+ const originalError = error.original || error;
+ return (
+ originalError instanceof SavedFieldNotFound ||
+ originalError instanceof SavedFieldTypeInvalidForAgg
+ );
+}
+
const createVisualizeEmbeddableAndLinkSavedSearch = async (
vis: Vis,
visualizeServices: VisualizeServices
@@ -37,7 +46,7 @@ const createVisualizeEmbeddableAndLinkSavedSearch = async (
})) as VisualizeEmbeddableContract;
embeddableHandler.getOutput$().subscribe((output) => {
- if (output.error) {
+ if (output.error && !isErrorRelatedToRuntimeFields(output.error)) {
data.search.showError(
((output.error as unknown) as ExpressionValueError['error']).original || output.error
);
diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts
index 64d61996495d7..965951bfbd88d 100644
--- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts
+++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts
@@ -11,13 +11,12 @@ import { EventEmitter } from 'events';
import { parse } from 'query-string';
import { i18n } from '@kbn/i18n';
-import { redirectWhenMissing } from '../../../../../kibana_utils/public';
-
import { getVisualizationInstance } from '../get_visualization_instance';
import { getEditBreadcrumbs, getCreateBreadcrumbs } from '../breadcrumbs';
import { SavedVisInstance, VisualizeServices, IEditorController } from '../../types';
import { VisualizeConstants } from '../../visualize_constants';
import { getVisEditorsRegistry } from '../../../services';
+import { redirectToSavedObjectPage } from '../utils';
/**
* This effect is responsible for instantiating a saved vis or creating a new one
@@ -43,9 +42,7 @@ export const useSavedVisInstance = (
chrome,
history,
dashboard,
- setActiveUrl,
toastNotifications,
- http: { basePath },
stateTransferService,
application: { navigateToApp },
} = services;
@@ -131,27 +128,8 @@ export const useSavedVisInstance = (
visEditorController,
});
} catch (error) {
- const managementRedirectTarget = {
- app: 'management',
- path: `kibana/objects/savedVisualizations/${visualizationIdFromUrl}`,
- };
-
try {
- redirectWhenMissing({
- history,
- navigateToApp,
- toastNotifications,
- basePath,
- mapping: {
- visualization: VisualizeConstants.LANDING_PAGE_PATH,
- search: managementRedirectTarget,
- 'index-pattern': managementRedirectTarget,
- 'index-pattern-field': managementRedirectTarget,
- },
- onBeforeRedirect() {
- setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH);
- },
- })(error);
+ redirectToSavedObjectPage(services, error, visualizationIdFromUrl);
} catch (e) {
toastNotifications.addWarning({
title: i18n.translate('visualize.createVisualization.failedToLoadErrorMessage', {
diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts
index 0e529507f97e3..c906ff5304c90 100644
--- a/src/plugins/visualize/public/application/utils/utils.ts
+++ b/src/plugins/visualize/public/application/utils/utils.ts
@@ -10,6 +10,8 @@ import { i18n } from '@kbn/i18n';
import { ChromeStart, DocLinksStart } from 'kibana/public';
import { Filter } from '../../../../data/public';
+import { redirectWhenMissing } from '../../../../kibana_utils/public';
+import { VisualizeConstants } from '../visualize_constants';
import { VisualizeServices, VisualizeEditorVisInstance } from '../types';
export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => {
@@ -58,3 +60,36 @@ export const visStateToEditorState = (
linked: savedVis && savedVis.id ? !!savedVis.savedSearchId : !!savedVisState.savedSearchId,
};
};
+
+export const redirectToSavedObjectPage = (
+ services: VisualizeServices,
+ error: any,
+ savedVisualizationsId?: string
+) => {
+ const {
+ history,
+ setActiveUrl,
+ toastNotifications,
+ http: { basePath },
+ application: { navigateToApp },
+ } = services;
+ const managementRedirectTarget = {
+ app: 'management',
+ path: `kibana/objects/savedVisualizations/${savedVisualizationsId}`,
+ };
+ redirectWhenMissing({
+ history,
+ navigateToApp,
+ toastNotifications,
+ basePath,
+ mapping: {
+ visualization: VisualizeConstants.LANDING_PAGE_PATH,
+ search: managementRedirectTarget,
+ 'index-pattern': managementRedirectTarget,
+ 'index-pattern-field': managementRedirectTarget,
+ },
+ onBeforeRedirect() {
+ setActiveUrl(VisualizeConstants.LANDING_PAGE_PATH);
+ },
+ })(error);
+};
diff --git a/test/common/services/index.ts b/test/common/services/index.ts
index 7404bd1d7f46e..cc4859b7016bf 100644
--- a/test/common/services/index.ts
+++ b/test/common/services/index.ts
@@ -15,6 +15,7 @@ import { RetryProvider } from './retry';
import { RandomnessProvider } from './randomness';
import { SecurityServiceProvider } from './security';
import { EsDeleteAllIndicesProvider } from './es_delete_all_indices';
+import { SavedObjectInfoProvider } from './saved_object_info';
export const services = {
deployment: DeploymentProvider,
@@ -26,4 +27,5 @@ export const services = {
randomness: RandomnessProvider,
security: SecurityServiceProvider,
esDeleteAllIndices: EsDeleteAllIndicesProvider,
+ savedObjectInfo: SavedObjectInfoProvider,
};
diff --git a/test/common/services/saved_object_info.ts b/test/common/services/saved_object_info.ts
new file mode 100644
index 0000000000000..02ab38d4ecb1d
--- /dev/null
+++ b/test/common/services/saved_object_info.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { Client } from '@elastic/elasticsearch';
+import url from 'url';
+import { Either, fromNullable, chain, getOrElse } from 'fp-ts/Either';
+import { flow } from 'fp-ts/function';
+import { FtrProviderContext } from '../ftr_provider_context';
+
+const pluck = (key: string) => (obj: any): Either =>
+ fromNullable(new Error(`Missing ${key}`))(obj[key]);
+
+const types = (node: string) => async (index: string = '.kibana') => {
+ let res: unknown;
+ try {
+ const { body } = await new Client({ node }).search({
+ index,
+ body: {
+ aggs: {
+ savedobjs: {
+ terms: {
+ field: 'type',
+ },
+ },
+ },
+ },
+ });
+
+ res = flow(
+ pluck('aggregations'),
+ chain(pluck('savedobjs')),
+ chain(pluck('buckets')),
+ getOrElse((err) => `${err.message}`)
+ )(body);
+ } catch (err) {
+ throw new Error(`Error while searching for saved object types: ${err}`);
+ }
+
+ return res;
+};
+
+export const SavedObjectInfoProvider: any = ({ getService }: FtrProviderContext) => {
+ const config = getService('config');
+
+ return {
+ types: types(url.format(config.get('servers.elasticsearch'))),
+ };
+};
diff --git a/test/functional/apps/discover/_discover.ts b/test/functional/apps/discover/_discover.ts
index cc62608fbde6d..bf90d90cc828c 100644
--- a/test/functional/apps/discover/_discover.ts
+++ b/test/functional/apps/discover/_discover.ts
@@ -11,6 +11,7 @@ import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
+ const savedObjectInfo = getService('savedObjectInfo');
const browser = getService('browser');
const log = getService('log');
const retry = getService('retry');
@@ -31,6 +32,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await kibanaServer.savedObjects.clean({ types: ['search'] });
await kibanaServer.importExport.load('discover');
+ log.info(
+ `\n### SAVED OBJECT TYPES IN index: [.kibana]: \n\t${await savedObjectInfo.types()}`
+ );
// and load a set of makelogs data
await esArchiver.loadIfNeeded('logstash_functional');
diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts
index 72deb74459ab9..e41422555f81d 100644
--- a/test/functional/apps/discover/_discover_histogram.ts
+++ b/test/functional/apps/discover/_discover_histogram.ts
@@ -21,9 +21,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
};
const testSubjects = getService('testSubjects');
const browser = getService('browser');
+ const retry = getService('retry');
- // FLAKY: https://github.com/elastic/kibana/issues/94532
- describe.skip('discover histogram', function describeIndexTests() {
+ describe('discover histogram', function describeIndexTests() {
before(async () => {
await esArchiver.loadIfNeeded('logstash_functional');
await esArchiver.load('long_window_logstash');
@@ -107,8 +107,11 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
canvasExists = await elasticChart.canvasExists();
expect(canvasExists).to.be(false);
await testSubjects.click('discoverChartToggle');
- canvasExists = await elasticChart.canvasExists();
- expect(canvasExists).to.be(true);
+ await retry.waitFor(`Discover histogram to be displayed`, async () => {
+ canvasExists = await elasticChart.canvasExists();
+ return canvasExists;
+ });
+
await PageObjects.discover.saveSearch('persisted hidden histogram');
await PageObjects.header.waitUntilLoadingHasFinished();
diff --git a/test/functional/apps/visualize/input_control_vis/input_control_options.ts b/test/functional/apps/visualize/input_control_vis/input_control_options.ts
index dc02cada9a712..2e3b5d758436e 100644
--- a/test/functional/apps/visualize/input_control_vis/input_control_options.ts
+++ b/test/functional/apps/visualize/input_control_vis/input_control_options.ts
@@ -31,7 +31,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
);
await PageObjects.visEditor.clickVisEditorTab('controls');
await PageObjects.visEditor.addInputControl();
- await comboBox.set('indexPatternSelect-0', 'logstash- ');
+ await comboBox.set('indexPatternSelect-0', 'logstash-');
await comboBox.set('fieldSelect-0', FIELD_NAME);
await PageObjects.visEditor.clickGo();
});
diff --git a/typings/elasticsearch/search.d.ts b/typings/elasticsearch/search.d.ts
index fce08df1c0a04..c9bf3b1d8b7bc 100644
--- a/typings/elasticsearch/search.d.ts
+++ b/typings/elasticsearch/search.d.ts
@@ -370,6 +370,16 @@ export type AggregateOf<
missing: {
doc_count: number;
} & SubAggregateOf;
+ multi_terms: {
+ doc_count_error_upper_bound: number;
+ sum_other_doc_count: number;
+ buckets: Array<
+ {
+ doc_count: number;
+ key: string[];
+ } & SubAggregateOf
+ >;
+ };
nested: {
doc_count: number;
} & SubAggregateOf;
diff --git a/vars/retryable.groovy b/vars/retryable.groovy
index ed84a00ece49d..bfd021ddd8167 100644
--- a/vars/retryable.groovy
+++ b/vars/retryable.groovy
@@ -48,7 +48,10 @@ def call(label, Closure closure) {
try {
closure()
- } catch (ex) {
+ } catch (org.jenkinsci.plugins.workflow.steps.FlowInterruptedException ex) {
+ // If the build was aborted, don't retry the step
+ throw ex
+ } catch (Exception ex) {
if (haveReachedMaxRetries()) {
print "Couldn't retry '${label}', have already reached the max number of retries for this build."
throw ex
diff --git a/x-pack/examples/alerting_example/server/plugin.ts b/x-pack/examples/alerting_example/server/plugin.ts
index db9c996147c94..f6131679874db 100644
--- a/x-pack/examples/alerting_example/server/plugin.ts
+++ b/x-pack/examples/alerting_example/server/plugin.ts
@@ -33,7 +33,7 @@ export class AlertingExamplePlugin implements Plugin {
+ it('handles empty variables', () => {
+ expect(buildAlertHistoryDocument({})).toBeNull();
+ });
+
+ it('returns null if rule type is not defined', () => {
+ expect(buildAlertHistoryDocument(getVariables({ rule: { type: undefined } }))).toBeNull();
+ });
+
+ it('returns null if alert variables are not defined', () => {
+ expect(buildAlertHistoryDocument(getVariables({ alert: undefined }))).toBeNull();
+ });
+
+ it('returns null if rule variables are not defined', () => {
+ expect(buildAlertHistoryDocument(getVariables({ rule: undefined }))).toBeNull();
+ });
+
+ it('includes @timestamp field if date is null', () => {
+ const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ date: undefined }));
+ expect(alertHistoryDoc).not.toBeNull();
+ expect(alertHistoryDoc!['@timestamp']).toBeTruthy();
+ });
+
+ it(`doesn't include context if context is empty`, () => {
+ const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ context: {} }));
+ expect(alertHistoryDoc).not.toBeNull();
+ expect(alertHistoryDoc!.kibana?.alert?.context).toBeFalsy();
+ });
+
+ it(`doesn't include params if params is empty`, () => {
+ const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ params: {} }));
+ expect(alertHistoryDoc).not.toBeNull();
+ expect(alertHistoryDoc!.rule?.params).toBeFalsy();
+ });
+
+ it(`doesn't include tags if tags is empty array`, () => {
+ const alertHistoryDoc = buildAlertHistoryDocument(getVariables({ tags: [] }));
+ expect(alertHistoryDoc).not.toBeNull();
+ expect(alertHistoryDoc!.tags).toBeFalsy();
+ });
+
+ it(`included message if context contains message`, () => {
+ const alertHistoryDoc = buildAlertHistoryDocument(
+ getVariables({
+ context: { contextVar1: 'contextValue1', contextVar2: 'contextValue2', message: 'hello!' },
+ })
+ );
+ expect(alertHistoryDoc).not.toBeNull();
+ expect(alertHistoryDoc!.message).toEqual('hello!');
+ });
+
+ it('builds alert history document from variables', () => {
+ expect(buildAlertHistoryDocument(getVariables())).toEqual({
+ '@timestamp': '2021-01-01T00:00:00.000Z',
+ kibana: {
+ alert: {
+ actionGroup: 'action-group-id',
+ actionGroupName: 'Action Group',
+ context: {
+ 'rule-type': {
+ contextVar1: 'contextValue1',
+ contextVar2: 'contextValue2',
+ },
+ },
+ id: 'alert-id',
+ },
+ },
+ event: {
+ kind: 'alert',
+ },
+ rule: {
+ id: 'rule-id',
+ name: 'rule-name',
+ params: {
+ 'rule-type': {
+ ruleParam: 1,
+ ruleParamString: 'another param',
+ },
+ },
+ space: 'space-id',
+ type: 'rule-type',
+ },
+ tags: ['abc', 'def'],
+ });
+ });
+});
diff --git a/x-pack/plugins/actions/common/alert_history_schema.ts b/x-pack/plugins/actions/common/alert_history_schema.ts
new file mode 100644
index 0000000000000..e1c923ab23f44
--- /dev/null
+++ b/x-pack/plugins/actions/common/alert_history_schema.ts
@@ -0,0 +1,90 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0; you may not use this file except in compliance with the Elastic License
+ * 2.0.
+ */
+
+import { isEmpty } from 'lodash';
+
+export const ALERT_HISTORY_PREFIX = 'kibana-alert-history-';
+export const AlertHistoryDefaultIndexName = `${ALERT_HISTORY_PREFIX}default`;
+export const AlertHistoryEsIndexConnectorId = 'preconfigured-alert-history-es-index';
+
+export const buildAlertHistoryDocument = (variables: Record) => {
+ const { date, alert: alertVariables, context, params, tags, rule: ruleVariables } = variables as {
+ date: string;
+ alert: Record;
+ context: Record;
+ params: Record;
+ rule: Record;
+ tags: string[];
+ };
+
+ if (!alertVariables || !ruleVariables) {
+ return null;
+ }
+
+ const { actionGroup, actionGroupName, id: alertId } = alertVariables as {
+ actionGroup: string;
+ actionGroupName: string;
+ id: string;
+ };
+
+ const { id: ruleId, name, spaceId, type } = ruleVariables as {
+ id: string;
+ name: string;
+ spaceId: string;
+ type: string;
+ };
+
+ if (!type) {
+ // can't build the document without a type
+ return null;
+ }
+
+ const ruleType = type.replace(/\./g, '__');
+
+ const rule = {
+ ...(ruleId ? { id: ruleId } : {}),
+ ...(name ? { name } : {}),
+ ...(!isEmpty(params) ? { params: { [ruleType]: params } } : {}),
+ ...(spaceId ? { space: spaceId } : {}),
+ ...(type ? { type } : {}),
+ };
+ const alert = {
+ ...(alertId ? { id: alertId } : {}),
+ ...(!isEmpty(context) ? { context: { [ruleType]: context } } : {}),
+ ...(actionGroup ? { actionGroup } : {}),
+ ...(actionGroupName ? { actionGroupName } : {}),
+ };
+
+ const alertHistoryDoc = {
+ '@timestamp': date ? date : new Date().toISOString(),
+ ...(tags && tags.length > 0 ? { tags } : {}),
+ ...(context?.message ? { message: context.message } : {}),
+ ...(!isEmpty(rule) ? { rule } : {}),
+ ...(!isEmpty(alert) ? { kibana: { alert } } : {}),
+ };
+
+ return !isEmpty(alertHistoryDoc) ? { ...alertHistoryDoc, event: { kind: 'alert' } } : null;
+};
+
+export const AlertHistoryDocumentTemplate = Object.freeze(
+ buildAlertHistoryDocument({
+ rule: {
+ id: '{{rule.id}}',
+ name: '{{rule.name}}',
+ type: '{{rule.type}}',
+ spaceId: '{{rule.spaceId}}',
+ },
+ context: '{{context}}',
+ params: '{{params}}',
+ tags: '{{rule.tags}}',
+ alert: {
+ id: '{{alert.id}}',
+ actionGroup: '{{alert.actionGroup}}',
+ actionGroupName: '{{alert.actionGroupName}}',
+ },
+ })
+);
diff --git a/x-pack/plugins/actions/common/index.ts b/x-pack/plugins/actions/common/index.ts
index 184ae9c226b8f..336aa2263af0c 100644
--- a/x-pack/plugins/actions/common/index.ts
+++ b/x-pack/plugins/actions/common/index.ts
@@ -6,7 +6,7 @@
*/
export * from './types';
+export * from './alert_history_schema';
+export * from './rewrite_request_case';
export const BASE_ACTION_API_PATH = '/api/actions';
-
-export * from './rewrite_request_case';
diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts
index 6544a3c426e42..ae7faca1465c7 100644
--- a/x-pack/plugins/actions/server/actions_client.test.ts
+++ b/x-pack/plugins/actions/server/actions_client.test.ts
@@ -405,6 +405,7 @@ describe('create()', () => {
enabled: true,
enabledActionTypes: ['some-not-ignored-action-type'],
allowedHosts: ['*'],
+ preconfiguredAlertHistoryEsIndex: false,
preconfigured: {},
proxyRejectUnauthorizedCertificates: true,
rejectUnauthorized: true,
diff --git a/x-pack/plugins/actions/server/actions_config.test.ts b/x-pack/plugins/actions/server/actions_config.test.ts
index c81f1f4a4bf2e..1b9de0162f340 100644
--- a/x-pack/plugins/actions/server/actions_config.test.ts
+++ b/x-pack/plugins/actions/server/actions_config.test.ts
@@ -18,6 +18,7 @@ const defaultActionsConfig: ActionsConfig = {
enabled: false,
allowedHosts: [],
enabledActionTypes: [],
+ preconfiguredAlertHistoryEsIndex: false,
preconfigured: {},
proxyRejectUnauthorizedCertificates: true,
rejectUnauthorized: true,
diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts
index 282ff22f770f0..5c0f720e8c5fc 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.test.ts
@@ -18,6 +18,7 @@ import {
ESIndexActionType,
ESIndexActionTypeExecutorOptions,
} from './es_index';
+import { AlertHistoryEsIndexConnectorId } from '../../common';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from '../../../../../src/core/server/elasticsearch/client/mocks';
@@ -115,6 +116,7 @@ describe('params validation', () => {
test('params validation succeeds when params is valid', () => {
const params: Record = {
documents: [{ rando: 'thing' }],
+ indexOverride: null,
};
expect(validateParams(actionType, params)).toMatchInlineSnapshot(`
Object {
@@ -123,6 +125,7 @@ describe('params validation', () => {
"rando": "thing",
},
],
+ "indexOverride": null,
}
`);
});
@@ -159,6 +162,7 @@ describe('execute()', () => {
config = { index: 'index-value', refresh: false, executionTimeField: null };
params = {
documents: [{ jim: 'bob' }],
+ indexOverride: null,
};
const actionId = 'some-id';
@@ -200,6 +204,7 @@ describe('execute()', () => {
config = { index: 'index-value', executionTimeField: 'field_to_use_for_time', refresh: true };
params = {
documents: [{ jimbob: 'jr' }],
+ indexOverride: null,
};
executorOptions = { actionId, config, secrets, params, services };
@@ -237,6 +242,7 @@ describe('execute()', () => {
config = { index: 'index-value', executionTimeField: null, refresh: false };
params = {
documents: [{ jim: 'bob' }],
+ indexOverride: null,
};
executorOptions = { actionId, config, secrets, params, services };
@@ -270,6 +276,7 @@ describe('execute()', () => {
config = { index: 'index-value', executionTimeField: null, refresh: false };
params = {
documents: [{ a: 1 }, { b: 2 }],
+ indexOverride: null,
};
executorOptions = { actionId, config, secrets, params, services };
@@ -305,12 +312,244 @@ describe('execute()', () => {
`);
});
+ test('renders parameter templates as expected', async () => {
+ expect(actionType.renderParameterTemplates).toBeTruthy();
+ const paramsWithTemplates = {
+ documents: [{ hello: '{{who}}' }],
+ indexOverride: null,
+ };
+ const variables = {
+ who: 'world',
+ };
+ const renderedParams = actionType.renderParameterTemplates!(
+ paramsWithTemplates,
+ variables,
+ 'action-type-id'
+ );
+ expect(renderedParams).toMatchInlineSnapshot(`
+ Object {
+ "documents": Array [
+ Object {
+ "hello": "world",
+ },
+ ],
+ "indexOverride": null,
+ }
+ `);
+ });
+
+ test('ignores indexOverride for generic es index connector', async () => {
+ expect(actionType.renderParameterTemplates).toBeTruthy();
+ const paramsWithTemplates = {
+ documents: [{ hello: '{{who}}' }],
+ indexOverride: 'hello-world',
+ };
+ const variables = {
+ who: 'world',
+ };
+ const renderedParams = actionType.renderParameterTemplates!(
+ paramsWithTemplates,
+ variables,
+ 'action-type-id'
+ );
+ expect(renderedParams).toMatchInlineSnapshot(`
+ Object {
+ "documents": Array [
+ Object {
+ "hello": "world",
+ },
+ ],
+ "indexOverride": null,
+ }
+ `);
+ });
+
+ test('renders parameter templates as expected for preconfigured alert history connector', async () => {
+ expect(actionType.renderParameterTemplates).toBeTruthy();
+ const paramsWithTemplates = {
+ documents: [{ hello: '{{who}}' }],
+ indexOverride: null,
+ };
+ const variables = {
+ date: '2021-01-01T00:00:00.000Z',
+ rule: {
+ id: 'rule-id',
+ name: 'rule-name',
+ type: 'rule-type',
+ },
+ context: {
+ contextVar1: 'contextValue1',
+ contextVar2: 'contextValue2',
+ },
+ params: {
+ ruleParam: 1,
+ ruleParamString: 'another param',
+ },
+ tags: ['abc', 'xyz'],
+ alert: {
+ id: 'alert-id',
+ actionGroup: 'action-group-id',
+ actionGroupName: 'Action Group',
+ },
+ state: {
+ alertStateValue: true,
+ alertStateAnotherValue: 'yes',
+ },
+ };
+ const renderedParams = actionType.renderParameterTemplates!(
+ paramsWithTemplates,
+ variables,
+ AlertHistoryEsIndexConnectorId
+ );
+ expect(renderedParams).toMatchInlineSnapshot(`
+ Object {
+ "documents": Array [
+ Object {
+ "@timestamp": "2021-01-01T00:00:00.000Z",
+ "event": Object {
+ "kind": "alert",
+ },
+ "kibana": Object {
+ "alert": Object {
+ "actionGroup": "action-group-id",
+ "actionGroupName": "Action Group",
+ "context": Object {
+ "rule-type": Object {
+ "contextVar1": "contextValue1",
+ "contextVar2": "contextValue2",
+ },
+ },
+ "id": "alert-id",
+ },
+ },
+ "rule": Object {
+ "id": "rule-id",
+ "name": "rule-name",
+ "params": Object {
+ "rule-type": Object {
+ "ruleParam": 1,
+ "ruleParamString": "another param",
+ },
+ },
+ "type": "rule-type",
+ },
+ "tags": Array [
+ "abc",
+ "xyz",
+ ],
+ },
+ ],
+ "indexOverride": null,
+ }
+ `);
+ });
+
+ test('passes through indexOverride for preconfigured alert history connector', async () => {
+ expect(actionType.renderParameterTemplates).toBeTruthy();
+ const paramsWithTemplates = {
+ documents: [{ hello: '{{who}}' }],
+ indexOverride: 'hello-world',
+ };
+ const variables = {
+ date: '2021-01-01T00:00:00.000Z',
+ rule: {
+ id: 'rule-id',
+ name: 'rule-name',
+ type: 'rule-type',
+ },
+ context: {
+ contextVar1: 'contextValue1',
+ contextVar2: 'contextValue2',
+ },
+ params: {
+ ruleParam: 1,
+ ruleParamString: 'another param',
+ },
+ tags: ['abc', 'xyz'],
+ alert: {
+ id: 'alert-id',
+ actionGroup: 'action-group-id',
+ actionGroupName: 'Action Group',
+ },
+ state: {
+ alertStateValue: true,
+ alertStateAnotherValue: 'yes',
+ },
+ };
+ const renderedParams = actionType.renderParameterTemplates!(
+ paramsWithTemplates,
+ variables,
+ AlertHistoryEsIndexConnectorId
+ );
+ expect(renderedParams).toMatchInlineSnapshot(`
+ Object {
+ "documents": Array [
+ Object {
+ "@timestamp": "2021-01-01T00:00:00.000Z",
+ "event": Object {
+ "kind": "alert",
+ },
+ "kibana": Object {
+ "alert": Object {
+ "actionGroup": "action-group-id",
+ "actionGroupName": "Action Group",
+ "context": Object {
+ "rule-type": Object {
+ "contextVar1": "contextValue1",
+ "contextVar2": "contextValue2",
+ },
+ },
+ "id": "alert-id",
+ },
+ },
+ "rule": Object {
+ "id": "rule-id",
+ "name": "rule-name",
+ "params": Object {
+ "rule-type": Object {
+ "ruleParam": 1,
+ "ruleParamString": "another param",
+ },
+ },
+ "type": "rule-type",
+ },
+ "tags": Array [
+ "abc",
+ "xyz",
+ ],
+ },
+ ],
+ "indexOverride": "hello-world",
+ }
+ `);
+ });
+
+ test('throws error for preconfigured alert history index when no variables are available', async () => {
+ expect(actionType.renderParameterTemplates).toBeTruthy();
+ const paramsWithTemplates = {
+ documents: [{ hello: '{{who}}' }],
+ indexOverride: null,
+ };
+ const variables = {};
+
+ expect(() =>
+ actionType.renderParameterTemplates!(
+ paramsWithTemplates,
+ variables,
+ AlertHistoryEsIndexConnectorId
+ )
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"error creating alert history document for ${AlertHistoryEsIndexConnectorId} connector"`
+ );
+ });
+
test('resolves with an error when an error occurs in the indexing operation', async () => {
const secrets = {};
// minimal params
const config = { index: 'index-value', refresh: false, executionTimeField: null };
const params = {
documents: [{ '': 'bob' }],
+ indexOverride: null,
};
const actionId = 'some-id';
diff --git a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts
index f7b0e7de478d8..3662fea00e31d 100644
--- a/x-pack/plugins/actions/server/builtin_action_types/es_index.ts
+++ b/x-pack/plugins/actions/server/builtin_action_types/es_index.ts
@@ -8,9 +8,11 @@
import { curry, find } from 'lodash';
import { i18n } from '@kbn/i18n';
import { schema, TypeOf } from '@kbn/config-schema';
-
import { Logger } from '../../../../../src/core/server';
import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types';
+import { renderMustacheObject } from '../lib/mustache_renderer';
+import { buildAlertHistoryDocument, AlertHistoryEsIndexConnectorId } from '../../common';
+import { ALERT_HISTORY_PREFIX } from '../../common/alert_history_schema';
export type ESIndexActionType = ActionType;
export type ESIndexActionTypeExecutorOptions = ActionTypeExecutorOptions<
@@ -38,6 +40,15 @@ export type ActionParamsType = TypeOf;
// eventually: https://github.com/elastic/kibana/projects/26#card-24087404
const ParamsSchema = schema.object({
documents: schema.arrayOf(schema.recordOf(schema.string(), schema.any())),
+ indexOverride: schema.nullable(
+ schema.string({
+ validate: (pattern) => {
+ if (!pattern.startsWith(ALERT_HISTORY_PREFIX)) {
+ return `index must start with "${ALERT_HISTORY_PREFIX}"`;
+ }
+ },
+ })
+ ),
});
export const ActionTypeId = '.index';
@@ -54,6 +65,7 @@ export function getActionType({ logger }: { logger: Logger }): ESIndexActionType
params: ParamsSchema,
},
executor: curry(executor)({ logger }),
+ renderParameterTemplates,
};
}
@@ -68,7 +80,7 @@ async function executor(
const params = execOptions.params;
const services = execOptions.services;
- const index = config.index;
+ const index = params.indexOverride || config.index;
const bulkBody = [];
for (const document of params.documents) {
@@ -107,6 +119,24 @@ async function executor(
}
}
+function renderParameterTemplates(
+ params: ActionParamsType,
+ variables: Record,
+ actionId: string
+): ActionParamsType {
+ const { documents, indexOverride } = renderMustacheObject(params, variables);
+
+ if (actionId === AlertHistoryEsIndexConnectorId) {
+ const alertHistoryDoc = buildAlertHistoryDocument(variables);
+ if (!alertHistoryDoc) {
+ throw new Error(`error creating alert history document for ${actionId} connector`);
+ }
+ return { documents: [alertHistoryDoc], indexOverride };
+ }
+
+ return { documents, indexOverride: null };
+}
+
function wrapErr(
errMessage: string,
actionId: string,
diff --git a/x-pack/plugins/actions/server/config.test.ts b/x-pack/plugins/actions/server/config.test.ts
index 2eecaa19da0c5..ad598bffe04b4 100644
--- a/x-pack/plugins/actions/server/config.test.ts
+++ b/x-pack/plugins/actions/server/config.test.ts
@@ -31,6 +31,7 @@ describe('config validation', () => {
"valueInBytes": 1048576,
},
"preconfigured": Object {},
+ "preconfiguredAlertHistoryEsIndex": false,
"proxyRejectUnauthorizedCertificates": true,
"rejectUnauthorized": true,
"responseTimeout": "PT1M",
@@ -74,6 +75,7 @@ describe('config validation', () => {
"secrets": Object {},
},
},
+ "preconfiguredAlertHistoryEsIndex": false,
"proxyRejectUnauthorizedCertificates": false,
"rejectUnauthorized": false,
"responseTimeout": "PT1M",
diff --git a/x-pack/plugins/actions/server/config.ts b/x-pack/plugins/actions/server/config.ts
index 4aa77ded315b8..36948478816c9 100644
--- a/x-pack/plugins/actions/server/config.ts
+++ b/x-pack/plugins/actions/server/config.ts
@@ -37,6 +37,7 @@ export const configSchema = schema.object({
defaultValue: [AllowedHosts.Any],
}
),
+ preconfiguredAlertHistoryEsIndex: schema.boolean({ defaultValue: false }),
preconfigured: schema.recordOf(schema.string(), preconfiguredActionSchema, {
defaultValue: {},
validate: validatePreconfigured,
diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts
index ab29f524c202d..4d32c2e2bf16d 100644
--- a/x-pack/plugins/actions/server/mocks.ts
+++ b/x-pack/plugins/actions/server/mocks.ts
@@ -40,10 +40,11 @@ const createStartMock = () => {
// this is a default renderer that escapes nothing
export function renderActionParameterTemplatesDefault(
actionTypeId: string,
+ actionId: string,
params: Record,
variables: Record
) {
- return renderActionParameterTemplates(undefined, actionTypeId, params, variables);
+ return renderActionParameterTemplates(undefined, actionTypeId, actionId, params, variables);
}
const createServicesMock = () => {
diff --git a/x-pack/plugins/actions/server/plugin.test.ts b/x-pack/plugins/actions/server/plugin.test.ts
index 30bbedbedbe9c..3485891a01267 100644
--- a/x-pack/plugins/actions/server/plugin.test.ts
+++ b/x-pack/plugins/actions/server/plugin.test.ts
@@ -23,6 +23,7 @@ import {
ActionsPluginsStart,
PluginSetupContract,
} from './plugin';
+import { AlertHistoryEsIndexConnectorId } from '../common';
describe('Actions Plugin', () => {
describe('setup()', () => {
@@ -36,6 +37,7 @@ describe('Actions Plugin', () => {
enabled: true,
enabledActionTypes: ['*'],
allowedHosts: ['*'],
+ preconfiguredAlertHistoryEsIndex: false,
preconfigured: {},
proxyRejectUnauthorizedCertificates: true,
rejectUnauthorized: true,
@@ -180,6 +182,7 @@ describe('Actions Plugin', () => {
});
describe('start()', () => {
+ let context: PluginInitializerContext;
let plugin: ActionsPlugin;
let coreSetup: ReturnType;
let coreStart: ReturnType;
@@ -187,10 +190,11 @@ describe('Actions Plugin', () => {
let pluginsStart: jest.Mocked;
beforeEach(() => {
- const context = coreMock.createPluginInitializerContext({
+ context = coreMock.createPluginInitializerContext({
enabled: true,
enabledActionTypes: ['*'],
allowedHosts: ['*'],
+ preconfiguredAlertHistoryEsIndex: false,
preconfigured: {
preconfiguredServerLog: {
actionTypeId: '.server-log',
@@ -223,15 +227,6 @@ describe('Actions Plugin', () => {
});
describe('getActionsClientWithRequest()', () => {
- it('should handle preconfigured actions', async () => {
- // coreMock.createSetup doesn't support Plugin generics
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await plugin.setup(coreSetup as any, pluginsSetup);
- const pluginStart = await plugin.start(coreStart, pluginsStart);
-
- expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true);
- });
-
it('should not throw error when ESO plugin has encryption key', async () => {
await plugin.setup(coreSetup, {
...pluginsSetup,
@@ -258,6 +253,99 @@ describe('Actions Plugin', () => {
});
});
+ describe('Preconfigured connectors', () => {
+ function getConfig(overrides = {}) {
+ return {
+ enabled: true,
+ enabledActionTypes: ['*'],
+ allowedHosts: ['*'],
+ preconfiguredAlertHistoryEsIndex: false,
+ preconfigured: {
+ preconfiguredServerLog: {
+ actionTypeId: '.server-log',
+ name: 'preconfigured-server-log',
+ config: {},
+ secrets: {},
+ },
+ },
+ proxyRejectUnauthorizedCertificates: true,
+ proxyBypassHosts: undefined,
+ proxyOnlyHosts: undefined,
+ rejectUnauthorized: true,
+ maxResponseContentLength: new ByteSizeValue(1000000),
+ responseTimeout: moment.duration('60s'),
+ ...overrides,
+ };
+ }
+
+ function setup(config: ActionsConfig) {
+ context = coreMock.createPluginInitializerContext(config);
+ plugin = new ActionsPlugin(context);
+ coreSetup = coreMock.createSetup();
+ coreStart = coreMock.createStart();
+ pluginsSetup = {
+ taskManager: taskManagerMock.createSetup(),
+ encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(),
+ licensing: licensingMock.createSetup(),
+ eventLog: eventLogMock.createSetup(),
+ usageCollection: usageCollectionPluginMock.createSetupContract(),
+ features: featuresPluginMock.createSetup(),
+ };
+ pluginsStart = {
+ licensing: licensingMock.createStart(),
+ taskManager: taskManagerMock.createStart(),
+ encryptedSavedObjects: encryptedSavedObjectsMock.createStart(),
+ };
+ }
+
+ it('should handle preconfigured actions', async () => {
+ setup(getConfig());
+ // coreMock.createSetup doesn't support Plugin generics
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await plugin.setup(coreSetup as any, pluginsSetup);
+ const pluginStart = await plugin.start(coreStart, pluginsStart);
+
+ expect(pluginStart.preconfiguredActions.length).toEqual(1);
+ expect(pluginStart.isActionExecutable('preconfiguredServerLog', '.server-log')).toBe(true);
+ });
+
+ it('should handle preconfiguredAlertHistoryEsIndex = true', async () => {
+ setup(getConfig({ preconfiguredAlertHistoryEsIndex: true }));
+
+ await plugin.setup(coreSetup, pluginsSetup);
+ const pluginStart = await plugin.start(coreStart, pluginsStart);
+
+ expect(pluginStart.preconfiguredActions.length).toEqual(2);
+ expect(
+ pluginStart.isActionExecutable('preconfigured-alert-history-es-index', '.index')
+ ).toBe(true);
+ });
+
+ it('should not allow preconfigured connector with same ID as AlertHistoryEsIndexConnectorId', async () => {
+ setup(
+ getConfig({
+ preconfigured: {
+ [AlertHistoryEsIndexConnectorId]: {
+ actionTypeId: '.index',
+ name: 'clashing preconfigured index connector',
+ config: {},
+ secrets: {},
+ },
+ },
+ })
+ );
+ // coreMock.createSetup doesn't support Plugin generics
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ await plugin.setup(coreSetup as any, pluginsSetup);
+ const pluginStart = await plugin.start(coreStart, pluginsStart);
+
+ expect(pluginStart.preconfiguredActions.length).toEqual(0);
+ expect(context.logger.get().warn).toHaveBeenCalledWith(
+ `Preconfigured connectors cannot have the id "${AlertHistoryEsIndexConnectorId}" because this is a reserved id.`
+ );
+ });
+ });
+
describe('isActionTypeEnabled()', () => {
const actionType: ActionType = {
id: 'my-action-type',
diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts
index bfe3b0a09ff2e..3c754d90c4af5 100644
--- a/x-pack/plugins/actions/server/plugin.ts
+++ b/x-pack/plugins/actions/server/plugin.ts
@@ -68,6 +68,9 @@ import {
} from './authorization/get_authorization_mode_by_source';
import { ensureSufficientLicense } from './lib/ensure_sufficient_license';
import { renderMustacheObject } from './lib/mustache_renderer';
+import { getAlertHistoryEsIndex } from './preconfigured_connectors/alert_history_es_index/alert_history_es_index';
+import { createAlertHistoryIndexTemplate } from './preconfigured_connectors/alert_history_es_index/create_alert_history_index_template';
+import { AlertHistoryEsIndexConnectorId } from '../common';
const EVENT_LOG_PROVIDER = 'actions';
export const EVENT_LOG_ACTIONS = {
@@ -98,6 +101,7 @@ export interface PluginStartContract {
preconfiguredActions: PreConfiguredAction[];
renderActionParameterTemplates(
actionTypeId: string,
+ actionId: string,
params: Params,
variables: Record
): Params;
@@ -178,12 +182,22 @@ export class ActionsPlugin implements Plugin {
return this.actionTypeRegistry!.isActionTypeEnabled(id, options);
@@ -468,12 +489,13 @@ export class ActionsPlugin implements Plugin(
actionTypeRegistry: ActionTypeRegistry | undefined,
actionTypeId: string,
+ actionId: string,
params: Params,
variables: Record
): Params {
const actionType = actionTypeRegistry?.get(actionTypeId);
if (actionType?.renderParameterTemplates) {
- return actionType.renderParameterTemplates(params, variables) as Params;
+ return actionType.renderParameterTemplates(params, variables, actionId) as Params;
} else {
return renderMustacheObject(params, variables);
}
diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts
new file mode 100644
index 0000000000000..38556591c4ea2
--- /dev/null
+++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/alert_history_es_index.ts
@@ -0,0 +1,26 @@
+/*
+ * 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 { PreConfiguredAction } from '../../types';
+import { ActionTypeId as EsIndexActionTypeId } from '../../builtin_action_types/es_index';
+import { AlertHistoryEsIndexConnectorId, AlertHistoryDefaultIndexName } from '../../../common';
+
+export function getAlertHistoryEsIndex(): Readonly {
+ return Object.freeze({
+ name: i18n.translate('xpack.actions.alertHistoryEsIndexConnector.name', {
+ defaultMessage: 'Alert history Elasticsearch index',
+ }),
+ actionTypeId: EsIndexActionTypeId,
+ id: AlertHistoryEsIndexConnectorId,
+ isPreconfigured: true,
+ config: {
+ index: AlertHistoryDefaultIndexName,
+ },
+ secrets: {},
+ });
+}
diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts
new file mode 100644
index 0000000000000..a7038d8dc62eb
--- /dev/null
+++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.test.ts
@@ -0,0 +1,52 @@
+/*
+ * 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 { ElasticsearchClient } from 'src/core/server';
+import { elasticsearchServiceMock, loggingSystemMock } from 'src/core/server/mocks';
+import { DeeplyMockedKeys } from '@kbn/utility-types/jest';
+import {
+ createAlertHistoryIndexTemplate,
+ getAlertHistoryIndexTemplate,
+} from './create_alert_history_index_template';
+
+type MockedLogger = ReturnType;
+
+describe('createAlertHistoryIndexTemplate', () => {
+ let logger: MockedLogger;
+ let clusterClient: DeeplyMockedKeys;
+
+ beforeEach(() => {
+ logger = loggingSystemMock.createLogger();
+ clusterClient = elasticsearchServiceMock.createClusterClient().asInternalUser;
+ });
+
+ test(`should create index template if it doesn't exist`, async () => {
+ // Response type for existsIndexTemplate is still TODO
+ clusterClient.indices.existsIndexTemplate.mockResolvedValue({
+ body: false,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ await createAlertHistoryIndexTemplate({ client: clusterClient, logger });
+ expect(clusterClient.indices.putIndexTemplate).toHaveBeenCalledWith({
+ name: `kibana-alert-history-template`,
+ body: getAlertHistoryIndexTemplate(),
+ create: true,
+ });
+ });
+
+ test(`shouldn't create index template if it already exists`, async () => {
+ // Response type for existsIndexTemplate is still TODO
+ clusterClient.indices.existsIndexTemplate.mockResolvedValue({
+ body: true,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ } as any);
+
+ await createAlertHistoryIndexTemplate({ client: clusterClient, logger });
+ expect(clusterClient.indices.putIndexTemplate).not.toHaveBeenCalled();
+ });
+});
diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts
new file mode 100644
index 0000000000000..fe9874fb1d671
--- /dev/null
+++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/create_alert_history_index_template.ts
@@ -0,0 +1,106 @@
+/*
+ * 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 { ElasticsearchClient, Logger } from 'src/core/server';
+import { ALERT_HISTORY_PREFIX } from '../../../common';
+import mappings from './mappings.json';
+
+export function getAlertHistoryIndexTemplate() {
+ return {
+ index_patterns: [`${ALERT_HISTORY_PREFIX}*`],
+ _meta: {
+ description:
+ 'System generated mapping for preconfigured alert history Elasticsearch index connector.',
+ },
+ template: {
+ settings: {
+ number_of_shards: 1,
+ auto_expand_replicas: '0-1',
+ },
+ mappings,
+ },
+ };
+}
+
+async function doesIndexTemplateExist({
+ client,
+ templateName,
+}: {
+ client: ElasticsearchClient;
+ templateName: string;
+}) {
+ let result;
+ try {
+ result = (await client.indices.existsIndexTemplate({ name: templateName })).body;
+ } catch (err) {
+ throw new Error(`error checking existence of index template: ${err.message}`);
+ }
+
+ return result;
+}
+
+async function createIndexTemplate({
+ client,
+ template,
+ templateName,
+}: {
+ client: ElasticsearchClient;
+ template: Record;
+ templateName: string;
+}) {
+ try {
+ await client.indices.putIndexTemplate({
+ name: templateName,
+ body: template,
+ create: true,
+ });
+ } catch (err) {
+ // The error message doesn't have a type attribute we can look to guarantee it's due
+ // to the template already existing (only long message) so we'll check ourselves to see
+ // if the template now exists. This scenario would happen if you startup multiple Kibana
+ // instances at the same time.
+ const existsNow = await doesIndexTemplateExist({ client, templateName });
+ if (!existsNow) {
+ throw new Error(`error creating index template: ${err.message}`);
+ }
+ }
+}
+
+async function createIndexTemplateIfNotExists({
+ client,
+ template,
+ templateName,
+}: {
+ client: ElasticsearchClient;
+ template: Record;
+ templateName: string;
+}) {
+ const indexTemplateExists = await doesIndexTemplateExist({ client, templateName });
+
+ if (!indexTemplateExists) {
+ await createIndexTemplate({ client, template, templateName });
+ }
+}
+
+export async function createAlertHistoryIndexTemplate({
+ client,
+ logger,
+}: {
+ client: ElasticsearchClient;
+ logger: Logger;
+}) {
+ try {
+ const indexTemplate = getAlertHistoryIndexTemplate();
+ await createIndexTemplateIfNotExists({
+ client,
+ templateName: `${ALERT_HISTORY_PREFIX}template`,
+ template: indexTemplate,
+ });
+ } catch (err) {
+ logger.error(`Could not initialize alert history index with mappings: ${err.message}.`);
+ }
+}
diff --git a/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json
new file mode 100644
index 0000000000000..56047f30d9489
--- /dev/null
+++ b/x-pack/plugins/actions/server/preconfigured_connectors/alert_history_es_index/mappings.json
@@ -0,0 +1,84 @@
+{
+ "dynamic": "false",
+ "properties": {
+ "@timestamp": {
+ "type": "date"
+ },
+ "kibana": {
+ "properties": {
+ "alert": {
+ "properties": {
+ "actionGroup": {
+ "type": "keyword"
+ },
+ "actionGroupName": {
+ "type": "keyword"
+ },
+ "actionSubgroup": {
+ "type": "keyword"
+ },
+ "context": {
+ "type": "object",
+ "enabled": false
+ },
+ "id": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+ },
+ "tags": {
+ "ignore_above": 1024,
+ "type": "keyword",
+ "meta": {
+ "isArray": "true"
+ }
+ },
+ "message": {
+ "norms": false,
+ "type": "text"
+ },
+ "event": {
+ "properties": {
+ "kind": {
+ "type": "keyword"
+ }
+ }
+ },
+ "rule": {
+ "properties": {
+ "author": {
+ "type": "keyword"
+ },
+ "category": {
+ "type": "keyword"
+ },
+ "id": {
+ "type": "keyword"
+ },
+ "license": {
+ "type": "keyword"
+ },
+ "name": {
+ "type": "text",
+ "fields": {
+ "keyword": {
+ "type": "keyword"
+ }
+ }
+ },
+ "params": {
+ "type": "object",
+ "enabled": false
+ },
+ "space": {
+ "type": "keyword"
+ },
+ "type": {
+ "type": "keyword"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/x-pack/plugins/actions/server/types.ts b/x-pack/plugins/actions/server/types.ts
index b7a6750a520ea..d6f99a766ed34 100644
--- a/x-pack/plugins/actions/server/types.ts
+++ b/x-pack/plugins/actions/server/types.ts
@@ -107,7 +107,11 @@ export interface ActionType<
config?: ValidatorType;
secrets?: ValidatorType;
};
- renderParameterTemplates?(params: Params, variables: Record): Params;
+ renderParameterTemplates?(
+ params: Params,
+ variables: Record,
+ actionId?: string
+ ): Params;
executor: ExecutorType;
}
diff --git a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
index 9999ea6a4d3d7..2ecf540485695 100644
--- a/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
+++ b/x-pack/plugins/alerting/server/task_runner/create_execution_handler.ts
@@ -117,6 +117,7 @@ export function createExecutionHandler<
params: transformActionParams({
actionsPlugin,
alertId,
+ alertType: alertType.id,
actionTypeId: action.actionTypeId,
alertName,
spaceId,
@@ -127,6 +128,7 @@ export function createExecutionHandler<
alertActionSubgroup: actionSubgroup,
context,
actionParams: action.params,
+ actionId: action.id,
state,
kibanaBaseUrl,
alertParams,
diff --git a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
index a3a7e9bbd9da5..50d710f6d6b14 100644
--- a/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/task_runner.test.ts
@@ -153,7 +153,7 @@ describe('Task Runner', () => {
actionsClient
);
taskRunnerFactoryInitializerParams.actionsPlugin.renderActionParameterTemplates.mockImplementation(
- (actionTypeId, params) => params
+ (actionTypeId, actionId, params) => params
);
});
diff --git a/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts b/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts
index 6379192e855d7..e325d597da145 100644
--- a/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts
+++ b/x-pack/plugins/alerting/server/task_runner/transform_action_params.test.ts
@@ -34,6 +34,8 @@ test('skips non string parameters', () => {
context: {},
state: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -68,6 +70,8 @@ test('missing parameters get emptied out', () => {
context: {},
state: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -95,6 +99,8 @@ test('context parameters are passed to templates', () => {
state: {},
context: { foo: 'fooVal' },
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -121,6 +127,8 @@ test('state parameters are passed to templates', () => {
state: { bar: 'barVal' },
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -147,6 +155,8 @@ test('alertId is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -173,6 +183,8 @@ test('alertName is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -199,6 +211,8 @@ test('tags is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -225,6 +239,8 @@ test('undefined tags is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
spaceId: 'spaceId-A',
alertInstanceId: '2',
@@ -250,6 +266,8 @@ test('empty tags is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: [],
spaceId: 'spaceId-A',
@@ -276,6 +294,8 @@ test('spaceId is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -302,6 +322,8 @@ test('alertInstanceId is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -328,6 +350,8 @@ test('alertActionGroup is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -354,6 +378,8 @@ test('alertActionGroupName is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -380,6 +406,8 @@ test('rule variables are passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -408,6 +436,8 @@ test('rule alert variables are passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -436,6 +466,8 @@ test('date is passed to templates', () => {
state: {},
context: {},
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -464,6 +496,8 @@ test('works recursively', () => {
state: { value: 'state' },
context: { value: 'context' },
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
@@ -494,6 +528,8 @@ test('works recursively with arrays', () => {
state: { value: 'state' },
context: { value: 'context' },
alertId: '1',
+ alertType: 'rule-type-id',
+ actionId: 'action-id',
alertName: 'alert-name',
tags: ['tag-A', 'tag-B'],
spaceId: 'spaceId-A',
diff --git a/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts b/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts
index 348bf01ea874b..3f9fe9e9c59e0 100644
--- a/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts
+++ b/x-pack/plugins/alerting/server/task_runner/transform_action_params.ts
@@ -16,6 +16,8 @@ import { PluginStartContract as ActionsPluginStartContract } from '../../../acti
interface TransformActionParamsOptions {
actionsPlugin: ActionsPluginStartContract;
alertId: string;
+ alertType: string;
+ actionId: string;
actionTypeId: string;
alertName: string;
spaceId: string;
@@ -34,6 +36,8 @@ interface TransformActionParamsOptions {
export function transformActionParams({
actionsPlugin,
alertId,
+ alertType,
+ actionId,
actionTypeId,
alertName,
spaceId,
@@ -68,6 +72,7 @@ export function transformActionParams({
rule: {
id: alertId,
name: alertName,
+ type: alertType,
spaceId,
tags,
},
@@ -78,5 +83,10 @@ export function transformActionParams({
actionSubgroup: alertActionSubgroup,
},
};
- return actionsPlugin.renderActionParameterTemplates(actionTypeId, actionParams, variables);
+ return actionsPlugin.renderActionParameterTemplates(
+ actionTypeId,
+ actionId,
+ actionParams,
+ variables
+ );
}
diff --git a/x-pack/plugins/apm/common/environment_filter_values.ts b/x-pack/plugins/apm/common/environment_filter_values.ts
index e091b53b2e5b8..c80541ee1ba6b 100644
--- a/x-pack/plugins/apm/common/environment_filter_values.ts
+++ b/x-pack/plugins/apm/common/environment_filter_values.ts
@@ -22,11 +22,13 @@ const environmentLabels: Record = {
};
export const ENVIRONMENT_ALL = {
+ esFieldValue: undefined,
value: ENVIRONMENT_ALL_VALUE,
text: environmentLabels[ENVIRONMENT_ALL_VALUE],
};
export const ENVIRONMENT_NOT_DEFINED = {
+ esFieldValue: undefined,
value: ENVIRONMENT_NOT_DEFINED_VALUE,
text: environmentLabels[ENVIRONMENT_NOT_DEFINED_VALUE],
};
@@ -35,6 +37,22 @@ export function getEnvironmentLabel(environment: string) {
return environmentLabels[environment] || environment;
}
+export function parseEnvironmentUrlParam(environment: string) {
+ if (environment === ENVIRONMENT_ALL_VALUE) {
+ return ENVIRONMENT_ALL;
+ }
+
+ if (environment === ENVIRONMENT_NOT_DEFINED_VALUE) {
+ return ENVIRONMENT_NOT_DEFINED;
+ }
+
+ return {
+ esFieldValue: environment,
+ value: environment,
+ text: environment,
+ };
+}
+
// returns the environment url param that should be used
// based on the requested environment. If the requested
// environment is different from the URL parameter, we'll
diff --git a/x-pack/plugins/apm/common/latency_aggregation_types.ts b/x-pack/plugins/apm/common/latency_aggregation_types.ts
index d9db58f223144..964d6f4ed1015 100644
--- a/x-pack/plugins/apm/common/latency_aggregation_types.ts
+++ b/x-pack/plugins/apm/common/latency_aggregation_types.ts
@@ -14,7 +14,7 @@ export enum LatencyAggregationType {
}
export const latencyAggregationTypeRt = t.union([
- t.literal('avg'),
- t.literal('p95'),
- t.literal('p99'),
+ t.literal(LatencyAggregationType.avg),
+ t.literal(LatencyAggregationType.p95),
+ t.literal(LatencyAggregationType.p99),
]);
diff --git a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts
index 1a17f82a52141..970e39bc4f86f 100644
--- a/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts
+++ b/x-pack/plugins/apm/common/runtime_types/iso_to_epoch_rt/index.ts
@@ -21,8 +21,5 @@ export const isoToEpochRt = new t.Type(
? t.failure(input, context)
: t.success(epochDate);
}),
- (a) => {
- const d = new Date(a);
- return d.toISOString();
- }
+ (output) => new Date(output).toISOString()
);
diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json
index e340f8bf19126..28e4a7b36e740 100644
--- a/x-pack/plugins/apm/kibana.json
+++ b/x-pack/plugins/apm/kibana.json
@@ -9,7 +9,8 @@
"licensing",
"triggersActionsUi",
"embeddable",
- "infra"
+ "infra",
+ "observability"
],
"optionalPlugins": [
"spaces",
@@ -18,7 +19,6 @@
"taskManager",
"actions",
"alerting",
- "observability",
"security",
"ml",
"home",
diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx
index 6a6db40892e10..407f460f25ad3 100644
--- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx
+++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/CreateEditCustomLinkFlyout/link_preview.test.tsx
@@ -14,15 +14,18 @@ import {
act,
waitFor,
} from '@testing-library/react';
-import * as apmApi from '../../../../../../services/rest/createCallApmApi';
+import {
+ getCallApmApiSpy,
+ CallApmApiSpy,
+} from '../../../../../../services/rest/callApmApiSpy';
export const removeExternalLinkText = (str: string) =>
str.replace(/\(opens in a new tab or window\)/g, '');
describe('LinkPreview', () => {
- let callApmApiSpy: jest.SpyInstance;
+ let callApmApiSpy: CallApmApiSpy;
beforeAll(() => {
- callApmApiSpy = jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({
+ callApmApiSpy = getCallApmApiSpy().mockResolvedValue({
transaction: { id: 'foo' },
});
});
diff --git a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx
index 77835afef863a..7d119b8c406da 100644
--- a/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx
+++ b/x-pack/plugins/apm/public/components/app/Settings/CustomizeUI/CustomLink/index.test.tsx
@@ -8,6 +8,7 @@
import { fireEvent, render, RenderResult } from '@testing-library/react';
import React from 'react';
import { act } from 'react-dom/test-utils';
+import { getCallApmApiSpy } from '../../../../../services/rest/callApmApiSpy';
import { CustomLinkOverview } from '.';
import { License } from '../../../../../../../licensing/common/license';
import { ApmPluginContextValue } from '../../../../../context/apm_plugin/apm_plugin_context';
@@ -17,7 +18,6 @@ import {
} from '../../../../../context/apm_plugin/mock_apm_plugin_context';
import { LicenseContext } from '../../../../../context/license/license_context';
import * as hooks from '../../../../../hooks/use_fetcher';
-import * as apmApi from '../../../../../services/rest/createCallApmApi';
import {
expectTextsInDocument,
expectTextsNotInDocument,
@@ -43,7 +43,7 @@ function getMockAPMContext({ canSave }: { canSave: boolean }) {
describe('CustomLink', () => {
beforeAll(() => {
- jest.spyOn(apmApi, 'callApmApi').mockResolvedValue({});
+ getCallApmApiSpy().mockResolvedValue({});
});
afterAll(() => {
jest.resetAllMocks();
diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx
index b30faac7a65af..c6ed4e640693f 100644
--- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx
+++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview.test.tsx
@@ -22,9 +22,12 @@ import * as useTransactionBreakdownHooks from '../../shared/charts/transaction_b
import { renderWithTheme } from '../../../utils/testHelpers';
import { ServiceOverview } from './';
import { waitFor } from '@testing-library/dom';
-import * as callApmApiModule from '../../../services/rest/createCallApmApi';
import * as useApmServiceContextHooks from '../../../context/apm_service/use_apm_service_context';
import { LatencyAggregationType } from '../../../../common/latency_aggregation_types';
+import {
+ getCallApmApiSpy,
+ getCreateCallApmApiSpy,
+} from '../../../services/rest/callApmApiSpy';
const KibanaReactContext = createKibanaReactContext({
usageCollection: { reportUiCounter: () => {} },
@@ -83,10 +86,10 @@ describe('ServiceOverview', () => {
/* eslint-disable @typescript-eslint/naming-convention */
const calls = {
'GET /api/apm/services/{serviceName}/error_groups/primary_statistics': {
- error_groups: [],
+ error_groups: [] as any[],
},
'GET /api/apm/services/{serviceName}/transactions/groups/primary_statistics': {
- transactionGroups: [],
+ transactionGroups: [] as any[],
totalTransactionGroups: 0,
isAggregationAccurate: true,
},
@@ -95,19 +98,17 @@ describe('ServiceOverview', () => {
};
/* eslint-enable @typescript-eslint/naming-convention */
- jest
- .spyOn(callApmApiModule, 'createCallApmApi')
- .mockImplementation(() => {});
-
- const callApmApi = jest
- .spyOn(callApmApiModule, 'callApmApi')
- .mockImplementation(({ endpoint }) => {
+ const callApmApiSpy = getCallApmApiSpy().mockImplementation(
+ ({ endpoint }) => {
const response = calls[endpoint as keyof typeof calls];
return response
? Promise.resolve(response)
: Promise.reject(`Response for ${endpoint} is not defined`);
- });
+ }
+ );
+
+ getCreateCallApmApiSpy().mockImplementation(() => callApmApiSpy as any);
jest
.spyOn(useTransactionBreakdownHooks, 'useTransactionBreakdown')
.mockReturnValue({
@@ -124,7 +125,7 @@ describe('ServiceOverview', () => {
);
await waitFor(() =>
- expect(callApmApi).toHaveBeenCalledTimes(Object.keys(calls).length)
+ expect(callApmApiSpy).toHaveBeenCalledTimes(Object.keys(calls).length)
);
expect((await findAllByText('Latency')).length).toBeGreaterThan(0);
diff --git a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts
index 29fabc51fd582..00447607cf787 100644
--- a/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts
+++ b/x-pack/plugins/apm/public/services/rest/apm_observability_overview_fetchers.test.ts
@@ -10,10 +10,10 @@ import {
fetchObservabilityOverviewPageData,
getHasData,
} from './apm_observability_overview_fetchers';
-import * as createCallApmApi from './createCallApmApi';
+import { getCallApmApiSpy } from './callApmApiSpy';
describe('Observability dashboard data', () => {
- const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi');
+ const callApmApiMock = getCallApmApiSpy();
const params = {
absoluteTime: {
start: moment('2020-07-02T13:25:11.629Z').valueOf(),
@@ -84,7 +84,7 @@ describe('Observability dashboard data', () => {
callApmApiMock.mockImplementation(() =>
Promise.resolve({
serviceCount: 0,
- transactionPerMinute: { value: null, timeseries: [] },
+ transactionPerMinute: { value: null, timeseries: [] as any },
})
);
const response = await fetchObservabilityOverviewPageData(params);
diff --git a/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts
new file mode 100644
index 0000000000000..ba9f740e06d0d
--- /dev/null
+++ b/x-pack/plugins/apm/public/services/rest/callApmApiSpy.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 * as createCallApmApi from './createCallApmApi';
+import type { AbstractAPMClient } from './createCallApmApi';
+
+export type CallApmApiSpy = jest.SpyInstance<
+ Promise,
+ Parameters
+>;
+
+export type CreateCallApmApiSpy = jest.SpyInstance;
+
+export const getCreateCallApmApiSpy = () =>
+ (jest.spyOn(
+ createCallApmApi,
+ 'createCallApmApi'
+ ) as unknown) as CreateCallApmApiSpy;
+export const getCallApmApiSpy = () =>
+ (jest.spyOn(createCallApmApi, 'callApmApi') as unknown) as CallApmApiSpy;
diff --git a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts
index b0cce3296fe21..0e82d70faf1e1 100644
--- a/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts
+++ b/x-pack/plugins/apm/public/services/rest/createCallApmApi.ts
@@ -6,30 +6,68 @@
*/
import { CoreSetup, CoreStart } from 'kibana/public';
-import { parseEndpoint } from '../../../common/apm_api/parse_endpoint';
+import * as t from 'io-ts';
+import type {
+ ClientRequestParamsOf,
+ EndpointOf,
+ ReturnOf,
+ RouteRepositoryClient,
+ ServerRouteRepository,
+ ServerRoute,
+} from '@kbn/server-route-repository';
+import { formatRequest } from '@kbn/server-route-repository/target/format_request';
import { FetchOptions } from '../../../common/fetch_options';
import { callApi } from './callApi';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import type { APMAPI } from '../../../server/routes/create_apm_api';
-// eslint-disable-next-line @kbn/eslint/no-restricted-paths
-import type { Client } from '../../../server/routes/typings';
-
-export type APMClient = Client;
-export type AutoAbortedAPMClient = Client;
+import type {
+ APMServerRouteRepository,
+ InspectResponse,
+ APMRouteHandlerResources,
+ // eslint-disable-next-line @kbn/eslint/no-restricted-paths
+} from '../../../server';
export type APMClientOptions = Omit<
FetchOptions,
'query' | 'body' | 'pathname' | 'signal'
> & {
- endpoint: string;
signal: AbortSignal | null;
- params?: {
- body?: any;
- query?: Record;
- path?: Record;
- };
};
+export type APMClient = RouteRepositoryClient<
+ APMServerRouteRepository,
+ APMClientOptions
+>;
+
+export type AutoAbortedAPMClient = RouteRepositoryClient<
+ APMServerRouteRepository,
+ Omit
+>;
+
+export type APIReturnType<
+ TEndpoint extends EndpointOf
+> = ReturnOf & {
+ _inspect?: InspectResponse;
+};
+
+export type APIEndpoint = EndpointOf;
+
+export type APIClientRequestParamsOf<
+ TEndpoint extends EndpointOf
+> = ClientRequestParamsOf;
+
+export type AbstractAPMRepository = ServerRouteRepository<
+ APMRouteHandlerResources,
+ {},
+ Record<
+ string,
+ ServerRoute
+ >
+>;
+
+export type AbstractAPMClient = RouteRepositoryClient<
+ AbstractAPMRepository,
+ APMClientOptions
+>;
+
export let callApmApi: APMClient = () => {
throw new Error(
'callApmApi has to be initialized before used. Call createCallApmApi first.'
@@ -37,9 +75,13 @@ export let callApmApi: APMClient = () => {
};
export function createCallApmApi(core: CoreStart | CoreSetup) {
- callApmApi = ((options: APMClientOptions) => {
- const { endpoint, params, ...opts } = options;
- const { method, pathname } = parseEndpoint(endpoint, params?.path);
+ callApmApi = ((options) => {
+ const { endpoint, ...opts } = options;
+ const { params } = (options as unknown) as {
+ params?: Partial>;
+ };
+
+ const { method, pathname } = formatRequest(endpoint, params?.path);
return callApi(core, {
...opts,
@@ -50,10 +92,3 @@ export function createCallApmApi(core: CoreStart | CoreSetup) {
});
}) as APMClient;
}
-
-// infer return type from API
-export type APIReturnType<
- TPath extends keyof APMAPI['_S']
-> = APMAPI['_S'][TPath] extends { ret: any }
- ? APMAPI['_S'][TPath]['ret']
- : unknown;
diff --git a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
index 319eb53313231..40d42298b967b 100644
--- a/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
+++ b/x-pack/plugins/apm/scripts/optimize-tsconfig/tsconfig.json
@@ -2,6 +2,7 @@
"include": [
"./x-pack/plugins/apm/**/*",
"./x-pack/plugins/observability/**/*",
+ "./x-pack/plugins/rule_registry/**/*",
"./typings/**/*"
],
"exclude": [
diff --git a/x-pack/plugins/apm/scripts/precommit.js b/x-pack/plugins/apm/scripts/precommit.js
index 695a9ba70f5d7..88d2e169dd542 100644
--- a/x-pack/plugins/apm/scripts/precommit.js
+++ b/x-pack/plugins/apm/scripts/precommit.js
@@ -28,19 +28,8 @@ const testTsconfig = resolve(root, 'x-pack/test/tsconfig.json');
const tasks = new Listr(
[
{
- title: 'Jest',
- task: () =>
- execa(
- 'node',
- [
- resolve(__dirname, './jest.js'),
- '--reporters',
- resolve(__dirname, '../../../../node_modules/jest-silent-reporter'),
- '--collect-coverage',
- 'false',
- ],
- execaOpts
- ),
+ title: 'Lint',
+ task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts),
},
{
title: 'Typescript',
@@ -72,11 +61,22 @@ const tasks = new Listr(
),
},
{
- title: 'Lint',
- task: () => execa('node', [resolve(__dirname, 'eslint.js')], execaOpts),
+ title: 'Jest',
+ task: () =>
+ execa(
+ 'node',
+ [
+ resolve(__dirname, './jest.js'),
+ '--reporters',
+ resolve(__dirname, '../../../../node_modules/jest-silent-reporter'),
+ '--collect-coverage',
+ 'false',
+ ],
+ execaOpts
+ ),
},
],
- { exitOnError: true, concurrent: true }
+ { exitOnError: true, concurrent: false }
);
tasks.run().catch((error) => {
diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts
index 00910353ac278..9ab56c1a303ea 100644
--- a/x-pack/plugins/apm/server/index.ts
+++ b/x-pack/plugins/apm/server/index.ts
@@ -120,5 +120,9 @@ export function mergeConfigs(
export const plugin = (initContext: PluginInitializerContext) =>
new APMPlugin(initContext);
-export { APMPlugin, APMPluginSetup } from './plugin';
+export { APMPlugin } from './plugin';
+export { APMPluginSetup } from './types';
+export { APMServerRouteRepository } from './routes/get_global_apm_server_route_repository';
+export { InspectResponse, APMRouteHandlerResources } from './routes/typings';
+
export type { ProcessorEvent } from '../common/processor_event';
diff --git a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts
index 473912c4177a9..b065da7123dec 100644
--- a/x-pack/plugins/apm/server/lib/alerts/action_variables.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/action_variables.ts
@@ -13,28 +13,28 @@ export const apmActionVariables = {
'xpack.apm.alerts.action_variables.serviceName',
{ defaultMessage: 'The service the alert is created for' }
),
- name: 'serviceName',
+ name: 'serviceName' as const,
},
transactionType: {
description: i18n.translate(
'xpack.apm.alerts.action_variables.transactionType',
{ defaultMessage: 'The transaction type the alert is created for' }
),
- name: 'transactionType',
+ name: 'transactionType' as const,
},
environment: {
description: i18n.translate(
'xpack.apm.alerts.action_variables.environment',
{ defaultMessage: 'The transaction type the alert is created for' }
),
- name: 'environment',
+ name: 'environment' as const,
},
threshold: {
description: i18n.translate('xpack.apm.alerts.action_variables.threshold', {
defaultMessage:
'Any trigger value above this value will cause the alert to fire',
}),
- name: 'threshold',
+ name: 'threshold' as const,
},
triggerValue: {
description: i18n.translate(
@@ -44,7 +44,7 @@ export const apmActionVariables = {
'The value that breached the threshold and triggered the alert',
}
),
- name: 'triggerValue',
+ name: 'triggerValue' as const,
},
interval: {
description: i18n.translate(
@@ -54,6 +54,6 @@ export const apmActionVariables = {
'The length and unit of the time period where the alert conditions were met',
}
),
- name: 'interval',
+ name: 'interval' as const,
},
};
diff --git a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts
index 9a0ba514bb479..e3d5e5481caa5 100644
--- a/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/alerting_es_client.ts
@@ -5,28 +5,24 @@
* 2.0.
*/
-import { ApiResponse } from '@elastic/elasticsearch';
-import { ThresholdMetActionGroupId } from '../../../common/alert_types';
import {
ESSearchRequest,
ESSearchResponse,
} from '../../../../../../typings/elasticsearch';
-import {
- AlertInstanceContext,
- AlertInstanceState,
- AlertServices,
-} from '../../../../alerting/server';
+import { AlertServices } from '../../../../alerting/server';
-export function alertingEsClient(
- services: AlertServices<
- AlertInstanceState,
- AlertInstanceContext,
- ThresholdMetActionGroupId
- >,
+export async function alertingEsClient(
+ scopedClusterClient: AlertServices<
+ never,
+ never,
+ never
+ >['scopedClusterClient'],
params: TParams
-): Promise>> {
- return (services.scopedClusterClient.asCurrentUser.search({
+): Promise> {
+ const response = await scopedClusterClient.asCurrentUser.search({
...params,
ignore_unavailable: true,
- }) as unknown) as Promise>>;
+ });
+
+ return (response.body as unknown) as ESSearchResponse;
}
diff --git a/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts b/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts
new file mode 100644
index 0000000000000..8d250a5765cce
--- /dev/null
+++ b/x-pack/plugins/apm/server/lib/alerts/create_apm_lifecycle_rule_type.ts
@@ -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 { createLifecycleRuleTypeFactory } from '../../../../rule_registry/server';
+import { APMRuleRegistry } from '../../plugin';
+
+export const createAPMLifecycleRuleType = createLifecycleRuleTypeFactory();
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts
index a9824c130faa5..9a362efa90ac0 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_apm_alerts.ts
@@ -6,38 +6,25 @@
*/
import { Observable } from 'rxjs';
-import { AlertingPlugin } from '../../../../alerting/server';
-import { ActionsPlugin } from '../../../../actions/server';
+import { Logger } from 'kibana/server';
import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type';
import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type';
import { registerErrorCountAlertType } from './register_error_count_alert_type';
import { APMConfig } from '../..';
import { MlPluginSetup } from '../../../../ml/server';
import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type';
+import { APMRuleRegistry } from '../../plugin';
-interface Params {
- alerting: AlertingPlugin['setup'];
- actions: ActionsPlugin['setup'];
+export interface RegisterRuleDependencies {
+ registry: APMRuleRegistry;
ml?: MlPluginSetup;
config$: Observable;
+ logger: Logger;
}
-export function registerApmAlerts(params: Params) {
- registerTransactionDurationAlertType({
- alerting: params.alerting,
- config$: params.config$,
- });
- registerTransactionDurationAnomalyAlertType({
- alerting: params.alerting,
- ml: params.ml,
- config$: params.config$,
- });
- registerErrorCountAlertType({
- alerting: params.alerting,
- config$: params.config$,
- });
- registerTransactionErrorRateAlertType({
- alerting: params.alerting,
- config$: params.config$,
- });
+export function registerApmAlerts(dependencies: RegisterRuleDependencies) {
+ registerTransactionDurationAlertType(dependencies);
+ registerTransactionDurationAnomalyAlertType(dependencies);
+ registerErrorCountAlertType(dependencies);
+ registerTransactionErrorRateAlertType(dependencies);
}
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts
index d7dd7aee3ca25..5758dea1860b2 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.test.ts
@@ -5,50 +5,17 @@
* 2.0.
*/
-import { Observable } from 'rxjs';
-import * as Rx from 'rxjs';
-import { toArray, map } from 'rxjs/operators';
-
-import { AlertingPlugin } from '../../../../alerting/server';
-import { APMConfig } from '../..';
-
import { registerErrorCountAlertType } from './register_error_count_alert_type';
-import { elasticsearchServiceMock } from 'src/core/server/mocks';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
-
-type Operator = (source: Rx.Observable) => Rx.Observable;
-const pipeClosure = (fn: Operator): Operator => {
- return (source: Rx.Observable) => {
- return Rx.defer(() => fn(source));
- };
-};
-const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe(
- pipeClosure((source$) => {
- return source$.pipe(map((i) => i));
- }),
- toArray()
-) as unknown) as Observable;
+import { createRuleTypeMocks } from './test_utils';
describe('Error count alert', () => {
it("doesn't send an alert when error count is less than threshold", async () => {
- let alertExecutor: any;
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ const { services, dependencies, executor } = createRuleTypeMocks();
- registerErrorCountAlertType({
- alerting,
- config$: mockedConfig$,
- });
- expect(alertExecutor).toBeDefined();
+ registerErrorCountAlertType(dependencies);
- const services = {
- scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
- alertInstanceFactory: jest.fn(),
- };
const params = { threshold: 1 };
services.scopedClusterClient.asCurrentUser.search.mockReturnValue(
@@ -71,30 +38,21 @@ describe('Error count alert', () => {
})
);
- await alertExecutor!({ services, params });
+ await executor({ params });
expect(services.alertInstanceFactory).not.toBeCalled();
});
- it('sends alerts with service name and environment', async () => {
- let alertExecutor: any;
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ it('sends alerts with service name and environment for those that exceeded the threshold', async () => {
+ const {
+ services,
+ dependencies,
+ executor,
+ scheduleActions,
+ } = createRuleTypeMocks();
- registerErrorCountAlertType({
- alerting,
- config$: mockedConfig$,
- });
- expect(alertExecutor).toBeDefined();
+ registerErrorCountAlertType(dependencies);
- const scheduleActions = jest.fn();
- const services = {
- scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
- alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
- };
- const params = { threshold: 1, windowSize: 5, windowUnit: 'm' };
+ const params = { threshold: 2, windowSize: 5, windowUnit: 'm' };
services.scopedClusterClient.asCurrentUser.search.mockReturnValue(
elasticsearchClientMock.createSuccessTransportRequestPromise({
@@ -106,18 +64,62 @@ describe('Error count alert', () => {
},
},
aggregations: {
- services: {
+ error_counts: {
buckets: [
{
- key: 'foo',
- environments: {
- buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }],
+ key: ['foo', 'env-foo'],
+ doc_count: 5,
+ latest: {
+ top: [
+ {
+ metrics: {
+ 'service.name': 'foo',
+ 'service.environment': 'env-foo',
+ },
+ },
+ ],
+ },
+ },
+ {
+ key: ['foo', 'env-foo-2'],
+ doc_count: 4,
+ latest: {
+ top: [
+ {
+ metrics: {
+ 'service.name': 'foo',
+ 'service.environment': 'env-foo-2',
+ },
+ },
+ ],
},
},
{
- key: 'bar',
- environments: {
- buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }],
+ key: ['bar', 'env-bar'],
+ doc_count: 3,
+ latest: {
+ top: [
+ {
+ metrics: {
+ 'service.name': 'bar',
+ 'service.environment': 'env-bar',
+ },
+ },
+ ],
+ },
+ },
+ {
+ key: ['bar', 'env-bar-2'],
+ doc_count: 1,
+ latest: {
+ top: [
+ {
+ metrics: {
+ 'service.name': 'bar',
+ 'service.environment': 'env-bar-2',
+ },
+ },
+ ],
},
},
],
@@ -134,115 +136,36 @@ describe('Error count alert', () => {
})
);
- await alertExecutor!({ services, params });
+ await executor({ params });
[
'apm.error_rate_foo_env-foo',
'apm.error_rate_foo_env-foo-2',
'apm.error_rate_bar_env-bar',
- 'apm.error_rate_bar_env-bar-2',
].forEach((instanceName) =>
expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
);
+ expect(scheduleActions).toHaveBeenCalledTimes(3);
+
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
environment: 'env-foo',
- threshold: 1,
- triggerValue: 2,
+ threshold: 2,
+ triggerValue: 5,
interval: '5m',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
environment: 'env-foo-2',
- threshold: 1,
- triggerValue: 2,
+ threshold: 2,
+ triggerValue: 4,
interval: '5m',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
environment: 'env-bar',
- threshold: 1,
- triggerValue: 2,
- interval: '5m',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'bar',
- environment: 'env-bar-2',
- threshold: 1,
- triggerValue: 2,
- interval: '5m',
- });
- });
- it('sends alerts with service name', async () => {
- let alertExecutor: any;
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
-
- registerErrorCountAlertType({
- alerting,
- config$: mockedConfig$,
- });
- expect(alertExecutor).toBeDefined();
-
- const scheduleActions = jest.fn();
- const services = {
- scopedClusterClient: elasticsearchServiceMock.createScopedClusterClient(),
- alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
- };
- const params = { threshold: 1, windowSize: 5, windowUnit: 'm' };
-
- services.scopedClusterClient.asCurrentUser.search.mockReturnValue(
- elasticsearchClientMock.createSuccessTransportRequestPromise({
- hits: {
- hits: [],
- total: {
- relation: 'eq',
- value: 2,
- },
- },
- aggregations: {
- services: {
- buckets: [
- {
- key: 'foo',
- },
- {
- key: 'bar',
- },
- ],
- },
- },
- took: 0,
- timed_out: false,
- _shards: {
- failed: 0,
- skipped: 0,
- successful: 1,
- total: 1,
- },
- })
- );
-
- await alertExecutor!({ services, params });
- ['apm.error_rate_foo', 'apm.error_rate_bar'].forEach((instanceName) =>
- expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
- );
-
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'foo',
- environment: undefined,
- threshold: 1,
- triggerValue: 2,
- interval: '5m',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'bar',
- environment: undefined,
- threshold: 1,
- triggerValue: 2,
+ threshold: 2,
+ triggerValue: 3,
interval: '5m',
});
});
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts
index 0120891a8f868..8240e0c369d1f 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_error_count_alert_type.ts
@@ -5,22 +5,11 @@
* 2.0.
*/
-import { schema, TypeOf } from '@kbn/config-schema';
-import { isEmpty } from 'lodash';
-import { Observable } from 'rxjs';
+import { schema } from '@kbn/config-schema';
import { take } from 'rxjs/operators';
-import { APMConfig } from '../..';
-import {
- AlertingPlugin,
- AlertInstanceContext,
- AlertInstanceState,
- AlertTypeState,
-} from '../../../../alerting/server';
-import {
- AlertType,
- ALERT_TYPES_CONFIG,
- ThresholdMetActionGroupId,
-} from '../../../common/alert_types';
+import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values';
+import { asMutableArray } from '../../../common/utils/as_mutable_array';
+import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
@@ -31,11 +20,8 @@ import { environmentQuery } from '../../../server/utils/queries';
import { getApmIndices } from '../settings/apm_indices/get_apm_indices';
import { apmActionVariables } from './action_variables';
import { alertingEsClient } from './alerting_es_client';
-
-interface RegisterAlertParams {
- alerting: AlertingPlugin['setup'];
- config$: Observable;
-}
+import { RegisterRuleDependencies } from './register_apm_alerts';
+import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type';
const paramsSchema = schema.object({
windowSize: schema.number(),
@@ -48,127 +34,131 @@ const paramsSchema = schema.object({
const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.ErrorCount];
export function registerErrorCountAlertType({
- alerting,
+ registry,
config$,
-}: RegisterAlertParams) {
- alerting.registerType<
- TypeOf,
- AlertTypeState,
- AlertInstanceState,
- AlertInstanceContext,
- ThresholdMetActionGroupId
- >({
- id: AlertType.ErrorCount,
- name: alertTypeConfig.name,
- actionGroups: alertTypeConfig.actionGroups,
- defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
- validate: {
- params: paramsSchema,
- },
- actionVariables: {
- context: [
- apmActionVariables.serviceName,
- apmActionVariables.environment,
- apmActionVariables.threshold,
- apmActionVariables.triggerValue,
- apmActionVariables.interval,
- ],
- },
- producer: 'apm',
- minimumLicenseRequired: 'basic',
- executor: async ({ services, params }) => {
- const config = await config$.pipe(take(1)).toPromise();
- const alertParams = params;
- const indices = await getApmIndices({
- config,
- savedObjectsClient: services.savedObjectsClient,
- });
- const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments'];
+}: RegisterRuleDependencies) {
+ registry.registerType(
+ createAPMLifecycleRuleType({
+ id: AlertType.ErrorCount,
+ name: alertTypeConfig.name,
+ actionGroups: alertTypeConfig.actionGroups,
+ defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
+ validate: {
+ params: paramsSchema,
+ },
+ actionVariables: {
+ context: [
+ apmActionVariables.serviceName,
+ apmActionVariables.environment,
+ apmActionVariables.threshold,
+ apmActionVariables.triggerValue,
+ apmActionVariables.interval,
+ ],
+ },
+ producer: 'apm',
+ minimumLicenseRequired: 'basic',
+ executor: async ({ services, params }) => {
+ const config = await config$.pipe(take(1)).toPromise();
+ const alertParams = params;
+ const indices = await getApmIndices({
+ config,
+ savedObjectsClient: services.savedObjectsClient,
+ });
- const searchParams = {
- index: indices['apm_oss.errorIndices'],
- size: 0,
- body: {
- track_total_hits: true,
- query: {
- bool: {
- filter: [
- {
- range: {
- '@timestamp': {
- gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
+ const searchParams = {
+ index: indices['apm_oss.errorIndices'],
+ size: 0,
+ body: {
+ query: {
+ bool: {
+ filter: [
+ {
+ range: {
+ '@timestamp': {
+ gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
+ },
},
},
- },
- { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
- ...(alertParams.serviceName
- ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }]
- : []),
- ...environmentQuery(alertParams.environment),
- ],
- },
- },
- aggs: {
- services: {
- terms: {
- field: SERVICE_NAME,
- size: 50,
+ { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
+ ...(alertParams.serviceName
+ ? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }]
+ : []),
+ ...environmentQuery(alertParams.environment),
+ ],
},
- aggs: {
- environments: {
- terms: {
- field: SERVICE_ENVIRONMENT,
- size: maxServiceEnvironments,
+ },
+ aggs: {
+ error_counts: {
+ multi_terms: {
+ terms: [
+ { field: SERVICE_NAME },
+ { field: SERVICE_ENVIRONMENT, missing: '' },
+ ],
+ size: 10000,
+ },
+ aggs: {
+ latest: {
+ top_metrics: {
+ metrics: asMutableArray([
+ { field: SERVICE_NAME },
+ { field: SERVICE_ENVIRONMENT },
+ ] as const),
+ sort: {
+ '@timestamp': 'desc' as const,
+ },
+ },
},
},
},
},
},
- },
- };
+ };
+
+ const response = await alertingEsClient(
+ services.scopedClusterClient,
+ searchParams
+ );
- const { body: response } = await alertingEsClient(services, searchParams);
- const errorCount = response.hits.total.value;
+ const errorCountResults =
+ response.aggregations?.error_counts.buckets.map((bucket) => {
+ const latest = bucket.latest.top[0].metrics;
- if (errorCount > alertParams.threshold) {
- function scheduleAction({
- serviceName,
- environment,
- }: {
- serviceName: string;
- environment?: string;
- }) {
- const alertInstanceName = [
- AlertType.ErrorCount,
- serviceName,
- environment,
- ]
- .filter((name) => name)
- .join('_');
+ return {
+ serviceName: latest['service.name'] as string,
+ environment: latest['service.environment'] as string | undefined,
+ errorCount: bucket.doc_count,
+ };
+ }) ?? [];
- const alertInstance = services.alertInstanceFactory(
- alertInstanceName
- );
- alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
- serviceName,
- environment,
- threshold: alertParams.threshold,
- triggerValue: errorCount,
- interval: `${alertParams.windowSize}${alertParams.windowUnit}`,
+ errorCountResults
+ .filter((result) => result.errorCount >= alertParams.threshold)
+ .forEach((result) => {
+ const { serviceName, environment, errorCount } = result;
+
+ services
+ .alertWithLifecycle({
+ id: [AlertType.ErrorCount, serviceName, environment]
+ .filter((name) => name)
+ .join('_'),
+ fields: {
+ [SERVICE_NAME]: serviceName,
+ ...(environment
+ ? { [SERVICE_ENVIRONMENT]: environment }
+ : {}),
+ [PROCESSOR_EVENT]: 'error',
+ },
+ })
+ .scheduleActions(alertTypeConfig.defaultActionGroupId, {
+ serviceName,
+ environment: environment || ENVIRONMENT_NOT_DEFINED.text,
+ threshold: alertParams.threshold,
+ triggerValue: errorCount,
+ interval: `${alertParams.windowSize}${alertParams.windowUnit}`,
+ });
});
- }
- response.aggregations?.services.buckets.forEach((serviceBucket) => {
- const serviceName = serviceBucket.key as string;
- if (isEmpty(serviceBucket.environments?.buckets)) {
- scheduleAction({ serviceName });
- } else {
- serviceBucket.environments.buckets.forEach((envBucket) => {
- const environment = envBucket.key as string;
- scheduleAction({ serviceName, environment });
- });
- }
- });
- }
- },
- });
+
+ return {};
+ },
+ })
+ );
}
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts
index 500e0744d5638..6ca1c4370d6ae 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_alert_type.ts
@@ -6,10 +6,9 @@
*/
import { schema } from '@kbn/config-schema';
-import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
-import { APMConfig } from '../..';
-import { AlertingPlugin } from '../../../../alerting/server';
+import { QueryContainer } from '@elastic/elasticsearch/api/types';
+import { parseEnvironmentUrlParam } from '../../../common/environment_filter_values';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
PROCESSOR_EVENT,
@@ -24,11 +23,8 @@ import { environmentQuery } from '../../../server/utils/queries';
import { getApmIndices } from '../settings/apm_indices/get_apm_indices';
import { apmActionVariables } from './action_variables';
import { alertingEsClient } from './alerting_es_client';
-
-interface RegisterAlertParams {
- alerting: AlertingPlugin['setup'];
- config$: Observable;
-}
+import { RegisterRuleDependencies } from './register_apm_alerts';
+import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type';
const paramsSchema = schema.object({
serviceName: schema.string(),
@@ -47,116 +43,126 @@ const paramsSchema = schema.object({
const alertTypeConfig = ALERT_TYPES_CONFIG[AlertType.TransactionDuration];
export function registerTransactionDurationAlertType({
- alerting,
+ registry,
config$,
-}: RegisterAlertParams) {
- alerting.registerType({
- id: AlertType.TransactionDuration,
- name: alertTypeConfig.name,
- actionGroups: alertTypeConfig.actionGroups,
- defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
- validate: {
- params: paramsSchema,
- },
- actionVariables: {
- context: [
- apmActionVariables.serviceName,
- apmActionVariables.transactionType,
- apmActionVariables.environment,
- apmActionVariables.threshold,
- apmActionVariables.triggerValue,
- apmActionVariables.interval,
- ],
- },
- producer: 'apm',
- minimumLicenseRequired: 'basic',
- executor: async ({ services, params }) => {
- const config = await config$.pipe(take(1)).toPromise();
- const alertParams = params;
- const indices = await getApmIndices({
- config,
- savedObjectsClient: services.savedObjectsClient,
- });
- const maxServiceEnvironments = config['xpack.apm.maxServiceEnvironments'];
+}: RegisterRuleDependencies) {
+ registry.registerType(
+ createAPMLifecycleRuleType({
+ id: AlertType.TransactionDuration,
+ name: alertTypeConfig.name,
+ actionGroups: alertTypeConfig.actionGroups,
+ defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
+ validate: {
+ params: paramsSchema,
+ },
+ actionVariables: {
+ context: [
+ apmActionVariables.serviceName,
+ apmActionVariables.transactionType,
+ apmActionVariables.environment,
+ apmActionVariables.threshold,
+ apmActionVariables.triggerValue,
+ apmActionVariables.interval,
+ ],
+ },
+ producer: 'apm',
+ minimumLicenseRequired: 'basic',
+ executor: async ({ services, params }) => {
+ const config = await config$.pipe(take(1)).toPromise();
+ const alertParams = params;
+ const indices = await getApmIndices({
+ config,
+ savedObjectsClient: services.savedObjectsClient,
+ });
- const searchParams = {
- index: indices['apm_oss.transactionIndices'],
- size: 0,
- body: {
- query: {
- bool: {
- filter: [
- {
- range: {
- '@timestamp': {
- gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
+ const searchParams = {
+ index: indices['apm_oss.transactionIndices'],
+ size: 0,
+ body: {
+ query: {
+ bool: {
+ filter: [
+ {
+ range: {
+ '@timestamp': {
+ gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
+ },
},
},
- },
- { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
- { term: { [SERVICE_NAME]: alertParams.serviceName } },
- { term: { [TRANSACTION_TYPE]: alertParams.transactionType } },
- ...environmentQuery(alertParams.environment),
- ],
+ { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
+ { term: { [SERVICE_NAME]: alertParams.serviceName } },
+ { term: { [TRANSACTION_TYPE]: alertParams.transactionType } },
+ ...environmentQuery(alertParams.environment),
+ ] as QueryContainer[],
+ },
},
- },
- aggs: {
- agg:
- alertParams.aggregationType === 'avg'
- ? { avg: { field: TRANSACTION_DURATION } }
- : {
- percentiles: {
- field: TRANSACTION_DURATION,
- percents: [
- alertParams.aggregationType === '95th' ? 95 : 99,
- ],
+ aggs: {
+ latency:
+ alertParams.aggregationType === 'avg'
+ ? { avg: { field: TRANSACTION_DURATION } }
+ : {
+ percentiles: {
+ field: TRANSACTION_DURATION,
+ percents: [
+ alertParams.aggregationType === '95th' ? 95 : 99,
+ ],
+ },
},
- },
- environments: {
- terms: {
- field: SERVICE_ENVIRONMENT,
- size: maxServiceEnvironments,
- },
},
},
- },
- };
+ };
- const { body: response } = await alertingEsClient(services, searchParams);
+ const response = await alertingEsClient(
+ services.scopedClusterClient,
+ searchParams
+ );
- if (!response.aggregations) {
- return;
- }
+ if (!response.aggregations) {
+ return {};
+ }
- const { agg, environments } = response.aggregations;
+ const { latency } = response.aggregations;
- const transactionDuration =
- 'values' in agg ? Object.values(agg.values)[0] : agg?.value;
+ const transactionDuration =
+ 'values' in latency
+ ? Object.values(latency.values)[0]
+ : latency?.value;
- const threshold = alertParams.threshold * 1000;
+ const threshold = alertParams.threshold * 1000;
- if (transactionDuration && transactionDuration > threshold) {
- const durationFormatter = getDurationFormatter(transactionDuration);
- const transactionDurationFormatted = durationFormatter(
- transactionDuration
- ).formatted;
+ if (transactionDuration && transactionDuration > threshold) {
+ const durationFormatter = getDurationFormatter(transactionDuration);
+ const transactionDurationFormatted = durationFormatter(
+ transactionDuration
+ ).formatted;
- environments.buckets.map((bucket) => {
- const environment = bucket.key;
- const alertInstance = services.alertInstanceFactory(
- `${AlertType.TransactionDuration}_${environment}`
+ const environmentParsed = parseEnvironmentUrlParam(
+ alertParams.environment
);
- alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
- transactionType: alertParams.transactionType,
- serviceName: alertParams.serviceName,
- environment,
- threshold,
- triggerValue: transactionDurationFormatted,
- interval: `${alertParams.windowSize}${alertParams.windowUnit}`,
- });
- });
- }
- },
- });
+ services
+ .alertWithLifecycle({
+ id: `${AlertType.TransactionDuration}_${environmentParsed.text}`,
+ fields: {
+ [SERVICE_NAME]: alertParams.serviceName,
+ ...(environmentParsed.esFieldValue
+ ? { [SERVICE_ENVIRONMENT]: environmentParsed.esFieldValue }
+ : {}),
+ [TRANSACTION_TYPE]: alertParams.transactionType,
+ },
+ })
+ .scheduleActions(alertTypeConfig.defaultActionGroupId, {
+ transactionType: alertParams.transactionType,
+ serviceName: alertParams.serviceName,
+ environment: environmentParsed.text,
+ threshold,
+ triggerValue: transactionDurationFormatted,
+ interval: `${alertParams.windowSize}${alertParams.windowUnit}`,
+ });
+ }
+
+ return {};
+ },
+ })
+ );
}
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts
index 5f6c07cae4b8f..b9346b2bf4649 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.test.ts
@@ -4,29 +4,11 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
-
-import { Observable } from 'rxjs';
-import * as Rx from 'rxjs';
-import { toArray, map } from 'rxjs/operators';
-import { AlertingPlugin } from '../../../../alerting/server';
import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type';
-import { APMConfig } from '../..';
import { ANOMALY_SEVERITY } from '../../../../ml/common';
import { Job, MlPluginSetup } from '../../../../ml/server';
import * as GetServiceAnomalies from '../service_map/get_service_anomalies';
-
-type Operator = (source: Rx.Observable) => Rx.Observable;
-const pipeClosure = (fn: Operator): Operator => {
- return (source: Rx.Observable) => {
- return Rx.defer(() => fn(source));
- };
-};
-const mockedConfig$ = (Rx.of('apm_oss.errorIndices').pipe(
- pipeClosure((source$) => {
- return source$.pipe(map((i) => i));
- }),
- toArray()
-) as unknown) as Observable;
+import { createRuleTypeMocks } from './test_utils';
describe('Transaction duration anomaly alert', () => {
afterEach(() => {
@@ -34,28 +16,21 @@ describe('Transaction duration anomaly alert', () => {
});
describe("doesn't send alert", () => {
it('ml is not defined', async () => {
- let alertExecutor: any;
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ const { services, dependencies, executor } = createRuleTypeMocks();
registerTransactionDurationAnomalyAlertType({
- alerting,
+ ...dependencies,
ml: undefined,
- config$: mockedConfig$,
});
- expect(alertExecutor).toBeDefined();
- const services = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(),
- };
const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR };
- await alertExecutor!({ services, params });
- expect(services.callCluster).not.toHaveBeenCalled();
+ await executor({ params });
+
+ expect(
+ services.scopedClusterClient.asCurrentUser.search
+ ).not.toHaveBeenCalled();
+
expect(services.alertInstanceFactory).not.toHaveBeenCalled();
});
@@ -64,13 +39,7 @@ describe('Transaction duration anomaly alert', () => {
.spyOn(GetServiceAnomalies, 'getMLJobs')
.mockReturnValue(Promise.resolve([]));
- let alertExecutor: any;
-
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ const { services, dependencies, executor } = createRuleTypeMocks();
const ml = ({
mlSystemProvider: () => ({ mlAnomalySearch: jest.fn() }),
@@ -78,117 +47,47 @@ describe('Transaction duration anomaly alert', () => {
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
- alerting,
+ ...dependencies,
ml,
- config$: mockedConfig$,
});
- expect(alertExecutor).toBeDefined();
- const services = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(),
- };
const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR };
- await alertExecutor!({ services, params });
- expect(services.callCluster).not.toHaveBeenCalled();
- expect(services.alertInstanceFactory).not.toHaveBeenCalled();
- });
+ await executor({ params });
+ expect(
+ services.scopedClusterClient.asCurrentUser.search
+ ).not.toHaveBeenCalled();
- it('anomaly is less than threshold', async () => {
- jest
- .spyOn(GetServiceAnomalies, 'getMLJobs')
- .mockReturnValue(
- Promise.resolve([{ job_id: '1' }, { job_id: '2' }] as Job[])
- );
-
- let alertExecutor: any;
-
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
-
- const ml = ({
- mlSystemProvider: () => ({
- mlAnomalySearch: () => ({
- hits: { total: { value: 0 } },
- }),
- }),
- anomalyDetectorsProvider: jest.fn(),
- } as unknown) as MlPluginSetup;
-
- registerTransactionDurationAnomalyAlertType({
- alerting,
- ml,
- config$: mockedConfig$,
- });
- expect(alertExecutor).toBeDefined();
-
- const services = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(),
- };
- const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR };
-
- await alertExecutor!({ services, params });
- expect(services.callCluster).not.toHaveBeenCalled();
expect(services.alertInstanceFactory).not.toHaveBeenCalled();
});
- });
- describe('sends alert', () => {
- it('with service name, environment and transaction type', async () => {
+ it('anomaly is less than threshold', async () => {
jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue(
- Promise.resolve([
+ Promise.resolve(([
{
job_id: '1',
- custom_settings: {
- job_tags: {
- environment: 'production',
- },
- },
- } as unknown,
+ custom_settings: { job_tags: { environment: 'development' } },
+ },
{
job_id: '2',
- custom_settings: {
- job_tags: {
- environment: 'production',
- },
- },
- } as unknown,
- ] as Job[])
+ custom_settings: { job_tags: { environment: 'production' } },
+ },
+ ] as unknown) as Job[])
);
- let alertExecutor: any;
-
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ const { services, dependencies, executor } = createRuleTypeMocks();
const ml = ({
mlSystemProvider: () => ({
mlAnomalySearch: () => ({
- hits: { total: { value: 2 } },
aggregations: {
- services: {
+ anomaly_groups: {
buckets: [
{
- key: 'foo',
- transaction_types: {
- buckets: [{ key: 'type-foo' }],
- },
- record_avg: { value: 80 },
- },
- {
- key: 'bar',
- transaction_types: {
- buckets: [{ key: 'type-bar' }],
+ doc_count: 1,
+ latest_score: {
+ top: [{ metrics: { record_score: 0, job_id: '1' } }],
},
- record_avg: { value: 20 },
},
],
},
@@ -199,84 +98,77 @@ describe('Transaction duration anomaly alert', () => {
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
- alerting,
+ ...dependencies,
ml,
- config$: mockedConfig$,
});
- expect(alertExecutor).toBeDefined();
- const scheduleActions = jest.fn();
- const services = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
- };
const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR };
- await alertExecutor!({ services, params });
-
- await alertExecutor!({ services, params });
- [
- 'apm.transaction_duration_anomaly_foo_production_type-foo',
- 'apm.transaction_duration_anomaly_bar_production_type-bar',
- ].forEach((instanceName) =>
- expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
- );
+ await executor({ params });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'foo',
- transactionType: 'type-foo',
- environment: 'production',
- threshold: 'minor',
- thresholdValue: 'critical',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'bar',
- transactionType: 'type-bar',
- environment: 'production',
- threshold: 'minor',
- thresholdValue: 'warning',
- });
+ expect(
+ services.scopedClusterClient.asCurrentUser.search
+ ).not.toHaveBeenCalled();
+ expect(services.alertInstanceFactory).not.toHaveBeenCalled();
});
+ });
- it('with service name', async () => {
+ describe('sends alert', () => {
+ it('for all services that exceeded the threshold', async () => {
jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue(
- Promise.resolve([
+ Promise.resolve(([
{
job_id: '1',
- custom_settings: {
- job_tags: {
- environment: 'production',
- },
- },
- } as unknown,
+ custom_settings: { job_tags: { environment: 'development' } },
+ },
{
job_id: '2',
- custom_settings: {
- job_tags: {
- environment: 'testing',
- },
- },
- } as unknown,
- ] as Job[])
+ custom_settings: { job_tags: { environment: 'production' } },
+ },
+ ] as unknown) as Job[])
);
- let alertExecutor: any;
-
- const alerting = {
- registerType: ({ executor }) => {
- alertExecutor = executor;
- },
- } as AlertingPlugin['setup'];
+ const {
+ services,
+ dependencies,
+ executor,
+ scheduleActions,
+ } = createRuleTypeMocks();
const ml = ({
mlSystemProvider: () => ({
mlAnomalySearch: () => ({
- hits: { total: { value: 2 } },
aggregations: {
- services: {
+ anomaly_groups: {
buckets: [
- { key: 'foo', record_avg: { value: 80 } },
- { key: 'bar', record_avg: { value: 20 } },
+ {
+ latest_score: {
+ top: [
+ {
+ metrics: {
+ record_score: 80,
+ job_id: '1',
+ partition_field_value: 'foo',
+ by_field_value: 'type-foo',
+ },
+ },
+ ],
+ },
+ },
+ {
+ latest_score: {
+ top: [
+ {
+ metrics: {
+ record_score: 20,
+ job_id: '2',
+ parttition_field_value: 'bar',
+ by_field_value: 'type-bar',
+ },
+ },
+ ],
+ },
+ },
],
},
},
@@ -286,58 +178,26 @@ describe('Transaction duration anomaly alert', () => {
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
- alerting,
+ ...dependencies,
ml,
- config$: mockedConfig$,
});
- expect(alertExecutor).toBeDefined();
- const scheduleActions = jest.fn();
- const services = {
- callCluster: jest.fn(),
- alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
- };
const params = { anomalySeverityType: ANOMALY_SEVERITY.MINOR };
- await alertExecutor!({ services, params });
+ await executor({ params });
+
+ expect(services.alertInstanceFactory).toHaveBeenCalledTimes(1);
- await alertExecutor!({ services, params });
- [
- 'apm.transaction_duration_anomaly_foo_production',
- 'apm.transaction_duration_anomaly_foo_testing',
- 'apm.transaction_duration_anomaly_bar_production',
- 'apm.transaction_duration_anomaly_bar_testing',
- ].forEach((instanceName) =>
- expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
+ expect(services.alertInstanceFactory).toHaveBeenCalledWith(
+ 'apm.transaction_duration_anomaly_foo_development_type-foo'
);
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
- transactionType: undefined,
- environment: 'production',
- threshold: 'minor',
- thresholdValue: 'critical',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'bar',
- transactionType: undefined,
- environment: 'production',
- threshold: 'minor',
- thresholdValue: 'warning',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'foo',
- transactionType: undefined,
- environment: 'testing',
- threshold: 'minor',
- thresholdValue: 'critical',
- });
- expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
- serviceName: 'bar',
- transactionType: undefined,
- environment: 'testing',
+ transactionType: 'type-foo',
+ environment: 'development',
threshold: 'minor',
- thresholdValue: 'warning',
+ triggerValue: 'critical',
});
});
});
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts
index 84c3ec7325fd2..15f4a8ea07801 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts
@@ -6,9 +6,16 @@
*/
import { schema } from '@kbn/config-schema';
-import { Observable } from 'rxjs';
-import { isEmpty } from 'lodash';
+import { compact } from 'lodash';
+import { ESSearchResponse } from 'typings/elasticsearch';
+import { QueryContainer } from '@elastic/elasticsearch/api/types';
import { getSeverity } from '../../../common/anomaly_detection';
+import {
+ SERVICE_ENVIRONMENT,
+ SERVICE_NAME,
+ TRANSACTION_TYPE,
+} from '../../../common/elasticsearch_fieldnames';
+import { asMutableArray } from '../../../common/utils/as_mutable_array';
import { ANOMALY_SEVERITY } from '../../../../ml/common';
import { KibanaRequest } from '../../../../../../src/core/server';
import {
@@ -16,17 +23,11 @@ import {
ALERT_TYPES_CONFIG,
ANOMALY_ALERT_SEVERITY_TYPES,
} from '../../../common/alert_types';
-import { AlertingPlugin } from '../../../../alerting/server';
-import { APMConfig } from '../..';
-import { MlPluginSetup } from '../../../../ml/server';
import { getMLJobs } from '../service_map/get_service_anomalies';
import { apmActionVariables } from './action_variables';
-
-interface RegisterAlertParams {
- alerting: AlertingPlugin['setup'];
- ml?: MlPluginSetup;
- config$: Observable;
-}
+import { RegisterRuleDependencies } from './register_apm_alerts';
+import { parseEnvironmentUrlParam } from '../../../common/environment_filter_values';
+import { createAPMLifecycleRuleType } from './create_apm_lifecycle_rule_type';
const paramsSchema = schema.object({
serviceName: schema.maybe(schema.string()),
@@ -46,203 +47,199 @@ const alertTypeConfig =
ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly];
export function registerTransactionDurationAnomalyAlertType({
- alerting,
+ registry,
ml,
- config$,
-}: RegisterAlertParams) {
- alerting.registerType({
- id: AlertType.TransactionDurationAnomaly,
- name: alertTypeConfig.name,
- actionGroups: alertTypeConfig.actionGroups,
- defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
- validate: {
- params: paramsSchema,
- },
- actionVariables: {
- context: [
- apmActionVariables.serviceName,
- apmActionVariables.transactionType,
- apmActionVariables.environment,
- apmActionVariables.threshold,
- apmActionVariables.triggerValue,
- ],
- },
- producer: 'apm',
- minimumLicenseRequired: 'basic',
- executor: async ({ services, params, state }) => {
- if (!ml) {
- return;
- }
- const alertParams = params;
- const request = {} as KibanaRequest;
- const { mlAnomalySearch } = ml.mlSystemProvider(
- request,
- services.savedObjectsClient
- );
- const anomalyDetectors = ml.anomalyDetectorsProvider(
- request,
- services.savedObjectsClient
- );
-
- const mlJobs = await getMLJobs(anomalyDetectors, alertParams.environment);
-
- const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find(
- (option) => option.type === alertParams.anomalySeverityType
- );
-
- if (!selectedOption) {
- throw new Error(
- `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.`
+ logger,
+}: RegisterRuleDependencies) {
+ registry.registerType(
+ createAPMLifecycleRuleType({
+ id: AlertType.TransactionDurationAnomaly,
+ name: alertTypeConfig.name,
+ actionGroups: alertTypeConfig.actionGroups,
+ defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
+ validate: {
+ params: paramsSchema,
+ },
+ actionVariables: {
+ context: [
+ apmActionVariables.serviceName,
+ apmActionVariables.transactionType,
+ apmActionVariables.environment,
+ apmActionVariables.threshold,
+ apmActionVariables.triggerValue,
+ ],
+ },
+ producer: 'apm',
+ minimumLicenseRequired: 'basic',
+ executor: async ({ services, params }) => {
+ if (!ml) {
+ return {};
+ }
+ const alertParams = params;
+ const request = {} as KibanaRequest;
+ const { mlAnomalySearch } = ml.mlSystemProvider(
+ request,
+ services.savedObjectsClient
+ );
+ const anomalyDetectors = ml.anomalyDetectorsProvider(
+ request,
+ services.savedObjectsClient
);
- }
- const threshold = selectedOption.threshold;
+ const mlJobs = await getMLJobs(
+ anomalyDetectors,
+ alertParams.environment
+ );
- if (mlJobs.length === 0) {
- return {};
- }
-
- const jobIds = mlJobs.map((job) => job.job_id);
- const anomalySearchParams = {
- terminateAfter: 1,
- body: {
- size: 0,
- query: {
- bool: {
- filter: [
- { term: { result_type: 'record' } },
- { terms: { job_id: jobIds } },
- {
- range: {
- timestamp: {
- gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
- format: 'epoch_millis',
+ const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find(
+ (option) => option.type === alertParams.anomalySeverityType
+ );
+
+ if (!selectedOption) {
+ throw new Error(
+ `Anomaly alert severity type ${alertParams.anomalySeverityType} is not supported.`
+ );
+ }
+
+ const threshold = selectedOption.threshold;
+
+ if (mlJobs.length === 0) {
+ return {};
+ }
+
+ const jobIds = mlJobs.map((job) => job.job_id);
+ const anomalySearchParams = {
+ body: {
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { term: { result_type: 'record' } },
+ { terms: { job_id: jobIds } },
+ { term: { is_interim: false } },
+ {
+ range: {
+ timestamp: {
+ gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
+ format: 'epoch_millis',
+ },
},
},
- },
- ...(alertParams.serviceName
- ? [
- {
- term: {
- partition_field_value: alertParams.serviceName,
+ ...(alertParams.serviceName
+ ? [
+ {
+ term: {
+ partition_field_value: alertParams.serviceName,
+ },
},
- },
- ]
- : []),
- ...(alertParams.transactionType
- ? [
- {
- term: {
- by_field_value: alertParams.transactionType,
+ ]
+ : []),
+ ...(alertParams.transactionType
+ ? [
+ {
+ term: {
+ by_field_value: alertParams.transactionType,
+ },
},
- },
- ]
- : []),
- {
- range: {
- record_score: {
- gte: threshold,
- },
- },
- },
- ],
- },
- },
- aggs: {
- services: {
- terms: {
- field: 'partition_field_value',
- size: 50,
+ ]
+ : []),
+ ] as QueryContainer[],
},
- aggs: {
- transaction_types: {
- terms: {
- field: 'by_field_value',
- },
+ },
+ aggs: {
+ anomaly_groups: {
+ multi_terms: {
+ terms: [
+ { field: 'partition_field_value' },
+ { field: 'by_field_value' },
+ { field: 'job_id' },
+ ],
+ size: 10000,
},
- record_avg: {
- avg: {
- field: 'record_score',
+ aggs: {
+ latest_score: {
+ top_metrics: {
+ metrics: asMutableArray([
+ { field: 'record_score' },
+ { field: 'partition_field_value' },
+ { field: 'by_field_value' },
+ { field: 'job_id' },
+ ] as const),
+ sort: {
+ '@timestamp': 'desc' as const,
+ },
+ },
},
},
},
},
},
- },
- };
-
- const response = ((await mlAnomalySearch(
- anomalySearchParams,
- jobIds
- )) as unknown) as {
- hits: { total: { value: number } };
- aggregations?: {
- services: {
- buckets: Array<{
- key: string;
- record_avg: { value: number };
- transaction_types: { buckets: Array<{ key: string }> };
- }>;
- };
};
- };
-
- const hitCount = response.hits.total.value;
-
- if (hitCount > 0) {
- function scheduleAction({
- serviceName,
- severity,
- environment,
- transactionType,
- }: {
- serviceName: string;
- severity: string;
- environment?: string;
- transactionType?: string;
- }) {
- const alertInstanceName = [
- AlertType.TransactionDurationAnomaly,
- serviceName,
- environment,
- transactionType,
- ]
- .filter((name) => name)
- .join('_');
-
- const alertInstance = services.alertInstanceFactory(
- alertInstanceName
- );
- alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
- serviceName,
- environment,
- transactionType,
- threshold: selectedOption?.label,
- thresholdValue: severity,
- });
- }
- mlJobs.map((job) => {
- const environment = job.custom_settings?.job_tags?.environment;
- response.aggregations?.services.buckets.forEach((serviceBucket) => {
- const serviceName = serviceBucket.key as string;
- const severity = getSeverity(serviceBucket.record_avg.value);
- if (isEmpty(serviceBucket.transaction_types?.buckets)) {
- scheduleAction({ serviceName, severity, environment });
- } else {
- serviceBucket.transaction_types?.buckets.forEach((typeBucket) => {
- const transactionType = typeBucket.key as string;
- scheduleAction({
- serviceName,
- severity,
- environment,
- transactionType,
- });
- });
- }
- });
+ const response: ESSearchResponse<
+ unknown,
+ typeof anomalySearchParams
+ > = (await mlAnomalySearch(anomalySearchParams, [])) as any;
+
+ const anomalies =
+ response.aggregations?.anomaly_groups.buckets
+ .map((bucket) => {
+ const latest = bucket.latest_score.top[0].metrics;
+
+ const job = mlJobs.find((j) => j.job_id === latest.job_id);
+
+ if (!job) {
+ logger.warn(
+ `Could not find matching job for job id ${latest.job_id}`
+ );
+ return undefined;
+ }
+
+ return {
+ serviceName: latest.partition_field_value as string,
+ transactionType: latest.by_field_value as string,
+ environment: job.custom_settings!.job_tags!.environment,
+ score: latest.record_score as number,
+ };
+ })
+ .filter((anomaly) =>
+ anomaly ? anomaly.score >= threshold : false
+ ) ?? [];
+
+ compact(anomalies).forEach((anomaly) => {
+ const { serviceName, environment, transactionType, score } = anomaly;
+
+ const parsedEnvironment = parseEnvironmentUrlParam(environment);
+
+ services
+ .alertWithLifecycle({
+ id: [
+ AlertType.TransactionDurationAnomaly,
+ serviceName,
+ environment,
+ transactionType,
+ ]
+ .filter((name) => name)
+ .join('_'),
+ fields: {
+ [SERVICE_NAME]: serviceName,
+ ...(parsedEnvironment.esFieldValue
+ ? { [SERVICE_ENVIRONMENT]: environment }
+ : {}),
+ [TRANSACTION_TYPE]: transactionType,
+ },
+ })
+ .scheduleActions(alertTypeConfig.defaultActionGroupId, {
+ serviceName,
+ transactionType,
+ environment,
+ threshold: selectedOption?.label,
+ triggerValue: getSeverity(score),
+ });
});
- }
- },
- });
+
+ return {};
+ },
+ })
+ );
}
diff --git a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts
index 148cd813a8a22..be5f4705482d0 100644
--- a/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts
+++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_error_rate_alert_type.test.ts
@@ -5,48 +5,19 @@
* 2.0.
*/
-import { Observable } from 'rxjs';
-import * as Rx from 'rxjs';
-import { toArray, map } from 'rxjs/operators';
-import { AlertingPlugin } from '../../../../alerting/server';
-import { APMConfig } from '../..';
import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type';
-import { elasticsearchServiceMock } from 'src/core/server/mocks';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { elasticsearchClientMock } from 'src/core/server/elasticsearch/client/mocks';
-
-type Operator = (source: Rx.Observable