From 886f2252a7068792c683f5138e44682e53764dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Tue, 21 Jul 2020 08:41:15 +0200 Subject: [PATCH] [APM] Handle ML errors (#72316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [APM] Handle ML errors * Add capability check * Improve test * Address Caue’s feedback * Move getSeverity * Fix tsc * Fix copy --- .../plugins/apm/common/anomaly_detection.ts | 50 +++++++++++ .../apm/common/ml_job_constants.test.ts | 41 --------- .../ServiceMap/Popover/AnomalyDetection.tsx | 2 +- .../ServiceMap/Popover/getSeverity.test.ts | 39 +++++++++ .../app/ServiceMap/Popover/getSeverity.ts} | 2 + .../generate_service_map_elements.ts | 2 +- .../app/ServiceMap/cytoscapeOptions.ts | 2 +- .../anomaly_detection/add_environments.tsx | 17 +++- .../Settings/anomaly_detection/create_jobs.ts | 80 +++++++++++------- .../app/Settings/anomaly_detection/index.tsx | 33 ++++++-- .../Settings/anomaly_detection/jobs_list.tsx | 84 +++++++++---------- .../apm/AnomalyDetectionSetupLink.test.tsx | 39 +++++---- .../Links/apm/AnomalyDetectionSetupLink.tsx | 20 ++++- .../anomaly_detection_error.ts | 16 ++++ .../create_anomaly_detection_jobs.ts | 26 +++--- .../get_anomaly_detection_jobs.ts | 16 ++-- .../routes/settings/anomaly_detection.ts | 62 +++++++++++--- 17 files changed, 355 insertions(+), 176 deletions(-) delete mode 100644 x-pack/plugins/apm/common/ml_job_constants.test.ts create mode 100644 x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts rename x-pack/plugins/apm/{common/ml_job_constants.ts => public/components/app/ServiceMap/Popover/getSeverity.ts} (80%) create mode 100644 x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts index 1fd927d82f186..9e0a3e3d0d889 100644 --- a/x-pack/plugins/apm/common/anomaly_detection.ts +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -4,9 +4,59 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + export interface ServiceAnomalyStats { transactionType?: string; anomalyScore?: number; actualValue?: number; jobId?: string; } + +export const MLErrorMessages: Record = { + INSUFFICIENT_LICENSE: i18n.translate( + 'xpack.apm.anomaly_detection.error.insufficient_license', + { + defaultMessage: + 'You must have a platinum license to use Anomaly Detection', + } + ), + MISSING_READ_PRIVILEGES: i18n.translate( + 'xpack.apm.anomaly_detection.error.missing_read_privileges', + { + defaultMessage: + 'You must have "read" privileges to Machine Learning in order to view Anomaly Detection jobs', + } + ), + MISSING_WRITE_PRIVILEGES: i18n.translate( + 'xpack.apm.anomaly_detection.error.missing_write_privileges', + { + defaultMessage: + 'You must have "write" privileges to Machine Learning and APM in order to create Anomaly Detection jobs', + } + ), + ML_NOT_AVAILABLE: i18n.translate( + 'xpack.apm.anomaly_detection.error.not_available', + { + defaultMessage: 'Machine learning is not available', + } + ), + ML_NOT_AVAILABLE_IN_SPACE: i18n.translate( + 'xpack.apm.anomaly_detection.error.not_available_in_space', + { + defaultMessage: 'Machine learning is not available in the selected space', + } + ), + UNEXPECTED: i18n.translate('xpack.apm.anomaly_detection.error.unexpected', { + defaultMessage: 'An unexpected error occurred', + }), +}; + +export enum ErrorCode { + INSUFFICIENT_LICENSE = 'INSUFFICIENT_LICENSE', + MISSING_READ_PRIVILEGES = 'MISSING_READ_PRIVILEGES', + MISSING_WRITE_PRIVILEGES = 'MISSING_WRITE_PRIVILEGES', + ML_NOT_AVAILABLE = 'ML_NOT_AVAILABLE', + ML_NOT_AVAILABLE_IN_SPACE = 'ML_NOT_AVAILABLE_IN_SPACE', + UNEXPECTED = 'UNEXPECTED', +} diff --git a/x-pack/plugins/apm/common/ml_job_constants.test.ts b/x-pack/plugins/apm/common/ml_job_constants.test.ts deleted file mode 100644 index 96e3ba826d201..0000000000000 --- a/x-pack/plugins/apm/common/ml_job_constants.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { getSeverity, severity } from './ml_job_constants'; - -describe('ml_job_constants', () => { - describe('getSeverity', () => { - describe('when score is undefined', () => { - it('returns undefined', () => { - expect(getSeverity(undefined)).toEqual(undefined); - }); - }); - - describe('when score < 25', () => { - it('returns warning', () => { - expect(getSeverity(10)).toEqual(severity.warning); - }); - }); - - describe('when score is between 25 and 50', () => { - it('returns minor', () => { - expect(getSeverity(40)).toEqual(severity.minor); - }); - }); - - describe('when score is between 50 and 75', () => { - it('returns major', () => { - expect(getSeverity(60)).toEqual(severity.major); - }); - }); - - describe('when score is 75 or more', () => { - it('returns critical', () => { - expect(getSeverity(100)).toEqual(severity.critical); - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx index 410ba8b5027fb..b3d19e1aab2cc 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -19,9 +19,9 @@ import { fontSize, px } from '../../../../style/variables'; import { asInteger, asDuration } from '../../../../utils/formatters'; import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; import { getSeverityColor, popoverWidth } from '../cytoscapeOptions'; -import { getSeverity } from '../../../../../common/ml_job_constants'; import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; +import { getSeverity } from './getSeverity'; const HealthStatusTitle = styled(EuiTitle)` display: inline; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts new file mode 100644 index 0000000000000..52b7d54236db6 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.test.ts @@ -0,0 +1,39 @@ +/* + * 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 { getSeverity, severity } from './getSeverity'; + +describe('getSeverity', () => { + describe('when score is undefined', () => { + it('returns undefined', () => { + expect(getSeverity(undefined)).toEqual(undefined); + }); + }); + + describe('when score < 25', () => { + it('returns warning', () => { + expect(getSeverity(10)).toEqual(severity.warning); + }); + }); + + describe('when score is between 25 and 50', () => { + it('returns minor', () => { + expect(getSeverity(40)).toEqual(severity.minor); + }); + }); + + describe('when score is between 50 and 75', () => { + it('returns major', () => { + expect(getSeverity(60)).toEqual(severity.major); + }); + }); + + describe('when score is 75 or more', () => { + it('returns critical', () => { + expect(getSeverity(100)).toEqual(severity.critical); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts similarity index 80% rename from x-pack/plugins/apm/common/ml_job_constants.ts rename to x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts index b8c2546bd0c84..f4eb2033e9231 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/getSeverity.ts @@ -11,6 +11,8 @@ export enum severity { warning = 'warning', } +// TODO: Replace with `getSeverity` from: +// https://github.com/elastic/kibana/blob/0f964f66916480f2de1f4b633e5afafc08cf62a0/x-pack/plugins/ml/common/util/anomaly_utils.ts#L129 export function getSeverity(score?: number) { if (typeof score !== 'number') { return undefined; diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts index e7d55cd570710..012256db3ab98 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/__stories__/generate_service_map_elements.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getSeverity } from '../../../../../common/ml_job_constants'; +import { getSeverity } from '../Popover/getSeverity'; export function generateServiceMapElements(size: number): any[] { const services = range(size).map((i) => { diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index dfcfbee1806a4..4a271019e06db 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -10,9 +10,9 @@ import { SPAN_DESTINATION_SERVICE_RESOURCE, } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; -import { severity, getSeverity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; +import { severity, getSeverity } from './Popover/getSeverity'; export const popoverWidth = 280; diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx index 4c056d48f4b14..c9328c4988e5f 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/add_environments.tsx @@ -17,8 +17,10 @@ import { EuiFlexGroup, EuiFlexItem, EuiFormRow, + EuiEmptyPrompt, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { createJobs } from './create_jobs'; @@ -34,7 +36,9 @@ export const AddEnvironments = ({ onCreateJobSuccess, onCancel, }: Props) => { - const { toasts } = useApmPluginContext().core.notifications; + const { notifications, application } = useApmPluginContext().core; + const canCreateJob = !!application.capabilities.ml.canCreateJob; + const { toasts } = notifications; const { data = [], status } = useFetcher( (callApmApi) => callApmApi({ @@ -56,6 +60,17 @@ export const AddEnvironments = ({ Array> >([]); + if (!canCreateJob) { + return ( + + {MLErrorMessages.MISSING_WRITE_PRIVILEGES}} + /> + + ); + } + const isLoading = status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; return ( diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts index 614632a5a3b09..acea38732b40a 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/create_jobs.ts @@ -6,8 +6,19 @@ import { i18n } from '@kbn/i18n'; import { NotificationsStart } from 'kibana/public'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; +const errorToastTitle = i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.failed.title', + { defaultMessage: 'Anomaly detection jobs could not be created' } +); + +const successToastTitle = i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.succeeded.title', + { defaultMessage: 'Anomaly detection jobs created' } +); + export async function createJobs({ environments, toasts, @@ -16,7 +27,7 @@ export async function createJobs({ toasts: NotificationsStart['toasts']; }) { try { - await callApmApi({ + const res = await callApmApi({ pathname: '/api/apm/settings/anomaly-detection/jobs', method: 'POST', params: { @@ -24,41 +35,50 @@ export async function createJobs({ }, }); + // a known error occurred + if (res?.errorCode) { + toasts.addDanger({ + title: errorToastTitle, + text: MLErrorMessages[res.errorCode], + }); + return false; + } + + // job created successfully toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.succeeded.title', - { defaultMessage: 'Anomaly detection jobs created' } - ), - text: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.succeeded.text', - { - defaultMessage: - 'Anomaly detection jobs successfully created for APM service environments [{environments}]. It will take some time for machine learning to start analyzing traffic for anomalies.', - values: { environments: environments.join(', ') }, - } - ), + title: successToastTitle, + text: getSuccessToastMessage(environments), }); return true; + + // an unknown/unexpected error occurred } catch (error) { toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.failed.title', - { - defaultMessage: 'Anomaly detection jobs could not be created', - } - ), - text: i18n.translate( - 'xpack.apm.anomalyDetection.createJobs.failed.text', - { - defaultMessage: - 'Something went wrong when creating one ore more anomaly detection jobs for APM service environments [{environments}]. Error: "{errorMessage}"', - values: { - environments: environments.join(', '), - errorMessage: error.message, - }, - } - ), + title: errorToastTitle, + text: getErrorToastMessage(environments, error), }); return false; } } + +function getSuccessToastMessage(environments: string[]) { + return i18n.translate( + 'xpack.apm.anomalyDetection.createJobs.succeeded.text', + { + defaultMessage: + 'Anomaly detection jobs successfully created for APM service environments [{environments}]. It will take some time for machine learning to start analyzing traffic for anomalies.', + values: { environments: environments.join(', ') }, + } + ); +} + +function getErrorToastMessage(environments: string[], error: Error) { + return i18n.translate('xpack.apm.anomalyDetection.createJobs.failed.text', { + defaultMessage: + 'Something went wrong when creating one ore more anomaly detection jobs for APM service environments [{environments}]. Error: "{errorMessage}"', + values: { + environments: environments.join(', '), + errorMessage: error.message, + }, + }); +} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx index f02350fafbabb..abbe1e2c83c7b 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/index.tsx @@ -7,7 +7,9 @@ import React, { useState } from 'react'; import { EuiTitle, EuiSpacer, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { EuiPanel } from '@elastic/eui'; +import { EuiPanel, EuiEmptyPrompt } from '@elastic/eui'; +import { MLErrorMessages } from '../../../../../common/anomaly_detection'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; import { JobsList } from './jobs_list'; import { AddEnvironments } from './add_environments'; import { useFetcher } from '../../../../hooks/useFetcher'; @@ -16,24 +18,31 @@ import { useLicense } from '../../../../hooks/useLicense'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; export type AnomalyDetectionApiResponse = APIReturnType< - '/api/apm/settings/anomaly-detection' + '/api/apm/settings/anomaly-detection', + 'GET' >; const DEFAULT_VALUE: AnomalyDetectionApiResponse = { jobs: [], hasLegacyJobs: false, + errorCode: undefined, }; export const AnomalyDetection = () => { + const plugin = useApmPluginContext(); + const canGetJobs = !!plugin.core.application.capabilities.ml.canGetJobs; const license = useLicense(); const hasValidLicense = license?.isActive && license?.hasAtLeast('platinum'); const [viewAddEnvironments, setViewAddEnvironments] = useState(false); const { refetch, data = DEFAULT_VALUE, status } = useFetcher( - (callApmApi) => - callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }), - [], + (callApmApi) => { + if (canGetJobs) { + return callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }); + } + }, + [canGetJobs], { preservePreviousData: false, showToastOnError: false } ); @@ -53,6 +62,17 @@ export const AnomalyDetection = () => { ); } + if (!canGetJobs) { + return ( + + {MLErrorMessages.MISSING_READ_PRIVILEGES}} + /> + + ); + } + return ( <> @@ -83,9 +103,8 @@ export const AnomalyDetection = () => { /> ) : ( { setViewAddEnvironments(true); }} diff --git a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx index 5954b82f3b9e7..67227f99cb5f1 100644 --- a/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx +++ b/x-pack/plugins/apm/public/components/app/Settings/anomaly_detection/jobs_list.tsx @@ -16,6 +16,10 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { + MLErrorMessages, + ErrorCode, +} from '../../../../../common/anomaly_detection'; import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; @@ -57,21 +61,12 @@ const columns: Array> = [ ]; interface Props { + data: AnomalyDetectionApiResponse; status: FETCH_STATUS; onAddEnvironments: () => void; - jobs: Jobs; - hasLegacyJobs: boolean; } -export const JobsList = ({ - status, - onAddEnvironments, - jobs, - hasLegacyJobs, -}: Props) => { - const isLoading = - status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; - - const hasFetchFailure = status === FETCH_STATUS.FAILURE; +export const JobsList = ({ data, status, onAddEnvironments }: Props) => { + const { jobs, hasLegacyJobs, errorCode } = data; return ( @@ -120,15 +115,10 @@ export const JobsList = ({ - ) : hasFetchFailure ? ( - - ) : ( - - ) - } + noItemsMessage={getNoItemsMessage({ + status, + errorCode, + })} columns={columns} items={jobs} /> @@ -139,28 +129,36 @@ export const JobsList = ({ ); }; -function EmptyStatePrompt() { - return ( - <> - {i18n.translate( - 'xpack.apm.settings.anomalyDetection.jobList.emptyListText', - { - defaultMessage: 'No anomaly detection jobs.', - } - )} - - ); -} +function getNoItemsMessage({ + status, + errorCode, +}: { + status: FETCH_STATUS; + errorCode?: ErrorCode; +}) { + // loading state + const isLoading = + status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; + if (isLoading) { + return ; + } -function FailureStatePrompt() { - return ( - <> - {i18n.translate( - 'xpack.apm.settings.anomalyDetection.jobList.failedFetchText', - { - defaultMessage: 'Unabled to fetch anomaly detection jobs.', - } - )} - + // A known error occured. Show specific error message + if (errorCode) { + return MLErrorMessages[errorCode]; + } + + // An unexpected error occurred. Show default error message + if (status === FETCH_STATUS.FAILURE) { + return i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.failedFetchText', + { defaultMessage: 'Unabled to fetch anomaly detection jobs.' } + ); + } + + // no errors occurred + return i18n.translate( + 'xpack.apm.settings.anomalyDetection.jobList.emptyListText', + { defaultMessage: 'No anomaly detection jobs.' } ); } diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx index 268d8bd7ea823..2149cb676f0d8 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx @@ -6,37 +6,48 @@ import { showAlert } from './AnomalyDetectionSetupLink'; +const dataWithJobs = { + hasLegacyJobs: false, + jobs: [ + { job_id: 'job1', environment: 'staging' }, + { job_id: 'job2', environment: 'production' }, + ], +}; +const dataWithoutJobs = ({ jobs: [] } as unknown) as any; + describe('#showAlert', () => { describe('when an environment is selected', () => { it('should return true when there are no jobs', () => { - const result = showAlert([], 'testing'); + const result = showAlert(dataWithoutJobs, 'testing'); expect(result).toBe(true); }); it('should return true when environment is not included in the jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - 'testing' - ); + const result = showAlert(dataWithJobs, 'testing'); expect(result).toBe(true); }); it('should return false when environment is included in the jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - 'staging' - ); + const result = showAlert(dataWithJobs, 'staging'); expect(result).toBe(false); }); }); + describe('there is no environment selected (All)', () => { it('should return true when there are no jobs', () => { - const result = showAlert([], undefined); + const result = showAlert(dataWithoutJobs, undefined); expect(result).toBe(true); }); it('should return false when there are any number of jobs', () => { - const result = showAlert( - [{ environment: 'staging' }, { environment: 'production' }], - undefined - ); + const result = showAlert(dataWithJobs, undefined); + expect(result).toBe(false); + }); + }); + + describe('when a known error occurred', () => { + it('should return false', () => { + const data = ({ + errorCode: 'MISSING_READ_PRIVILEGES', + } as unknown) as any; + const result = showAlert(data, undefined); expect(result).toBe(false); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx index 6f3a5df480d7e..e989244d43148 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -6,16 +6,25 @@ import React from 'react'; import { EuiButtonEmpty, EuiToolTip, EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { ErrorCode } from '../../../../../common/anomaly_detection'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { APMLink } from './APMLink'; import { getEnvironmentLabel } from '../../../../../common/environment_filter_values'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; +export type AnomalyDetectionApiResponse = APIReturnType< + '/api/apm/settings/anomaly-detection', + 'GET' +>; + +const DEFAULT_DATA = { jobs: [], hasLegacyJobs: false, errorCode: undefined }; + export function AnomalyDetectionSetupLink() { const { uiFilters } = useUrlParams(); const environment = uiFilters.environment; - const { data = { jobs: [], hasLegacyJobs: false }, status } = useFetcher( + const { data = DEFAULT_DATA, status } = useFetcher( (callApmApi) => callApmApi({ pathname: `/api/apm/settings/anomaly-detection` }), [], @@ -28,7 +37,7 @@ export function AnomalyDetectionSetupLink() { {ANOMALY_DETECTION_LINK_LABEL} - {isFetchSuccess && showAlert(data.jobs, environment) && ( + {isFetchSuccess && showAlert(data, environment) && ( @@ -59,9 +68,14 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( ); export function showAlert( - jobs: Array<{ environment: string }> = [], + { jobs = [], errorCode }: AnomalyDetectionApiResponse, environment: string | undefined ) { + // don't show warning if the user is missing read privileges + if (errorCode === ErrorCode.MISSING_READ_PRIVILEGES) { + return false; + } + return ( // No job exists, or jobs.length === 0 || diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts new file mode 100644 index 0000000000000..993dcf4c5354b --- /dev/null +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/anomaly_detection_error.ts @@ -0,0 +1,16 @@ +/* + * 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 { ErrorCode, MLErrorMessages } from '../../../common/anomaly_detection'; + +export class AnomalyDetectionError extends Error { + constructor(public code: ErrorCode) { + super(MLErrorMessages[code]); + + this.name = this.constructor.name; + Error.captureStackTrace(this, this.constructor); + } +} diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts index c387c5152b1c5..e5338ac9f5797 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/create_anomaly_detection_jobs.ts @@ -7,6 +7,7 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; import { snakeCase } from 'lodash'; +import { ErrorCode } from '../../../common/anomaly_detection'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; import { @@ -15,6 +16,7 @@ import { } from '../../../common/elasticsearch_fieldnames'; import { APM_ML_JOB_GROUP, ML_MODULE_ID_APM_TRANSACTION } from './constants'; import { getEnvironmentUiFilterES } from '../helpers/convert_ui_filters/get_environment_ui_filter_es'; +import { AnomalyDetectionError } from './anomaly_detection_error'; export type CreateAnomalyDetectionJobsAPIResponse = PromiseReturnType< typeof createAnomalyDetectionJobs @@ -25,21 +27,20 @@ export async function createAnomalyDetectionJobs( logger: Logger ) { const { ml, indices } = setup; + if (!ml) { - logger.warn('Anomaly detection plugin is not available.'); - return []; + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE); } + const mlCapabilities = await ml.mlSystem.mlCapabilities(); if (!mlCapabilities.mlFeatureEnabledInSpace) { - logger.warn('Anomaly detection feature is not enabled for the space.'); - return []; + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE_IN_SPACE); } + if (!mlCapabilities.isPlatinumOrTrialLicense) { - logger.warn( - 'Unable to create anomaly detection jobs due to insufficient license.' - ); - return []; + throw new AnomalyDetectionError(ErrorCode.INSUFFICIENT_LICENSE); } + logger.info( `Creating ML anomaly detection jobs for environments: [${environments}].` ); @@ -59,9 +60,8 @@ export async function createAnomalyDetectionJobs( `Failed to create anomaly detection ML jobs for: [${failedJobIds}]:` ); failedJobs.forEach(({ error }) => logger.error(JSON.stringify(error))); - throw new Error( - `Failed to create anomaly detection ML jobs for: [${failedJobIds}].` - ); + + throw new AnomalyDetectionError(ErrorCode.UNEXPECTED); } return jobResponses; @@ -70,11 +70,11 @@ export async function createAnomalyDetectionJobs( async function createAnomalyDetectionJob({ ml, environment, - indexPatternName = 'apm-*-transaction-*', + indexPatternName, }: { ml: Required['ml']; environment: string; - indexPatternName?: string | undefined; + indexPatternName: string; }) { const randomToken = uuid().substr(-4); diff --git a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts index 13b30f159eed1..62d4243a06028 100644 --- a/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts +++ b/x-pack/plugins/apm/server/lib/anomaly_detection/get_anomaly_detection_jobs.ts @@ -5,8 +5,10 @@ */ import { Logger } from 'kibana/server'; +import { ErrorCode } from '../../../common/anomaly_detection'; import { Setup } from '../helpers/setup_request'; import { getMlJobsWithAPMGroup } from './get_ml_jobs_with_apm_group'; +import { AnomalyDetectionError } from './anomaly_detection_error'; export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const { ml } = setup; @@ -15,14 +17,12 @@ export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { } const mlCapabilities = await ml.mlSystem.mlCapabilities(); - if ( - !( - mlCapabilities.mlFeatureEnabledInSpace && - mlCapabilities.isPlatinumOrTrialLicense - ) - ) { - logger.warn('Anomaly detection integration is not availble for this user.'); - return []; + if (!mlCapabilities.mlFeatureEnabledInSpace) { + throw new AnomalyDetectionError(ErrorCode.ML_NOT_AVAILABLE_IN_SPACE); + } + + if (!mlCapabilities.isPlatinumOrTrialLicense) { + throw new AnomalyDetectionError(ErrorCode.INSUFFICIENT_LICENSE); } const response = await getMlJobsWithAPMGroup(ml); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts index 4d564b773e397..218d47fcf9bb4 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection.ts @@ -5,12 +5,32 @@ */ import * as t from 'io-ts'; +import { ErrorCode } from '../../../common/anomaly_detection'; +import { PromiseReturnType } from '../../../typings/common'; +import { InsufficientMLCapabilities } from '../../../../ml/server'; import { createRoute } from '../create_route'; import { getAnomalyDetectionJobs } from '../../lib/anomaly_detection/get_anomaly_detection_jobs'; import { createAnomalyDetectionJobs } from '../../lib/anomaly_detection/create_anomaly_detection_jobs'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getAllEnvironments } from '../../lib/environments/get_all_environments'; import { hasLegacyJobs } from '../../lib/anomaly_detection/has_legacy_jobs'; +import { AnomalyDetectionError } from '../../lib/anomaly_detection/anomaly_detection_error'; + +type Jobs = PromiseReturnType; + +function getMlErrorCode(e: Error) { + // Missing privileges + if (e instanceof InsufficientMLCapabilities) { + return ErrorCode.MISSING_READ_PRIVILEGES; + } + + if (e instanceof AnomalyDetectionError) { + return e.code; + } + + // unexpected error + return ErrorCode.UNEXPECTED; +} // get ML anomaly detection jobs for each environment export const anomalyDetectionJobsRoute = createRoute(() => ({ @@ -18,14 +38,25 @@ export const anomalyDetectionJobsRoute = createRoute(() => ({ path: '/api/apm/settings/anomaly-detection', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); - const [jobs, legacyJobs] = await Promise.all([ - getAnomalyDetectionJobs(setup, context.logger), - hasLegacyJobs(setup), - ]); - return { - jobs, - hasLegacyJobs: legacyJobs, - }; + + try { + const [jobs, legacyJobs] = await Promise.all([ + getAnomalyDetectionJobs(setup, context.logger), + hasLegacyJobs(setup), + ]); + return { + jobs, + hasLegacyJobs: legacyJobs, + }; + } catch (e) { + const mlErrorCode = getMlErrorCode(e); + context.logger.warn(`Error while retrieving ML jobs: "${e.message}"`); + return { + jobs: [] as Jobs, + hasLegacyJobs: false, + errorCode: mlErrorCode, + }; + } }, })); @@ -44,11 +75,16 @@ export const createAnomalyDetectionJobsRoute = createRoute(() => ({ handler: async ({ context, request }) => { const { environments } = context.params.body; const setup = await setupRequest(context, request); - return await createAnomalyDetectionJobs( - setup, - environments, - context.logger - ); + + try { + await createAnomalyDetectionJobs(setup, environments, context.logger); + } catch (e) { + const mlErrorCode = getMlErrorCode(e); + context.logger.warn(`Error while creating ML job: "${e.message}"`); + return { + errorCode: mlErrorCode, + }; + } }, }));