From dfaadc00348178ab837c22054cff2bf08b9e43cd Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Tue, 14 Jan 2020 15:35:52 -0600 Subject: [PATCH] [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. --- .../components/app/ServiceMap/Cytoscape.tsx | 30 +- .../app/ServiceMap/Popover/Buttons.tsx | 68 +++++ .../app/ServiceMap/Popover/Info.tsx | 56 ++++ .../ServiceMap/Popover/ServiceMetricList.tsx | 177 ++++++++++++ .../app/ServiceMap/Popover/index.tsx | 126 +++++++++ .../app/ServiceMap/cytoscapeOptions.ts | 17 +- .../app/ServiceMap/get_cytoscape_elements.ts | 12 +- .../public/components/app/ServiceMap/icons.ts | 21 +- .../app/ServiceMap/icons/documents.svg | 1 + .../components/app/ServiceMap/index.tsx | 28 +- .../metrics/by_agent/shared/memory/index.ts | 6 +- .../get_service_map_from_trace_ids.ts | 3 +- .../get_service_map_service_node_info.ts | 267 ++++++++++++++++++ .../apm/server/routes/create_apm_api.ts | 5 +- .../plugins/apm/server/routes/service_map.ts | 33 +++ 15 files changed, 815 insertions(+), 35 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg create mode 100644 x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index bc020815cc9cb..d69fa5d895b9e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -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 ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx new file mode 100644 index 0000000000000..a8c45c83a382a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Buttons.tsx @@ -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) => 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 ( + <> + + + {i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', { + defaultMessage: 'Service Details' + })} + + + + + {i18n.translate('xpack.apm.serviceMap.focusMapButtonText', { + defaultMessage: 'Focus map' + })} + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx new file mode 100644 index 0000000000000..1c5443e404f9b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/Info.tsx @@ -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 && ( + + {title} + {description} + + ) + )} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx new file mode 100644 index 0000000000000..8ce6d9d57c4ac --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/ServiceMetricList.tsx @@ -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 ( + + + + ); +} + +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 ? ( + + ) : ( + <> + {numInstances && numInstances > 1 && ( + +
+ + {i18n.translate('xpack.apm.serviceMap.numInstancesMetric', { + values: { numInstances }, + defaultMessage: '{numInstances} instances' + })} + +
+
+ )} + + + + {listItems.map(({ title, description }) => ( + + {title} + {description} + + ))} + +
+ + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx new file mode 100644 index 0000000000000..dfb78aaa0214c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -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 =
; + + 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 ( + {}} + isOpen={isOpen} + style={popoverStyle} + > + + + +

{label}

+
+ +
+ + + {isService ? ( + + ) : ( + + )} + + {isService && ( + + )} +
+
+ ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index d4e792ccf761b..1a6247388a655 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -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, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts index c9caa27af41c5..106e9a1d82f29 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -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'] }; } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index d5cfb49e458c6..722f64c6a7e58 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -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]; + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg new file mode 100644 index 0000000000000..b0648d14f20ba --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/documents.svg @@ -0,0 +1 @@ + diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index d3cc2b14e2c68..a8e6f964f4d0c 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -4,31 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ +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, { - useMemo, + useCallback, useEffect, - useState, + useMemo, useRef, - useCallback + useState } from 'react'; -import { find, isEqual } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { EuiButton } from '@elastic/eui'; -import { ElementDefinition } from 'cytoscape'; 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} > + ) : ( diff --git a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts index 8c6ed2ebcec75..870660c429ca3 100644 --- a/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts +++ b/x-pack/legacy/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts @@ -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: [ { diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts index ea9af12ac7f9a..d3711e9582d15 100644 --- a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -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); diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts new file mode 100644 index 0000000000000..6c4d540103cec --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_service_node_info.ts @@ -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 + }; +} diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts index a9a8241da39d1..cf27d20c24360 100644 --- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts @@ -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; }; diff --git a/x-pack/legacy/plugins/apm/server/routes/service_map.ts b/x-pack/legacy/plugins/apm/server/routes/service_map.ts index 94b176147f7a1..584598805f8b3 100644 --- a/x-pack/legacy/plugins/apm/server/routes/service_map.ts +++ b/x-pack/legacy/plugins/apm/server/routes/service_map.ts @@ -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 + }); + } +}));