diff --git a/x-pack/plugins/apm/common/ml_job_constants.ts b/x-pack/plugins/apm/common/ml_job_constants.ts index 6df0d324981a1..0f0add7c4226b 100644 --- a/x-pack/plugins/apm/common/ml_job_constants.ts +++ b/x-pack/plugins/apm/common/ml_job_constants.ts @@ -11,6 +11,8 @@ export enum severity { warning = 'warning', } +export const APM_ML_JOB_GROUP_NAME = 'apm'; + export function getMlPrefix(serviceName: string, transactionType?: string) { const maybeTransactionType = transactionType ? `${transactionType}-` : ''; return encodeForMlApi(`${serviceName}-${maybeTransactionType}`); diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 43f3585d0ebb2..7d7a7811eeba2 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -34,6 +34,16 @@ export interface Connection { destination: ConnectionNode; } +export interface ServiceAnomaly { + anomaly_score: number; + anomaly_severity: string; + actual_value: number; + typical_value: number; + ml_job_id: string; +} + +export type ServiceNode = ConnectionNode & Partial; + export interface ServiceNodeMetrics { avgMemoryUsage: number | null; avgCpuUsage: number | null; diff --git a/x-pack/plugins/apm/common/utils/left_join.ts b/x-pack/plugins/apm/common/utils/left_join.ts new file mode 100644 index 0000000000000..f3c4e48df755b --- /dev/null +++ b/x-pack/plugins/apm/common/utils/left_join.ts @@ -0,0 +1,21 @@ +/* + * 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 { Assign, Omit } from 'utility-types'; + +export function leftJoin< + TL extends object, + K extends keyof TL, + TR extends Pick +>(leftRecords: TL[], matchKey: K, rightRecords: TR[]) { + const rightLookup = new Map( + rightRecords.map((record) => [record[matchKey], record]) + ); + return leftRecords.map((record) => { + const matchProp = (record[matchKey] as unknown) as TR[K]; + const matchingRightRecord = rightLookup.get(matchProp); + return { ...record, ...matchingRightRecord }; + }) as Array>>>; +} diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 2de3c9c97065d..1b8e7c4dc5431 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -17,7 +17,8 @@ "actions", "alerts", "observability", - "security" + "security", + "ml" ], "server": true, "ui": true, 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 d9254b487d037..ff68288916af4 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 @@ -9,24 +9,15 @@ import { EuiFlexItem, EuiHorizontalRule, EuiTitle, - EuiIconTip, - EuiHealth, } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { i18n } from '@kbn/i18n'; import cytoscape from 'cytoscape'; import React, { MouseEvent } from 'react'; -import styled from 'styled-components'; -import { fontSize, px } from '../../../../style/variables'; import { Buttons } from './Buttons'; import { Info } from './Info'; import { ServiceMetricFetcher } from './ServiceMetricFetcher'; -import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; -import { getSeverityColor } from '../cytoscapeOptions'; -import { asInteger } from '../../../../utils/formatters'; -import { getMetricChangeDescription } from '../../../../../../ml/public'; - -const popoverMinWidth = 280; +import { AnomalyDetection } from './anomaly_detection'; +import { ServiceNode } from '../../../../../common/service_map'; +import { popoverMinWidth } from '../cytoscapeOptions'; interface ContentsProps { isService: boolean; @@ -36,31 +27,6 @@ interface ContentsProps { selectedNodeServiceName: string; } -const HealthStatusTitle = styled(EuiTitle)` - display: inline; - text-transform: uppercase; -`; - -const VerticallyCentered = styled.div` - display: flex; - align-items: center; -`; - -const SubduedText = styled.span` - color: ${theme.euiTextSubduedColor}; -`; - -const EnableText = styled.section` - color: ${theme.euiTextSubduedColor}; - line-height: 1.4; - font-size: ${fontSize}; - width: ${px(popoverMinWidth)}; -`; - -export const ContentLine = styled.section` - line-height: 2; -`; - // IE 11 does not handle flex properties as expected. With browser detection, // we can use regular div elements to render contents that are almost identical. // @@ -85,37 +51,6 @@ const FlexColumnGroup = (props: { const FlexColumnItem = (props: { children: React.ReactNode }) => isIE11 ?
: ; -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 from the Integrations menu in the Service details view.', - } -); - export function Contents({ selectedNodeData, isService, @@ -123,23 +58,6 @@ export function Contents({ onFocusClick, selectedNodeServiceName, }: ContentsProps) { - // Anomaly Detection - const severity = selectedNodeData.severity; - const maxScore = selectedNodeData.max_score; - const actualValue = selectedNodeData.actual_value; - const typicalValue = selectedNodeData.typical_value; - const jobId = selectedNodeData.job_id; - const hasAnomalyDetection = [ - severity, - maxScore, - actualValue, - typicalValue, - jobId, - ].every((value) => value !== undefined); - const anomalyDescription = hasAnomalyDetection - ? getMetricChangeDescription(actualValue, typicalValue).message - : null; - return ( {isService && ( - {hasAnomalyDetection ? ( - <> -
- -

{ANOMALY_DETECTION_TITLE}

-
-   - -
- - - - - - - {ANOMALY_DETECTION_SCORE_METRIC} - - - - -
- {asInteger(maxScore)} -  ({anomalyDescription}) -
-
-
-
- - - {ANOMALY_DETECTION_LINK} - - - - ) : ( - <> - -

{ANOMALY_DETECTION_TITLE}

-
- {ANOMALY_DETECTION_DISABLED_TEXT} - - )} +
)} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx new file mode 100644 index 0000000000000..ad4dc2ced2bfb --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Popover/anomaly_detection.tsx @@ -0,0 +1,156 @@ +/* + * 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 theme from '@elastic/eui/dist/eui_theme_light.json'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiIconTip, + EuiHealth, +} from '@elastic/eui'; +import { fontSize, px } from '../../../../style/variables'; +import { asInteger } from '../../../../utils/formatters'; +import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink'; +import { getSeverityColor, popoverMinWidth } from '../cytoscapeOptions'; +import { getMetricChangeDescription } from '../../../../../../ml/public'; +import { ServiceNode } from '../../../../../common/service_map'; + +const HealthStatusTitle = styled(EuiTitle)` + display: inline; + text-transform: uppercase; +`; + +const VerticallyCentered = styled.div` + display: flex; + align-items: center; +`; + +const SubduedText = styled.span` + color: ${theme.euiTextSubduedColor}; +`; + +const EnableText = styled.section` + color: ${theme.euiTextSubduedColor}; + line-height: 1.4; + font-size: ${fontSize}; + width: ${px(popoverMinWidth)}; +`; + +export const ContentLine = styled.section` + line-height: 2; +`; + +interface AnomalyDetectionProps { + serviceNodeData: cytoscape.NodeDataDefinition & ServiceNode; +} + +export function AnomalyDetection({ serviceNodeData }: AnomalyDetectionProps) { + const anomalySeverity = serviceNodeData.anomaly_severity; + const anomalyScore = serviceNodeData.anomaly_score; + const actualValue = serviceNodeData.actual_value; + const typicalValue = serviceNodeData.typical_value; + const mlJobId = serviceNodeData.ml_job_id; + const hasAnomalyDetectionScore = + anomalySeverity !== undefined && anomalyScore !== undefined; + const anomalyDescription = + hasAnomalyDetectionScore && + actualValue !== undefined && + typicalValue !== undefined + ? getMetricChangeDescription(actualValue, typicalValue).message + : null; + + return ( + <> +
+ +

{ANOMALY_DETECTION_TITLE}

+
+   + + {!mlJobId && {ANOMALY_DETECTION_DISABLED_TEXT}} +
+ {hasAnomalyDetectionScore && ( + + + + + + {ANOMALY_DETECTION_SCORE_METRIC} + + + +
+ {getDisplayedAnomalyScore(anomalyScore as number)} + {anomalyDescription && ( +  ({anomalyDescription}) + )} +
+
+
+
+ )} + {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 from the Integrations menu in the Service details view.', + } +); + +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/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 0e4666b7bff17..9b35b0b33a70d 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -13,7 +13,9 @@ import { import { severity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; -export const getSeverityColor = (nodeSeverity: string) => { +export const popoverMinWidth = 280; + +export const getSeverityColor = (nodeSeverity?: string) => { switch (nodeSeverity) { case severity.warning: return theme.euiColorVis0; @@ -27,24 +29,26 @@ export const getSeverityColor = (nodeSeverity: string) => { } }; -const getBorderColor = (el: cytoscape.NodeSingular) => { - const nodeSeverity = el.data('severity'); - const severityColor = getSeverityColor(nodeSeverity); - if (severityColor) { - return severityColor; +const getBorderColor: cytoscape.Css.MapperFunction< + cytoscape.NodeSingular, + string +> = (el: cytoscape.NodeSingular) => { + const hasAnomalyDetectionJob = el.data('ml_job_id') !== undefined; + const nodeSeverity = el.data('anomaly_severity'); + if (hasAnomalyDetectionJob) { + return getSeverityColor(nodeSeverity) || theme.euiColorMediumShade; } if (el.hasClass('primary') || el.selected()) { return theme.euiColorPrimary; - } else { - return theme.euiColorMediumShade; } + return theme.euiColorMediumShade; }; const getBorderStyle: cytoscape.Css.MapperFunction< cytoscape.NodeSingular, cytoscape.Css.LineStyle > = (el: cytoscape.NodeSingular) => { - const nodeSeverity = el.data('severity'); + const nodeSeverity = el.data('anomaly_severity'); if (nodeSeverity === severity.critical) { return 'double'; } else { @@ -53,7 +57,7 @@ const getBorderStyle: cytoscape.Css.MapperFunction< }; const getBorderWidth = (el: cytoscape.NodeSingular) => { - const nodeSeverity = el.data('severity'); + const nodeSeverity = el.data('anomaly_severity'); if (nodeSeverity === severity.minor || nodeSeverity === severity.major) { return 4; @@ -183,6 +187,7 @@ const style: cytoscape.Stylesheet[] = [ // actually "hidden" { selector: 'edge[isInverseEdge]', + // @ts-ignore style: { visibility: 'hidden' }, }, { diff --git a/x-pack/plugins/apm/public/services/rest/ml.ts b/x-pack/plugins/apm/public/services/rest/ml.ts index 99c162bde02da..47032501d9fbe 100644 --- a/x-pack/plugins/apm/public/services/rest/ml.ts +++ b/x-pack/plugins/apm/public/services/rest/ml.ts @@ -11,6 +11,7 @@ import { TRANSACTION_TYPE, } from '../../../common/elasticsearch_fieldnames'; import { + APM_ML_JOB_GROUP_NAME, getMlJobId, getMlPrefix, encodeForMlApi, @@ -55,7 +56,7 @@ export async function startMLJob({ }) { const transactionIndices = await getTransactionIndices(); const groups = [ - 'apm', + APM_ML_JOB_GROUP_NAME, encodeForMlApi(serviceName), encodeForMlApi(transactionType), ]; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index e529f4d4ab1ed..5a4bc62b87486 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -59,6 +59,9 @@ function getMockRequest() { }, }, }, + plugins: { + ml: undefined, + }, } as unknown) as APMRequestHandlerContext & { core: { elasticsearch: { 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 34f3fd9b40bb0..c41dff79a916a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -36,6 +36,7 @@ function decodeUiFilters( export interface Setup { client: ESClient; internalClient: ESClient; + ml?: ReturnType; config: APMConfig; indices: ApmIndicesConfig; dynamicIndexPattern?: IIndexPattern; @@ -93,6 +94,7 @@ export async function setupRequest( internalClient: getESClient(context, request, { clientAsInternalUser: true, }), + ml: getMlSetup(context, request), config, dynamicIndexPattern, }; @@ -104,3 +106,16 @@ export async function setupRequest( ...coreSetupRequest, } as InferSetup; } + +function getMlSetup(context: APMRequestHandlerContext, request: KibanaRequest) { + if (!context.plugins.ml) { + return; + } + const ml = context.plugins.ml; + const mlClient = ml.mlClient.asScoped(request).callAsCurrentUser; + return { + mlSystem: ml.mlSystemProvider(mlClient, request), + anomalyDetectors: ml.anomalyDetectorsProvider(mlClient), + mlClient, + }; +} 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..7b26078d5ffbf --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -0,0 +1,129 @@ +/* + * 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 { intersection } from 'lodash'; +import { leftJoin } from '../../../common/utils/left_join'; +import { Job as AnomalyDetectionJob } from '../../../../ml/server'; +import { PromiseReturnType } from '../../../typings/common'; +import { IEnvOptions } from './get_service_map'; +import { APM_ML_JOB_GROUP_NAME } from '../../../common/ml_job_constants'; + +type ApmMlJobCategory = NonNullable>; +const getApmMlJobCategory = ( + mlJob: AnomalyDetectionJob, + serviceNames: string[] +) => { + const apmJobGroups = mlJob.groups.filter( + (groupName) => groupName !== APM_ML_JOB_GROUP_NAME + ); + if (apmJobGroups.length === mlJob.groups.length) { + // ML job missing "apm" group name + return; + } + const [serviceName] = intersection(apmJobGroups, serviceNames); + if (!serviceName) { + // APM ML job service was not found + return; + } + const [transactionType] = apmJobGroups.filter( + (groupName) => groupName !== serviceName + ); + if (!transactionType) { + // APM ML job transaction type was not found. + return; + } + return { jobId: mlJob.job_id, serviceName, transactionType }; +}; + +export type ServiceAnomalies = PromiseReturnType; + +export async function getServiceAnomalies( + options: IEnvOptions, + serviceNames: string[] +) { + const { start, end, ml } = options.setup; + + if (!ml || serviceNames.length === 0) { + return []; + } + + const { jobs: apmMlJobs } = await ml.anomalyDetectors.jobs('apm'); + const apmMlJobCategories = apmMlJobs + .map((job) => getApmMlJobCategory(job, serviceNames)) + .filter( + (apmJobCategory) => apmJobCategory !== undefined + ) as ApmMlJobCategory[]; + const apmJobIds = apmMlJobs.map((job) => job.job_id); + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { result_type: 'record' } }, + { + terms: { + job_id: apmJobIds, + }, + }, + { + range: { + timestamp: { gte: start, lte: end, format: 'epoch_millis' }, + }, + }, + ], + }, + }, + aggs: { + jobs: { + terms: { field: 'job_id', size: apmJobIds.length }, + aggs: { + top_score_hits: { + top_hits: { + sort: [{ record_score: { order: 'desc' as const } }], + _source: ['record_score', 'timestamp', 'typical', 'actual'], + size: 1, + }, + }, + }, + }, + }, + }, + }; + + const response = (await ml.mlSystem.mlAnomalySearch(params)) as { + aggregations: { + jobs: { + buckets: Array<{ + key: string; + top_score_hits: { + hits: { + hits: Array<{ + _source: { + record_score: number; + timestamp: number; + typical: number[]; + actual: number[]; + }; + }>; + }; + }; + }>; + }; + }; + }; + const anomalyScores = response.aggregations.jobs.buckets.map((jobBucket) => { + const jobId = jobBucket.key; + const bucketSource = jobBucket.top_score_hits.hits.hits?.[0]?._source; + return { + jobId, + anomalyScore: bucketSource.record_score, + timestamp: bucketSource.timestamp, + typical: bucketSource.typical[0], + actual: bucketSource.actual[0], + }; + }); + return leftJoin(apmMlJobCategories, 'jobId', anomalyScores); +} 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 47ba9ecc78ffc..9f3ded82d7cbd 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 @@ -9,15 +9,18 @@ import { SERVICE_ENVIRONMENT, SERVICE_NAME, } from '../../../common/elasticsearch_fieldnames'; -import { getMlIndex } from '../../../common/ml_job_constants'; import { getServicesProjection } from '../../../common/projections/services'; import { mergeProjection } from '../../../common/projections/util/merge_projection'; import { PromiseReturnType } from '../../../typings/common'; -import { rangeFilter } from '../helpers/range_filter'; import { Setup, SetupTimeRange } from '../helpers/setup_request'; -import { transformServiceMapResponses } from './transform_service_map_responses'; +import { + transformServiceMapResponses, + getAllNodes, + getServiceNodes, +} from './transform_service_map_responses'; import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; import { getTraceSampleIds } from './get_trace_sample_ids'; +import { getServiceAnomalies, ServiceAnomalies } from './get_service_anomalies'; export interface IEnvOptions { setup: Setup & SetupTimeRange; @@ -129,58 +132,30 @@ async function getServicesData(options: IEnvOptions) { ); } -function getAnomaliesData(options: IEnvOptions) { - const { start, end, client } = options.setup; - const rangeQuery = { range: rangeFilter(start, end, 'timestamp') }; - - const params = { - index: getMlIndex('*'), - body: { - size: 0, - query: { - bool: { filter: [{ term: { result_type: 'record' } }, rangeQuery] }, - }, - aggs: { - jobs: { - terms: { field: 'job_id', size: 10 }, - aggs: { - top_score_hits: { - top_hits: { - sort: [{ record_score: { order: 'desc' as const } }], - _source: ['job_id', 'record_score', 'typical', 'actual'], - size: 1, - }, - }, - }, - }, - }, - }, - }; - - return client.search(params); -} - -export type AnomaliesResponse = PromiseReturnType; +export { ServiceAnomalies }; export type ConnectionsResponse = PromiseReturnType; export type ServicesResponse = PromiseReturnType; export type ServiceMapAPIResponse = PromiseReturnType; export async function getServiceMap(options: IEnvOptions) { - const [connectionData, servicesData, anomaliesData]: [ - // explicit types to avoid TS "excessively deep" error - ConnectionsResponse, - ServicesResponse, - AnomaliesResponse - // @ts-ignore - ] = await Promise.all([ + const [connectionData, servicesData] = await Promise.all([ getConnectionData(options), getServicesData(options), - getAnomaliesData(options), ]); + // Derive all related service names from connection and service data + const allNodes = getAllNodes(servicesData, connectionData.connections); + const serviceNodes = getServiceNodes(allNodes); + const serviceNames = serviceNodes.map( + (serviceData) => serviceData[SERVICE_NAME] + ); + + // Get related service anomalies + const serviceAnomalies = await getServiceAnomalies(options, serviceNames); + return transformServiceMapResponses({ ...connectionData, - anomalies: anomaliesData, + anomalies: serviceAnomalies, services: servicesData, }); } diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts index 6d2bd783e9cde..f07b575cc0a35 100644 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AnomaliesResponse } from './get_service_map'; +import { ServiceAnomalies } from './get_service_map'; import { addAnomaliesDataToNodes } from './ml_helpers'; describe('addAnomaliesDataToNodes', () => { @@ -22,76 +22,54 @@ describe('addAnomaliesDataToNodes', () => { }, ]; - const anomaliesResponse = { - aggregations: { - jobs: { - buckets: [ - { - key: 'opbeans-ruby-request-high_mean_response_time', - top_score_hits: { - hits: { - hits: [ - { - _source: { - record_score: 50, - actual: [2000], - typical: [1000], - job_id: 'opbeans-ruby-request-high_mean_response_time', - }, - }, - ], - }, - }, - }, - { - key: 'opbeans-java-request-high_mean_response_time', - top_score_hits: { - hits: { - hits: [ - { - _source: { - record_score: 100, - actual: [9000], - typical: [3000], - job_id: 'opbeans-java-request-high_mean_response_time', - }, - }, - ], - }, - }, - }, - ], - }, + const serviceAnomalies: ServiceAnomalies = [ + { + jobId: 'opbeans-ruby-request-high_mean_response_time', + serviceName: 'opbeans-ruby', + transactionType: 'request', + anomalyScore: 50, + timestamp: 1591351200000, + actual: 2000, + typical: 1000, + }, + { + jobId: 'opbeans-java-request-high_mean_response_time', + serviceName: 'opbeans-java', + transactionType: 'request', + anomalyScore: 100, + timestamp: 1591351200000, + actual: 9000, + typical: 3000, }, - }; + ]; const result = [ { 'service.name': 'opbeans-ruby', 'agent.name': 'ruby', 'service.environment': null, - max_score: 50, - severity: 'major', + anomaly_score: 50, + anomaly_severity: 'major', actual_value: 2000, typical_value: 1000, - job_id: 'opbeans-ruby-request-high_mean_response_time', + ml_job_id: 'opbeans-ruby-request-high_mean_response_time', }, { 'service.name': 'opbeans-java', 'agent.name': 'java', 'service.environment': null, - max_score: 100, - severity: 'critical', + anomaly_score: 100, + anomaly_severity: 'critical', actual_value: 9000, typical_value: 3000, - job_id: 'opbeans-java-request-high_mean_response_time', + ml_job_id: 'opbeans-java-request-high_mean_response_time', }, ]; expect( addAnomaliesDataToNodes( nodes, - (anomaliesResponse as unknown) as AnomaliesResponse + (serviceAnomalies as unknown) as ServiceAnomalies ) ).toEqual(result); }); diff --git a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts index 3289958733b2b..8162417616b6c 100644 --- a/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts +++ b/x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts @@ -5,65 +5,59 @@ */ import { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames'; -import { - getMlJobServiceName, - getSeverity, -} from '../../../common/ml_job_constants'; -import { ConnectionNode } from '../../../common/service_map'; -import { AnomaliesResponse } from './get_service_map'; +import { getSeverity } from '../../../common/ml_job_constants'; +import { ConnectionNode, ServiceNode } from '../../../common/service_map'; +import { ServiceAnomalies } from './get_service_map'; export function addAnomaliesDataToNodes( nodes: ConnectionNode[], - anomaliesResponse: AnomaliesResponse + serviceAnomalies: ServiceAnomalies ) { - const anomaliesMap = ( - anomaliesResponse.aggregations?.jobs.buckets ?? [] - ).reduce<{ - [key: string]: { - max_score?: number; - actual_value?: number; - typical_value?: number; - job_id?: string; - }; - }>((previousValue, currentValue) => { - const key = getMlJobServiceName(currentValue.key.toString()); - const hitSource = currentValue.top_score_hits.hits.hits[0]._source as { - record_score: number; - actual: [number]; - typical: [number]; - job_id: string; - }; - const maxScore = hitSource.record_score; - const actualValue = hitSource.actual[0]; - const typicalValue = hitSource.typical[0]; - const jobId = hitSource.job_id; + const anomaliesMap = serviceAnomalies.reduce( + (acc, anomalyJob) => { + const serviceAnomaly: typeof acc[string] | undefined = + acc[anomalyJob.serviceName]; + const hasAnomalyJob = serviceAnomaly !== undefined; + const hasAnomalyScore = serviceAnomaly?.anomaly_score !== undefined; + const hasNewAnomalyScore = anomalyJob.anomalyScore !== undefined; + const hasNewMaxAnomalyScore = + hasNewAnomalyScore && + (!hasAnomalyScore || + (anomalyJob?.anomalyScore ?? 0) > + (serviceAnomaly?.anomaly_score ?? 0)); - if ((previousValue[key]?.max_score ?? 0) > maxScore) { - return previousValue; - } + if (!hasAnomalyJob || hasNewMaxAnomalyScore) { + acc[anomalyJob.serviceName] = { + anomaly_score: anomalyJob.anomalyScore, + actual_value: anomalyJob.actual, + typical_value: anomalyJob.typical, + ml_job_id: anomalyJob.jobId, + }; + } - return { - ...previousValue, - [key]: { - max_score: maxScore, - actual_value: actualValue, - typical_value: typicalValue, - job_id: jobId, - }, - }; - }, {}); + return acc; + }, + {} as { + [serviceName: string]: { + anomaly_score?: number; + actual_value?: number; + typical_value?: number; + ml_job_id: string; + }; + } + ); - const servicesDataWithAnomalies = nodes.map((service) => { - const serviceAnomalies = anomaliesMap[service[SERVICE_NAME]]; - if (serviceAnomalies) { - const maxScore = serviceAnomalies.max_score; + const servicesDataWithAnomalies: ServiceNode[] = nodes.map((service) => { + const serviceAnomaly = anomaliesMap[service[SERVICE_NAME]]; + if (serviceAnomaly) { + const anomalyScore = serviceAnomaly.anomaly_score; return { ...service, - max_score: maxScore, - severity: getSeverity(maxScore), - actual_value: serviceAnomalies.actual_value, - typical_value: serviceAnomalies.typical_value, - job_id: serviceAnomalies.job_id, + anomaly_score: anomalyScore, + anomaly_severity: getSeverity(anomalyScore), + actual_value: serviceAnomaly.actual_value, + typical_value: serviceAnomaly.typical_value, + ml_job_id: serviceAnomaly.ml_job_id, }; } return service; 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 0aa3f13b9b90c..6c9880c2dc4df 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 @@ -12,7 +12,7 @@ import { SPAN_SUBTYPE, SPAN_TYPE, } from '../../../common/elasticsearch_fieldnames'; -import { AnomaliesResponse } from './get_service_map'; +import { ServiceAnomalies } from './get_service_map'; import { transformServiceMapResponses, ServiceMapResponse, @@ -36,14 +36,12 @@ const javaService = { [AGENT_NAME]: 'java', }; -const anomalies = ({ - aggregations: { jobs: { buckets: [] } }, -} as unknown) as AnomaliesResponse; +const serviceAnomalies: ServiceAnomalies = []; describe('transformServiceMapResponses', () => { it('maps external destinations to internal services', () => { const response: ServiceMapResponse = { - anomalies, + anomalies: serviceAnomalies, services: [nodejsService, javaService], discoveredServices: [ { @@ -75,7 +73,7 @@ describe('transformServiceMapResponses', () => { it('collapses external destinations based on span.destination.resource.name', () => { const response: ServiceMapResponse = { - anomalies, + anomalies: serviceAnomalies, services: [nodejsService, javaService], discoveredServices: [ { @@ -111,7 +109,7 @@ describe('transformServiceMapResponses', () => { it('picks the first span.type/subtype in an alphabetically sorted list', () => { const response: ServiceMapResponse = { - anomalies, + anomalies: serviceAnomalies, services: [javaService], discoveredServices: [], connections: [ @@ -150,7 +148,7 @@ describe('transformServiceMapResponses', () => { it('processes connections without a matching "service" aggregation', () => { const response: ServiceMapResponse = { - anomalies, + anomalies: serviceAnomalies, services: [javaService], discoveredServices: [], connections: [ 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 8580fed587567..53abf54cbcf31 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 @@ -20,7 +20,7 @@ import { import { ConnectionsResponse, ServicesResponse, - AnomaliesResponse, + ServiceAnomalies, } from './get_service_map'; import { addAnomaliesDataToNodes } from './ml_helpers'; @@ -38,14 +38,10 @@ function getConnectionId(connection: Connection) { )}`; } -export type ServiceMapResponse = ConnectionsResponse & { - anomalies: AnomaliesResponse; - services: ServicesResponse; -}; - -export function transformServiceMapResponses(response: ServiceMapResponse) { - const { anomalies, discoveredServices, services, connections } = response; - +export function getAllNodes( + services: ServiceMapResponse['services'], + connections: ServiceMapResponse['connections'] +) { // Derive the rest of the map nodes from the connections and add the services // from the services data query const allNodes: ConnectionNode[] = connections @@ -58,11 +54,29 @@ export function transformServiceMapResponses(response: ServiceMapResponse) { })) ); + return allNodes; +} + +export function getServiceNodes(allNodes: ConnectionNode[]) { // List of nodes that are services const serviceNodes = allNodes.filter( (node) => SERVICE_NAME in node ) as ServiceConnectionNode[]; + return serviceNodes; +} + +export type ServiceMapResponse = ConnectionsResponse & { + anomalies: ServiceAnomalies; + services: ServicesResponse; +}; + +export function transformServiceMapResponses(response: ServiceMapResponse) { + const { anomalies, discoveredServices, services, connections } = response; + + const allNodes = getAllNodes(services, connections); + const serviceNodes = getServiceNodes(allNodes); + // List of nodes that are externals const externalNodes = allNodes.filter( (node) => SPAN_DESTINATION_SERVICE_RESOURCE in node diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index d32d16d4c3cc8..f0a05dfc0df30 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -31,10 +31,11 @@ import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_ import { LicensingPluginSetup } from '../../licensing/public'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; -import { PluginSetupContract as FeaturesPluginSetup } from '../../../plugins/features/server'; +import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; import { APM_FEATURE } from './feature'; import { apmIndices, apmTelemetry } from './saved_objects'; import { createElasticCloudInstructions } from './tutorial/elastic_cloud'; +import { MlPluginSetup } from '../../ml/server'; export interface APMPluginSetup { config$: Observable; @@ -62,6 +63,7 @@ export class APMPlugin implements Plugin { observability?: ObservabilityPluginSetup; features: FeaturesPluginSetup; security?: SecurityPluginSetup; + ml?: MlPluginSetup; } ) { this.logger = this.initContext.logger.get(); @@ -126,6 +128,7 @@ export class APMPlugin implements Plugin { plugins: { observability: plugins.observability, security: plugins.security, + ml: plugins.ml, }, }); diff --git a/x-pack/plugins/apm/server/routes/typings.ts b/x-pack/plugins/apm/server/routes/typings.ts index 05f52f1732c98..bc31cb7a582af 100644 --- a/x-pack/plugins/apm/server/routes/typings.ts +++ b/x-pack/plugins/apm/server/routes/typings.ts @@ -18,6 +18,7 @@ import { ObservabilityPluginSetup } from '../../../observability/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { FetchOptions } from '../../public/services/rest/callApi'; import { SecurityPluginSetup } from '../../../security/public'; +import { MlPluginSetup } from '../../../ml/server'; import { APMConfig } from '..'; export interface Params { @@ -67,6 +68,7 @@ export type APMRequestHandlerContext< plugins: { observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; + ml?: MlPluginSetup; }; }; @@ -114,6 +116,7 @@ export interface ServerAPI { plugins: { observability?: ObservabilityPluginSetup; security?: SecurityPluginSetup; + ml?: MlPluginSetup; }; } ) => void; diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts index 1e50950bc3bce..be27ee2d44a82 100644 --- a/x-pack/plugins/ml/server/shared.ts +++ b/x-pack/plugins/ml/server/shared.ts @@ -5,3 +5,4 @@ */ export * from '../common/types/anomalies'; +export * from '../common/types/anomaly_detection_jobs'; 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 73696dfdeef86..880aebfde409c 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 @@ -6,12 +6,13 @@ import { APICaller } from 'kibana/server'; import { LicenseCheck } from '../license_checks'; +import { Job } from '../../../common/types/anomaly_detection_jobs'; export interface AnomalyDetectorsProvider { anomalyDetectorsProvider( callAsCurrentUser: APICaller ): { - jobs(jobId?: string): Promise; + jobs(jobId?: string): Promise<{ count: number; jobs: Job[] }>; }; }