[7.x] [APM] Transaction duration anomaly alerting integration (#75719) (#76224)

* [APM] Transaction duration anomaly alerting integration (#75719)

* Closes #72636. Adds alerting integration for APM transaction duration anomalies.

* Code review feedback

* Display alert summary with the selected anomaly severity label instead of the anomaly score.

* - refactored ALL_OPTION and NOT_DEFINED_OPTION to be shared from common/environment_filter_values
- utilize getEnvironmentLabel in the alerting trigger components and added support for the 'All' label

* refactor get_all_environments to minimize exports and be more consistent and clean

* - Reorg the alerts menu for different alert types (threshold/anomaly)
- default environment alert settings to the selected filter

* - Filters default transaction type to only those supported in the APM anomaly detection jobs
- Removes Service name and transaction type from the set of expressions in the alerting setup

* - remove bell icon from alerts menu

* Adds target service back into the anomaly alert setup as a ready-only expression

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

* Fixes prettier linting

* Fixes prettier linting again

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Oliver Gupte 2020-08-28 14:43:15 -07:00 committed by GitHub
parent 9f630a26da
commit 5a08b86087
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 578 additions and 122 deletions

View file

@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n';
export enum AlertType {
ErrorRate = 'apm.error_rate',
TransactionDuration = 'apm.transaction_duration',
TransactionDurationAnomaly = 'apm.transaction_duration_anomaly',
}
export const ALERT_TYPES_CONFIG = {
@ -45,6 +46,24 @@ export const ALERT_TYPES_CONFIG = {
defaultActionGroupId: 'threshold_met',
producer: 'apm',
},
[AlertType.TransactionDurationAnomaly]: {
name: i18n.translate('xpack.apm.transactionDurationAnomalyAlert.name', {
defaultMessage: 'Transaction duration anomaly',
}),
actionGroups: [
{
id: 'threshold_met',
name: i18n.translate(
'xpack.apm.transactionDurationAlert.thresholdMet',
{
defaultMessage: 'Threshold met',
}
),
},
],
defaultActionGroupId: 'threshold_met',
producer: 'apm',
},
};
export const TRANSACTION_ALERT_AGGREGATION_TYPES = {

View file

@ -6,14 +6,30 @@
import { i18n } from '@kbn/i18n';
export const ENVIRONMENT_ALL = 'ENVIRONMENT_ALL';
export const ENVIRONMENT_NOT_DEFINED = 'ENVIRONMENT_NOT_DEFINED';
const ENVIRONMENT_ALL_VALUE = 'ENVIRONMENT_ALL';
const ENVIRONMENT_NOT_DEFINED_VALUE = 'ENVIRONMENT_NOT_DEFINED';
const environmentLabels: Record<string, string> = {
[ENVIRONMENT_ALL_VALUE]: i18n.translate(
'xpack.apm.filter.environment.allLabel',
{ defaultMessage: 'All' }
),
[ENVIRONMENT_NOT_DEFINED_VALUE]: i18n.translate(
'xpack.apm.filter.environment.notDefinedLabel',
{ defaultMessage: 'Not defined' }
),
};
export const ENVIRONMENT_ALL = {
value: ENVIRONMENT_ALL_VALUE,
text: environmentLabels[ENVIRONMENT_ALL_VALUE],
};
export const ENVIRONMENT_NOT_DEFINED = {
value: ENVIRONMENT_NOT_DEFINED_VALUE,
text: environmentLabels[ENVIRONMENT_NOT_DEFINED_VALUE],
};
export function getEnvironmentLabel(environment: string) {
if (environment === ENVIRONMENT_NOT_DEFINED) {
return i18n.translate('xpack.apm.filter.environment.notDefinedLabel', {
defaultMessage: 'Not defined',
});
}
return environment;
return environmentLabels[environment] || environment;
}

View file

@ -18,27 +18,37 @@ import { useApmPluginContext } from '../../../../hooks/useApmPluginContext';
const alertLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.alerts',
{
defaultMessage: 'Alerts',
}
{ defaultMessage: 'Alerts' }
);
const transactionDurationLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.transactionDuration',
{ defaultMessage: 'Transaction duration' }
);
const errorRateLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.errorRate',
{ defaultMessage: 'Error rate' }
);
const createThresholdAlertLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.createThresholdAlert',
{
defaultMessage: 'Create threshold alert',
}
{ defaultMessage: 'Create threshold alert' }
);
const createAnomalyAlertAlertLabel = i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.createAnomalyAlert',
{ defaultMessage: 'Create anomaly alert' }
);
const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold';
const CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID =
'create_transaction_duration';
const CREATE_ERROR_RATE_ALERT_PANEL_ID = 'create_error_rate';
interface Props {
canReadAlerts: boolean;
canSaveAlerts: boolean;
canReadAnomalies: boolean;
}
export function AlertIntegrations(props: Props) {
const { canSaveAlerts, canReadAlerts } = props;
const { canSaveAlerts, canReadAlerts, canReadAnomalies } = props;
const plugin = useApmPluginContext();
@ -52,9 +62,7 @@ export function AlertIntegrations(props: Props) {
iconSide="right"
onClick={() => setPopoverOpen(true)}
>
{i18n.translate('xpack.apm.serviceDetails.alertsMenu.alerts', {
defaultMessage: 'Alerts',
})}
{alertLabel}
</EuiButtonEmpty>
);
@ -66,10 +74,10 @@ export function AlertIntegrations(props: Props) {
...(canSaveAlerts
? [
{
name: createThresholdAlertLabel,
panel: CREATE_THRESHOLD_ALERT_PANEL_ID,
icon: 'bell',
name: transactionDurationLabel,
panel: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID,
},
{ name: errorRateLabel, panel: CREATE_ERROR_RATE_ALERT_PANEL_ID },
]
: []),
...(canReadAlerts
@ -77,9 +85,7 @@ export function AlertIntegrations(props: Props) {
{
name: i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.viewActiveAlerts',
{
defaultMessage: 'View active alerts',
}
{ defaultMessage: 'View active alerts' }
),
href: plugin.core.http.basePath.prepend(
'/app/management/insightsAndAlerting/triggersActions/alerts'
@ -91,29 +97,38 @@ export function AlertIntegrations(props: Props) {
],
},
{
id: CREATE_THRESHOLD_ALERT_PANEL_ID,
title: createThresholdAlertLabel,
id: CREATE_TRANSACTION_DURATION_ALERT_PANEL_ID,
title: transactionDurationLabel,
items: [
{
name: i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.transactionDuration',
{
defaultMessage: 'Transaction duration',
}
),
name: createThresholdAlertLabel,
onClick: () => {
setAlertType(AlertType.TransactionDuration);
setPopoverOpen(false);
},
},
...(canReadAnomalies
? [
{
name: createAnomalyAlertAlertLabel,
onClick: () => {
setAlertType(AlertType.TransactionDurationAnomaly);
setPopoverOpen(false);
},
},
]
: []),
],
},
{
id: CREATE_ERROR_RATE_ALERT_PANEL_ID,
title: errorRateLabel,
items: [
{
name: i18n.translate(
'xpack.apm.serviceDetails.alertsMenu.errorRate',
{
defaultMessage: 'Error rate',
}
),
name: createThresholdAlertLabel,
onClick: () => {
setAlertType(AlertType.ErrorRate);
setPopoverOpen(false);
},
},
],

View file

@ -26,19 +26,18 @@ export function ServiceDetails({ tab }: Props) {
const plugin = useApmPluginContext();
const { urlParams } = useUrlParams();
const { serviceName } = urlParams;
const canReadAlerts = !!plugin.core.application.capabilities.apm[
'alerting:show'
];
const canSaveAlerts = !!plugin.core.application.capabilities.apm[
'alerting:save'
];
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 { core } = useApmPluginContext();
const isMlPluginEnabled = 'ml' in plugin.plugins;
const canReadAnomalies = !!(
isMlPluginEnabled &&
capabilities.ml.canAccessML &&
capabilities.ml.canGetJobs
);
const ADD_DATA_LABEL = i18n.translate('xpack.apm.addDataButtonLabel', {
defaultMessage: 'Add data',
@ -58,12 +57,15 @@ export function ServiceDetails({ tab }: Props) {
<AlertIntegrations
canReadAlerts={canReadAlerts}
canSaveAlerts={canSaveAlerts}
canReadAnomalies={canReadAnomalies}
/>
</EuiFlexItem>
)}
<EuiFlexItem grow={false}>
<EuiButtonEmpty
href={core.http.basePath.prepend('/app/home#/tutorial/apm')}
href={plugin.core.http.basePath.prepend(
'/app/home#/tutorial/apm'
)}
size="s"
color="primary"
iconType="plusInCircle"

View file

@ -15,14 +15,14 @@ import {
ENVIRONMENT_ALL,
ENVIRONMENT_NOT_DEFINED,
} from '../../../../common/environment_filter_values';
import { useEnvironments, ALL_OPTION } from '../../../hooks/useEnvironments';
import { useEnvironments } from '../../../hooks/useEnvironments';
function updateEnvironmentUrl(
location: ReturnType<typeof useLocation>,
environment?: string
) {
const nextEnvironmentQueryParam =
environment !== ENVIRONMENT_ALL ? environment : undefined;
environment !== ENVIRONMENT_ALL.value ? environment : undefined;
history.push({
...location,
search: fromQuery({
@ -32,13 +32,6 @@ function updateEnvironmentUrl(
});
}
const NOT_DEFINED_OPTION = {
value: ENVIRONMENT_NOT_DEFINED,
text: i18n.translate('xpack.apm.filter.environment.notDefinedLabel', {
defaultMessage: 'Not defined',
}),
};
const SEPARATOR_OPTION = {
text: `- ${i18n.translate(
'xpack.apm.filter.environment.selectEnvironmentLabel',
@ -49,16 +42,16 @@ const SEPARATOR_OPTION = {
function getOptions(environments: string[]) {
const environmentOptions = environments
.filter((env) => env !== ENVIRONMENT_NOT_DEFINED)
.filter((env) => env !== ENVIRONMENT_NOT_DEFINED.value)
.map((environment) => ({
value: environment,
text: environment,
}));
return [
ALL_OPTION,
...(environments.includes(ENVIRONMENT_NOT_DEFINED)
? [NOT_DEFINED_OPTION]
ENVIRONMENT_ALL,
...(environments.includes(ENVIRONMENT_NOT_DEFINED.value)
? [ENVIRONMENT_NOT_DEFINED]
: []),
...(environmentOptions.length > 0 ? [SEPARATOR_OPTION] : []),
...environmentOptions,
@ -83,7 +76,7 @@ export function EnvironmentFilter() {
defaultMessage: 'environment',
})}
options={getOptions(environments)}
value={environment || ENVIRONMENT_ALL}
value={environment || ENVIRONMENT_ALL.value}
onChange={(event) => {
updateEnvironmentUrl(location, event.target.value);
}}

View file

@ -12,8 +12,12 @@ import { ForLastExpression } from '../../../../../triggers_actions_ui/public';
import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { ServiceAlertTrigger } from '../ServiceAlertTrigger';
import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression';
import { useEnvironments, ALL_OPTION } from '../../../hooks/useEnvironments';
import { useEnvironments } from '../../../hooks/useEnvironments';
import { useUrlParams } from '../../../hooks/useUrlParams';
import {
ENVIRONMENT_ALL,
getEnvironmentLabel,
} from '../../../../common/environment_filter_values';
export interface ErrorRateAlertTriggerParams {
windowSize: number;
@ -39,7 +43,7 @@ export function ErrorRateAlertTrigger(props: Props) {
threshold: 25,
windowSize: 1,
windowUnit: 'm',
environment: ALL_OPTION.value,
environment: urlParams.environment || ENVIRONMENT_ALL.value,
};
const params = {
@ -51,11 +55,7 @@ export function ErrorRateAlertTrigger(props: Props) {
const fields = [
<PopoverExpression
value={
params.environment === ALL_OPTION.value
? ALL_OPTION.text
: params.environment
}
value={getEnvironmentLabel(params.environment)}
title={i18n.translate('xpack.apm.errorRateAlertTrigger.environment', {
defaultMessage: 'Environment',
})}

View file

@ -8,8 +8,8 @@ import React, { useState } from 'react';
import { EuiExpression, EuiPopover } from '@elastic/eui';
interface Props {
title: string;
value: string;
title: React.ReactNode;
value: React.ReactNode;
children?: React.ReactNode;
}

View file

@ -12,11 +12,15 @@ import {
ALERT_TYPES_CONFIG,
TRANSACTION_ALERT_AGGREGATION_TYPES,
} from '../../../../common/alert_types';
import { ALL_OPTION, useEnvironments } from '../../../hooks/useEnvironments';
import { useEnvironments } from '../../../hooks/useEnvironments';
import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { ServiceAlertTrigger } from '../ServiceAlertTrigger';
import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression';
import {
ENVIRONMENT_ALL,
getEnvironmentLabel,
} from '../../../../common/environment_filter_values';
interface Params {
windowSize: number;
@ -54,7 +58,7 @@ export function TransactionDurationAlertTrigger(props: Props) {
windowSize: 5,
windowUnit: 'm',
transactionType: transactionTypes[0],
environment: ALL_OPTION.value,
environment: urlParams.environment || ENVIRONMENT_ALL.value,
};
const params = {
@ -64,11 +68,7 @@ export function TransactionDurationAlertTrigger(props: Props) {
const fields = [
<PopoverExpression
value={
params.environment === ALL_OPTION.value
? ALL_OPTION.text
: params.environment
}
value={getEnvironmentLabel(params.environment)}
title={i18n.translate(
'xpack.apm.transactionDurationAlertTrigger.environment',
{

View file

@ -0,0 +1,108 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui';
import { getSeverityColor } from '../../app/ServiceMap/cytoscapeOptions';
import { useTheme } from '../../../hooks/useTheme';
import { severity as Severity } from '../../app/ServiceMap/Popover/getSeverity';
type SeverityScore = 0 | 25 | 50 | 75;
const ANOMALY_SCORES: SeverityScore[] = [0, 25, 50, 75];
const anomalyScoreSeverityMap: {
[key in SeverityScore]: { label: string; severity: Severity };
} = {
0: {
label: i18n.translate('xpack.apm.alerts.anomalySeverity.warningLabel', {
defaultMessage: 'warning',
}),
severity: Severity.warning,
},
25: {
label: i18n.translate('xpack.apm.alerts.anomalySeverity.minorLabel', {
defaultMessage: 'minor',
}),
severity: Severity.minor,
},
50: {
label: i18n.translate('xpack.apm.alerts.anomalySeverity.majorLabel', {
defaultMessage: 'major',
}),
severity: Severity.major,
},
75: {
label: i18n.translate('xpack.apm.alerts.anomalySeverity.criticalLabel', {
defaultMessage: 'critical',
}),
severity: Severity.critical,
},
};
export function AnomalySeverity({
severityScore,
}: {
severityScore: SeverityScore;
}) {
const theme = useTheme();
const { label, severity } = anomalyScoreSeverityMap[severityScore];
const defaultColor = theme.eui.euiColorMediumShade;
const color = getSeverityColor(theme, severity) || defaultColor;
return (
<EuiHealth color={color} style={{ lineHeight: 'inherit' }}>
{label}
</EuiHealth>
);
}
const getOption = (value: SeverityScore) => {
return {
value: value.toString(10),
inputDisplay: <AnomalySeverity severityScore={value} />,
dropdownDisplay: (
<>
<AnomalySeverity severityScore={value} />
<EuiSpacer size="xs" />
<EuiText size="xs" color="subdued">
<p className="euiTextColor--subdued">
<FormattedMessage
id="xpack.apm.alerts.anomalySeverity.scoreDetailsDescription"
defaultMessage="score {value} and above"
values={{ value }}
/>
</p>
</EuiText>
</>
),
};
};
interface Props {
onChange: (value: SeverityScore) => void;
value: SeverityScore;
}
export function SelectAnomalySeverity({ onChange, value }: Props) {
const options = ANOMALY_SCORES.map((anomalyScore) => getOption(anomalyScore));
return (
<EuiSuperSelect
hasDividers
style={{ width: 200 }}
options={options}
valueOfSelected={value.toString(10)}
onChange={(selectedValue: string) => {
const selectedAnomalyScore = parseInt(
selectedValue,
10
) as SeverityScore;
onChange(selectedAnomalyScore);
}}
/>
);
}

View file

@ -0,0 +1,131 @@
/*
* 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 { EuiExpression, EuiSelect } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React from 'react';
import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types';
import { useEnvironments } from '../../../hooks/useEnvironments';
import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { ServiceAlertTrigger } from '../ServiceAlertTrigger';
import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression';
import {
AnomalySeverity,
SelectAnomalySeverity,
} from './SelectAnomalySeverity';
import {
ENVIRONMENT_ALL,
getEnvironmentLabel,
} from '../../../../common/environment_filter_values';
import {
TRANSACTION_PAGE_LOAD,
TRANSACTION_REQUEST,
} from '../../../../common/transaction_types';
interface Params {
windowSize: number;
windowUnit: string;
serviceName: string;
transactionType: string;
environment: string;
anomalyScore: 0 | 25 | 50 | 75;
}
interface Props {
alertParams: Params;
setAlertParams: (key: string, value: any) => void;
setAlertProperty: (key: string, value: any) => void;
}
export function TransactionDurationAnomalyAlertTrigger(props: Props) {
const { setAlertParams, alertParams, setAlertProperty } = props;
const { urlParams } = useUrlParams();
const transactionTypes = useServiceTransactionTypes(urlParams);
const { serviceName, start, end } = urlParams;
const { environmentOptions } = useEnvironments({ serviceName, start, end });
const supportedTransactionTypes = transactionTypes.filter((transactionType) =>
[TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST].includes(transactionType)
);
if (!supportedTransactionTypes.length || !serviceName) {
return null;
}
const defaults: Params = {
windowSize: 15,
windowUnit: 'm',
transactionType: supportedTransactionTypes[0], // 'page-load' for RUM, 'request' otherwise
serviceName,
environment: urlParams.environment || ENVIRONMENT_ALL.value,
anomalyScore: 75,
};
const params = {
...defaults,
...alertParams,
};
const fields = [
<EuiExpression
description={i18n.translate(
'xpack.apm.transactionDurationAnomalyAlertTrigger.service',
{
defaultMessage: 'Service',
}
)}
value={serviceName}
/>,
<PopoverExpression
value={getEnvironmentLabel(params.environment)}
title={i18n.translate(
'xpack.apm.transactionDurationAnomalyAlertTrigger.environment',
{
defaultMessage: 'Environment',
}
)}
>
<EuiSelect
value={params.environment}
options={environmentOptions}
onChange={(e) => setAlertParams('environment', e.target.value)}
compressed
/>
</PopoverExpression>,
<PopoverExpression
value={<AnomalySeverity severityScore={params.anomalyScore} />}
title={i18n.translate(
'xpack.apm.transactionDurationAnomalyAlertTrigger.anomalySeverity',
{
defaultMessage: 'Has anomaly with severity',
}
)}
>
<SelectAnomalySeverity
value={params.anomalyScore}
onChange={(value) => {
setAlertParams('anomalyScore', value);
}}
/>
</PopoverExpression>,
];
return (
<ServiceAlertTrigger
alertTypeName={
ALERT_TYPES_CONFIG['apm.transaction_duration_anomaly'].name
}
fields={fields}
defaults={defaults}
setAlertParams={setAlertParams}
setAlertProperty={setAlertProperty}
/>
);
}
// Default export is required for React.lazy loading
//
// eslint-disable-next-line import/no-default-export
export default TransactionDurationAnomalyAlertTrigger;

View file

@ -5,30 +5,22 @@
*/
import { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { useFetcher } from './useFetcher';
import {
ENVIRONMENT_NOT_DEFINED,
ENVIRONMENT_ALL,
ENVIRONMENT_NOT_DEFINED,
} from '../../common/environment_filter_values';
import { callApmApi } from '../services/rest/createCallApmApi';
export const ALL_OPTION = {
value: ENVIRONMENT_ALL,
text: i18n.translate('xpack.apm.environment.allLabel', {
defaultMessage: 'All',
}),
};
function getEnvironmentOptions(environments: string[]) {
const environmentOptions = environments
.filter((env) => env !== ENVIRONMENT_NOT_DEFINED)
.filter((env) => env !== ENVIRONMENT_NOT_DEFINED.value)
.map((environment) => ({
value: environment,
text: environment,
}));
return [ALL_OPTION, ...environmentOptions];
return [ENVIRONMENT_ALL, ...environmentOptions];
}
export function useEnvironments({

View file

@ -168,5 +168,21 @@ export class ApmPlugin implements Plugin<ApmPluginSetup, ApmPluginStart> {
}),
requiresAppContext: true,
});
plugins.triggers_actions_ui.alertTypeRegistry.register({
id: AlertType.TransactionDurationAnomaly,
name: i18n.translate('xpack.apm.alertTypes.transactionDurationAnomaly', {
defaultMessage: 'Transaction duration anomaly',
}),
iconClass: 'bell',
alertParamsExpression: lazy(
() =>
import('./components/shared/TransactionDurationAnomalyAlertTrigger')
),
validate: () => ({
errors: [],
}),
requiresAppContext: true,
});
}
}

View file

@ -8,12 +8,15 @@ import { Observable } from 'rxjs';
import { AlertingPlugin } from '../../../../alerts/server';
import { ActionsPlugin } from '../../../../actions/server';
import { registerTransactionDurationAlertType } from './register_transaction_duration_alert_type';
import { registerTransactionDurationAnomalyAlertType } from './register_transaction_duration_anomaly_alert_type';
import { registerErrorRateAlertType } from './register_error_rate_alert_type';
import { APMConfig } from '../..';
import { MlPluginSetup } from '../../../../ml/server';
interface Params {
alerts: AlertingPlugin['setup'];
actions: ActionsPlugin['setup'];
ml?: MlPluginSetup;
config$: Observable<APMConfig>;
}
@ -22,6 +25,11 @@ export function registerApmAlerts(params: Params) {
alerts: params.alerts,
config$: params.config$,
});
registerTransactionDurationAnomalyAlertType({
alerts: params.alerts,
ml: params.ml,
config$: params.config$,
});
registerErrorRateAlertType({
alerts: params.alerts,
config$: params.config$,

View file

@ -75,7 +75,7 @@ export function registerErrorRateAlertType({
});
const environmentTerm =
alertParams.environment === ENVIRONMENT_ALL
alertParams.environment === ENVIRONMENT_ALL.value
? []
: [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }];

View file

@ -89,7 +89,7 @@ export function registerTransactionDurationAlertType({
});
const environmentTerm =
alertParams.environment === ENVIRONMENT_ALL
alertParams.environment === ENVIRONMENT_ALL.value
? []
: [{ term: { [SERVICE_ENVIRONMENT]: alertParams.environment } }];

View file

@ -0,0 +1,136 @@
/*
* 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 { schema, TypeOf } from '@kbn/config-schema';
import { Observable } from 'rxjs';
import { i18n } from '@kbn/i18n';
import { KibanaRequest } from '../../../../../../src/core/server';
import { AlertType, ALERT_TYPES_CONFIG } from '../../../common/alert_types';
import { AlertingPlugin } from '../../../../alerts/server';
import { APMConfig } from '../..';
import { MlPluginSetup } from '../../../../ml/server';
import { getMLJobIds } from '../service_map/get_service_anomalies';
interface RegisterAlertParams {
alerts: AlertingPlugin['setup'];
ml?: MlPluginSetup;
config$: Observable<APMConfig>;
}
const paramsSchema = schema.object({
serviceName: schema.string(),
transactionType: schema.string(),
windowSize: schema.number(),
windowUnit: schema.string(),
environment: schema.string(),
anomalyScore: schema.number(),
});
const alertTypeConfig =
ALERT_TYPES_CONFIG[AlertType.TransactionDurationAnomaly];
export function registerTransactionDurationAnomalyAlertType({
alerts,
ml,
config$,
}: RegisterAlertParams) {
alerts.registerType({
id: AlertType.TransactionDurationAnomaly,
name: alertTypeConfig.name,
actionGroups: alertTypeConfig.actionGroups,
defaultActionGroupId: alertTypeConfig.defaultActionGroupId,
validate: {
params: paramsSchema,
},
actionVariables: {
context: [
{
description: i18n.translate(
'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.serviceName',
{
defaultMessage: 'Service name',
}
),
name: 'serviceName',
},
{
description: i18n.translate(
'xpack.apm.registerTransactionDurationAnomalyAlertType.variables.transactionType',
{
defaultMessage: 'Transaction type',
}
),
name: 'transactionType',
},
],
},
producer: 'apm',
executor: async ({ services, params, state }) => {
if (!ml) {
return;
}
const alertParams = params as TypeOf<typeof paramsSchema>;
const mlClient = services.getLegacyScopedClusterClient(ml.mlClient);
const request = { params: 'DummyKibanaRequest' } as KibanaRequest;
const { mlAnomalySearch } = ml.mlSystemProvider(mlClient, request);
const anomalyDetectors = ml.anomalyDetectorsProvider(mlClient, request);
const mlJobIds = await getMLJobIds(
anomalyDetectors,
alertParams.environment
);
const anomalySearchParams = {
body: {
size: 0,
query: {
bool: {
filter: [
{ term: { result_type: 'record' } },
{ terms: { job_id: mlJobIds } },
{
range: {
timestamp: {
gte: `now-${alertParams.windowSize}${alertParams.windowUnit}`,
format: 'epoch_millis',
},
},
},
{
term: {
partition_field_value: alertParams.serviceName,
},
},
{
range: {
record_score: {
gte: alertParams.anomalyScore,
},
},
},
],
},
},
},
};
const response = ((await mlAnomalySearch(
anomalySearchParams
)) as unknown) as { hits: { total: { value: number } } };
const hitCount = response.hits.total.value;
if (hitCount > 0) {
const alertInstance = services.alertInstanceFactory(
AlertType.TransactionDurationAnomaly
);
alertInstance.scheduleActions(alertTypeConfig.defaultActionGroupId, {
serviceName: alertParams.serviceName,
});
}
return {};
},
});
}

View file

@ -22,7 +22,7 @@ export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) {
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
}
const response = await getMlJobsWithAPMGroup(ml);
const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors);
return response.jobs
.filter((job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2)
.map((job) => {

View file

@ -4,14 +4,16 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { Setup } from '../helpers/setup_request';
import { MlPluginSetup } from '../../../../ml/server';
import { APM_ML_JOB_GROUP } from './constants';
// returns ml jobs containing "apm" group
// workaround: the ML api returns 404 when no jobs are found. This is handled so instead of throwing an empty response is returned
export async function getMlJobsWithAPMGroup(ml: NonNullable<Setup['ml']>) {
export async function getMlJobsWithAPMGroup(
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>
) {
try {
return await ml.anomalyDetectors.jobs(APM_ML_JOB_GROUP);
return await anomalyDetectors.jobs(APM_ML_JOB_GROUP);
} catch (e) {
if (e.statusCode === 404) {
return { count: 0, jobs: [] };

View file

@ -23,7 +23,7 @@ export async function hasLegacyJobs(setup: Setup) {
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
}
const response = await getMlJobsWithAPMGroup(ml);
const response = await getMlJobsWithAPMGroup(ml.anomalyDetectors);
return response.jobs.some(
(job) =>
job.job_id.endsWith('high_mean_response_time') &&

View file

@ -48,7 +48,7 @@ export async function getAllEnvironments({
terms: {
field: SERVICE_ENVIRONMENT,
size: 100,
missing: includeMissing ? ENVIRONMENT_NOT_DEFINED : undefined,
missing: includeMissing ? ENVIRONMENT_NOT_DEFINED.value : undefined,
},
},
},

View file

@ -21,7 +21,7 @@ describe('getEnvironmentUiFilterES', () => {
});
it('should create a filter for missing service environments', () => {
const uiFilterES = getEnvironmentUiFilterES(ENVIRONMENT_NOT_DEFINED);
const uiFilterES = getEnvironmentUiFilterES(ENVIRONMENT_NOT_DEFINED.value);
expect(uiFilterES).toHaveLength(1);
expect(uiFilterES[0]).toHaveProperty(
['bool', 'must_not', 'exists', 'field'],

View file

@ -12,7 +12,7 @@ export function getEnvironmentUiFilterES(environment?: string): ESFilter[] {
if (!environment) {
return [];
}
if (environment === ENVIRONMENT_NOT_DEFINED) {
if (environment === ENVIRONMENT_NOT_DEFINED.value) {
return [{ bool: { must_not: { exists: { field: SERVICE_ENVIRONMENT } } } }];
}
return [{ term: { [SERVICE_ENVIRONMENT]: environment } }];

View file

@ -98,7 +98,11 @@ export async function setupRequest<TParams extends SetupRequestParams>(
context,
request,
}),
ml: getMlSetup(context, request),
ml: getMlSetup(
context.plugins.ml,
context.core.savedObjects.client,
request
),
config,
};
@ -110,20 +114,21 @@ export async function setupRequest<TParams extends SetupRequestParams>(
} as InferSetup<TParams>;
}
function getMlSetup(context: APMRequestHandlerContext, request: KibanaRequest) {
if (!context.plugins.ml) {
function getMlSetup(
ml: APMRequestHandlerContext['plugins']['ml'],
savedObjectsClient: APMRequestHandlerContext['core']['savedObjects']['client'],
request: KibanaRequest
) {
if (!ml) {
return;
}
const ml = context.plugins.ml;
const mlClient = ml.mlClient.asScoped(request);
const mlSystem = ml.mlSystemProvider(mlClient, request);
return {
mlSystem: ml.mlSystemProvider(mlClient, request),
anomalyDetectors: ml.anomalyDetectorsProvider(mlClient, request),
modules: ml.modulesProvider(
mlClient,
request,
context.core.savedObjects.client
),
mlClient,
mlSystem,
modules: ml.modulesProvider(mlClient, request, savedObjectsClient),
anomalyDetectors: ml.anomalyDetectorsProvider(mlClient, request),
mlAnomalySearch: mlSystem.mlAnomalySearch,
};
}

View file

@ -16,6 +16,8 @@ import {
ML_ERRORS,
} from '../../../common/anomaly_detection';
import { getMlJobsWithAPMGroup } from '../anomaly_detection/get_ml_jobs_with_apm_group';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
import { MlPluginSetup } from '../../../../ml/server';
export const DEFAULT_ANOMALIES = { mlJobIds: [], serviceAnomalies: {} };
@ -43,7 +45,7 @@ export async function getServiceAnomalies({
throw Boom.forbidden(ML_ERRORS.ML_NOT_AVAILABLE_IN_SPACE);
}
const mlJobIds = await getMLJobIds(ml, environment);
const mlJobIds = await getMLJobIds(ml.anomalyDetectors, environment);
const params = {
body: {
size: 0,
@ -136,16 +138,17 @@ function transformResponseToServiceAnomalies(
}
export async function getMLJobIds(
ml: Required<Setup>['ml'],
anomalyDetectors: ReturnType<MlPluginSetup['anomalyDetectorsProvider']>,
environment?: string
) {
const response = await getMlJobsWithAPMGroup(ml);
const response = await getMlJobsWithAPMGroup(anomalyDetectors);
// to filter out legacy jobs we are filtering by the existence of `apm_ml_version` in `custom_settings`
// and checking that it is compatable.
const mlJobs = response.jobs.filter(
(job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2
);
if (environment) {
if (environment && environment !== ENVIRONMENT_ALL.value) {
const matchingMLJob = mlJobs.find(
(job) => job.custom_settings?.job_tags?.environment === environment
);

View file

@ -66,7 +66,10 @@ export async function getAnomalySeries({
let mlJobIds: string[] = [];
try {
mlJobIds = await getMLJobIds(setup.ml, uiFilters.environment);
mlJobIds = await getMLJobIds(
setup.ml.anomalyDetectors,
uiFilters.environment
);
} catch (error) {
logger.error(error);
return;

View file

@ -47,7 +47,7 @@ export async function getEnvironments(
environments: {
terms: {
field: SERVICE_ENVIRONMENT,
missing: ENVIRONMENT_NOT_DEFINED,
missing: ENVIRONMENT_NOT_DEFINED.value,
},
},
},

View file

@ -83,6 +83,7 @@ export class APMPlugin implements Plugin<APMPluginSetup> {
registerApmAlerts({
alerts: plugins.alerts,
actions: plugins.actions,
ml: plugins.ml,
config$: mergedConfig$,
});
}

View file

@ -23,7 +23,13 @@ export function getAnomalyDetectorsProvider({
}: SharedServicesChecks): AnomalyDetectorsProvider {
return {
anomalyDetectorsProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) {
const hasMlCapabilities = getHasMlCapabilities(request);
// APM is using this service in anomaly alert, kibana alerting doesn't provide request object
// So we are adding a dummy request for now
// TODO: Remove this once kibana alerting provides request object
const hasMlCapabilities =
request.params !== 'DummyKibanaRequest'
? getHasMlCapabilities(request)
: (_caps: string[]) => Promise.resolve();
return {
async jobs(jobId?: string) {
isFullLicense();

View file

@ -4747,7 +4747,6 @@
"xpack.apm.customLink.empty": "カスタムリンクが見つかりません。独自のカスタムリンク、たとえば特定のダッシュボードまたは外部リンクへのリンクをセットアップします。",
"xpack.apm.emptyMessage.noDataFoundDescription": "別の時間範囲を試すか検索フィルターをリセットしてください。",
"xpack.apm.emptyMessage.noDataFoundLabel": "データが見つかりません。",
"xpack.apm.environment.allLabel": "すべて",
"xpack.apm.error.prompt.body": "詳細はブラウザの開発者コンソールをご確認ください。",
"xpack.apm.error.prompt.title": "申し訳ございませんが、エラーが発生しました :(",
"xpack.apm.errorGroupDetails.avgLabel": "平均",
@ -4785,6 +4784,7 @@
"xpack.apm.fetcher.error.title": "リソースの取得中にエラーが発生しました",
"xpack.apm.fetcher.error.url": "URL",
"xpack.apm.filter.environment.label": "環境",
"xpack.apm.filter.environment.allLabel": "すべて",
"xpack.apm.filter.environment.notDefinedLabel": "未定義",
"xpack.apm.filter.environment.selectEnvironmentLabel": "環境を選択",
"xpack.apm.formatters.hoursTimeUnitLabel": "h",

View file

@ -4748,7 +4748,6 @@
"xpack.apm.customLink.empty": "未找到定制链接。设置自己的定制链接,如特定仪表板的链接或外部链接。",
"xpack.apm.emptyMessage.noDataFoundDescription": "尝试其他时间范围或重置搜索筛选。",
"xpack.apm.emptyMessage.noDataFoundLabel": "未找到任何数据",
"xpack.apm.environment.allLabel": "全部",
"xpack.apm.error.prompt.body": "有关详情,请查看您的浏览器开发者控制台。",
"xpack.apm.error.prompt.title": "抱歉,发生错误 :(",
"xpack.apm.errorGroupDetails.avgLabel": "平均",
@ -4786,6 +4785,7 @@
"xpack.apm.fetcher.error.title": "提取资源时出错",
"xpack.apm.fetcher.error.url": "URL",
"xpack.apm.filter.environment.label": "环境",
"xpack.apm.filter.environment.allLabel": "全部",
"xpack.apm.filter.environment.notDefinedLabel": "未定义",
"xpack.apm.filter.environment.selectEnvironmentLabel": "选择环境",
"xpack.apm.formatters.hoursTimeUnitLabel": "h",