Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
- 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
  • Loading branch information
ogupte committed Jul 8, 2020
1 parent d43c460 commit f08bfca
Show file tree
Hide file tree
Showing 11 changed files with 464 additions and 80 deletions.
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 @@ -14,6 +14,7 @@ import {
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_SUBTYPE,
SPAN_TYPE,
TRANSACTION_TYPE,
} from './elasticsearch_fieldnames';

export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition {
Expand All @@ -37,8 +38,11 @@ export interface Connection {
export interface ServiceNodeMetrics {
avgMemoryUsage: number | null;
avgCpuUsage: number | null;
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
transactionMetrics: Array<{
[TRANSACTION_TYPE]: string;
avgTransactionDuration: number | null;
avgRequestsPerMinute: number | null;
}>;
avgErrorsPerMinute: number | null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,18 @@ export function Contents({
</EuiTitle>
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
{/* //TODO [APM ML] add service health stats here:
isService && (
{/* isService && (
<FlexColumnItem>
<ServiceHealth serviceNodeData={selectedNodeData} />
<EuiHorizontalRule margin="xs" />
</FlexColumnItem>
)*/}
<FlexColumnItem>
{isService ? (
<ServiceMetricFetcher serviceName={selectedNodeServiceName} />
<ServiceMetricFetcher
serviceName={selectedNodeServiceName}
anomalies={selectedNodeData.anomalies}
/>
) : (
<Info {...selectedNodeData} />
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/*
* 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 React, { useState, useEffect, useMemo } from 'react';
import { isNumber } from 'lodash';
import styled, { css } from 'styled-components';
import { i18n } from '@kbn/i18n';
import { EuiSelect } from '@elastic/eui';
import { ServiceNodeMetrics } from '../../../../../common/service_map';
import { ItemRow, ItemTitle, ItemDescription } from './ServiceMetricList';
import { asDuration, asInteger, tpmUnit } from '../../../../utils/formatters';
import { getSeverity } from '../../../../../common/ml_job_constants';
import { getSeverityColor } from '../cytoscapeOptions';
import { useTheme } from '../../../../hooks/useTheme';
import { TRANSACTION_TYPE } from '../../../../../common/elasticsearch_fieldnames';

const AnomalyScore = styled.span<{
readonly severityColor: string | undefined;
}>`
font-weight: bold;
${(props) =>
props.severityColor &&
css`
color: ${props.severityColor};
`}
`;

const ActualValue = styled.span`
color: silver;
`;

interface TransactionAnomaly {
[TRANSACTION_TYPE]: string;
anomaly_score: number;
actual_value: number;
}

function getMaxAnomalyTransactionType(anomalies: TransactionAnomaly[] = []) {
const maxScore = Math.max(
...anomalies.map(({ anomaly_score: anomalyScore }) => anomalyScore)
);
const maxAnomaly = anomalies.find(
({ anomaly_score: anomalyScore }) => anomalyScore === maxScore
);
return maxAnomaly?.[TRANSACTION_TYPE] ?? anomalies[0]?.[TRANSACTION_TYPE];
}

interface Props {
anomalies: undefined | TransactionAnomaly[];
transactionMetrics: ServiceNodeMetrics['transactionMetrics'];
}

export function ServiceHealth({ anomalies, transactionMetrics }: Props) {
const theme = useTheme();
const transactionTypes = useMemo(
() =>
Array.isArray(transactionMetrics)
? transactionMetrics.map(
(transactionTypeMetrics) => transactionTypeMetrics[TRANSACTION_TYPE]
)
: [],
[transactionMetrics]
);
const [selectedType, setSelectedType] = useState(
getMaxAnomalyTransactionType(anomalies)
);
const selectedAnomaly = Array.isArray(anomalies)
? anomalies.find((anomaly) => anomaly[TRANSACTION_TYPE] === selectedType)
: undefined;
const selectedTransactionMetrics = transactionMetrics.find(
(transactionTypeMetrics) =>
transactionTypeMetrics[TRANSACTION_TYPE] === selectedType
);

useEffect(() => {
setSelectedType(getMaxAnomalyTransactionType(anomalies));
}, [anomalies]);

const listItems = [];

if (selectedTransactionMetrics?.avgTransactionDuration) {
const { avgTransactionDuration } = selectedTransactionMetrics;
listItems.push({
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverMetric',
{
defaultMessage: 'Trans. duration (avg.)',
}
),
description: isNumber(avgTransactionDuration)
? asDuration(avgTransactionDuration)
: null,
});
}

if (selectedAnomaly) {
listItems.push({
title: i18n.translate('xpack.apm.serviceMap.anomalyScorePopoverMetric', {
defaultMessage: 'Anomaly score (max.)',
}),
description: (
<>
<AnomalyScore
severityColor={getSeverityColor(
theme,
getSeverity(selectedAnomaly.anomaly_score)
)}
>
{asInteger(selectedAnomaly.anomaly_score)}
</AnomalyScore>
&nbsp;
<ActualValue>
({asDuration(selectedAnomaly.actual_value)})
</ActualValue>
</>
),
});
}

if (selectedTransactionMetrics?.avgRequestsPerMinute) {
const { avgRequestsPerMinute } = selectedTransactionMetrics;
listItems.push({
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Req. per minute (avg.)',
}
),
description: isNumber(avgRequestsPerMinute)
? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}`
: null,
});
}

return (
<>
<EuiSelect
compressed
fullWidth
prepend="Type"
options={transactionTypes.map((type) => ({
value: type,
text: type,
}))}
onChange={(e) => {
setSelectedType(e.target.value);
}}
defaultValue={selectedType}
/>
<table>
<tbody>
{listItems.map(
({ title, description }) =>
description && (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
)
)}
</tbody>
</table>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,40 @@
*/

import React from 'react';
import {
EuiLoadingSpinner,
EuiFlexGroup,
EuiHorizontalRule,
} from '@elastic/eui';
import { ServiceNodeMetrics } from '../../../../../common/service_map';
import { useFetcher } from '../../../../hooks/useFetcher';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { ServiceMetricList } from './ServiceMetricList';
import { ServiceHealth } from './ServiceHealth';

interface ServiceMetricFetcherProps {
serviceName: string;
anomalies:
| undefined
| Array<{
'transaction.type': string;
anomaly_score: number;
actual_value: number;
}>;
}

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

const { data = {} as ServiceNodeMetrics, status } = useFetcher(
const {
data = ({ transactionMetrics: [] } as unknown) as ServiceNodeMetrics,
status,
} = useFetcher(
(callApmApi) => {
if (serviceName && start && end) {
return callApmApi({
Expand All @@ -37,5 +54,30 @@ export function ServiceMetricFetcher({
);
const isLoading = status === 'loading';

return <ServiceMetricList {...data} isLoading={isLoading} />;
if (isLoading) {
return <LoadingSpinner />;
}

return (
<>
<ServiceHealth
anomalies={anomalies}
transactionMetrics={data.transactionMetrics}
/>
<EuiHorizontalRule margin="xs" />
<ServiceMetricList {...data} />
</>
);
}

function LoadingSpinner() {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,12 @@
* you may not use this file except in compliance with the Elastic License.
*/

import { EuiFlexGroup, EuiLoadingSpinner } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { isNumber } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { ServiceNodeMetrics } from '../../../../../common/service_map';
import { asDuration, asPercent, tpmUnit } from '../../../../utils/formatters';

function LoadingSpinner() {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
);
}
import { asPercent } from '../../../../utils/formatters';

export const ItemRow = styled('tr')`
line-height: 2;
Expand All @@ -37,41 +24,14 @@ export const ItemDescription = styled('td')`
text-align: right;
`;

interface ServiceMetricListProps extends ServiceNodeMetrics {
isLoading: boolean;
}
type ServiceMetricListProps = ServiceNodeMetrics;

export function ServiceMetricList({
avgTransactionDuration,
avgRequestsPerMinute,
avgErrorsPerMinute,
avgCpuUsage,
avgMemoryUsage,
isLoading,
}: ServiceMetricListProps) {
const listItems = [
{
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverMetric',
{
defaultMessage: 'Trans. duration (avg.)',
}
),
description: isNumber(avgTransactionDuration)
? asDuration(avgTransactionDuration)
: null,
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Req. per minute (avg.)',
}
),
description: isNumber(avgRequestsPerMinute)
? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}`
: null,
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric',
Expand Down Expand Up @@ -100,9 +60,7 @@ export function ServiceMetricList({
},
];

return isLoading ? (
<LoadingSpinner />
) : (
return (
<table>
<tbody>
{listItems.map(
Expand Down
Loading

0 comments on commit f08bfca

Please sign in to comment.