Skip to content

Commit

Permalink
Closes elastic#68485 by:
Browse files Browse the repository at this point in the history
  - 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
  • Loading branch information
ogupte committed Jun 8, 2020
1 parent 48ef260 commit acc3db6
Show file tree
Hide file tree
Showing 17 changed files with 413 additions and 228 deletions.
2 changes: 2 additions & 0 deletions x-pack/plugins/apm/common/ml_job_constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
21 changes: 21 additions & 0 deletions x-pack/plugins/apm/common/utils/left_join.ts
Original file line number Diff line number Diff line change
@@ -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<TL, K>
>(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<Assign<TL, Partial<Omit<TR, K>>>>;
}
3 changes: 2 additions & 1 deletion x-pack/plugins/apm/kibana.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"actions",
"alerts",
"observability",
"security"
"security",
"ml"
],
"server": true,
"ui": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
<FlexColumnGroup
Expand All @@ -154,49 +160,45 @@ export function Contents({
</FlexColumnItem>
{isService && (
<FlexColumnItem>
{hasAnomalyDetection ? (
<>
<section>
<HealthStatusTitle size="xxs">
<h3>{ANOMALY_DETECTION_TITLE}</h3>
</HealthStatusTitle>
&nbsp;
<EuiIconTip
type="iInCircle"
content={ANOMALY_DETECTION_TOOLTIP}
/>
</section>
<ContentLine>
<EuiFlexGroup>
<EuiFlexItem>
<VerticallyCentered>
<EuiHealth color={getSeverityColor(severity)} />
<SubduedText>
{ANOMALY_DETECTION_SCORE_METRIC}
</SubduedText>
</VerticallyCentered>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
{asInteger(maxScore)}
<SubduedText>&nbsp;({anomalyDescription})</SubduedText>
</div>
</EuiFlexItem>
</EuiFlexGroup>
</ContentLine>
<ContentLine>
<MLJobLink external jobId={jobId}>
{ANOMALY_DETECTION_LINK}
</MLJobLink>
</ContentLine>
</>
) : (
<>
<HealthStatusTitle size="xxs">
<h3>{ANOMALY_DETECTION_TITLE}</h3>
</HealthStatusTitle>
<section>
<HealthStatusTitle size="xxs">
<h3>{ANOMALY_DETECTION_TITLE}</h3>
</HealthStatusTitle>
&nbsp;
<EuiIconTip type="iInCircle" content={ANOMALY_DETECTION_TOOLTIP} />
{!mlJobId && (
<EnableText>{ANOMALY_DETECTION_DISABLED_TEXT}</EnableText>
</>
)}
</section>
{hasAnomalyDetectionScore && (
<ContentLine>
<EuiFlexGroup>
<EuiFlexItem>
<VerticallyCentered>
<EuiHealth color={getSeverityColor(anomalySeverity)} />
<SubduedText>{ANOMALY_DETECTION_SCORE_METRIC}</SubduedText>
</VerticallyCentered>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
{asInteger(anomalyScore)}
{anomalyDescription && (
<SubduedText>&nbsp;({anomalyDescription})</SubduedText>
)}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</ContentLine>
)}
{mlJobId && !hasAnomalyDetectionScore && (
<EnableText>{ANOMALY_DETECTION_NO_DATA_TEXT}</EnableText>
)}
{mlJobId && (
<ContentLine>
<MLJobLink external jobId={mlJobId}>
{ANOMALY_DETECTION_LINK}
</MLJobLink>
</ContentLine>
)}
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -183,7 +188,7 @@ const style: cytoscape.Stylesheet[] = [
// actually "hidden"
{
selector: 'edge[isInverseEdge]',
style: { visibility: 'hidden' },
style: { visibility: 'none' },
},
{
selector: 'edge.nodeHover',
Expand Down
3 changes: 2 additions & 1 deletion x-pack/plugins/apm/public/services/rest/ml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
TRANSACTION_TYPE,
} from '../../../common/elasticsearch_fieldnames';
import {
APM_ML_JOB_GROUP_NAME,
getMlJobId,
getMlPrefix,
encodeForMlApi,
Expand Down Expand Up @@ -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),
];
Expand Down
3 changes: 3 additions & 0 deletions x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ function getMockRequest() {
},
},
},
plugins: {
ml: undefined,
},
} as unknown) as APMRequestHandlerContext & {
core: {
elasticsearch: {
Expand Down
25 changes: 25 additions & 0 deletions x-pack/plugins/apm/server/lib/helpers/setup_request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -36,6 +37,7 @@ function decodeUiFilters(
export interface Setup {
client: ESClient;
internalClient: ESClient;
ml?: ReturnType<typeof getMlSetup>;
config: APMConfig;
indices: ApmIndicesConfig;
dynamicIndexPattern?: IIndexPattern;
Expand Down Expand Up @@ -93,6 +95,7 @@ export async function setupRequest<TParams extends SetupRequestParams>(
internalClient: getESClient(context, request, {
clientAsInternalUser: true,
}),
ml: getMlSetup(context, request),
config,
dynamicIndexPattern,
};
Expand All @@ -104,3 +107,25 @@ export async function setupRequest<TParams extends SetupRequestParams>(
...coreSetupRequest,
} as InferSetup<TParams>;
}

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<AnomalyDetectionJob[]> => {
const mlJobsResponse = await mlClient('ml.jobs', { jobId });
return mlJobsResponse.jobs;
},
};
}
Loading

0 comments on commit acc3db6

Please sign in to comment.