From ec08dcae074a283b6be7877fa177bf8ab2b66db9 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Sat, 22 Aug 2020 00:21:25 -0700 Subject: [PATCH] Closes #72636. Adds alerting integration for APM transaction duration anomalies. --- x-pack/plugins/apm/common/alert_types.ts | 19 +++ .../AlertIntegrations/index.tsx | 18 ++- .../components/app/ServiceDetails/index.tsx | 24 +-- .../SelectAnomalySeverity.tsx | 113 +++++++++++++ .../index.tsx | 153 ++++++++++++++++++ x-pack/plugins/apm/public/plugin.ts | 15 ++ .../server/lib/alerts/register_apm_alerts.ts | 8 + ...transaction_duration_anomaly_alert_type.ts | 136 ++++++++++++++++ .../get_ml_jobs_with_apm_group.ts | 10 +- .../apm/server/lib/helpers/setup_request.ts | 27 ++-- .../lib/service_map/get_service_anomalies.ts | 9 +- x-pack/plugins/apm/server/plugin.ts | 1 + .../providers/anomaly_detectors.ts | 8 +- 13 files changed, 512 insertions(+), 29 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/SelectAnomalySeverity.tsx create mode 100644 x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx create mode 100644 x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts diff --git a/x-pack/plugins/apm/common/alert_types.ts b/x-pack/plugins/apm/common/alert_types.ts index ad826a446d823e..a1161354e04f45 100644 --- a/x-pack/plugins/apm/common/alert_types.ts +++ b/x-pack/plugins/apm/common/alert_types.ts @@ -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 = { diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx index 80d5f739bea5a8..2ac77a3810ecf1 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/AlertIntegrations/index.tsx @@ -35,10 +35,11 @@ const CREATE_THRESHOLD_ALERT_PANEL_ID = 'create_threshold'; 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(); @@ -105,6 +106,21 @@ export function AlertIntegrations(props: Props) { setAlertType(AlertType.TransactionDuration); }, }, + ...(canReadAnomalies + ? [ + { + name: i18n.translate( + 'xpack.apm.serviceDetails.alertsMenu.transactionDurationAnomaly', + { + defaultMessage: 'Transaction duration anomaly', + } + ), + onClick: () => { + setAlertType(AlertType.TransactionDurationAnomaly); + }, + }, + ] + : []), { name: i18n.translate( 'xpack.apm.serviceDetails.alertsMenu.errorRate', diff --git a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx index 4488a962d0ba88..b5a4ca4799afde 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceDetails/index.tsx @@ -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) { )} { + const { label, severity } = anomalyScoreSeverityMap[value]; + const defaultColor = theme.eui.euiColorMediumShade; + const color = getSeverityColor(theme, severity) || defaultColor; + return { + value: value.toString(10), + inputDisplay: ( + <> + + {label} + + + ), + dropdownDisplay: ( + <> + + {label} + + + +

+ +

+
+ + ), + }; +}; + +interface Props { + onChange: (value: SeverityScore) => void; + value: SeverityScore; +} + +export function SelectAnomalySeverity({ onChange, value }: Props) { + const theme = useTheme(); + const options = ANOMALY_SCORES.map((anomalyScore) => + getOption(theme, anomalyScore) + ); + const [anomalyScore, setAnomalyScore] = useState(value); + + useEffect(() => { + setAnomalyScore(value); + }, [value]); + + return ( + { + const selectedAnomalyScore = parseInt( + selectedValue, + 10 + ) as SeverityScore; + setAnomalyScore(selectedAnomalyScore); + onChange(selectedAnomalyScore); + }} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx new file mode 100644 index 00000000000000..9df493a16680e6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAnomalyAlertTrigger/index.tsx @@ -0,0 +1,153 @@ +/* + * 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 { EuiText, EuiSelect, EuiExpression } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { ALERT_TYPES_CONFIG } from '../../../../common/alert_types'; +import { ALL_OPTION, useEnvironments } from '../../../hooks/useEnvironments'; +import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { ServiceAlertTrigger } from '../ServiceAlertTrigger'; +import { PopoverExpression } from '../ServiceAlertTrigger/PopoverExpression'; +import { SelectAnomalySeverity } from './SelectAnomalySeverity'; + +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 }); + + if (!transactionTypes.length || !serviceName) { + return null; + } + + const defaults: Params = { + windowSize: 15, + windowUnit: 'm', + transactionType: transactionTypes[0], + serviceName, + environment: urlParams.environment || ALL_OPTION.value, + anomalyScore: 75, + }; + + const params = { + ...defaults, + ...alertParams, + }; + + const fields = [ + +
{serviceName}
+ + } + />, + + + setAlertParams('environment', e.target.value as Params['environment']) + } + compressed + /> + , + + { + return { + text: key, + value: key, + }; + })} + onChange={(e) => + setAlertParams( + 'transactionType', + e.target.value as Params['transactionType'] + ) + } + compressed + /> + , + + { + setAlertParams('anomalyScore', value); + }} + /> + , + ]; + + return ( + + ); +} + +// Default export is required for React.lazy loading +// +// eslint-disable-next-line import/no-default-export +export default TransactionDurationAnomalyAlertTrigger; diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index cf729fe0ff3010..9e6846de765d26 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -166,5 +166,20 @@ export class ApmPlugin implements Plugin { }), 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, + }); } } 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 4b8e9cf937a2b2..44ca80143bcd9a 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 @@ -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; } @@ -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$, 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 new file mode 100644 index 00000000000000..05facdfb7ac89e --- /dev/null +++ b/x-pack/plugins/apm/server/lib/alerts/register_transaction_duration_anomaly_alert_type.ts @@ -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; +} + +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; + 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 {}; + }, + }); +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts index 5c0a3d17648aad..8c8cb7e9e52d30 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_ml_jobs_with_apm_group.ts @@ -4,14 +4,18 @@ * 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) { +export async function getMlJobsWithAPMGroup({ + anomalyDetectors, +}: { + anomalyDetectors: ReturnType; +}) { 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: [] }; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index ddad2eb2d22dc9..a242a0adb6d4c7 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -98,7 +98,11 @@ export async function setupRequest( 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( } as InferSetup; } -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, }; } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts index 03716382af8593..05df730b1b4bd6 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -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: {} }; @@ -136,16 +138,19 @@ function transformResponseToServiceAnomalies( } export async function getMLJobIds( - ml: Required['ml'], + ml: { + anomalyDetectors: ReturnType; + }, environment?: string ) { const response = await getMlJobsWithAPMGroup(ml); + // 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) { const matchingMLJob = mlJobs.find( (job) => job.custom_settings?.job_tags?.environment === environment ); diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index deafda67b806d7..71202c62e6f6c1 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -83,6 +83,7 @@ export class APMPlugin implements Plugin { registerApmAlerts({ alerts: plugins.alerts, actions: plugins.actions, + ml: plugins.ml, config$: mergedConfig$, }); } diff --git a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts index 1140af0b764049..603b4fba17adbf 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/anomaly_detectors.ts @@ -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();