[APM] Service maps anomaly detection status in popover (#65217)

* Adds ML status to the service maps popover. Modifies the query a bit to
get the actual vs typical values necessary to generate a proper anomaly description

* fixed failures and updated tests

* component clean up

* makes the ML link open in a new window

* - Closes #64278 by removing the framework badge.
- changes anomaly score display to integer formatting
- update link test to 'View anomalies'

* - Closes #65244 by displaying a message for services without anomalies detected
- removes unecessary service framework name in service map queries
- adds date range filter for anomaly detection
This commit is contained in:
Oliver Gupte 2020-05-05 12:15:15 -07:00 committed by GitHub
parent bec09fde87
commit 4896b65c98
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 624 additions and 434 deletions

View file

@ -9,7 +9,6 @@ import { ILicense } from '../../licensing/public';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
SERVICE_FRAMEWORK_NAME,
SERVICE_NAME,
SPAN_SUBTYPE,
SPAN_TYPE,
@ -19,7 +18,6 @@ import {
export interface ServiceConnectionNode {
[SERVICE_NAME]: string;
[SERVICE_ENVIRONMENT]: string | null;
[SERVICE_FRAMEWORK_NAME]: string | null;
[AGENT_NAME]: string;
}
export interface ExternalConnectionNode {

View file

@ -134,6 +134,11 @@ export function Cytoscape({
);
cy.remove(absentElements);
cy.add(elements);
// ensure all elements get latest data properties
elements.forEach(elementDefinition => {
const el = cy.getElementById(elementDefinition.data.id as string);
el.data(elementDefinition.data);
});
cy.trigger('data');
}
}, [cy, elements]);

View file

@ -8,14 +8,23 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiTitle
EuiTitle,
EuiIconTip,
EuiHealth
} from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import cytoscape from 'cytoscape';
import React from 'react';
import { SERVICE_FRAMEWORK_NAME } from '../../../../../common/elasticsearch_fieldnames';
import styled from 'styled-components';
import { fontSize, px } from '../../../../style/variables';
import { Buttons } from './Buttons';
import { Info } from './Info';
import { ServiceMetricFetcher } from './ServiceMetricFetcher';
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
import { getSeverityColor } from '../cytoscapeOptions';
import { asInteger } from '../../../../utils/formatters';
import { getMetricChangeDescription } from '../../../../../../ml/public';
const popoverMinWidth = 280;
@ -27,6 +36,31 @@ interface ContentsProps {
selectedNodeServiceName: string;
}
const HealthStatusTitle = styled(EuiTitle)`
display: inline;
text-transform: uppercase;
`;
const VerticallyCentered = styled.div`
display: flex;
align-items: center;
`;
const SubduedText = styled.span`
color: ${theme.euiTextSubduedColor};
`;
const EnableText = styled.section`
color: ${theme.euiTextSubduedColor};
line-height: 1.4;
font-size: ${fontSize};
width: ${px(popoverMinWidth)};
`;
export const ContentLine = styled.section`
line-height: 2;
`;
// IE 11 does not handle flex properties as expected. With browser detection,
// we can use regular div elements to render contents that are almost identical.
//
@ -51,6 +85,37 @@ const FlexColumnGroup = (props: {
const FlexColumnItem = (props: { children: React.ReactNode }) =>
isIE11 ? <div {...props} /> : <EuiFlexItem {...props} />;
const ANOMALY_DETECTION_TITLE = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverTitle',
{ defaultMessage: 'Anomaly Detection' }
);
const ANOMALY_DETECTION_INFO = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverInfo',
{
defaultMessage:
'Display the health of your service by enabling the anomaly detection feature in Machine Learning.'
}
);
const ANOMALY_DETECTION_SCORE_METRIC = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverScoreMetric',
{ defaultMessage: 'Score (max.)' }
);
const ANOMALY_DETECTION_LINK = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverLink',
{ defaultMessage: 'View anomalies' }
);
const ANOMALY_DETECTION_ENABLE_TEXT = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverEnable',
{
defaultMessage:
'Enable anomaly detection from the Integrations menu in the Service details view.'
}
);
export function Contents({
selectedNodeData,
isService,
@ -58,7 +123,23 @@ export function Contents({
onFocusClick,
selectedNodeServiceName
}: ContentsProps) {
const frameworkName = selectedNodeData[SERVICE_FRAMEWORK_NAME];
// Anomaly Detection
const severity = selectedNodeData.severity;
const maxScore = selectedNodeData.max_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;
return (
<FlexColumnGroup
direction="column"
@ -71,12 +152,50 @@ export function Contents({
</EuiTitle>
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
{isService && (
<FlexColumnItem>
<section>
<HealthStatusTitle size="xxs">
<h3>{ANOMALY_DETECTION_TITLE}</h3>
</HealthStatusTitle>
&nbsp;
<EuiIconTip type="iInCircle" content={ANOMALY_DETECTION_INFO} />
</section>
{hasAnomalyDetection ? (
<>
<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>
</>
) : (
<EnableText>{ANOMALY_DETECTION_ENABLE_TEXT}</EnableText>
)}
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
)}
<FlexColumnItem>
{isService ? (
<ServiceMetricFetcher
frameworkName={frameworkName}
serviceName={selectedNodeServiceName}
/>
<ServiceMetricFetcher serviceName={selectedNodeServiceName} />
) : (
<Info {...selectedNodeData} />
)}

View file

@ -16,7 +16,6 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module)
avgRequestsPerMinute={164.47222031860858}
avgCpuUsage={0.32809666568309237}
avgMemoryUsage={0.5504868173242986}
frameworkName="Spring"
numInstances={2}
isLoading={false}
/>

View file

@ -11,12 +11,10 @@ import { useUrlParams } from '../../../../hooks/useUrlParams';
import { ServiceMetricList } from './ServiceMetricList';
interface ServiceMetricFetcherProps {
frameworkName?: string;
serviceName: string;
}
export function ServiceMetricFetcher({
frameworkName,
serviceName
}: ServiceMetricFetcherProps) {
const {
@ -39,11 +37,5 @@ export function ServiceMetricFetcher({
);
const isLoading = status === 'loading';
return (
<ServiceMetricList
{...data}
frameworkName={frameworkName}
isLoading={isLoading}
/>
);
return <ServiceMetricList {...data} isLoading={isLoading} />;
}

View file

@ -34,21 +34,20 @@ const BadgeRow = styled(EuiFlexItem)`
padding-bottom: ${lightTheme.gutterTypes.gutterSmall};
`;
const ItemRow = styled('tr')`
export const ItemRow = styled('tr')`
line-height: 2;
`;
const ItemTitle = styled('td')`
export const ItemTitle = styled('td')`
color: ${lightTheme.textColors.subdued};
padding-right: 1rem;
`;
const ItemDescription = styled('td')`
export const ItemDescription = styled('td')`
text-align: right;
`;
interface ServiceMetricListProps extends ServiceNodeMetrics {
frameworkName?: string;
isLoading: boolean;
}
@ -58,7 +57,6 @@ export function ServiceMetricList({
avgErrorsPerMinute,
avgCpuUsage,
avgMemoryUsage,
frameworkName,
numInstances,
isLoading
}: ServiceMetricListProps) {
@ -112,7 +110,7 @@ export function ServiceMetricList({
: null
}
];
const showBadgeRow = frameworkName || numInstances > 1;
const showBadgeRow = numInstances > 1;
return isLoading ? (
<LoadingSpinner />
@ -121,7 +119,6 @@ export function ServiceMetricList({
{showBadgeRow && (
<BadgeRow>
<EuiFlexGroup gutterSize="none">
{frameworkName && <EuiBadge>{frameworkName}</EuiBadge>}
{numInstances > 1 && (
<EuiBadge iconType="apps" color="hollow">
{i18n.translate('xpack.apm.serviceMap.numInstancesMetric', {

View file

@ -13,9 +13,7 @@ import {
import { severity } from '../../../../common/ml_job_constants';
import { defaultIcon, iconForNode } from './icons';
const getBorderColor = (el: cytoscape.NodeSingular) => {
const nodeSeverity = el.data('severity');
export const getSeverityColor = (nodeSeverity: string) => {
switch (nodeSeverity) {
case severity.warning:
return theme.euiColorVis0;
@ -24,11 +22,20 @@ const getBorderColor = (el: cytoscape.NodeSingular) => {
case severity.critical:
return theme.euiColorVis9;
default:
if (el.hasClass('primary') || el.selected()) {
return theme.euiColorPrimary;
} else {
return theme.euiColorMediumShade;
}
return;
}
};
const getBorderColor = (el: cytoscape.NodeSingular) => {
const nodeSeverity = el.data('severity');
const severityColor = getSeverityColor(nodeSeverity);
if (severityColor) {
return severityColor;
}
if (el.hasClass('primary') || el.selected()) {
return theme.euiColorPrimary;
} else {
return theme.euiColorMediumShade;
}
};

View file

@ -10,7 +10,7 @@ import { getRenderedHref } from '../../../../utils/testHelpers';
import { MLJobLink } from './MLJobLink';
describe('MLJobLink', () => {
it('should produce the correct URL', async () => {
it('should produce the correct URL with serviceName', async () => {
const href = await getRenderedHref(
() => (
<MLJobLink
@ -21,6 +21,18 @@ describe('MLJobLink', () => {
{ search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location
);
expect(href).toEqual(
`/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))`
);
});
it('should produce the correct URL with jobId', async () => {
const href = await getRenderedHref(
() => (
<MLJobLink jobId="myservicename-mytransactiontype-high_mean_response_time" />
),
{ search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location
);
expect(href).toEqual(
`/basepath/app/ml#/timeseriesexplorer?_g=(ml:(jobIds:!(myservicename-mytransactiontype-high_mean_response_time)),refreshInterval:(pause:true,value:'0'),time:(from:now%2Fw,to:now-4h))`
);

View file

@ -8,22 +8,33 @@ import React from 'react';
import { getMlJobId } from '../../../../../common/ml_job_constants';
import { MLLink } from './MLLink';
interface Props {
interface PropsServiceName {
serviceName: string;
transactionType?: string;
}
interface PropsJobId {
jobId: string;
}
export const MLJobLink: React.FC<Props> = ({
serviceName,
transactionType,
children
}) => {
const jobId = getMlJobId(serviceName, transactionType);
type Props = (PropsServiceName | PropsJobId) & {
external?: boolean;
};
export const MLJobLink: React.FC<Props> = props => {
const jobId =
'jobId' in props
? props.jobId
: getMlJobId(props.serviceName, props.transactionType);
const query = {
ml: { jobIds: [jobId] }
};
return (
<MLLink children={children} query={query} path="/timeseriesexplorer" />
<MLLink
children={props.children}
query={query}
path="/timeseriesexplorer"
external={props.external}
/>
);
};

View file

@ -22,9 +22,10 @@ interface Props {
query?: MlRisonData;
path?: string;
children?: React.ReactNode;
external?: boolean;
}
export function MLLink({ children, path = '', query = {} }: Props) {
export function MLLink({ children, path = '', query = {}, external }: Props) {
const { core } = useApmPluginContext();
const location = useLocation();
@ -41,5 +42,12 @@ export function MLLink({ children, path = '', query = {} }: Props) {
hash: `${path}?_g=${rison.encode(risonQuery as RisonValue)}`
});
return <EuiLink children={children} href={href} />;
return (
<EuiLink
children={children}
href={href}
external={external}
target={external ? '_blank' : undefined}
/>
);
}

View file

@ -4,9 +4,13 @@
* you may not use this file except in compliance with the Elastic License.
*/
export function rangeFilter(start: number, end: number) {
export function rangeFilter(
start: number,
end: number,
timestampField = '@timestamp'
) {
return {
'@timestamp': {
[timestampField]: {
gte: start,
lte: end,
format: 'epoch_millis'

View file

@ -9,7 +9,6 @@ import {
SPAN_DESTINATION_SERVICE_RESOURCE,
SERVICE_NAME,
SERVICE_ENVIRONMENT,
SERVICE_FRAMEWORK_NAME,
AGENT_NAME,
SPAN_TYPE,
SPAN_SUBTYPE
@ -19,7 +18,6 @@ import { dedupeConnections } from './';
const nodejsService = {
[SERVICE_NAME]: 'opbeans-node',
[SERVICE_ENVIRONMENT]: 'production',
[SERVICE_FRAMEWORK_NAME]: null,
[AGENT_NAME]: 'nodejs'
};
@ -32,7 +30,6 @@ const nodejsExternal = {
const javaService = {
[SERVICE_NAME]: 'opbeans-java',
[SERVICE_ENVIRONMENT]: 'production',
[SERVICE_FRAMEWORK_NAME]: null,
[AGENT_NAME]: 'java'
};

View file

@ -7,7 +7,6 @@ import { chunk } from 'lodash';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
SERVICE_FRAMEWORK_NAME,
SERVICE_NAME
} from '../../../common/elasticsearch_fieldnames';
import { getServicesProjection } from '../../../common/projections/services';
@ -19,6 +18,7 @@ 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';
import { rangeFilter } from '../helpers/range_filter';
export interface IEnvOptions {
setup: Setup & SetupTimeRange;
@ -107,11 +107,6 @@ async function getServicesData(options: IEnvOptions) {
terms: {
field: AGENT_NAME
}
},
service_framework_name: {
terms: {
field: SERVICE_FRAMEWORK_NAME
}
}
}
}
@ -129,38 +124,32 @@ async function getServicesData(options: IEnvOptions) {
[SERVICE_NAME]: bucket.key as string,
[AGENT_NAME]:
(bucket.agent_name.buckets[0]?.key as string | undefined) || '',
[SERVICE_ENVIRONMENT]: options.environment || null,
[SERVICE_FRAMEWORK_NAME]:
(bucket.service_framework_name.buckets[0]?.key as
| string
| undefined) || null
[SERVICE_ENVIRONMENT]: options.environment || null
};
}) || []
);
}
function getAnomaliesData(options: IEnvOptions) {
const { client } = options.setup;
const { start, end, client } = options.setup;
const rangeQuery = { range: rangeFilter(start, end, 'timestamp') };
const params = {
index: getMlIndex('*'),
body: {
size: 0,
query: {
exists: {
field: 'bucket_span'
}
bool: { filter: [{ term: { result_type: 'record' } }, rangeQuery] }
},
aggs: {
jobs: {
terms: {
field: 'job_id',
size: 10
},
terms: { field: 'job_id', size: 10 },
aggs: {
max_score: {
max: {
field: 'anomaly_score'
top_score_hits: {
top_hits: {
sort: [{ record_score: { order: 'desc' as const } }],
_source: ['job_id', 'record_score', 'typical', 'actual'],
size: 1
}
}
}
@ -178,7 +167,13 @@ export type ServicesResponse = PromiseReturnType<typeof getServicesData>;
export type ServiceMapAPIResponse = PromiseReturnType<typeof getServiceMap>;
export async function getServiceMap(options: IEnvOptions) {
const [connectionData, servicesData, anomaliesData] = await Promise.all([
const [connectionData, servicesData, anomaliesData]: [
// explicit types to avoid TS "excessively deep" error
ConnectionsResponse,
ServicesResponse,
AnomaliesResponse
// @ts-ignore
] = await Promise.all([
getConnectionData(options),
getServicesData(options),
getAnomaliesData(options)

View file

@ -13,14 +13,12 @@ describe('addAnomaliesToServicesData', () => {
{
'service.name': 'opbeans-ruby',
'agent.name': 'ruby',
'service.environment': null,
'service.framework.name': 'Ruby on Rails'
'service.environment': null
},
{
'service.name': 'opbeans-java',
'agent.name': 'java',
'service.environment': null,
'service.framework.name': null
'service.environment': null
}
];
@ -30,11 +28,37 @@ describe('addAnomaliesToServicesData', () => {
buckets: [
{
key: 'opbeans-ruby-request-high_mean_response_time',
max_score: { value: 50 }
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',
max_score: { value: 100 }
top_score_hits: {
hits: {
hits: [
{
_source: {
record_score: 100,
actual: [9000],
typical: [3000],
job_id: 'opbeans-java-request-high_mean_response_time'
}
}
]
}
}
}
]
}
@ -46,17 +70,21 @@ describe('addAnomaliesToServicesData', () => {
'service.name': 'opbeans-ruby',
'agent.name': 'ruby',
'service.environment': null,
'service.framework.name': 'Ruby on Rails',
max_score: 50,
severity: 'major'
severity: 'major',
actual_value: 2000,
typical_value: 1000,
job_id: 'opbeans-ruby-request-high_mean_response_time'
},
{
'service.name': 'opbeans-java',
'agent.name': 'java',
'service.environment': null,
'service.framework.name': null,
max_score: 100,
severity: 'critical'
severity: 'critical',
actual_value: 9000,
typical_value: 3000,
job_id: 'opbeans-java-request-high_mean_response_time'
}
];

View file

@ -18,29 +18,54 @@ export function addAnomaliesToServicesData(
const anomaliesMap = (
anomaliesResponse.aggregations?.jobs.buckets ?? []
).reduce<{
[key: string]: { max_score?: number };
[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;
if ((previousValue[key]?.max_score ?? 0) > maxScore) {
return previousValue;
}
return {
...previousValue,
[key]: {
max_score: Math.max(
previousValue[key]?.max_score ?? 0,
currentValue.max_score.value ?? 0
)
max_score: maxScore,
actual_value: actualValue,
typical_value: typicalValue,
job_id: jobId
}
};
}, {});
const servicesDataWithAnomalies = servicesData.map(service => {
const score = anomaliesMap[service[SERVICE_NAME]]?.max_score;
return {
...service,
max_score: score,
severity: getSeverity(score)
};
const serviceAnomalies = anomaliesMap[service[SERVICE_NAME]];
if (serviceAnomalies) {
const maxScore = serviceAnomalies.max_score;
return {
...service,
max_score: maxScore,
severity: getSeverity(maxScore),
actual_value: serviceAnomalies.actual_value,
typical_value: serviceAnomalies.typical_value,
job_id: serviceAnomalies.job_id
};
}
return service;
});
return servicesDataWithAnomalies;

View file

@ -13,6 +13,7 @@ import {
MlSetupDependencies,
MlStartDependencies,
} from './plugin';
import { getMetricChangeDescription } from './application/formatters/metric_change_description';
export const plugin: PluginInitializer<
MlPluginSetup,
@ -21,4 +22,4 @@ export const plugin: PluginInitializer<
MlStartDependencies
> = () => new MlPlugin();
export { MlPluginSetup, MlPluginStart };
export { MlPluginSetup, MlPluginStart, getMetricChangeDescription };