[APM] Alerting: Add global option to create all alert types (#78151)

* adding alert to service page

* sending on alert per service environment and transaction type

* addressing PR comment

* addressing PR comment

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Cauê Marcondes 2020-09-28 14:12:58 +01:00 committed by GitHub
parent 88df93bed5
commit 966f00ac59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1419 additions and 119 deletions

View file

@ -4,8 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { AlertType } from '../../../../../../common/alert_types';
import { AlertAdd } from '../../../../../../../triggers_actions_ui/public';
import { AlertType } from '../../../../common/alert_types';
import { AlertAdd } from '../../../../../triggers_actions_ui/public';
type AlertAddProps = React.ComponentProps<typeof AlertAdd>;

View file

@ -34,11 +34,17 @@ export function ServiceAlertTrigger(props: Props) {
useEffect(() => {
// we only want to run this on mount to set default values
setAlertProperty('name', `${alertTypeName} | ${params.serviceName}`);
setAlertProperty('tags', [
'apm',
`service.name:${params.serviceName}`.toLowerCase(),
]);
const alertName = params.serviceName
? `${alertTypeName} | ${params.serviceName}`
: alertTypeName;
setAlertProperty('name', alertName);
const tags = ['apm'];
if (params.serviceName) {
tags.push(`service.name:${params.serviceName}`.toLowerCase());
}
setAlertProperty('tags', tags);
Object.keys(params).forEach((key) => {
setAlertParams(key, params[key]);
});

View file

@ -90,16 +90,16 @@ export function TransactionDurationAlertTrigger(props: Props) {
const fields = [
<ServiceField value={serviceName} />,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
/>,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
/>,
<PopoverExpression
value={params.aggregationType}
title={i18n.translate('xpack.apm.transactionDurationAlertTrigger.when', {

View file

@ -19,10 +19,6 @@ import {
SelectAnomalySeverity,
} from './SelectAnomalySeverity';
import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values';
import {
TRANSACTION_PAGE_LOAD,
TRANSACTION_REQUEST,
} from '../../../../common/transaction_types';
import {
EnvironmentField,
ServiceField,
@ -32,8 +28,8 @@ import {
interface Params {
windowSize: number;
windowUnit: string;
serviceName: string;
transactionType: string;
serviceName?: string;
transactionType?: string;
environment: string;
anomalySeverityType:
| ANOMALY_SEVERITY.CRITICAL
@ -53,23 +49,17 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) {
const { urlParams } = useUrlParams();
const transactionTypes = useServiceTransactionTypes(urlParams);
const { serviceName } = useParams<{ serviceName?: string }>();
const { start, end } = urlParams;
const { start, end, transactionType } = urlParams;
const { environmentOptions } = useEnvironments({ serviceName, start, end });
const supportedTransactionTypes = transactionTypes.filter((transactionType) =>
[TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST].includes(transactionType)
);
if (!supportedTransactionTypes.length || !serviceName) {
if (serviceName && !transactionTypes.length) {
return null;
}
// 'page-load' for RUM, 'request' otherwise
const transactionType = supportedTransactionTypes[0];
const defaults: Params = {
windowSize: 15,
windowUnit: 'm',
transactionType,
transactionType: transactionType || transactionTypes[0],
serviceName,
environment: urlParams.environment || ENVIRONMENT_ALL.value,
anomalySeverityType: ANOMALY_SEVERITY.CRITICAL,
@ -82,7 +72,11 @@ export function TransactionDurationAnomalyAlertTrigger(props: Props) {
const fields = [
<ServiceField value={serviceName} />,
<TransactionTypeField currentValue={transactionType} />,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}

View file

@ -43,7 +43,7 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
const { start, end, transactionType } = urlParams;
const { environmentOptions } = useEnvironments({ serviceName, start, end });
if (!transactionTypes.length || !serviceName) {
if (serviceName && !transactionTypes.length) {
return null;
}
@ -62,16 +62,16 @@ export function TransactionErrorRateAlertTrigger(props: Props) {
const fields = [
<ServiceField value={serviceName} />,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
/>,
<TransactionTypeField
currentValue={params.transactionType}
options={transactionTypes.map((key) => ({ text: key, value: key }))}
onChange={(e) => setAlertParams('transactionType', e.target.value)}
/>,
<EnvironmentField
currentValue={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
/>,
<IsAboveField
value={params.threshold}
unit="%"

View file

@ -0,0 +1,61 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { ServiceField, TransactionTypeField } from './fields';
import { act, fireEvent, render } from '@testing-library/react';
import { expectTextsInDocument } from '../../utils/testHelpers';
describe('alerting fields', () => {
describe('Service Fiels', () => {
it('renders with value', () => {
const component = render(<ServiceField value="foo" />);
expectTextsInDocument(component, ['foo']);
});
it('renders with All when value is not defined', () => {
const component = render(<ServiceField />);
expectTextsInDocument(component, ['All']);
});
});
describe('Transaction Type Field', () => {
it('renders select field when multiple options available', () => {
const options = [
{ text: 'Foo', value: 'foo' },
{ text: 'Bar', value: 'bar' },
];
const { getByText, getByTestId } = render(
<TransactionTypeField currentValue="Foo" options={options} />
);
act(() => {
fireEvent.click(getByText('Foo'));
});
const selectBar = getByTestId('transactionTypeField');
expect(selectBar instanceof HTMLSelectElement).toBeTruthy();
const selectOptions = (selectBar as HTMLSelectElement).options;
expect(selectOptions.length).toEqual(2);
expect(
Object.values(selectOptions).map((option) => option.value)
).toEqual(['foo', 'bar']);
});
it('renders read-only field when single option available', () => {
const options = [{ text: 'Bar', value: 'bar' }];
const component = render(
<TransactionTypeField currentValue="Bar" options={options} />
);
expectTextsInDocument(component, ['Bar']);
});
it('renders read-only All option when no option available', () => {
const component = render(<TransactionTypeField currentValue="" />);
expectTextsInDocument(component, ['All']);
});
it('renders current value when available', () => {
const component = render(<TransactionTypeField currentValue="foo" />);
expectTextsInDocument(component, ['foo']);
});
});
});

View file

@ -11,13 +11,17 @@ import { EuiSelectOption } from '@elastic/eui';
import { getEnvironmentLabel } from '../../../common/environment_filter_values';
import { PopoverExpression } from './ServiceAlertTrigger/PopoverExpression';
const ALL_OPTION = i18n.translate('xpack.apm.alerting.fields.all_option', {
defaultMessage: 'All',
});
export function ServiceField({ value }: { value?: string }) {
return (
<EuiExpression
description={i18n.translate('xpack.apm.alerting.fields.service', {
defaultMessage: 'Service',
})}
value={value}
value={value || ALL_OPTION}
/>
);
}
@ -53,7 +57,7 @@ export function TransactionTypeField({
options,
onChange,
}: {
currentValue: string;
currentValue?: string;
options?: EuiSelectOption[];
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
}) {
@ -61,13 +65,16 @@ export function TransactionTypeField({
defaultMessage: 'Type',
});
if (!options || options.length === 1) {
return <EuiExpression description={label} value={currentValue} />;
if (!options || options.length <= 1) {
return (
<EuiExpression description={label} value={currentValue || ALL_OPTION} />
);
}
return (
<PopoverExpression value={currentValue} title={label}>
<EuiSelect
data-test-subj="transactionTypeField"
value={currentValue}
options={options}
onChange={onChange}

View file

@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Capabilities } from 'kibana/public';
import { ApmPluginSetupDeps } from '../../plugin';
export const getAlertingCapabilities = (
plugins: ApmPluginSetupDeps,
capabilities: Capabilities
) => {
const canReadAlerts = !!capabilities.apm['alerting:show'];
const canSaveAlerts = !!capabilities.apm['alerting:save'];
const isAlertingPluginEnabled = 'alerts' in plugins;
const isAlertingAvailable =
isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts);
const isMlPluginEnabled = 'ml' in plugins;
const canReadAnomalies = !!(
isMlPluginEnabled &&
capabilities.ml.canAccessML &&
capabilities.ml.canGetJobs
);
return {
isAlertingAvailable,
canReadAlerts,
canSaveAlerts,
canReadAnomalies,
};
};

View file

@ -0,0 +1,186 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
EuiButtonEmpty,
EuiContextMenu,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { AlertType } from '../../../../../common/alert_types';
import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
import { AlertingFlyout } from '../../../alerting/AlertingFlyout';
const alertLabel = i18n.translate('xpack.apm.home.alertsMenu.alerts', {
defaultMessage: 'Alerts',
});
const transactionDurationLabel = i18n.translate(
'xpack.apm.home.alertsMenu.transactionDuration',
{ defaultMessage: 'Transaction duration' }
);
const transactionErrorRateLabel = i18n.translate(
'xpack.apm.home.alertsMenu.transactionErrorRate',
{ defaultMessage: 'Transaction error rate' }
);
const errorCountLabel = i18n.translate('xpack.apm.home.alertsMenu.errorCount', {
defaultMessage: 'Error count',
});
const createThresholdAlertLabel = i18n.translate(
'xpack.apm.home.alertsMenu.createThresholdAlert',
{ defaultMessage: 'Create threshold alert' }
);
const createAnomalyAlertAlertLabel = i18n.translate(
'xpack.apm.home.alertsMenu.createAnomalyAlert',
{ defaultMessage: 'Create anomaly alert' }
);
const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID =
'create_transaction_duration_panel';
const CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID =
'create_transaction_error_rate_panel';
const CREATE_ERROR_COUNT_ALERT_PANEL_ID = 'create_error_count_panel';
interface Props {
canReadAlerts: boolean;
canSaveAlerts: boolean;
canReadAnomalies: boolean;
}
export function AlertingPopoverAndFlyout(props: Props) {
const { canSaveAlerts, canReadAlerts, canReadAnomalies } = props;
const plugin = useApmPluginContext();
const [popoverOpen, setPopoverOpen] = useState(false);
const [alertType, setAlertType] = useState<AlertType | null>(null);
const button = (
<EuiButtonEmpty
iconType="arrowDown"
iconSide="right"
onClick={() => setPopoverOpen(true)}
>
{alertLabel}
</EuiButtonEmpty>
);
const panels: EuiContextMenuPanelDescriptor[] = [
{
id: 0,
title: alertLabel,
items: [
...(canSaveAlerts
? [
{
name: transactionDurationLabel,
panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID,
},
{
name: transactionErrorRateLabel,
panel: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID,
},
{
name: errorCountLabel,
panel: CREATE_ERROR_COUNT_ALERT_PANEL_ID,
},
]
: []),
...(canReadAlerts
? [
{
name: i18n.translate(
'xpack.apm.home.alertsMenu.viewActiveAlerts',
{ defaultMessage: 'View active alerts' }
),
href: plugin.core.http.basePath.prepend(
'/app/management/insightsAndAlerting/triggersActions/alerts'
),
icon: 'tableOfContents',
},
]
: []),
],
},
// transaction duration panel
{
id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID,
title: transactionDurationLabel,
items: [
// anomaly alerts
...(canReadAnomalies
? [
{
name: createAnomalyAlertAlertLabel,
onClick: () => {
setAlertType(AlertType.TransactionDurationAnomaly);
setPopoverOpen(false);
},
},
]
: []),
],
},
// transaction error rate panel
{
id: CREATE_TRANSACTION_ERROR_RATE_ALERT_PANEL_ID,
title: transactionErrorRateLabel,
items: [
// threshold alerts
{
name: createThresholdAlertLabel,
onClick: () => {
setAlertType(AlertType.TransactionErrorRate);
setPopoverOpen(false);
},
},
],
},
// error alerts panel
{
id: CREATE_ERROR_COUNT_ALERT_PANEL_ID,
title: errorCountLabel,
items: [
{
name: createThresholdAlertLabel,
onClick: () => {
setAlertType(AlertType.ErrorCount);
setPopoverOpen(false);
},
},
],
},
];
return (
<>
<EuiPopover
id="integrations-menu"
button={button}
isOpen={popoverOpen}
closePopover={() => setPopoverOpen(false)}
panelPaddingSize="none"
anchorPosition="downRight"
>
<EuiContextMenu initialPanelId={0} panels={panels} />
</EuiPopover>
<AlertingFlyout
alertType={alertType}
addFlyoutVisible={!!alertType}
setAddFlyoutVisibility={(visible) => {
if (!visible) {
setAlertType(null);
}
}}
/>
</>
);
}

View file

@ -15,17 +15,19 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { $ElementType } from 'utility-types';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { getAlertingCapabilities } from '../../alerting/get_alert_capabilities';
import { ApmHeader } from '../../shared/ApmHeader';
import { EuiTabLink } from '../../shared/EuiTabLink';
import { AnomalyDetectionSetupLink } from '../../shared/Links/apm/AnomalyDetectionSetupLink';
import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink';
import { ServiceOverviewLink } from '../../shared/Links/apm/ServiceOverviewLink';
import { SettingsLink } from '../../shared/Links/apm/SettingsLink';
import { AnomalyDetectionSetupLink } from '../../shared/Links/apm/AnomalyDetectionSetupLink';
import { TraceOverviewLink } from '../../shared/Links/apm/TraceOverviewLink';
import { SetupInstructionsLink } from '../../shared/Links/SetupInstructionsLink';
import { ServiceMap } from '../ServiceMap';
import { ServiceOverview } from '../ServiceOverview';
import { TraceOverview } from '../TraceOverview';
import { AlertingPopoverAndFlyout } from './alerting_popover_flyout';
function getHomeTabs({
serviceMapEnabled = true,
@ -83,13 +85,21 @@ interface Props {
}
export function Home({ tab }: Props) {
const { config, core } = useApmPluginContext();
const canAccessML = !!core.application.capabilities.ml?.canAccessML;
const { config, core, plugins } = useApmPluginContext();
const capabilities = core.application.capabilities;
const canAccessML = !!capabilities.ml?.canAccessML;
const homeTabs = getHomeTabs(config);
const selectedTab = homeTabs.find(
(homeTab) => homeTab.name === tab
) as $ElementType<typeof homeTabs, number>;
const {
isAlertingAvailable,
canReadAlerts,
canSaveAlerts,
canReadAnomalies,
} = getAlertingCapabilities(plugins, core.application.capabilities);
return (
<div>
<ApmHeader>
@ -106,6 +116,15 @@ export function Home({ tab }: Props) {
</EuiButtonEmpty>
</SettingsLink>
</EuiFlexItem>
{isAlertingAvailable && (
<EuiFlexItem grow={false}>
<AlertingPopoverAndFlyout
canReadAlerts={canReadAlerts}
canSaveAlerts={canSaveAlerts}
canReadAnomalies={canReadAnomalies}
/>
</EuiFlexItem>
)}
{canAccessML && (
<EuiFlexItem grow={false}>
<AnomalyDetectionSetupLink />

View file

@ -7,14 +7,14 @@
import {
EuiButtonEmpty,
EuiContextMenu,
EuiPopover,
EuiContextMenuPanelDescriptor,
EuiPopover,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { useState } from 'react';
import { AlertType } from '../../../../../common/alert_types';
import { AlertingFlyout } from './AlertingFlyout';
import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
import { AlertingFlyout } from '../../../alerting/AlertingFlyout';
const alertLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.alerts',
@ -53,7 +53,7 @@ interface Props {
canReadAnomalies: boolean;
}
export function AlertIntegrations(props: Props) {
export function AlertingPopoverAndFlyout(props: Props) {
const { canSaveAlerts, canReadAlerts, canReadAnomalies } = props;
const plugin = useApmPluginContext();

View file

@ -14,8 +14,9 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { getAlertingCapabilities } from '../../alerting/get_alert_capabilities';
import { ApmHeader } from '../../shared/ApmHeader';
import { AlertIntegrations } from './AlertIntegrations';
import { AlertingPopoverAndFlyout } from './alerting_popover_flyout';
import { ServiceDetailTabs } from './ServiceDetailTabs';
interface Props extends RouteComponentProps<{ serviceName: string }> {
@ -23,20 +24,15 @@ interface Props extends RouteComponentProps<{ serviceName: string }> {
}
export function ServiceDetails({ match, tab }: Props) {
const plugin = useApmPluginContext();
const { core, plugins } = useApmPluginContext();
const { serviceName } = match.params;
const capabilities = plugin.core.application.capabilities;
const canReadAlerts = !!capabilities.apm['alerting:show'];
const canSaveAlerts = !!capabilities.apm['alerting:save'];
const isAlertingPluginEnabled = 'alerts' in plugin.plugins;
const isAlertingAvailable =
isAlertingPluginEnabled && (canReadAlerts || canSaveAlerts);
const isMlPluginEnabled = 'ml' in plugin.plugins;
const canReadAnomalies = !!(
isMlPluginEnabled &&
capabilities.ml.canAccessML &&
capabilities.ml.canGetJobs
);
const {
isAlertingAvailable,
canReadAlerts,
canSaveAlerts,
canReadAnomalies,
} = getAlertingCapabilities(plugins, core.application.capabilities);
const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', {
defaultMessage: 'Add data',
@ -53,7 +49,7 @@ export function ServiceDetails({ match, tab }: Props) {
</EuiFlexItem>
{isAlertingAvailable && (
<EuiFlexItem grow={false}>
<AlertIntegrations
<AlertingPopoverAndFlyout
canReadAlerts={canReadAlerts}
canSaveAlerts={canSaveAlerts}
canReadAnomalies={canReadAnomalies}
@ -62,9 +58,7 @@ export function ServiceDetails({ match, tab }: Props) {
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={plugin.core.http.basePath.prepend(
'/app/home#/tutorial/apm'
)}
href={core.http.basePath.prepend('/app/home#/tutorial/apm')}
size="s"
color="primary"
iconType="plusInCircle"

View file

@ -0,0 +1,197 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Observable } from 'rxjs';
import * as Rx from 'rxjs';
import { toArray, map } from 'rxjs/operators';
import { AlertingPlugin } from '../../../../alerts/server';
import { APMConfig } from '../..';
import { registerErrorCountAlertType } from './register_error_count_alert_type';
type Operator<T1, T2> = (source: Rx.Observable<T1>) => Rx.Observable<T2>;
const pipeClosure = <T1, T2>(fn: Operator<T1, T2>): Operator<T1, T2> => {
return (source: Rx.Observable<T1>) => {
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<APMConfig>;
describe('Error count alert', () => {
it("doesn't send an alert when error count is less than threshold", async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerErrorCountAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 0,
},
},
})),
alertInstanceFactory: jest.fn(),
};
const params = { threshold: 1 };
await alertExecutor!({ services, params });
expect(services.alertInstanceFactory).not.toBeCalled();
});
it('sends alerts with service name and environment', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerErrorCountAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const scheduleActions = jest.fn();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 2,
},
},
aggregations: {
services: {
buckets: [
{
key: 'foo',
environments: {
buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }],
},
},
{
key: 'bar',
environments: {
buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }],
},
},
],
},
},
})),
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
};
const params = { threshold: 1 };
await alertExecutor!({ services, 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).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
environment: 'env-foo',
threshold: 1,
triggerValue: 2,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
environment: 'env-foo-2',
threshold: 1,
triggerValue: 2,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
environment: 'env-bar',
threshold: 1,
triggerValue: 2,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
environment: 'env-bar-2',
threshold: 1,
triggerValue: 2,
});
});
it('sends alerts with service name', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerErrorCountAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const scheduleActions = jest.fn();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 2,
},
},
aggregations: {
services: {
buckets: [
{
key: 'foo',
},
{
key: 'bar',
},
],
},
},
})),
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
};
const params = { threshold: 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,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
environment: undefined,
threshold: 1,
triggerValue: 2,
});
});
});

View file

@ -5,22 +5,21 @@
*/
import { schema } from '@kbn/config-schema';
import { isEmpty } from 'lodash';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { ProcessorEvent } from '../../../common/processor_event';
import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es';
import { APMConfig } from '../..';
import { AlertingPlugin } from '../../../../alerts/server';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import {
ESSearchResponse,
ESSearchRequest,
} from '../../../typings/elasticsearch';
import {
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
} from '../../../common/elasticsearch_fieldnames';
import { AlertingPlugin } from '../../../../alerts/server';
import { ProcessorEvent } from '../../../common/processor_event';
import { ESSearchResponse } from '../../../typings/elasticsearch';
import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es';
import { getApmIndices } from '../settings/apm_indices/get_apm_indices';
import { APMConfig } from '../..';
import { apmActionVariables } from './action_variables';
interface RegisterAlertParams {
@ -32,7 +31,7 @@ const paramsSchema = schema.object({
windowSize: schema.number(),
windowUnit: schema.string(),
threshold: schema.number(),
serviceName: schema.string(),
serviceName: schema.maybe(schema.string()),
environment: schema.string(),
});
@ -83,30 +82,74 @@ export function registerErrorCountAlertType({
},
},
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.error } },
{ term: { [SERVICE_NAME]: alertParams.serviceName } },
...(alertParams.serviceName
? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }]
: []),
...getEnvironmentUiFilterES(alertParams.environment),
],
},
},
aggs: {
services: {
terms: {
field: SERVICE_NAME,
size: 50,
},
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
},
},
},
},
};
const response: ESSearchResponse<
unknown,
ESSearchRequest
typeof searchParams
> = await services.callCluster('search', searchParams);
const errorCount = response.hits.total.value;
if (errorCount > alertParams.threshold) {
const alertInstance = services.alertInstanceFactory(
AlertType.ErrorCount
);
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName: alertParams.serviceName,
environment: alertParams.environment,
threshold: alertParams.threshold,
triggerValue: errorCount,
function scheduleAction({
serviceName,
environment,
}: {
serviceName: string;
environment?: string;
}) {
const alertInstanceName = [
AlertType.ErrorCount,
serviceName,
environment,
]
.filter((name) => name)
.join('_');
const alertInstance = services.alertInstanceFactory(
alertInstanceName
);
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName,
environment,
threshold: alertParams.threshold,
triggerValue: errorCount,
});
}
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 });
});
}
});
}
},

