From acc3db620f9cb24e0db59d67c21e85bc9b4ca87a Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Sun, 7 Jun 2020 20:44:39 -0700 Subject: [PATCH] Closes #68485 by: - use the ML plugin to query for all APM jobs - inspect the ml job groups to find all jobs related to a particular service - use the mlAnomalySearch client to get ml job buckets with the max anomaly score - query for the model_plot buckets to obtain actual/median values for the ML description - return the relevant ML job with the max anomaly score for a service - indicate to the user that no anomalies were found for a service with an ml job --- x-pack/plugins/apm/common/ml_job_constants.ts | 2 + x-pack/plugins/apm/common/utils/left_join.ts | 21 +++ x-pack/plugins/apm/kibana.json | 3 +- .../app/ServiceMap/Popover/Contents.tsx | 112 +++++++------ .../app/ServiceMap/cytoscapeOptions.ts | 27 +-- x-pack/plugins/apm/public/services/rest/ml.ts | 3 +- .../server/lib/helpers/setup_request.test.ts | 3 + .../apm/server/lib/helpers/setup_request.ts | 25 +++ .../lib/service_map/get_service_anomalies.ts | 158 ++++++++++++++++++ .../server/lib/service_map/get_service_map.ts | 66 +++----- .../server/lib/service_map/ml_helpers.test.ts | 76 +++------ .../apm/server/lib/service_map/ml_helpers.ts | 90 +++++----- .../transform_service_map_responses.test.ts | 14 +- .../transform_service_map_responses.ts | 32 +++- x-pack/plugins/apm/server/plugin.ts | 5 +- x-pack/plugins/apm/server/routes/typings.ts | 3 + x-pack/plugins/ml/server/shared.ts | 1 + 17 files changed, 413 insertions(+), 228 deletions(-) create mode 100644 x-pack/plugins/apm/common/utils/left_join.ts create mode 100644 x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts 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/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..f17e78914a1c9 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 @@ -116,6 +116,14 @@ const ANOMALY_DETECTION_DISABLED_TEXT = i18n.translate( } ); +const ANOMALY_DETECTION_NO_DATA_TEXT = i18n.translate( + 'xpack.apm.serviceMap.anomalyDetectionPopoverNoData', + { + defaultMessage: + 'No anomaly score found with the current filters. See details in the anomaly explorer:', + } +); + export function Contents({ selectedNodeData, isService, @@ -124,21 +132,19 @@ export function Contents({ selectedNodeServiceName, }: ContentsProps) { // Anomaly Detection - const severity = selectedNodeData.severity; - const maxScore = selectedNodeData.max_score; + const anomalySeverity = selectedNodeData.anomaly_severity; + const anomalyScore = selectedNodeData.anomaly_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; + const mlJobId = selectedNodeData.ml_job_id; + const hasAnomalyDetectionScore = + anomalySeverity !== undefined && anomalyScore !== undefined; + const anomalyDescription = + hasAnomalyDetectionScore && + actualValue !== undefined && + typicalValue !== undefined + ? 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_TITLE}

