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:
Nathan L Smith 2020-05-04 13:40:03 -05:00 committed by GitHub
parent 33b2b5c92c
commit 63121fb47e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 329 additions and 11 deletions

View file

@ -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);
});
});
});
});

View file

@ -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;
}
}

View file

@ -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 }
}
);

View file

@ -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
}
},
{

View file

@ -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
});
}

View 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);
});
});

View 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;
}