View file

@ -0,0 +1,326 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Observable } from 'rxjs';
import * as Rx from 'rxjs';
import { toArray, map } from 'rxjs/operators';
import { AlertingPlugin } from '../../../../alerts/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<T1, T2> = (source: Rx.Observable<T1>) => Rx.Observable<T2>;
const pipeClosure = <T1, T2>(fn: Operator<T1, T2>): Operator<T1, T2> => {
return (source: Rx.Observable<T1>) => {
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<APMConfig>;
describe('Transaction duration anomaly alert', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("doesn't send alert", () => {
it('ml is not defined', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerTransactionDurationAnomalyAlertType({
alerts,
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();
expect(services.alertInstanceFactory).not.toHaveBeenCalled();
});
it('ml jobs are not available', async () => {
jest
.spyOn(GetServiceAnomalies, 'getMLJobs')
.mockReturnValue(Promise.resolve([]));
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
const ml = ({
mlSystemProvider: () => ({ mlAnomalySearch: jest.fn() }),
anomalyDetectorsProvider: jest.fn(),
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
alerts,
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();
});
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 alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
const ml = ({
mlSystemProvider: () => ({
mlAnomalySearch: () => ({
hits: { total: { value: 0 } },
}),
}),
anomalyDetectorsProvider: jest.fn(),
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
alerts,
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 () => {
jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue(
Promise.resolve([
{
job_id: '1',
custom_settings: {
job_tags: {
environment: 'production',
},
},
} as unknown,
{
job_id: '2',
custom_settings: {
job_tags: {
environment: 'production',
},
},
} as unknown,
] as Job[])
);
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
const ml = ({
mlSystemProvider: () => ({
mlAnomalySearch: () => ({
hits: { total: { value: 2 } },
aggregations: {
services: {
buckets: [
{
key: 'foo',
transaction_types: {
buckets: [{ key: 'type-foo' }],
},
},
{
key: 'bar',
transaction_types: {
buckets: [{ key: 'type-bar' }],
},
},
],
},
},
}),
}),
anomalyDetectorsProvider: jest.fn(),
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
alerts,
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)
);
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: 'type-foo',
environment: 'production',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: 'type-bar',
environment: 'production',
});
});
it('with service name', async () => {
jest.spyOn(GetServiceAnomalies, 'getMLJobs').mockReturnValue(
Promise.resolve([
{
job_id: '1',
custom_settings: {
job_tags: {
environment: 'production',
},
},
} as unknown,
{
job_id: '2',
custom_settings: {
job_tags: {
environment: 'testing',
},
},
} as unknown,
] as Job[])
);
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
const ml = ({
mlSystemProvider: () => ({
mlAnomalySearch: () => ({
hits: { total: { value: 2 } },
aggregations: {
services: {
buckets: [{ key: 'foo' }, { key: 'bar' }],
},
},
}),
}),
anomalyDetectorsProvider: jest.fn(),
} as unknown) as MlPluginSetup;
registerTransactionDurationAnomalyAlertType({
alerts,
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',
'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(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: undefined,
environment: 'production',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: undefined,
environment: 'production',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: undefined,
environment: 'testing',
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: undefined,
environment: 'testing',
});
});
});
});

View file

@ -6,6 +6,7 @@
import { schema } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { isEmpty } from 'lodash';
import { ANOMALY_SEVERITY } from '../../../../ml/common';
import { KibanaRequest } from '../../../../../../src/core/server';
import {
@ -16,7 +17,7 @@ import {
import { AlertingPlugin } from '../../../../alerts/server';
import { APMConfig } from '../..';
import { MlPluginSetup } from '../../../../ml/server';
import { getMLJobIds } from '../service_map/get_service_anomalies';
import { getMLJobs } from '../service_map/get_service_anomalies';
import { apmActionVariables } from './action_variables';
interface RegisterAlertParams {
@ -26,8 +27,8 @@ interface RegisterAlertParams {
}
const paramsSchema = schema.object({
serviceName: schema.string(),
transactionType: schema.string(),
serviceName: schema.maybe(schema.string()),
transactionType: schema.maybe(schema.string()),
windowSize: schema.number(),
windowUnit: schema.string(),
environment: schema.string(),
@ -72,10 +73,7 @@ export function registerTransactionDurationAnomalyAlertType({
const { mlAnomalySearch } = ml.mlSystemProvider(request);
const anomalyDetectors = ml.anomalyDetectorsProvider(request);
const mlJobIds = await getMLJobIds(
anomalyDetectors,
alertParams.environment
);
const mlJobs = await getMLJobs(anomalyDetectors, alertParams.environment);
const selectedOption = ANOMALY_ALERT_SEVERITY_TYPES.find(
(option) => option.type === alertParams.anomalySeverityType
@ -89,19 +87,19 @@ export function registerTransactionDurationAnomalyAlertType({
const threshold = selectedOption.threshold;
if (mlJobIds.length === 0) {
if (mlJobs.length === 0) {
return {};
}
const anomalySearchParams = {
terminateAfter: 1,
body: {
terminateAfter: 1,
size: 0,
query: {
bool: {
filter: [
{ term: { result_type: 'record' } },
{ terms: { job_id: mlJobIds } },
{ terms: { job_id: mlJobs.map((job) => job.job_id) } },
{
range: {
timestamp: {
@ -110,11 +108,24 @@ export function registerTransactionDurationAnomalyAlertType({
},
},
},
{
term: {
partition_field_value: alertParams.serviceName,
},
},
...(alertParams.serviceName
? [
{
term: {
partition_field_value: alertParams.serviceName,
},
},
]
: []),
...(alertParams.transactionType
? [
{
term: {
by_field_value: alertParams.transactionType,
},
},
]
: []),
{
range: {
record_score: {
@ -125,22 +136,82 @@ export function registerTransactionDurationAnomalyAlertType({
],
},
},
aggs: {
services: {
terms: {
field: 'partition_field_value',
size: 50,
},
aggs: {
transaction_types: {
terms: {
field: 'by_field_value',
},
},
},
},
},
},
};
const response = ((await mlAnomalySearch(
anomalySearchParams
)) as unknown) as { hits: { total: { value: number } } };
)) as unknown) as {
hits: { total: { value: number } };
aggregations?: {
services: {
buckets: Array<{
key: string;
transaction_types: { buckets: Array<{ key: string }> };
}>;
};
};
};
const hitCount = response.hits.total.value;
if (hitCount > 0) {
const alertInstance = services.alertInstanceFactory(
AlertType.TransactionDurationAnomaly
);
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName: alertParams.serviceName,
transactionType: alertParams.transactionType,
environment: alertParams.environment,
function scheduleAction({
serviceName,
environment,
transactionType,
}: {
serviceName: 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,
});
}
mlJobs.map((job) => {
const environment = job.custom_settings?.job_tags?.environment;
response.aggregations?.services.buckets.forEach((serviceBucket) => {
const serviceName = serviceBucket.key as string;
if (isEmpty(serviceBucket.transaction_types?.buckets)) {
scheduleAction({ serviceName, environment });
} else {
serviceBucket.transaction_types?.buckets.forEach((typeBucket) => {
const transactionType = typeBucket.key as string;
scheduleAction({ serviceName, environment, transactionType });
});
}
});
});
}
},

View file

@ -0,0 +1,289 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Observable } from 'rxjs';
import * as Rx from 'rxjs';
import { toArray, map } from 'rxjs/operators';
import { AlertingPlugin } from '../../../../alerts/server';
import { APMConfig } from '../..';
import { registerTransactionErrorRateAlertType } from './register_transaction_error_rate_alert_type';
type Operator<T1, T2> = (source: Rx.Observable<T1>) => Rx.Observable<T2>;
const pipeClosure = <T1, T2>(fn: Operator<T1, T2>): Operator<T1, T2> => {
return (source: Rx.Observable<T1>) => {
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<APMConfig>;
describe('Transaction error rate alert', () => {
it("doesn't send an alert when rate is less than threshold", async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerTransactionErrorRateAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 0,
},
},
})),
alertInstanceFactory: jest.fn(),
};
const params = { threshold: 1 };
await alertExecutor!({ services, params });
expect(services.alertInstanceFactory).not.toBeCalled();
});
it('sends alerts with service name, transaction type and environment', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerTransactionErrorRateAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const scheduleActions = jest.fn();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 4,
},
},
aggregations: {
erroneous_transactions: {
doc_count: 2,
},
services: {
buckets: [
{
key: 'foo',
transaction_types: {
buckets: [
{
key: 'type-foo',
environments: {
buckets: [{ key: 'env-foo' }, { key: 'env-foo-2' }],
},
},
],
},
},
{
key: 'bar',
transaction_types: {
buckets: [
{
key: 'type-bar',
environments: {
buckets: [{ key: 'env-bar' }, { key: 'env-bar-2' }],
},
},
],
},
},
],
},
},
})),
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
};
const params = { threshold: 10 };
await alertExecutor!({ services, params });
[
'apm.transaction_error_rate_foo_type-foo_env-foo',
'apm.transaction_error_rate_foo_type-foo_env-foo-2',
'apm.transaction_error_rate_bar_type-bar_env-bar',
'apm.transaction_error_rate_bar_type-bar_env-bar-2',
].forEach((instanceName) =>
expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
);
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: 'type-foo',
environment: 'env-foo',
threshold: 10,
triggerValue: 50,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: 'type-foo',
environment: 'env-foo-2',
threshold: 10,
triggerValue: 50,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: 'type-bar',
environment: 'env-bar',
threshold: 10,
triggerValue: 50,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: 'type-bar',
environment: 'env-bar-2',
threshold: 10,
triggerValue: 50,
});
});
it('sends alerts with service name and transaction type', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerTransactionErrorRateAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const scheduleActions = jest.fn();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 4,
},
},
aggregations: {
erroneous_transactions: {
doc_count: 2,
},
services: {
buckets: [
{
key: 'foo',
transaction_types: {
buckets: [{ key: 'type-foo' }],
},
},
{
key: 'bar',
transaction_types: {
buckets: [{ key: 'type-bar' }],
},
},
],
},
},
})),
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
};
const params = { threshold: 10 };
await alertExecutor!({ services, params });
[
'apm.transaction_error_rate_foo_type-foo',
'apm.transaction_error_rate_bar_type-bar',
].forEach((instanceName) =>
expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
);
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: 'type-foo',
environment: undefined,
threshold: 10,
triggerValue: 50,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: 'type-bar',
environment: undefined,
threshold: 10,
triggerValue: 50,
});
});
it('sends alerts with service name', async () => {
let alertExecutor: any;
const alerts = {
registerType: ({ executor }) => {
alertExecutor = executor;
},
} as AlertingPlugin['setup'];
registerTransactionErrorRateAlertType({
alerts,
config$: mockedConfig$,
});
expect(alertExecutor).toBeDefined();
const scheduleActions = jest.fn();
const services = {
callCluster: jest.fn(() => ({
hits: {
total: {
value: 4,
},
},
aggregations: {
erroneous_transactions: {
doc_count: 2,
},
services: {
buckets: [{ key: 'foo' }, { key: 'bar' }],
},
},
})),
alertInstanceFactory: jest.fn(() => ({ scheduleActions })),
};
const params = { threshold: 10 };
await alertExecutor!({ services, params });
[
'apm.transaction_error_rate_foo',
'apm.transaction_error_rate_bar',
].forEach((instanceName) =>
expect(services.alertInstanceFactory).toHaveBeenCalledWith(instanceName)
);
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'foo',
transactionType: undefined,
environment: undefined,
threshold: 10,
triggerValue: 50,
});
expect(scheduleActions).toHaveBeenCalledWith('threshold_met', {
serviceName: 'bar',
transactionType: undefined,
environment: undefined,
threshold: 10,
triggerValue: 50,
});
});
});