+
+   + + {!mlJobId && ( {ANOMALY_DETECTION_DISABLED_TEXT} - + )} +
+ {hasAnomalyDetectionScore && ( + + + + + + {ANOMALY_DETECTION_SCORE_METRIC} + + + +
+ {asInteger(anomalyScore)} + {anomalyDescription && ( +  ({anomalyDescription}) + )} +
+
+
+
+ )} + {mlJobId && !hasAnomalyDetectionScore && ( + {ANOMALY_DETECTION_NO_DATA_TEXT} + )} + {mlJobId && ( + + + {ANOMALY_DETECTION_LINK} + + )}
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..7b2a382a79139 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,7 @@ import { import { severity } from '../../../../common/ml_job_constants'; import { defaultIcon, iconForNode } from './icons'; -export const getSeverityColor = (nodeSeverity: string) => { +export const getSeverityColor = (nodeSeverity?: string) => { switch (nodeSeverity) { case severity.warning: return theme.euiColorVis0; @@ -27,24 +27,29 @@ 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) || + (getSeverityColor(severity.warning) as string) + ); } 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 +58,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,7 +188,7 @@ const style: cytoscape.Stylesheet[] = [ // actually "hidden" { selector: 'edge[isInverseEdge]', - style: { visibility: 'hidden' }, + style: { visibility: 'none' }, }, { selector: 'edge.nodeHover', diff --git a/x-pack/plugins/apm/public/services/rest/ml.ts b/x-pack/plugins/apm/public/services/rest/ml.ts index 7d1f386fa896c..557f3e93ae051 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(http); 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..afcf1265f2960 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -19,6 +19,7 @@ import { APMRequestHandlerContext } from '../../routes/typings'; import { getESClient } from './es_client'; import { ProcessorEvent } from '../../../common/processor_event'; import { getDynamicIndexPattern } from '../index_pattern/get_dynamic_index_pattern'; +import { Job as AnomalyDetectionJob } from '../../../../ml/server'; function decodeUiFilters( indexPattern: IIndexPattern | undefined, @@ -36,6 +37,7 @@ function decodeUiFilters( export interface Setup { client: ESClient; internalClient: ESClient; + ml?: ReturnType; config: APMConfig; indices: ApmIndicesConfig; dynamicIndexPattern?: IIndexPattern; @@ -93,6 +95,7 @@ export async function setupRequest( internalClient: getESClient(context, request, { clientAsInternalUser: true, }), + ml: getMlSetup(context, request), config, dynamicIndexPattern, }; @@ -104,3 +107,25 @@ 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 { + ...ml.mlSystemProvider(mlClient, request), + mlClient, + /** + * https://www.elastic.co/guide/en/elasticsearch/reference/7.x/ml-get-job.html#ml-get-job-desc + * @param {string | string[]} [jobId] - job id, group name, or a wildcard, returns all jobs if nothing passed in + */ + mlJobs: async ( + jobId?: string | string[] + ): Promise => { + const mlJobsResponse = await mlClient('ml.jobs', { jobId }); + return mlJobsResponse.jobs; + }, + }; +} 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..2a13606babfb5 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_anomalies.ts @@ -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 { intersection } from 'lodash'; +import { leftJoin } from '../../../common/utils/left_join'; +import { rangeFilter } from '../helpers/range_filter'; +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 apmMlJobs = await ml.mlJobs('apm'); + const apmMlJobCategories = apmMlJobs + .map((job) => getApmMlJobCategory(job, serviceNames)) + .filter( + (apmJobCategory) => apmJobCategory !== undefined + ) as ApmMlJobCategory[]; + const apmJobIds = apmMlJobs.map((job) => job.job_id); + const rangeQuery = { range: rangeFilter(start, end, 'timestamp') }; + const params = { + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { result_type: 'bucket' } }, + { + terms: { + job_id: apmJobIds, + }, + }, + rangeQuery, + ], + }, + }, + aggs: { + jobs: { + terms: { field: 'job_id', size: apmJobIds.length }, + aggs: { + top_score_hits: { + top_hits: { + sort: [{ anomaly_score: { order: 'desc' as const } }], + _source: ['anomaly_score', 'timestamp'], + size: 1, + }, + }, + }, + }, + }, + }, + }; + + const response = await ml.mlAnomalySearch(params); + const anomalyScores: Array<{ + jobId: string; + anomalyScore: number; + timestamp: number; + }> = response.aggregations.jobs.buckets.map((jobBucket: any) => { + const jobId = jobBucket.key; + const bucketSource = jobBucket.top_score_hits.hits.hits?.[0]?._source; + return { + jobId, + anomalyScore: bucketSource.anomaly_score, + timestamp: bucketSource.timestamp, + }; + }); + const anomalyModelValuePromises = anomalyScores.map( + ({ jobId, timestamp }) => { + return (async () => { + try { + const modelPlotResponse = await ml.mlAnomalySearch({ + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { result_type: 'model_plot' } }, + { term: { job_id: jobId } }, + { term: { timestamp } }, + ], + }, + }, + _source: ['actual', 'model_median'], + }, + }); + if (modelPlotResponse.hits.hits.length === 0) { + return; + } + const { actual, model_median } = modelPlotResponse.hits.hits[0] + ._source as { + actual: number; + model_median: number; + }; + return { jobId, actual, model_median }; + } catch (error) { + return; + } + })(); + } + ); + + const anomalyModelValues = filterNonNullable( + await Promise.all(anomalyModelValuePromises) + ); + + return leftJoin( + apmMlJobCategories, + 'jobId', + leftJoin(anomalyScores, 'jobId', anomalyModelValues) + ); +} + +function filterNonNullable(array: T[]) { + return array.filter( + (element) => element !== undefined || element !== null + ) as Array>; +} 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..425337c6d71d0 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,31 @@ 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]: [ + const [connectionData, servicesData]: [ // explicit types to avoid TS "excessively deep" error ConnectionsResponse, - ServicesResponse, - AnomaliesResponse - // @ts-ignore - ] = await Promise.all([ - getConnectionData(options), - getServicesData(options), - getAnomaliesData(options), - ]); + ServicesResponse + ] = await Promise.all([getConnectionData(options), getServicesData(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..9e761bd2181be 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, + model_median: 1000, + }, + { + jobId: 'opbeans-java-request-high_mean_response_time', + serviceName: 'opbeans-java', + transactionType: 'request', + anomalyScore: 100, + timestamp: 1591351200000, + actual: 9000, + model_median: 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..7473ec53d63c2 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 { getSeverity } from '../../../common/ml_job_constants'; import { ConnectionNode } from '../../../common/service_map'; -import { AnomaliesResponse } from './get_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.model_median, + 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 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..1b04a0802fae6 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 { Job } from '../common/types/anomaly_detection_jobs';