Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[APM] Service maps - Fix missing ML status for services with jobs but no anomalies #68486

Merged
Merged
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:',
ogupte marked this conversation as resolved.
Show resolved Hide resolved
}
);

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>
ogupte marked this conversation as resolved.
Show resolved Hide resolved
<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' },
ogupte marked this conversation as resolved.
Show resolved Hide resolved
},
{
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 });
ogupte marked this conversation as resolved.
Show resolved Hide resolved
return mlJobsResponse.jobs;
},
};
}
Loading