Skip to content

Commit

Permalink
[APM] Service maps anomaly detection integration by environment (#70932)
Browse files Browse the repository at this point in the history
* Closes #69480 & #70419.
- Adds anomaly detection integration to service maps backed by apm ML jobs per environment
- Loads transaction stats and anomalies for each transaction types
- Renders a selector in the popop to choose a transaction type to view stats

* - implements original anomaly detection integration design for service maps popover
- only aggregates transaction KPIs and anomaly scores for transaction.type = "request" or "page-load"
- supports environment filter 'All' option to display data from all APM anomaly detection jobs
- handle case where popover metrics don't exist for services outside the current environment filter

* fixes some CI errors

* Simplified messaging for service popop with not data in the current environment

* PR feedback, renamed max anomalies -> service anomalies including the file name

* - defines custom_settings.job_tags.apm_ml_version in ML job creation,
  then filters for it when returing valid APM ML jobs

* changes shape of of service anomalies from an array to a object keyed by serviceName

* removes the url encoding from ML job link href to how it was previously.

* PR feedback

* Popover no data state simplified:
- renders the "no data" message as plain text instead of in a callout
- hides the 'Anomaly detection' section if there is not anomaly data.

* Fixes filtering bug when user selects 'Environment: Not defined'. Now
filters properly by filtering for docs where service.environment does
not exist

* filters jobs fetched in the settings page by `job.custom_settings.job_tags.apm_ml_version`

* Fixed bad import from last upstream merge

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
  • Loading branch information
ogupte and elasticmachine authored Jul 9, 2020
1 parent 77a97fc commit b302565
Show file tree
Hide file tree
Showing 19 changed files with 659 additions and 120 deletions.
12 changes: 12 additions & 0 deletions x-pack/plugins/apm/common/anomaly_detection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/*
* 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.
*/

export interface ServiceAnomalyStats {
transactionType?: string;
anomalyScore?: number;
actualValue?: number;
jobId?: string;
}
8 changes: 6 additions & 2 deletions x-pack/plugins/apm/common/service_map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ import {
SPAN_SUBTYPE,
SPAN_TYPE,
} from './elasticsearch_fieldnames';
import { ServiceAnomalyStats } from './anomaly_detection';

export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition {
[SERVICE_NAME]: string;
[SERVICE_ENVIRONMENT]: string | null;
[AGENT_NAME]: string;
serviceAnomalyStats?: ServiceAnomalyStats;
}
export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition {
[SPAN_DESTINATION_SERVICE_RESOURCE]: string;
Expand All @@ -37,8 +39,10 @@ export interface Connection {
export interface ServiceNodeMetrics {
avgMemoryUsage: number | null;
avgCpuUsage: number | null;
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
transactionStats: {
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
};
avgErrorsPerMinute: number | null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
/*
* 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 { i18n } from '@kbn/i18n';
import React from 'react';
import styled from 'styled-components';
import {
EuiFlexGroup,
EuiFlexItem,
EuiTitle,
EuiIconTip,
EuiHealth,
} from '@elastic/eui';
import { useTheme } from '../../../../hooks/useTheme';
import { fontSize, px } from '../../../../style/variables';
import { asInteger, asDuration } from '../../../../utils/formatters';
import { MLJobLink } from '../../../shared/Links/MachineLearningLinks/MLJobLink';
import { getSeverityColor, popoverWidth } from '../cytoscapeOptions';
import { getSeverity } from '../../../../../common/ml_job_constants';
import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types';
import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection';

const HealthStatusTitle = styled(EuiTitle)`
display: inline;
text-transform: uppercase;
`;

const VerticallyCentered = styled.div`
display: flex;
align-items: center;
`;

const SubduedText = styled.span`
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
`;

const EnableText = styled.section`
color: ${({ theme }) => theme.eui.euiTextSubduedColor};
line-height: 1.4;
font-size: ${fontSize};
width: ${px(popoverWidth)};
`;

export const ContentLine = styled.section`
line-height: 2;
`;

interface Props {
serviceName: string;
serviceAnomalyStats: ServiceAnomalyStats | undefined;
}
export function AnomalyDetection({ serviceName, serviceAnomalyStats }: Props) {
const theme = useTheme();

const anomalyScore = serviceAnomalyStats?.anomalyScore;
const anomalySeverity = getSeverity(anomalyScore);
const actualValue = serviceAnomalyStats?.actualValue;
const mlJobId = serviceAnomalyStats?.jobId;
const transactionType =
serviceAnomalyStats?.transactionType ?? TRANSACTION_REQUEST;
const hasAnomalyDetectionScore = anomalyScore !== undefined;

return (
<>
<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(theme, anomalySeverity)} />
<SubduedText>{ANOMALY_DETECTION_SCORE_METRIC}</SubduedText>
</VerticallyCentered>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
{getDisplayedAnomalyScore(anomalyScore as number)}
{actualValue && (
<SubduedText>&nbsp;({asDuration(actualValue)})</SubduedText>
)}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</ContentLine>
)}
{mlJobId && !hasAnomalyDetectionScore && (
<EnableText>{ANOMALY_DETECTION_NO_DATA_TEXT}</EnableText>
)}
{mlJobId && (
<ContentLine>
<MLJobLink
external
jobId={mlJobId}
serviceName={serviceName}
transactionType={transactionType}
>
{ANOMALY_DETECTION_LINK}
</MLJobLink>
</ContentLine>
)}
</>
);
}

function getDisplayedAnomalyScore(score: number) {
if (score > 0 && score < 1) {
return '< 1';
}
return asInteger(score);
}

const ANOMALY_DETECTION_TITLE = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverTitle',
{ defaultMessage: 'Anomaly Detection' }
);

const ANOMALY_DETECTION_TOOLTIP = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverTooltip',
{
defaultMessage:
'Service health indicators are powered by 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_DISABLED_TEXT = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverDisabled',
{
defaultMessage:
'Display service health indicators by enabling anomaly detection in APM settings.',
}
);

const ANOMALY_DETECTION_NO_DATA_TEXT = i18n.translate(
'xpack.apm.serviceMap.anomalyDetectionPopoverNoData',
{
defaultMessage: `We couldn't find an anomaly score within the selected time range. See details in the anomaly explorer.`,
}
);
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import React, { MouseEvent } from 'react';
import { Buttons } from './Buttons';
import { Info } from './Info';
import { ServiceMetricFetcher } from './ServiceMetricFetcher';
import { popoverMinWidth } from '../cytoscapeOptions';
import { popoverWidth } from '../cytoscapeOptions';

interface ContentsProps {
isService: boolean;
Expand Down Expand Up @@ -60,24 +60,20 @@ export function Contents({
<FlexColumnGroup
direction="column"
gutterSize="s"
style={{ minWidth: popoverMinWidth }}
style={{ width: popoverWidth }}
>
<FlexColumnItem>
<EuiTitle size="xxs">
<h3>{label}</h3>
</EuiTitle>
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
{/* //TODO [APM ML] add service health stats here:
isService && (
<FlexColumnItem>
<ServiceHealth serviceNodeData={selectedNodeData} />
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
)*/}
<FlexColumnItem>
{isService ? (
<ServiceMetricFetcher serviceName={selectedNodeServiceName} />
<ServiceMetricFetcher
serviceName={selectedNodeServiceName}
serviceAnomalyStats={selectedNodeData.serviceAnomalyStats}
/>
) : (
<Info {...selectedNodeData} />
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,40 +12,33 @@ storiesOf('app/ServiceMap/Popover/ServiceMetricList', module)
.add('example', () => (
<ServiceMetricList
avgErrorsPerMinute={15.738888706725826}
avgTransactionDuration={61634.38905590272}
avgRequestsPerMinute={164.47222031860858}
transactionStats={{
avgTransactionDuration: 61634.38905590272,
avgRequestsPerMinute: 164.47222031860858,
}}
avgCpuUsage={0.32809666568309237}
avgMemoryUsage={0.5504868173242986}
isLoading={false}
/>
))
.add('loading', () => (
<ServiceMetricList
avgErrorsPerMinute={null}
avgTransactionDuration={null}
avgRequestsPerMinute={null}
avgCpuUsage={null}
avgMemoryUsage={null}
isLoading={true}
/>
))
.add('some null values', () => (
<ServiceMetricList
avgErrorsPerMinute={7.615972134074397}
avgTransactionDuration={238792.54809512055}
avgRequestsPerMinute={8.439583235652972}
transactionStats={{
avgTransactionDuration: 238792.54809512055,
avgRequestsPerMinute: 8.439583235652972,
}}
avgCpuUsage={null}
avgMemoryUsage={null}
isLoading={false}
/>
))
.add('all null values', () => (
<ServiceMetricList
avgErrorsPerMinute={null}
avgTransactionDuration={null}
avgRequestsPerMinute={null}
transactionStats={{
avgTransactionDuration: null,
avgRequestsPerMinute: null,
}}
avgCpuUsage={null}
avgMemoryUsage={null}
isLoading={false}
/>
));
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,38 @@
*/

import React from 'react';
import {
EuiLoadingSpinner,
EuiFlexGroup,
EuiHorizontalRule,
EuiText,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isNumber } from 'lodash';
import { ServiceNodeMetrics } from '../../../../../common/service_map';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useFetcher, FETCH_STATUS } from '../../../../hooks/useFetcher';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { ServiceMetricList } from './ServiceMetricList';
import { AnomalyDetection } from './AnomalyDetection';
import { ServiceAnomalyStats } from '../../../../../common/anomaly_detection';

interface ServiceMetricFetcherProps {
serviceName: string;
serviceAnomalyStats: ServiceAnomalyStats | undefined;
}

export function ServiceMetricFetcher({
serviceName,
serviceAnomalyStats,
}: ServiceMetricFetcherProps) {
const {
urlParams: { start, end, environment },
} = useUrlParams();

const { data = {} as ServiceNodeMetrics, status } = useFetcher(
const {
data = { transactionStats: {} } as ServiceNodeMetrics,
status,
} = useFetcher(
(callApmApi) => {
if (serviceName && start && end) {
return callApmApi({
Expand All @@ -35,7 +50,62 @@ export function ServiceMetricFetcher({
preservePreviousData: false,
}
);
const isLoading = status === 'loading';

return <ServiceMetricList {...data} isLoading={isLoading} />;
const isLoading =
status === FETCH_STATUS.PENDING || status === FETCH_STATUS.LOADING;

if (isLoading) {
return <LoadingSpinner />;
}

const {
avgCpuUsage,
avgErrorsPerMinute,
avgMemoryUsage,
transactionStats: { avgRequestsPerMinute, avgTransactionDuration },
} = data;

const hasServiceData = [
avgCpuUsage,
avgErrorsPerMinute,
avgMemoryUsage,
avgRequestsPerMinute,
avgTransactionDuration,
].some((stat) => isNumber(stat));

if (environment && !hasServiceData) {
return (
<EuiText color="subdued">
{i18n.translate('xpack.apm.serviceMap.popoverMetrics.noDataText', {
defaultMessage: `No data for selected environment. Try switching to another environment.`,
})}
</EuiText>
);
}
return (
<>
{serviceAnomalyStats && (
<>
<AnomalyDetection
serviceName={serviceName}
serviceAnomalyStats={serviceAnomalyStats}
/>
<EuiHorizontalRule margin="xs" />
</>
)}
<ServiceMetricList {...data} />
</>
);
}

function LoadingSpinner() {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
);
}
Loading

0 comments on commit b302565

Please sign in to comment.