diff --git a/x-pack/plugins/apm/common/anomaly_detection.ts b/x-pack/plugins/apm/common/anomaly_detection.ts new file mode 100644 index 0000000000000..1fd927d82f186 --- /dev/null +++ b/x-pack/plugins/apm/common/anomaly_detection.ts @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export interface ServiceAnomalyStats { + transactionType?: string; + anomalyScore?: number; + actualValue?: number; + jobId?: string; +} diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 43f3585d0ebb2..b50db270ef544 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -15,11 +15,13 @@ import { SPAN_SUBTYPE, SPAN_TYPE, } from './elasticsearch_fieldnames'; +import { ServiceAnomalyStats } from './anomaly_detection'; export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition { [SERVICE_NAME]: string; [SERVICE_ENVIRONMENT]: string | null; [AGENT_NAME]: string; + serviceAnomalyStats?: ServiceAnomalyStats; } export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition { [SPAN_DESTINATION_SERVICE_RESOURCE]: string; @@ -37,8 +39,10 @@ export interface Connection { export interface ServiceNodeMetrics { avgMemoryUsage: number | null; avgCpuUsage: number | null; - avgTransactionDuration: number | null; - avgRequestsPerMinute: number | null; + transactionStats: { + avgTransactionDuration: number | null; + avgRequestsPerMinute: number | null; + }; avgErrorsPerMinute: number | null; } 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 new file mode 100644 index 0000000000000..410ba8b5027fb --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/AnomalyDetection.tsx @@ -0,0 +1,158 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import React from 'react'; +import styled from 'styled-components'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiIconTip, + EuiHealth, +} from '@elastic/eui'; +import { useTheme } from '../../../../hooks/useTheme'; +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'; + +const HealthStatusTitle = styled(EuiTitle)` + display: inline; + text-transform: uppercase; +`; + +const VerticallyCentered = styled.div` + display: flex; + align-items: center; +`; + +const SubduedText = styled.span` + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; +`; + +const EnableText = styled.section` + color: ${({ theme }) => theme.eui.euiTextSubduedColor}; + line-height: 1.4; + font-size: ${fontSize}; + width: ${px(popoverWidth)}; +`; + +export const ContentLine = styled.section` + line-height: 2; +`; + +interface Props { + serviceName: string; + serviceAnomalyStats: ServiceAnomalyStats | undefined; +} +export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) { + const theme = useTheme(); + + const anomalyScore = serviceAnomalyStats?.anomalyScore; + const anomalySeverity = getSeverity(anomalyScore); + const actualValue = serviceAnomalyStats?.actualValue; + const mlJobId = serviceAnomalyStats?.jobId; + const transactionType = + serviceAnomalyStats?.transactionType ?? TRANSACTION_REQUEST; + const hasAnomalyDetectionScore = anomalyScore !== undefined; + + return ( + <> + + + {ANOMALY_DETECTION_TITLE} + + + + {!mlJobId && {ANOMALY_DETECTION_DISABLED_TEXT}} + + {hasAnomalyDetectionScore && ( + + + + + + {ANOMALY_DETECTION_SCORE_METRIC} + + + + + {getDisplayedAnomalyScore(anomalyScore as number)} + {actualValue && ( + ({asDuration(actualValue)}) + )} + + + + + )} + {mlJobId && !hasAnomalyDetectionScore && ( + {ANOMALY_DETECTION_NO_DATA_TEXT} + )} + {mlJobId && ( + + + {ANOMALY_DETECTION_LINK} + + + )} + > + ); +} + +function getDisplayedAnomalyScore(score: number) { + if (score > 0 && score < 1) { + return '< 1'; + } + return asInteger(score); +} + +const ANOMALY_DETECTION_TITLE = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverTitle', + { defaultMessage: 'Anomaly Detection' } +); + +const ANOMALY_DETECTION_TOOLTIP = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverTooltip', + { + defaultMessage: + 'Service health indicators are powered by the anomaly detection feature in machine learning', + } +); + +const ANOMALY_DETECTION_SCORE_METRIC = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric', + { defaultMessage: 'Score (max.)' } +); + +const ANOMALY_DETECTION_LINK = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverLink', + { defaultMessage: 'View anomalies' } +); + +const ANOMALY_DETECTION_DISABLED_TEXT = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverDisabled', + { + defaultMessage: + 'Display service health indicators by enabling anomaly detection in APM settings.', + } +); + +const ANOMALY_DETECTION_NO_DATA_TEXT = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverNoData', + { + defaultMessage: `We couldn't find an anomaly score within the selected time range. See details in the anomaly explorer.`, + } +); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx index 78779bdcc2052..c696a93773ceb 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Contents.tsx @@ -15,7 +15,7 @@ import React, { MouseEvent } from 'react'; import { Buttons } from './Buttons'; import { Info } from './Info'; import { ServiceMetricFetcher } from './ServiceMetricFetcher'; -import { popoverMinWidth } from '../cytoscapeOptions'; +import { popoverWidth } from '../cytoscapeOptions'; interface ContentsProps { isService: boolean; @@ -60,7 +60,7 @@ export function Contents({ @@ -68,16 +68,12 @@ export function Contents({ - {/* //TODO [APM ML] add service health stats here: - isService && ( - - - - - )*/} {isService ? ( - + ) : ( )} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx index 2edd36f0d1380..ccf147ed1d90d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/Popover.stories.tsx @@ -12,40 +12,33 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module) .add('example', () => ( - )) - .add('loading', () => ( - )) .add('some null values', () => ( )) .add('all null values', () => ( )); diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx index 718e43984d7f3..957678877a134 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricFetcher.tsx @@ -5,23 +5,38 @@ */ import React from 'react'; +import { + EuiLoadingSpinner, + EuiFlexGroup, + EuiHorizontalRule, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { isNumber } from 'lodash'; import { ServiceNodeMetrics } from '../../../../../common/service_map'; -import { useFetcher } from '../../../../hooks/useFetcher'; +import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { ServiceMetricList } from './ServiceMetricList'; +import { AnomalyDetection } from './AnomalyDetection'; +import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection'; interface ServiceMetricFetcherProps { serviceName: string; + serviceAnomalyStats: ServiceAnomalyStats | undefined; } export function ServiceMetricFetcher({ serviceName, + serviceAnomalyStats, }: ServiceMetricFetcherProps) { const { urlParams: { start, end, environment }, } = useUrlParams(); - const { data = {} as ServiceNodeMetrics, status } = useFetcher( + const { + data = { transactionStats: {} } as ServiceNodeMetrics, + status, + } = useFetcher( (callApmApi) => { if (serviceName && start && end) { return callApmApi({ @@ -35,7 +50,62 @@ export function ServiceMetricFetcher({ preservePreviousData: false, } ); - const isLoading = status === 'loading'; - return ; + const isLoading = + status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING; + + if (isLoading) { + return ; + } + + const { + avgCpuUsage, + avgErrorsPerMinute, + avgMemoryUsage, + transactionStats: { avgRequestsPerMinute, avgTransactionDuration }, + } = data; + + const hasServiceData = [ + avgCpuUsage, + avgErrorsPerMinute, + avgMemoryUsage, + avgRequestsPerMinute, + avgTransactionDuration, + ].some((stat) => isNumber(stat)); + + if (environment && !hasServiceData) { + return ( + + {i18n.translate('xpack.apm.serviceMap.popoverMetrics.noDataText', { + defaultMessage: `No data for selected environment. Try switching to another environment.`, + })} + + ); + } + return ( + <> + {serviceAnomalyStats && ( + <> + + + > + )} + + > + ); +} + +function LoadingSpinner() { + return ( + + + + ); } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx index d66be9c61e42d..f82f434e7ded1 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { isNumber } from 'lodash'; import React from 'react'; @@ -12,18 +11,6 @@ import styled from 'styled-components'; import { ServiceNodeMetrics } from '../../../../../common/service_map'; import { asDuration, asPercent, tpmUnit } from '../../../../utils/formatters'; -function LoadingSpinner() { - return ( - - - - ); -} - export const ItemRow = styled('tr')` line-height: 2; `; @@ -37,17 +24,13 @@ export const ItemDescription = styled('td')` text-align: right; `; -interface ServiceMetricListProps extends ServiceNodeMetrics { - isLoading: boolean; -} +type ServiceMetricListProps = ServiceNodeMetrics; export function ServiceMetricList({ - avgTransactionDuration, - avgRequestsPerMinute, avgErrorsPerMinute, avgCpuUsage, avgMemoryUsage, - isLoading, + transactionStats, }: ServiceMetricListProps) { const listItems = [ { @@ -57,8 +40,8 @@ export function ServiceMetricList({ defaultMessage: 'Trans. duration (avg.)', } ), - description: isNumber(avgTransactionDuration) - ? asDuration(avgTransactionDuration) + description: isNumber(transactionStats.avgTransactionDuration) + ? asDuration(transactionStats.avgTransactionDuration) : null, }, { @@ -68,8 +51,10 @@ export function ServiceMetricList({ defaultMessage: 'Req. per minute (avg.)', } ), - description: isNumber(avgRequestsPerMinute) - ? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}` + description: isNumber(transactionStats.avgRequestsPerMinute) + ? `${transactionStats.avgRequestsPerMinute.toFixed(2)} ${tpmUnit( + 'request' + )}` : null, }, { @@ -100,9 +85,7 @@ export function ServiceMetricList({ }, ]; - return isLoading ? ( - - ) : ( + return ( {listItems.map( 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 5a2a3d2a2644e..dfcfbee1806a4 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -10,10 +10,11 @@ import { SPAN_DESTINATION_SERVICE_RESOURCE, } from '../../../../common/elasticsearch_fieldnames'; import { EuiTheme } from '../../../../../observability/public'; -import { severity } from '../../../../common/ml_job_constants'; +import { severity, getSeverity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; +import { ServiceAnomalyStats } from '../../../../common/anomaly_detection'; -export const popoverMinWidth = 280; +export const popoverWidth = 280; export function getSeverityColor(theme: EuiTheme, nodeSeverity?: string) { switch (nodeSeverity) { @@ -29,12 +30,19 @@ export function getSeverityColor(theme: EuiTheme, nodeSeverity?: string) { } } +function getNodeSeverity(el: cytoscape.NodeSingular) { + const serviceAnomalyStats: ServiceAnomalyStats | undefined = el.data( + 'serviceAnomalyStats' + ); + return getSeverity(serviceAnomalyStats?.anomalyScore); +} + function getBorderColorFn( theme: EuiTheme ): cytoscape.Css.MapperFunction { return (el: cytoscape.NodeSingular) => { - const hasAnomalyDetectionJob = el.data('ml_job_id') !== undefined; - const nodeSeverity = el.data('anomaly_severity'); + const hasAnomalyDetectionJob = el.data('serviceAnomalyStats') !== undefined; + const nodeSeverity = getNodeSeverity(el); if (hasAnomalyDetectionJob) { return ( getSeverityColor(theme, nodeSeverity) || theme.eui.euiColorMediumShade @@ -51,7 +59,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction< cytoscape.NodeSingular, cytoscape.Css.LineStyle > = (el: cytoscape.NodeSingular) => { - const nodeSeverity = el.data('anomaly_severity'); + const nodeSeverity = getNodeSeverity(el); if (nodeSeverity === severity.critical) { return 'double'; } else { @@ -60,7 +68,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction< }; function getBorderWidth(el: cytoscape.NodeSingular) { - const nodeSeverity = el.data('anomaly_severity'); + const nodeSeverity = getNodeSeverity(el); if (nodeSeverity === severity.minor || nodeSeverity === severity.major) { return 4; diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index b4cf3a65fea35..c832d3ded6175 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -18,8 +18,24 @@ describe('MLJobLink', () => { { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location ); - expect(href).toEqual( - `/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))` + expect(href).toMatchInlineSnapshot( + `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))"` + ); + }); + it('should produce the correct URL with jobId, serviceName, and transactionType', async () => { + const href = await getRenderedHref( + () => ( + + ), + { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + ); + + expect(href).toMatchInlineSnapshot( + `"/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))&_a=(mlTimeSeriesExplorer:(entities:(service.name:opbeans-test,transaction.type:request)))"` ); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx index 1e1f9ea5f23b7..f3c5b49287293 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.tsx @@ -5,24 +5,35 @@ */ import React from 'react'; -import { MLLink } from './MLLink'; +import { EuiLink } from '@elastic/eui'; +import { useTimeSeriesExplorerHref } from './useTimeSeriesExplorerHref'; interface Props { jobId: string; external?: boolean; + serviceName?: string; + transactionType?: string; } -export const MLJobLink: React.FC = (props) => { - const query = { - ml: { jobIds: [props.jobId] }, - }; +export const MLJobLink: React.FC = ({ + jobId, + serviceName, + transactionType, + external, + children, +}) => { + const href = useTimeSeriesExplorerHref({ + jobId, + serviceName, + transactionType, + }); return ( - ); }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts new file mode 100644 index 0000000000000..625b9205b6ce0 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/useTimeSeriesExplorerHref.ts @@ -0,0 +1,58 @@ +/* + * 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 url from 'url'; +import querystring from 'querystring'; +import rison from 'rison-node'; +import { useLocation } from '../../../../hooks/useLocation'; +import { useApmPluginContext } from '../../../../hooks/useApmPluginContext'; +import { getTimepickerRisonData } from '../rison_helpers'; + +export function useTimeSeriesExplorerHref({ + jobId, + serviceName, + transactionType, +}: { + jobId: string; + serviceName?: string; + transactionType?: string; +}) { + const { core } = useApmPluginContext(); + const location = useLocation(); + + const search = querystring.stringify( + { + _g: rison.encode({ + ml: { jobIds: [jobId] }, + ...getTimepickerRisonData(location.search), + }), + ...(serviceName && transactionType + ? { + _a: rison.encode({ + mlTimeSeriesExplorer: { + entities: { + 'service.name': serviceName, + 'transaction.type': transactionType, + }, + }, + }), + } + : null), + }, + undefined, + undefined, + { + encodeURIComponent(str: string) { + return str; + }, + } + ); + + return url.format({ + pathname: core.http.basePath.prepend('/app/ml'), + hash: url.format({ pathname: '/timeseriesexplorer', search }), + }); +} 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 e74a546beb2d3..e723393a24013 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 @@ -6,6 +6,7 @@ import { Logger } from 'kibana/server'; import uuid from 'uuid/v4'; +import { snakeCase } from 'lodash'; import { PromiseReturnType } from '../../../../observability/typings/common'; import { Setup } from '../helpers/setup_request'; import { @@ -76,13 +77,12 @@ async function createAnomalyDetectionJob({ environment: string; indexPatternName?: string | undefined; }) { - const convertedEnvironmentName = convertToMLIdentifier(environment); const randomToken = uuid().substr(-4); return ml.modules.setup({ moduleId: ML_MODULE_ID_APM_TRANSACTION, - prefix: `${APM_ML_JOB_GROUP}-${convertedEnvironmentName}-${randomToken}-`, - groups: [APM_ML_JOB_GROUP, convertedEnvironmentName], + prefix: `${APM_ML_JOB_GROUP}-${snakeCase(environment)}-${randomToken}-`, + groups: [APM_ML_JOB_GROUP], indexPatternName, query: { bool: { @@ -99,7 +99,11 @@ async function createAnomalyDetectionJob({ jobOverrides: [ { custom_settings: { - job_tags: { environment }, + job_tags: { + environment, + // identifies this as an APM ML job & facilitates future migrations + apm_ml_version: 2, + }, }, }, ], @@ -115,7 +119,3 @@ const ENVIRONMENT_NOT_DEFINED_FILTER = { }, }, }; - -export function convertToMLIdentifier(value: string) { - return value.replace(/\s+/g, '_').toLowerCase(); -} 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 0d00adbfedf41..8fdebeb597eaf 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 @@ -27,12 +27,12 @@ export async function getAnomalyDetectionJobs(setup: Setup, logger: Logger) { const response = await getMlJobsWithAPMGroup(ml); return response.jobs + .filter((job) => (job.custom_settings?.job_tags?.apm_ml_version ?? 0) >= 2) .map((job) => { const environment = job.custom_settings?.job_tags?.environment ?? ''; return { job_id: job.job_id, environment, }; - }) - .filter((job) => job.environment); + }); } 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 new file mode 100644 index 0000000000000..3e5ef5eb37b02 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -0,0 +1,166 @@ +/* + * 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 { Logger } from 'kibana/server'; +import { Setup, SetupTimeRange } from '../helpers/setup_request'; +import { PromiseReturnType } from '../../../typings/common'; +import { + TRANSACTION_PAGE_LOAD, + TRANSACTION_REQUEST, +} from '../../../common/transaction_types'; +import { ServiceAnomalyStats } from '../../../common/anomaly_detection'; +import { APM_ML_JOB_GROUP } from '../anomaly_detection/constants'; + +export const DEFAULT_ANOMALIES = { mlJobIds: [], serviceAnomalies: {} }; + +export type ServiceAnomaliesResponse = PromiseReturnType< + typeof getServiceAnomalies +>; + +export async function getServiceAnomalies({ + setup, + logger, + environment, +}: { + setup: Setup & SetupTimeRange; + logger: Logger; + environment?: string; +}) { + const { ml, start, end } = setup; + + if (!ml) { + logger.warn('Anomaly detection plugin is not available.'); + return DEFAULT_ANOMALIES; + } + const mlCapabilities = await ml.mlSystem.mlCapabilities(); + if (!mlCapabilities.mlFeatureEnabledInSpace) { + logger.warn('Anomaly detection feature is not enabled for the space.'); + return DEFAULT_ANOMALIES; + } + if (!mlCapabilities.isPlatinumOrTrialLicense) { + logger.warn( + 'Unable to create anomaly detection jobs due to insufficient license.' + ); + return DEFAULT_ANOMALIES; + } + + let mlJobIds: string[] = []; + try { + mlJobIds = await getMLJobIds(ml, environment); + } catch (error) { + logger.error(error); + return DEFAULT_ANOMALIES; + } + + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { result_type: 'record' } }, + { terms: { job_id: mlJobIds } }, + { + range: { + timestamp: { gte: start, lte: end, format: 'epoch_millis' }, + }, + }, + { + terms: { + // Only retrieving anomalies for transaction types "request" and "page-load" + by_field_value: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], + }, + }, + ], + }, + }, + aggs: { + services: { + terms: { field: 'partition_field_value' }, + aggs: { + top_score: { + top_hits: { + sort: { record_score: 'desc' }, + _source: { includes: ['actual', 'job_id', 'by_field_value'] }, + size: 1, + }, + }, + }, + }, + }, + }, + }; + const response = await ml.mlSystem.mlAnomalySearch(params); + return { + mlJobIds, + serviceAnomalies: transformResponseToServiceAnomalies( + response as ServiceAnomaliesAggResponse + ), + }; +} + +interface ServiceAnomaliesAggResponse { + aggregations: { + services: { + buckets: Array<{ + key: string; + top_score: { + hits: { + hits: Array<{ + sort: [number]; + _source: { + actual: [number]; + job_id: string; + by_field_value: string; + }; + }>; + }; + }; + }>; + }; + }; +} + +function transformResponseToServiceAnomalies( + response: ServiceAnomaliesAggResponse +): Record { + const serviceAnomaliesMap = response.aggregations.services.buckets.reduce( + (statsByServiceName, { key: serviceName, top_score: topScoreAgg }) => { + return { + ...statsByServiceName, + [serviceName]: { + transactionType: topScoreAgg.hits.hits[0]?._source?.by_field_value, + anomalyScore: topScoreAgg.hits.hits[0]?.sort?.[0], + actualValue: topScoreAgg.hits.hits[0]?._source?.actual?.[0], + jobId: topScoreAgg.hits.hits[0]?._source?.job_id, + }, + }; + }, + {} + ); + return serviceAnomaliesMap; +} + +export async function getMLJobIds( + ml: Required['ml'], + environment?: string +) { + const response = await ml.anomalyDetectors.jobs(APM_ML_JOB_GROUP); + // 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) { + const matchingMLJob = mlJobs.find( + (job) => job.custom_settings?.job_tags?.environment === environment + ); + if (!matchingMLJob) { + throw new Error(`ML job Not Found for environment "${environment}".`); + } + return [matchingMLJob.job_id]; + } + return mlJobs.map((job) => job.job_id); +} diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts index 4d488cd1a5509..ea2bb14efdfc7 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { chunk } from 'lodash'; +import { Logger } from 'kibana/server'; import { AGENT_NAME, SERVICE_ENVIRONMENT, @@ -16,11 +17,17 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request'; import { transformServiceMapResponses } from './transform_service_map_responses'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; +import { + getServiceAnomalies, + ServiceAnomaliesResponse, + DEFAULT_ANOMALIES, +} from './get_service_anomalies'; export interface IEnvOptions { setup: Setup & SetupTimeRange; serviceName?: string; environment?: string; + logger: Logger; } async function getConnectionData({ @@ -132,13 +139,23 @@ export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; export async function getServiceMap(options: IEnvOptions) { - const [connectionData, servicesData] = await Promise.all([ + const { logger } = options; + const anomaliesPromise: Promise = getServiceAnomalies( + options + ).catch((error) => { + logger.warn(`Unable to retrieve anomalies for service maps.`); + logger.error(error); + return DEFAULT_ANOMALIES; + }); + const [connectionData, servicesData, anomalies] = await Promise.all([ getConnectionData(options), getServicesData(options), + anomaliesPromise, ]); return transformServiceMapResponses({ ...connectionData, services: servicesData, + anomalies, }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts index e521efa687388..be92bfe5a0099 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -12,11 +12,17 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, TRANSACTION_DURATION, + TRANSACTION_TYPE, METRIC_SYSTEM_CPU_PERCENT, METRIC_SYSTEM_FREE_MEMORY, METRIC_SYSTEM_TOTAL_MEMORY, } from '../../../common/elasticsearch_fieldnames'; import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory'; +import { + TRANSACTION_REQUEST, + TRANSACTION_PAGE_LOAD, +} from '../../../common/transaction_types'; +import { ENVIRONMENT_NOT_DEFINED } from '../../../common/environment_filter_values'; interface Options { setup: Setup & SetupTimeRange; @@ -37,12 +43,23 @@ export async function getServiceMapServiceNodeInfo({ }: Options & { serviceName: string; environment?: string }) { const { start, end } = setup; + const environmentNotDefinedFilter = { + bool: { must_not: [{ exists: { field: SERVICE_ENVIRONMENT } }] }, + }; + const filter: ESFilter[] = [ { range: rangeFilter(start, end) }, { term: { [SERVICE_NAME]: serviceName } }, - ...(environment ? [{ term: { [SERVICE_ENVIRONMENT]: environment } }] : []), ]; + if (environment) { + filter.push( + environment === ENVIRONMENT_NOT_DEFINED + ? environmentNotDefinedFilter + : { term: { [SERVICE_ENVIRONMENT]: environment } } + ); + } + const minutes = Math.abs((end - start) / (1000 * 60)); const taskParams = { @@ -53,19 +70,19 @@ export async function getServiceMapServiceNodeInfo({ const [ errorMetrics, - transactionMetrics, + transactionStats, cpuMetrics, memoryMetrics, ] = await Promise.all([ getErrorMetrics(taskParams), - getTransactionMetrics(taskParams), + getTransactionStats(taskParams), getCpuMetrics(taskParams), getMemoryMetrics(taskParams), ]); return { ...errorMetrics, - ...transactionMetrics, + transactionStats, ...cpuMetrics, ...memoryMetrics, }; @@ -99,7 +116,7 @@ async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) { }; } -async function getTransactionMetrics({ +async function getTransactionStats({ setup, filter, minutes, @@ -109,17 +126,28 @@ async function getTransactionMetrics({ }> { const { indices, client } = setup; - const response = await client.search({ + const params = { index: indices['apm_oss.transactionIndices'], body: { - size: 1, + size: 0, query: { bool: { - filter: filter.concat({ - term: { - [PROCESSOR_EVENT]: 'transaction', + filter: [ + ...filter, + { + term: { + [PROCESSOR_EVENT]: 'transaction', + }, }, - }), + { + terms: { + [TRANSACTION_TYPE]: [ + TRANSACTION_REQUEST, + TRANSACTION_PAGE_LOAD, + ], + }, + }, + ], }, }, track_total_hits: true, @@ -131,14 +159,12 @@ async function getTransactionMetrics({ }, }, }, - }); - + }; + const response = await client.search(params); + const docCount = response.hits.total.value; return { avgTransactionDuration: response.aggregations?.duration.value ?? null, - avgRequestsPerMinute: - response.hits.total.value > 0 - ? response.hits.total.value / minutes - : null, + avgRequestsPerMinute: docCount > 0 ? docCount / minutes : null, }; } diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts index 1e26634bdf0f1..7e4bcfdda7382 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.test.ts @@ -35,6 +35,18 @@ const javaService = { [AGENT_NAME]: 'java', }; +const anomalies = { + mlJobIds: ['apm-test-1234-ml-module-name'], + serviceAnomalies: { + 'opbeans-test': { + transactionType: 'request', + actualValue: 10000, + anomalyScore: 50, + jobId: 'apm-test-1234-ml-module-name', + }, + }, +}; + describe('transformServiceMapResponses', () => { it('maps external destinations to internal services', () => { const response: ServiceMapResponse = { @@ -51,6 +63,7 @@ describe('transformServiceMapResponses', () => { destination: nodejsExternal, }, ], + anomalies, }; const { elements } = transformServiceMapResponses(response); @@ -89,6 +102,7 @@ describe('transformServiceMapResponses', () => { }, }, ], + anomalies, }; const { elements } = transformServiceMapResponses(response); @@ -126,6 +140,7 @@ describe('transformServiceMapResponses', () => { }, }, ], + anomalies, }; const { elements } = transformServiceMapResponses(response); @@ -150,6 +165,7 @@ describe('transformServiceMapResponses', () => { destination: nodejsService, }, ], + anomalies, }; const { elements } = transformServiceMapResponses(response); diff --git a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts index 2e394f44b25b1..7f5e34f68f922 100644 --- a/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts +++ b/x-pack/plugins/apm/server/lib/service_map/transform_service_map_responses.ts @@ -18,6 +18,7 @@ import { ExternalConnectionNode, } from '../../../common/service_map'; import { ConnectionsResponse, ServicesResponse } from './get_service_map'; +import { ServiceAnomaliesResponse } from './get_service_anomalies'; function getConnectionNodeId(node: ConnectionNode): string { if ('span.destination.service.resource' in node) { @@ -63,10 +64,11 @@ export function getServiceNodes(allNodes: ConnectionNode[]) { export type ServiceMapResponse = ConnectionsResponse & { services: ServicesResponse; + anomalies: ServiceAnomaliesResponse; }; export function transformServiceMapResponses(response: ServiceMapResponse) { - const { discoveredServices, services, connections } = response; + const { discoveredServices, services, connections, anomalies } = response; const allNodes = getAllNodes(services, connections); const serviceNodes = getServiceNodes(allNodes); @@ -100,21 +102,23 @@ export function transformServiceMapResponses(response: ServiceMapResponse) { serviceName = node[SERVICE_NAME]; } - const matchedServiceNodes = serviceNodes.filter( - (serviceNode) => serviceNode[SERVICE_NAME] === serviceName - ); + const matchedServiceNodes = serviceNodes + .filter((serviceNode) => serviceNode[SERVICE_NAME] === serviceName) + .map((serviceNode) => pickBy(serviceNode, identity)); + const mergedServiceNode = Object.assign({}, ...matchedServiceNodes); + + const serviceAnomalyStats = serviceName + ? anomalies.serviceAnomalies[serviceName] + : null; if (matchedServiceNodes.length) { return { ...map, - [node.id]: Object.assign( - { - id: matchedServiceNodes[0][SERVICE_NAME], - }, - ...matchedServiceNodes.map((serviceNode) => - pickBy(serviceNode, identity) - ) - ), + [node.id]: { + id: matchedServiceNodes[0][SERVICE_NAME], + ...mergedServiceNode, + ...(serviceAnomalyStats ? { serviceAnomalyStats } : null), + }, }; } diff --git a/x-pack/plugins/apm/server/routes/service_map.ts b/x-pack/plugins/apm/server/routes/service_map.ts index a3e2f708b0b22..50123131a42e7 100644 --- a/x-pack/plugins/apm/server/routes/service_map.ts +++ b/x-pack/plugins/apm/server/routes/service_map.ts @@ -37,11 +37,12 @@ export const serviceMapRoute = createRoute(() => ({ } context.licensing.featureUsage.notifyUsage(APM_SERVICE_MAPS_FEATURE_NAME); + const logger = context.logger; const setup = await setupRequest(context, request); const { query: { serviceName, environment }, } = context.params; - return getServiceMap({ setup, serviceName, environment }); + return getServiceMap({ setup, serviceName, environment, logger }); }, }));