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 anomaly detection integration by environment #70932

Merged
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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