Service map anomaly indicators (#64718)
Get an aggregation of the anomaly scores and show style the ring around the node icon.
This commit is contained in:
parent
33b2b5c92c
commit
63121fb47e
|
@ -4,7 +4,14 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { getMlIndex, getMlJobId, getMlPrefix } from './ml_job_constants';
|
||||
import {
|
||||
getMlIndex,
|
||||
getMlJobId,
|
||||
getMlPrefix,
|
||||
getMlJobServiceName,
|
||||
getSeverity,
|
||||
severity
|
||||
} from './ml_job_constants';
|
||||
|
||||
describe('ml_job_constants', () => {
|
||||
it('getMlPrefix', () => {
|
||||
|
@ -38,4 +45,44 @@ describe('ml_job_constants', () => {
|
|||
'.ml-anomalies-myservicename-mytransactiontype-high_mean_response_time'
|
||||
);
|
||||
});
|
||||
|
||||
describe('getMlJobServiceName', () => {
|
||||
it('extracts the service name from a job id', () => {
|
||||
expect(
|
||||
getMlJobServiceName('opbeans-node-request-high_mean_response_time')
|
||||
).toEqual('opbeans-node');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSeverity', () => {
|
||||
describe('when score is undefined', () => {
|
||||
it('returns undefined', () => {
|
||||
expect(getSeverity(undefined)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score < 25', () => {
|
||||
it('returns warning', () => {
|
||||
expect(getSeverity(10)).toEqual(severity.warning);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is between 25 and 50', () => {
|
||||
it('returns minor', () => {
|
||||
expect(getSeverity(40)).toEqual(severity.minor);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is between 50 and 75', () => {
|
||||
it('returns major', () => {
|
||||
expect(getSeverity(60)).toEqual(severity.major);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when score is 75 or more', () => {
|
||||
it('returns critical', () => {
|
||||
expect(getSeverity(100)).toEqual(severity.critical);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -4,6 +4,13 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export enum severity {
|
||||
critical = 'critical',
|
||||
major = 'major',
|
||||
minor = 'minor',
|
||||
warning = 'warning'
|
||||
}
|
||||
|
||||
export function getMlPrefix(serviceName: string, transactionType?: string) {
|
||||
const maybeTransactionType = transactionType ? `${transactionType}-` : '';
|
||||
return encodeForMlApi(`${serviceName}-${maybeTransactionType}`);
|
||||
|
@ -13,6 +20,13 @@ export function getMlJobId(serviceName: string, transactionType?: string) {
|
|||
return `${getMlPrefix(serviceName, transactionType)}high_mean_response_time`;
|
||||
}
|
||||
|
||||
export function getMlJobServiceName(jobId: string) {
|
||||
return jobId
|
||||
.split('-')
|
||||
.slice(0, -2)
|
||||
.join('-');
|
||||
}
|
||||
|
||||
export function getMlIndex(serviceName: string, transactionType?: string) {
|
||||
return `.ml-anomalies-${getMlJobId(serviceName, transactionType)}`;
|
||||
}
|
||||
|
@ -20,3 +34,19 @@ export function getMlIndex(serviceName: string, transactionType?: string) {
|
|||
export function encodeForMlApi(value: string) {
|
||||
return value.replace(/\s+/g, '_').toLowerCase();
|
||||
}
|
||||
|
||||
export function getSeverity(score?: number) {
|
||||
if (typeof score !== 'number') {
|
||||
return undefined;
|
||||
} else if (score < 25) {
|
||||
return severity.warning;
|
||||
} else if (score >= 25 && score < 50) {
|
||||
return severity.minor;
|
||||
} else if (score >= 50 && score < 75) {
|
||||
return severity.major;
|
||||
} else if (score >= 75) {
|
||||
return severity.critical;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -304,3 +304,44 @@ storiesOf('app/ServiceMap/Cytoscape', module)
|
|||
}
|
||||
)
|
||||
.addParameters({ options: { showPanel: false } });
|
||||
|
||||
storiesOf('app/ServiceMap/Cytoscape', module).add(
|
||||
'node severity',
|
||||
() => {
|
||||
const elements = [
|
||||
{ data: { id: 'undefined', 'service.name': 'severity: undefined' } },
|
||||
{
|
||||
data: {
|
||||
id: 'warning',
|
||||
'service.name': 'severity: warning',
|
||||
severity: 'warning'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'minor',
|
||||
'service.name': 'severity: minor',
|
||||
severity: 'minor'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'major',
|
||||
'service.name': 'severity: major',
|
||||
severity: 'major'
|
||||
}
|
||||
},
|
||||
{
|
||||
data: {
|
||||
id: 'critical',
|
||||
'service.name': 'severity: critical',
|
||||
severity: 'critical'
|
||||
}
|
||||
}
|
||||
];
|
||||
return <Cytoscape elements={elements} height={300} width={1340} />;
|
||||
},
|
||||
{
|
||||
info: { propTables: false, source: false }
|
||||
}
|
||||
);
|
||||
|
|
|
@ -10,8 +10,52 @@ import {
|
|||
SERVICE_NAME,
|
||||
SPAN_DESTINATION_SERVICE_RESOURCE
|
||||
} from '../../../../common/elasticsearch_fieldnames';
|
||||
import { severity } from '../../../../common/ml_job_constants';
|
||||
import { defaultIcon, iconForNode } from './icons';
|
||||
|
||||
const getBorderColor = (el: cytoscape.NodeSingular) => {
|
||||
const nodeSeverity = el.data('severity');
|
||||
|
||||
switch (nodeSeverity) {
|
||||
case severity.warning:
|
||||
return theme.euiColorVis0;
|
||||
case severity.minor || severity.major:
|
||||
return theme.euiColorVis5;
|
||||
case severity.critical:
|
||||
return theme.euiColorVis9;
|
||||
default:
|
||||
if (el.hasClass('primary') || el.selected()) {
|
||||
return theme.euiColorPrimary;
|
||||
} else {
|
||||
return theme.euiColorMediumShade;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderStyle: cytoscape.Css.MapperFunction<
|
||||
cytoscape.NodeSingular,
|
||||
cytoscape.Css.LineStyle
|
||||
> = (el: cytoscape.NodeSingular) => {
|
||||
const nodeSeverity = el.data('severity');
|
||||
if (nodeSeverity === severity.critical) {
|
||||
return 'double';
|
||||
} else {
|
||||
return 'solid';
|
||||
}
|
||||
};
|
||||
|
||||
const getBorderWidth = (el: cytoscape.NodeSingular) => {
|
||||
const nodeSeverity = el.data('severity');
|
||||
|
||||
if (nodeSeverity === severity.minor || nodeSeverity === severity.major) {
|
||||
return 4;
|
||||
} else if (nodeSeverity === severity.critical) {
|
||||
return 12;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
};
|
||||
|
||||
// IE 11 does not properly load some SVGs or draw certain shapes. This causes
|
||||
// a runtime error and the map fails work at all. We would prefer to do some
|
||||
// kind of feature detection rather than browser detection, but some of these
|
||||
|
@ -55,11 +99,9 @@ const style: cytoscape.Stylesheet[] = [
|
|||
isService(el) ? '60%' : '40%',
|
||||
'background-width': (el: cytoscape.NodeSingular) =>
|
||||
isService(el) ? '60%' : '40%',
|
||||
'border-color': (el: cytoscape.NodeSingular) =>
|
||||
el.hasClass('primary') || el.selected()
|
||||
? theme.euiColorPrimary
|
||||
: theme.euiColorMediumShade,
|
||||
'border-width': 2,
|
||||
'border-color': getBorderColor,
|
||||
'border-style': getBorderStyle,
|
||||
'border-width': getBorderWidth,
|
||||
color: (el: cytoscape.NodeSingular) =>
|
||||
el.hasClass('primary') || el.selected()
|
||||
? theme.euiColorPrimaryText
|
||||
|
@ -149,7 +191,7 @@ const style: cytoscape.Stylesheet[] = [
|
|||
{
|
||||
selector: 'node.hover',
|
||||
style: {
|
||||
'border-width': 2
|
||||
'border-width': getBorderWidth
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -17,6 +17,8 @@ import { Setup, SetupTimeRange } from '../helpers/setup_request';
|
|||
import { dedupeConnections } from './dedupe_connections';
|
||||
import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids';
|
||||
import { getTraceSampleIds } from './get_trace_sample_ids';
|
||||
import { addAnomaliesToServicesData } from './ml_helpers';
|
||||
import { getMlIndex } from '../../../common/ml_job_constants';
|
||||
|
||||
export interface IEnvOptions {
|
||||
setup: Setup & SetupTimeRange;
|
||||
|
@ -137,19 +139,58 @@ async function getServicesData(options: IEnvOptions) {
|
|||
);
|
||||
}
|
||||
|
||||
function getAnomaliesData(options: IEnvOptions) {
|
||||
const { client } = options.setup;
|
||||
|
||||
const params = {
|
||||
index: getMlIndex('*'),
|
||||
body: {
|
||||
size: 0,
|
||||
query: {
|
||||
exists: {
|
||||
field: 'bucket_span'
|
||||
}
|
||||
},
|
||||
aggs: {
|
||||
jobs: {
|
||||
terms: {
|
||||
field: 'job_id',
|
||||
size: 10
|
||||
},
|
||||
aggs: {
|
||||
max_score: {
|
||||
max: {
|
||||
field: 'anomaly_score'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return client.search(params);
|
||||
}
|
||||
|
||||
export type AnomaliesResponse = PromiseReturnType<typeof getAnomaliesData>;
|
||||
export type ConnectionsResponse = PromiseReturnType<typeof getConnectionData>;
|
||||
export type ServicesResponse = PromiseReturnType<typeof getServicesData>;
|
||||
|
||||
export type ServiceMapAPIResponse = PromiseReturnType<typeof getServiceMap>;
|
||||
|
||||
export async function getServiceMap(options: IEnvOptions) {
|
||||
const [connectionData, servicesData] = await Promise.all([
|
||||
const [connectionData, servicesData, anomaliesData] = await Promise.all([
|
||||
getConnectionData(options),
|
||||
getServicesData(options)
|
||||
getServicesData(options),
|
||||
getAnomaliesData(options)
|
||||
]);
|
||||
|
||||
const servicesDataWithAnomalies = addAnomaliesToServicesData(
|
||||
servicesData,
|
||||
anomaliesData
|
||||
);
|
||||
|
||||
return dedupeConnections({
|
||||
...connectionData,
|
||||
services: servicesData
|
||||
services: servicesDataWithAnomalies
|
||||
});
|
||||
}
|
||||
|
|
70
x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts
Normal file
70
x-pack/plugins/apm/server/lib/service_map/ml_helpers.test.ts
Normal file
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 { AnomaliesResponse } from './get_service_map';
|
||||
import { addAnomaliesToServicesData } from './ml_helpers';
|
||||
|
||||
describe('addAnomaliesToServicesData', () => {
|
||||
it('adds anomalies to services data', () => {
|
||||
const servicesData = [
|
||||
{
|
||||
'service.name': 'opbeans-ruby',
|
||||
'agent.name': 'ruby',
|
||||
'service.environment': null,
|
||||
'service.framework.name': 'Ruby on Rails'
|
||||
},
|
||||
{
|
||||
'service.name': 'opbeans-java',
|
||||
'agent.name': 'java',
|
||||
'service.environment': null,
|
||||
'service.framework.name': null
|
||||
}
|
||||
];
|
||||
|
||||
const anomaliesResponse = {
|
||||
aggregations: {
|
||||
jobs: {
|
||||
buckets: [
|
||||
{
|
||||
key: 'opbeans-ruby-request-high_mean_response_time',
|
||||
max_score: { value: 50 }
|
||||
},
|
||||
{
|
||||
key: 'opbeans-java-request-high_mean_response_time',
|
||||
max_score: { value: 100 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const result = [
|
||||
{
|
||||
'service.name': 'opbeans-ruby',
|
||||
'agent.name': 'ruby',
|
||||
'service.environment': null,
|
||||
'service.framework.name': 'Ruby on Rails',
|
||||
max_score: 50,
|
||||
severity: 'major'
|
||||
},
|
||||
{
|
||||
'service.name': 'opbeans-java',
|
||||
'agent.name': 'java',
|
||||
'service.environment': null,
|
||||
'service.framework.name': null,
|
||||
max_score: 100,
|
||||
severity: 'critical'
|
||||
}
|
||||
];
|
||||
|
||||
expect(
|
||||
addAnomaliesToServicesData(
|
||||
servicesData,
|
||||
(anomaliesResponse as unknown) as AnomaliesResponse
|
||||
)
|
||||
).toEqual(result);
|
||||
});
|
||||
});
|
47
x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts
Normal file
47
x-pack/plugins/apm/server/lib/service_map/ml_helpers.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* 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 { SERVICE_NAME } from '../../../common/elasticsearch_fieldnames';
|
||||
import {
|
||||
getMlJobServiceName,
|
||||
getSeverity
|
||||
} from '../../../common/ml_job_constants';
|
||||
import { AnomaliesResponse, ServicesResponse } from './get_service_map';
|
||||
|
||||
export function addAnomaliesToServicesData(
|
||||
servicesData: ServicesResponse,
|
||||
anomaliesResponse: AnomaliesResponse
|
||||
) {
|
||||
const anomaliesMap = (
|
||||
anomaliesResponse.aggregations?.jobs.buckets ?? []
|
||||
).reduce<{
|
||||
[key: string]: { max_score?: number };
|
||||
}>((previousValue, currentValue) => {
|
||||
const key = getMlJobServiceName(currentValue.key.toString());
|
||||
|
||||
return {
|
||||
...previousValue,
|
||||
[key]: {
|
||||
max_score: Math.max(
|
||||
previousValue[key]?.max_score ?? 0,
|
||||
currentValue.max_score.value ?? 0
|
||||
)
|
||||
}
|
||||
};
|
||||
}, {});
|
||||
|
||||
const servicesDataWithAnomalies = servicesData.map(service => {
|
||||
const score = anomaliesMap[service[SERVICE_NAME]]?.max_score;
|
||||
|
||||
return {
|
||||
...service,
|
||||
max_score: score,
|
||||
severity: getSeverity(score)
|
||||
};
|
||||
});
|
||||
|
||||
return servicesDataWithAnomalies;
|
||||
}
|
Loading…
Reference in a new issue