[APM] Service map popover (#53524)

Add a popover when clicking on service map nodes and an endpoint to fetch metrics to show in the popover.

Closes #52869.
This commit is contained in:
Nathan L Smith 2020-01-14 15:35:52 -06:00 committed by GitHub
parent 4869e02b62
commit 52709b8deb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 818 additions and 38 deletions

View file

@ -26,7 +26,7 @@ interface CytoscapeProps {
children?: ReactNode;
elements: cytoscape.ElementDefinition[];
serviceName?: string;
style: CSSProperties;
style?: CSSProperties;
}
function useCytoscape(options: cytoscape.CytoscapeOptions) {
@ -69,8 +69,8 @@ export function Cytoscape({
// Set up cytoscape event handlers
useEffect(() => {
if (cy) {
cy.on('data', event => {
const dataHandler: cytoscape.EventHandler = event => {
if (cy) {
// Add the "primary" class to the node if its id matches the serviceName.
if (cy.nodes().length > 0 && serviceName) {
cy.nodes().removeClass('primary');
@ -80,8 +80,30 @@ export function Cytoscape({
if (event.cy.elements().length > 0) {
cy.layout(cytoscapeOptions.layout as cytoscape.LayoutOptions).run();
}
});
}
};
const mouseoverHandler: cytoscape.EventHandler = event => {
event.target.addClass('hover');
event.target.connectedEdges().addClass('nodeHover');
};
const mouseoutHandler: cytoscape.EventHandler = event => {
event.target.removeClass('hover');
event.target.connectedEdges().removeClass('nodeHover');
};
if (cy) {
cy.on('data', dataHandler);
cy.on('mouseover', 'edge, node', mouseoverHandler);
cy.on('mouseout', 'edge, node', mouseoutHandler);
}
return () => {
if (cy) {
cy.removeListener('data', undefined, dataHandler);
cy.removeListener('mouseover', 'edge, node', mouseoverHandler);
cy.removeListener('mouseout', 'edge, node', mouseoutHandler);
}
};
}, [cy, serviceName]);
return (

View file

@ -0,0 +1,68 @@
/*
* 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.
*/
/* eslint-disable @elastic/eui/href-or-on-click */
import { EuiButton, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import React, { MouseEvent } from 'react';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { getAPMHref } from '../../../shared/Links/apm/APMLink';
interface ButtonsProps {
focusedServiceName?: string;
onFocusClick?: (event: MouseEvent<HTMLAnchorElement>) => void;
selectedNodeServiceName: string;
}
export function Buttons({
focusedServiceName,
onFocusClick = () => {},
selectedNodeServiceName
}: ButtonsProps) {
const currentSearch = useUrlParams().urlParams.kuery ?? '';
const detailsUrl = getAPMHref(
`/services/${selectedNodeServiceName}/transactions`,
currentSearch
);
const focusUrl = getAPMHref(
`/services/${selectedNodeServiceName}/service-map`,
currentSearch
);
const isAlreadyFocused = focusedServiceName === selectedNodeServiceName;
return (
<>
<EuiFlexItem>
<EuiButton href={detailsUrl} fill={true}>
{i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', {
defaultMessage: 'Service Details'
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
isDisabled={isAlreadyFocused}
color="secondary"
href={focusUrl}
onClick={onFocusClick}
title={
isAlreadyFocused
? i18n.translate('xpack.apm.serviceMap.alreadyFocusedTitleText', {
defaultMessage: 'Map is already focused'
})
: undefined
}
>
{i18n.translate('xpack.apm.serviceMap.focusMapButtonText', {
defaultMessage: 'Focus map'
})}
</EuiButton>
</EuiFlexItem>
</>
);
}

View file

@ -0,0 +1,56 @@
/*
* 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 from 'react';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
const ItemRow = styled.div`
line-height: 2;
`;
const ItemTitle = styled.dt`
color: ${lightTheme.textColors.subdued};
`;
const ItemDescription = styled.dd``;
interface InfoProps {
type: string;
subtype?: string;
}
export function Info({ type, subtype }: InfoProps) {
const listItems = [
{
title: i18n.translate('xpack.apm.serviceMap.typePopoverMetric', {
defaultMessage: 'Type'
}),
description: type
},
{
title: i18n.translate('xpack.apm.serviceMap.subtypePopoverMetric', {
defaultMessage: 'Subtype'
}),
description: subtype
}
];
return (
<>
{listItems.map(
({ title, description }) =>
description && (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
)
)}
</>
);
}

View file

@ -0,0 +1,177 @@
/*
* 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 {
EuiFlexGroup,
EuiLoadingSpinner,
EuiFlexItem,
EuiBadge
} from '@elastic/eui';
import lightTheme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { isNumber } from 'lodash';
import React from 'react';
import styled from 'styled-components';
import { ServiceNodeMetrics } from '../../../../../server/lib/service_map/get_service_map_service_node_info';
import {
asDuration,
asPercent,
toMicroseconds,
tpmUnit
} from '../../../../utils/formatters';
import { useUrlParams } from '../../../../hooks/useUrlParams';
import { useFetcher } from '../../../../hooks/useFetcher';
function LoadingSpinner() {
return (
<EuiFlexGroup
alignItems="center"
justifyContent="spaceAround"
style={{ height: 170 }}
>
<EuiLoadingSpinner size="xl" />
</EuiFlexGroup>
);
}
const ItemRow = styled('tr')`
line-height: 2;
`;
const ItemTitle = styled('td')`
color: ${lightTheme.textColors.subdued};
padding-right: 1rem;
`;
const ItemDescription = styled('td')`
text-align: right;
`;
const na = i18n.translate('xpack.apm.serviceMap.NotAvailableMetric', {
defaultMessage: 'N/A'
});
interface MetricListProps {
serviceName: string;
}
export function ServiceMetricList({ serviceName }: MetricListProps) {
const {
urlParams: { start, end, environment }
} = useUrlParams();
const { data = {} as ServiceNodeMetrics, status } = useFetcher(
callApmApi => {
if (serviceName && start && end) {
return callApmApi({
pathname: '/api/apm/service-map/service/{serviceName}',
params: {
path: {
serviceName
},
query: {
start,
end,
environment
}
}
});
}
},
[serviceName, start, end, environment],
{
preservePreviousData: false
}
);
const {
avgTransactionDuration,
avgRequestsPerMinute,
avgErrorsPerMinute,
avgCpuUsage,
avgMemoryUsage,
numInstances
} = data;
const isLoading = status === 'loading';
const listItems = [
{
title: i18n.translate(
'xpack.apm.serviceMap.avgTransDurationPopoverMetric',
{
defaultMessage: 'Trans. duration (avg.)'
}
),
description: isNumber(avgTransactionDuration)
? asDuration(toMicroseconds(avgTransactionDuration, 'milliseconds'))
: na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgReqPerMinutePopoverMetric',
{
defaultMessage: 'Req. per minute (avg.)'
}
),
description: isNumber(avgRequestsPerMinute)
? `${avgRequestsPerMinute.toFixed(2)} ${tpmUnit('request')}`
: na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgErrorsPerMinutePopoverMetric',
{
defaultMessage: 'Errors per minute (avg.)'
}
),
description: avgErrorsPerMinute?.toFixed(2) ?? na
},
{
title: i18n.translate('xpack.apm.serviceMap.avgCpuUsagePopoverMetric', {
defaultMessage: 'CPU usage (avg.)'
}),
description: isNumber(avgCpuUsage) ? asPercent(avgCpuUsage, 1) : na
},
{
title: i18n.translate(
'xpack.apm.serviceMap.avgMemoryUsagePopoverMetric',
{
defaultMessage: 'Memory usage (avg.)'
}
),
description: isNumber(avgMemoryUsage) ? asPercent(avgMemoryUsage, 1) : na
}
];
return isLoading ? (
<LoadingSpinner />
) : (
<>
{numInstances && numInstances > 1 && (
<EuiFlexItem>
<div>
<EuiBadge iconType="apps" color="hollow">
{i18n.translate('xpack.apm.serviceMap.numInstancesMetric', {
values: { numInstances },
defaultMessage: '{numInstances} instances'
})}
</EuiBadge>
</div>
</EuiFlexItem>
)}
<table>
<tbody>
{listItems.map(({ title, description }) => (
<ItemRow key={title}>
<ItemTitle>{title}</ItemTitle>
<ItemDescription>{description}</ItemDescription>
</ItemRow>
))}
</tbody>
</table>
</>
);
}

View file

@ -0,0 +1,126 @@
/*
* 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 {
EuiFlexGroup,
EuiFlexItem,
EuiHorizontalRule,
EuiPopover,
EuiTitle
} from '@elastic/eui';
import cytoscape from 'cytoscape';
import React, {
CSSProperties,
useContext,
useEffect,
useState,
useCallback
} from 'react';
import { CytoscapeContext } from '../Cytoscape';
import { Buttons } from './Buttons';
import { Info } from './Info';
import { ServiceMetricList } from './ServiceMetricList';
const popoverMinWidth = 280;
interface PopoverProps {
focusedServiceName?: string;
}
export function Popover({ focusedServiceName }: PopoverProps) {
const cy = useContext(CytoscapeContext);
const [selectedNode, setSelectedNode] = useState<
cytoscape.NodeSingular | undefined
>(undefined);
const onFocusClick = useCallback(() => setSelectedNode(undefined), [
setSelectedNode
]);
useEffect(() => {
const selectHandler: cytoscape.EventHandler = event => {
setSelectedNode(event.target);
};
const unselectHandler: cytoscape.EventHandler = () => {
setSelectedNode(undefined);
};
if (cy) {
cy.on('select', 'node', selectHandler);
cy.on('unselect', 'node', unselectHandler);
cy.on('data viewport', unselectHandler);
}
return () => {
if (cy) {
cy.removeListener('select', 'node', selectHandler);
cy.removeListener('unselect', 'node', unselectHandler);
cy.removeListener('data viewport', undefined, unselectHandler);
}
};
}, [cy]);
const renderedHeight = selectedNode?.renderedHeight() ?? 0;
const renderedWidth = selectedNode?.renderedWidth() ?? 0;
const { x, y } = selectedNode?.renderedPosition() ?? { x: 0, y: 0 };
const isOpen = !!selectedNode;
const selectedNodeServiceName: string = selectedNode?.data('id');
const isService = selectedNode?.data('type') === 'service';
const triggerStyle: CSSProperties = {
background: 'transparent',
height: renderedHeight,
position: 'absolute',
width: renderedWidth
};
const trigger = <div className="trigger" style={triggerStyle} />;
const zoom = cy?.zoom() ?? 1;
const height = selectedNode?.height() ?? 0;
const translateY = y - (zoom + 1) * (height / 2);
const popoverStyle: CSSProperties = {
position: 'absolute',
transform: `translate(${x}px, ${translateY}px)`
};
const data = selectedNode?.data() ?? {};
const label = data.label || selectedNodeServiceName;
return (
<EuiPopover
anchorPosition={'upCenter'}
button={trigger}
closePopover={() => {}}
isOpen={isOpen}
style={popoverStyle}
>
<EuiFlexGroup
direction="column"
gutterSize="s"
style={{ minWidth: popoverMinWidth }}
>
<EuiFlexItem>
<EuiTitle size="xxs">
<h3>{label}</h3>
</EuiTitle>
<EuiHorizontalRule margin="xs" />
</EuiFlexItem>
<EuiFlexItem>
{isService ? (
<ServiceMetricList serviceName={selectedNodeServiceName} />
) : (
<Info {...data} />
)}
</EuiFlexItem>
{isService && (
<Buttons
focusedServiceName={focusedServiceName}
onFocusClick={onFocusClick}
selectedNodeServiceName={selectedNodeServiceName}
/>
)}
</EuiFlexGroup>
</EuiPopover>
);
}

View file

@ -3,9 +3,9 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import cytoscape from 'cytoscape';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { icons, defaultIcon } from './icons';
import cytoscape from 'cytoscape';
import { defaultIcon, iconForNode } from './icons';
const layout = {
name: 'dagre',
@ -13,8 +13,8 @@ const layout = {
rankDir: 'LR'
};
function isDatabaseOrExternal(agentName: string) {
return !agentName;
function isService(el: cytoscape.NodeSingular) {
return el.data('type') === 'service';
}
const style: cytoscape.Stylesheet[] = [
@ -27,11 +27,11 @@ const style: cytoscape.Stylesheet[] = [
//
// @ts-ignore
'background-image': (el: cytoscape.NodeSingular) =>
icons[el.data('agentName')] || defaultIcon,
iconForNode(el) ?? defaultIcon,
'background-height': (el: cytoscape.NodeSingular) =>
isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%',
isService(el) ? '80%' : '40%',
'background-width': (el: cytoscape.NodeSingular) =>
isDatabaseOrExternal(el.data('agentName')) ? '40%' : '80%',
isService(el) ? '80%' : '40%',
'border-color': (el: cytoscape.NodeSingular) =>
el.hasClass('primary')
? theme.euiColorSecondary
@ -47,7 +47,7 @@ const style: cytoscape.Stylesheet[] = [
'min-zoomed-font-size': theme.euiSizeL,
'overlay-opacity': 0,
shape: (el: cytoscape.NodeSingular) =>
isDatabaseOrExternal(el.data('agentName')) ? 'diamond' : 'ellipse',
isService(el) ? 'ellipse' : 'diamond',
'text-background-color': theme.euiColorLightestShade,
'text-background-opacity': 0,
'text-background-padding': theme.paddingSizes.xs,
@ -90,7 +90,6 @@ const style: cytoscape.Stylesheet[] = [
export const cytoscapeOptions: cytoscape.CytoscapeOptions = {
autoungrabify: true,
autounselectify: true,
boxSelectionEnabled: false,
layout,
maxZoom: 3,

View file

@ -101,7 +101,17 @@ export function getCytoscapeElements(
`/services/${node['service.name']}/service-map`,
search
),
agentName: node['agent.name'] || node['agent.name']
agentName: node['agent.name'] || node['agent.name'],
type: 'service'
};
}
if ('span.type' in node) {
data = {
// For nodes with span.type "db", convert it to "database". Otherwise leave it as-is.
type: node['span.type'] === 'db' ? 'database' : node['span.type'],
// Externals should not have a subtype so make it undefined if the type is external.
subtype: node['span.type'] !== 'external' && node['span.subtype']
};
}

View file

@ -5,7 +5,9 @@
*/
import theme from '@elastic/eui/dist/eui_theme_light.json';
import cytoscape from 'cytoscape';
import databaseIcon from './icons/database.svg';
import documentsIcon from './icons/documents.svg';
import globeIcon from './icons/globe.svg';
function getAvatarIcon(
@ -24,10 +26,16 @@ function getAvatarIcon(
}
// The colors here are taken from the logos of the corresponding technologies
export const icons: { [key: string]: string } = {
const icons: { [key: string]: string } = {
cache: databaseIcon,
database: databaseIcon,
dotnet: getAvatarIcon('.N', '#8562AD'),
external: globeIcon,
messaging: documentsIcon,
resource: globeIcon
};
const serviceIcons: { [key: string]: string } = {
dotnet: getAvatarIcon('.N', '#8562AD'),
go: getAvatarIcon('Go', '#00A9D6'),
java: getAvatarIcon('Jv', '#41717E'),
'js-base': getAvatarIcon('JS', '#F0DB4E', theme.euiTextColor),
@ -37,3 +45,12 @@ export const icons: { [key: string]: string } = {
};
export const defaultIcon = getAvatarIcon();
export function iconForNode(node: cytoscape.NodeSingular) {
const type = node.data('type');
if (type === 'service') {
return serviceIcons[node.data('agentName') as string];
} else {
return icons[type];
}
}

View file

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" class="euiIcon euiIcon--medium euiIcon-isLoaded" focusable="false" role="img" aria-hidden="true"><title></title><path d="M8.8 0c.274 0 .537.113.726.312l3.2 3.428c.176.186.274.433.274.689V13a1 1 0 01-1 1H2a1 1 0 01-1-1V1a1 1 0 011-1h6.8zM12 5H8.5a.5.5 0 01-.5-.5V1H2v12h10V5zm-7.5 6a.5.5 0 110-1h5a.5.5 0 110 1h-5zm0-3a.5.5 0 010-1h5a.5.5 0 110 1h-5zm1 8a.5.5 0 110-1H14V6.5a.5.5 0 111 0V15a1 1 0 01-1 1H5.5z"></path></svg>

After

Width:  |  Height:  |  Size: 506 B

View file

@ -4,31 +4,32 @@
* you may not use this file except in compliance with the Elastic License.
*/
import theme from '@elastic/eui/dist/eui_theme_light.json';
import React, {
useMemo,
useEffect,
useState,
useRef,
useCallback
} from 'react';
import { find, isEqual } from 'lodash';
import { i18n } from '@kbn/i18n';
import { EuiButton } from '@elastic/eui';
import theme from '@elastic/eui/dist/eui_theme_light.json';
import { i18n } from '@kbn/i18n';
import { ElementDefinition } from 'cytoscape';
import { find, isEqual } from 'lodash';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState
} from 'react';
import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public';
import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { useCallApmApi } from '../../../hooks/useCallApmApi';
import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity';
import { useLicense } from '../../../hooks/useLicense';
import { useLocation } from '../../../hooks/useLocation';
import { useUrlParams } from '../../../hooks/useUrlParams';
import { Controls } from './Controls';
import { Cytoscape } from './Cytoscape';
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
import { useCallApmApi } from '../../../hooks/useCallApmApi';
import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity';
import { useLocation } from '../../../hooks/useLocation';
import { LoadingOverlay } from './LoadingOverlay';
import { useApmPluginContext } from '../../../hooks/useApmPluginContext';
import { getCytoscapeElements } from './get_cytoscape_elements';
import { LoadingOverlay } from './LoadingOverlay';
import { PlatinumLicensePrompt } from './PlatinumLicensePrompt';
import { Popover } from './Popover';
interface ServiceMapProps {
serviceName?: string;
@ -205,6 +206,7 @@ export function ServiceMap({ serviceName }: ServiceMapProps) {
style={cytoscapeDivStyle}
>
<Controls />
<Popover focusedServiceName={serviceName} />
</Cytoscape>
</LoadingOverlay>
) : (

View file

@ -43,7 +43,7 @@ const chartBase: ChartBase = {
series
};
const percentUsedScript = {
export const percentMemoryUsedScript = {
lang: 'expression',
source: `1 - doc['${METRIC_SYSTEM_FREE_MEMORY}'] / doc['${METRIC_SYSTEM_TOTAL_MEMORY}']`
};
@ -59,8 +59,8 @@ export async function getMemoryChartData(
serviceNodeName,
chartBase,
aggs: {
memoryUsedAvg: { avg: { script: percentUsedScript } },
memoryUsedMax: { max: { script: percentUsedScript } }
memoryUsedAvg: { avg: { script: percentMemoryUsedScript } },
memoryUsedMax: { max: { script: percentMemoryUsedScript } }
},
additionalFilters: [
{

View file

@ -163,7 +163,8 @@ export async function getServiceMapFromTraceIds({
}
/* if there is an outgoing span, create a new path */
if (event['span.type'] == 'external' || event['span.type'] == 'messaging') {
if (event['destination.address'] != null
&& event['destination.address'] != '') {
def outgoingLocation = getDestination(event);
def outgoingPath = new ArrayList(basePath);
outgoingPath.add(outgoingLocation);

View file

@ -0,0 +1,267 @@
/*
* 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 { Setup, SetupTimeRange } from '../helpers/setup_request';
import { ESFilter } from '../../../typings/elasticsearch';
import { rangeFilter } from '../helpers/range_filter';
import {
PROCESSOR_EVENT,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
TRANSACTION_DURATION,
METRIC_SYSTEM_CPU_PERCENT,
METRIC_SYSTEM_FREE_MEMORY,
METRIC_SYSTEM_TOTAL_MEMORY,
SERVICE_NODE_NAME
} from '../../../common/elasticsearch_fieldnames';
import { percentMemoryUsedScript } from '../metrics/by_agent/shared/memory';
import { PromiseReturnType } from '../../../typings/common';
interface Options {
setup: Setup & SetupTimeRange;
environment?: string;
serviceName: string;
}
interface TaskParameters {
setup: Setup;
minutes: number;
filter: ESFilter[];
}
export type ServiceNodeMetrics = PromiseReturnType<
typeof getServiceMapServiceNodeInfo
>;
export async function getServiceMapServiceNodeInfo({
serviceName,
environment,
setup
}: Options & { serviceName: string; environment?: string }) {
const { start, end } = setup;
const filter: ESFilter[] = [
{ range: rangeFilter(start, end) },
{ term: { [SERVICE_NAME]: serviceName } },
...(environment
? [{ term: { [SERVICE_ENVIRONMENT]: SERVICE_ENVIRONMENT } }]
: [])
];
const minutes = Math.abs((end - start) / (1000 * 60));
const taskParams = {
setup,
minutes,
filter
};
const [
errorMetrics,
transactionMetrics,
cpuMetrics,
memoryMetrics,
instanceMetrics
] = await Promise.all([
getErrorMetrics(taskParams),
getTransactionMetrics(taskParams),
getCpuMetrics(taskParams),
getMemoryMetrics(taskParams),
getNumInstances(taskParams)
]);
return {
...errorMetrics,
...transactionMetrics,
...cpuMetrics,
...memoryMetrics,
...instanceMetrics
};
}
async function getErrorMetrics({ setup, minutes, filter }: TaskParameters) {
const { client, indices } = setup;
const response = await client.search({
index: indices['apm_oss.errorIndices'],
body: {
size: 0,
query: {
bool: {
filter: filter.concat({
term: {
[PROCESSOR_EVENT]: 'error'
}
})
}
},
track_total_hits: true
}
});
return {
avgErrorsPerMinute:
response.hits.total.value > 0 ? response.hits.total.value / minutes : null
};
}
async function getTransactionMetrics({
setup,
filter,
minutes
}: TaskParameters) {
const { indices, client } = setup;
const response = await client.search({
index: indices['apm_oss.transactionIndices'],
body: {
size: 1,
query: {
bool: {
filter: filter.concat({
term: {
[PROCESSOR_EVENT]: 'transaction'
}
})
}
},
track_total_hits: true,
aggs: {
duration: {
avg: {
field: TRANSACTION_DURATION
}
}
}
}
});
return {
avgTransactionDuration: response.aggregations?.duration.value,
avgRequestsPerMinute:
response.hits.total.value > 0 ? response.hits.total.value / minutes : null
};
}
async function getCpuMetrics({ setup, filter }: TaskParameters) {
const { indices, client } = setup;
const response = await client.search({
index: indices['apm_oss.metricsIndices'],
body: {
size: 0,
query: {
bool: {
filter: filter.concat([
{
term: {
[PROCESSOR_EVENT]: 'metric'
}
},
{
exists: {
field: METRIC_SYSTEM_CPU_PERCENT
}
}
])
}
},
aggs: {
avgCpuUsage: {
avg: {
field: METRIC_SYSTEM_CPU_PERCENT
}
}
}
}
});
return {
avgCpuUsage: response.aggregations?.avgCpuUsage.value
};
}
async function getMemoryMetrics({ setup, filter }: TaskParameters) {
const { client, indices } = setup;
const response = await client.search({
index: indices['apm_oss.metricsIndices'],
body: {
query: {
bool: {
filter: filter.concat([
{
term: {
[PROCESSOR_EVENT]: 'metric'
}
},
{
exists: {
field: METRIC_SYSTEM_FREE_MEMORY
}
},
{
exists: {
field: METRIC_SYSTEM_TOTAL_MEMORY
}
}
])
}
},
aggs: {
avgMemoryUsage: {
avg: {
script: percentMemoryUsedScript
}
}
}
}
});
return {
avgMemoryUsage: response.aggregations?.avgMemoryUsage.value
};
}
async function getNumInstances({ setup, filter }: TaskParameters) {
const { client, indices } = setup;
const response = await client.search({
index: indices['apm_oss.transactionIndices'],
body: {
query: {
bool: {
filter: filter.concat([
{
term: {
[PROCESSOR_EVENT]: 'transaction'
}
},
{
exists: {
field: SERVICE_NODE_NAME
}
},
{
exists: {
field: METRIC_SYSTEM_TOTAL_MEMORY
}
}
])
}
},
aggs: {
instances: {
cardinality: {
field: SERVICE_NODE_NAME
}
}
}
}
});
return {
numInstances: response.aggregations?.instances.value || 1
};
}

View file

@ -58,7 +58,7 @@ import {
uiFiltersEnvironmentsRoute
} from './ui_filters';
import { createApi } from './create_api';
import { serviceMapRoute } from './service_map';
import { serviceMapRoute, serviceMapServiceNodeRoute } from './service_map';
const createApmApi = () => {
const api = createApi()
@ -123,7 +123,8 @@ const createApmApi = () => {
.add(transactionByTraceIdRoute)
// Service map
.add(serviceMapRoute);
.add(serviceMapRoute)
.add(serviceMapServiceNodeRoute);
return api;
};

View file

@ -10,6 +10,7 @@ import { setupRequest } from '../lib/helpers/setup_request';
import { createRoute } from './create_route';
import { uiFiltersRt, rangeRt } from './default_api_types';
import { getServiceMap } from '../lib/service_map/get_service_map';
import { getServiceMapServiceNodeInfo } from '../lib/service_map/get_service_map_service_node_info';
export const serviceMapRoute = createRoute(() => ({
path: '/api/apm/service-map',
@ -32,3 +33,35 @@ export const serviceMapRoute = createRoute(() => ({
return getServiceMap({ setup, serviceName, environment, after });
}
}));
export const serviceMapServiceNodeRoute = createRoute(() => ({
path: `/api/apm/service-map/service/{serviceName}`,
params: {
path: t.type({
serviceName: t.string
}),
query: t.intersection([
rangeRt,
t.partial({
environment: t.string
})
])
},
handler: async ({ context, request }) => {
if (!context.config['xpack.apm.serviceMapEnabled']) {
throw Boom.notFound();
}
const setup = await setupRequest(context, request);
const {
query: { environment },
path: { serviceName }
} = context.params;
return getServiceMapServiceNodeInfo({
setup,
serviceName,
environment
});
}
}));