View file

@ -7,6 +7,7 @@
import { schema } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { take } from 'rxjs/operators';
import { isEmpty } from 'lodash';
import { ProcessorEvent } from '../../../common/processor_event';
import { EventOutcome } from '../../../common/event_outcome';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
@ -16,6 +17,7 @@ import {
SERVICE_NAME,
TRANSACTION_TYPE,
EVENT_OUTCOME,
SERVICE_ENVIRONMENT,
} from '../../../common/elasticsearch_fieldnames';
import { AlertingPlugin } from '../../../../alerts/server';
import { getApmIndices } from '../settings/apm_indices/get_apm_indices';
@ -32,8 +34,8 @@ const paramsSchema = schema.object({
windowSize: schema.number(),
windowUnit: schema.string(),
threshold: schema.number(),
transactionType: schema.string(),
serviceName: schema.string(),
transactionType: schema.maybe(schema.string()),
serviceName: schema.maybe(schema.string()),
environment: schema.string(),
});
@ -84,8 +86,18 @@ export function registerTransactionErrorRateAlertType({
},
},
{ term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } },
{ term: { [SERVICE_NAME]: alertParams.serviceName } },
{ term: { [TRANSACTION_TYPE]: alertParams.transactionType } },
...(alertParams.serviceName
? [{ term: { [SERVICE_NAME]: alertParams.serviceName } }]
: []),
...(alertParams.transactionType
? [
{
term: {
[TRANSACTION_TYPE]: alertParams.transactionType,
},
},
]
: []),
...getEnvironmentUiFilterES(alertParams.environment),
],
},
@ -94,6 +106,24 @@ export function registerTransactionErrorRateAlertType({
erroneous_transactions: {
filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } },
},
services: {
terms: {
field: SERVICE_NAME,
size: 50,
},
aggs: {
transaction_types: {
terms: { field: TRANSACTION_TYPE },
aggs: {
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
},
},
},
},
},
},
},
},
};
@ -114,16 +144,53 @@ export function registerTransactionErrorRateAlertType({
(errornousTransactionsCount / totalTransactionCount) * 100;
if (transactionErrorRate > alertParams.threshold) {
const alertInstance = services.alertInstanceFactory(
AlertType.TransactionErrorRate
);
function scheduleAction({
serviceName,
environment,
transactionType,
}: {
serviceName: string;
environment?: string;
transactionType?: string;
}) {
const alertInstanceName = [
AlertType.TransactionErrorRate,
serviceName,
transactionType,
environment,
]
.filter((name) => name)
.join('_');
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName: alertParams.serviceName,
transactionType: alertParams.transactionType,
environment: alertParams.environment,
threshold: alertParams.threshold,
triggerValue: transactionErrorRate,
const alertInstance = services.alertInstanceFactory(
alertInstanceName
);
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName,
transactionType,
environment,
threshold: alertParams.threshold,
triggerValue: transactionErrorRate,
});
}
response.aggregations?.services.buckets.forEach((serviceBucket) => {
const serviceName = serviceBucket.key as string;
if (isEmpty(serviceBucket.transaction_types?.buckets)) {
scheduleAction({ serviceName });
} else {
serviceBucket.transaction_types.buckets.forEach((typeBucket) => {
const transactionType = typeBucket.key as string;
if (isEmpty(typeBucket.environments?.buckets)) {
scheduleAction({ serviceName, transactionType });
} else {
typeBucket.environments.buckets.forEach((envBucket) => {
const environment = envBucket.key as string;
scheduleAction({ serviceName, transactionType, environment });
});
}
});
}
});
}
},

View file

@ -180,7 +180,7 @@ function transformResponseToServiceAnomalies(
return serviceAnomaliesMap;
}
export async function getMLJobIds(
export async function getMLJobs(
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>,
environment?: string
) {
@ -198,7 +198,15 @@ export async function getMLJobIds(
if (!matchingMLJob) {
return [];
}
return [matchingMLJob.job_id];
return [matchingMLJob];
}
return mlJobs;
}
export async function getMLJobIds(
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>,
environment?: string
) {
const mlJobs = await getMLJobs(anomalyDetectors, environment);
return mlJobs.map((job) => job.job_id);
}