Skip to content

Commit c38ed9b

Browse files
oguptedgieselaar
andauthored
Service Map Data API at Runtime (#54027) (#54655)
* [APM] Runtime service maps * Make nodes interactive * Don't use smaller range query on initial request * Address feedback from Ron * Get all services separately * Get single service as well * Query both transactions/spans for initial request * Optimize 'top' query for service maps * Use agent.name from scripted metric * adds basic loading overlay * filter out service map node self reference edges from being rendered * Make service map initial load time range configurable with `xpack.apm.serviceMapInitialTimeRange` default to last 1 hour in milliseconds * ensure destination.address is not missing in the composite agg when fetching sample trace ids * wip: added incremental data fetch & progress bar * implement progressive loading design while blocking service map interaction during loading * adds filter that destination.address exists before fetching sample trace ids * reduce pairs of connections to 1 bi-directional connection with arrows on both ends of the edge * Optimize query; add update button * Allow user interaction after 5s, auto update in that time, otherwise show toast for user to update the map with button * Correctly reduce nodes/connections * - remove non-interactive state while loading - use cytoscape element definition types * - readability improvements to the ServiceMap component - only show the update map button toast after last request loads * addresses feedback for changes to the Cytoscape component * Add span.type/span.subtype do external nodes * PR feedback Co-authored-by: Dario Gieselaar <[email protected]> Co-authored-by: Dario Gieselaar <[email protected]>
1 parent 9e17412 commit c38ed9b

File tree

19 files changed

+1142
-63
lines changed

19 files changed

+1142
-63
lines changed

x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export const HTTP_REQUEST_METHOD = 'http.request.method';
1414
export const USER_ID = 'user.id';
1515
export const USER_AGENT_NAME = 'user_agent.name';
1616

17+
export const DESTINATION_ADDRESS = 'destination.address';
18+
1719
export const OBSERVER_VERSION_MAJOR = 'observer.version_major';
1820
export const OBSERVER_LISTENING = 'observer.listening';
1921
export const PROCESSOR_EVENT = 'processor.event';
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
export interface ServiceConnectionNode {
8+
'service.name': string;
9+
'service.environment': string | null;
10+
'agent.name': string;
11+
}
12+
export interface ExternalConnectionNode {
13+
'destination.address': string;
14+
'span.type': string;
15+
'span.subtype': string;
16+
}
17+
18+
export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode;
19+
20+
export interface Connection {
21+
source: ConnectionNode;
22+
destination: ConnectionNode;
23+
}

x-pack/legacy/plugins/apm/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ export const apm: LegacyPluginInitializer = kibana => {
7171
autocreateApmIndexPattern: Joi.boolean().default(true),
7272

7373
// service map
74-
serviceMapEnabled: Joi.boolean().default(false)
74+
serviceMapEnabled: Joi.boolean().default(false),
75+
serviceMapInitialTimeRange: Joi.number().default(60 * 1000 * 60) // last 1 hour
7576
}).default();
7677
},
7778

x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export function Cytoscape({
7373
cy.on('data', event => {
7474
// Add the "primary" class to the node if its id matches the serviceName.
7575
if (cy.nodes().length > 0 && serviceName) {
76+
cy.nodes().removeClass('primary');
7677
cy.getElementById(serviceName).addClass('primary');
7778
}
7879

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
7+
import theme from '@elastic/eui/dist/eui_theme_light.json';
8+
import React from 'react';
9+
import { EuiProgress, EuiText, EuiSpacer } from '@elastic/eui';
10+
import styled from 'styled-components';
11+
import { i18n } from '@kbn/i18n';
12+
13+
const Container = styled.div`
14+
position: relative;
15+
`;
16+
17+
const Overlay = styled.div`
18+
position: absolute;
19+
top: 0;
20+
z-index: 1;
21+
display: flex;
22+
flex-direction: column;
23+
align-items: center;
24+
width: 100%;
25+
padding: ${theme.gutterTypes.gutterMedium};
26+
`;
27+
28+
const ProgressBarContainer = styled.div`
29+
width: 50%;
30+
max-width: 600px;
31+
`;
32+
33+
interface Props {
34+
children: React.ReactNode;
35+
isLoading: boolean;
36+
percentageLoaded: number;
37+
}
38+
39+
export const LoadingOverlay = ({
40+
children,
41+
isLoading,
42+
percentageLoaded
43+
}: Props) => (
44+
<Container>
45+
{isLoading && (
46+
<Overlay>
47+
<ProgressBarContainer>
48+
<EuiProgress
49+
value={percentageLoaded}
50+
max={100}
51+
color="primary"
52+
size="m"
53+
/>
54+
</ProgressBarContainer>
55+
<EuiSpacer size="s" />
56+
<EuiText size="s" textAlign="center">
57+
{i18n.translate('xpack.apm.loadingServiceMap', {
58+
defaultMessage:
59+
'Loading service map... This might take a short while.'
60+
})}
61+
</EuiText>
62+
</Overlay>
63+
)}
64+
{children}
65+
</Container>
66+
);

x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,13 @@ import theme from '@elastic/eui/dist/eui_theme_light.json';
88
import { icons, defaultIcon } from './icons';
99

1010
const layout = {
11-
animate: true,
12-
animationEasing: theme.euiAnimSlightBounce as cytoscape.Css.TransitionTimingFunction,
13-
animationDuration: parseInt(theme.euiAnimSpeedFast, 10),
1411
name: 'dagre',
1512
nodeDimensionsIncludeLabels: true,
16-
rankDir: 'LR',
17-
spacingFactor: 2
13+
rankDir: 'LR'
1814
};
1915

2016
function isDatabaseOrExternal(agentName: string) {
21-
return agentName === 'database' || agentName === 'external';
17+
return !agentName;
2218
}
2319

2420
const style: cytoscape.Stylesheet[] = [
@@ -47,7 +43,7 @@ const style: cytoscape.Stylesheet[] = [
4743
'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif',
4844
'font-size': theme.euiFontSizeXS,
4945
height: theme.avatarSizing.l.size,
50-
label: 'data(id)',
46+
label: 'data(label)',
5147
'min-zoomed-font-size': theme.euiSizeL,
5248
'overlay-opacity': 0,
5349
shape: (el: cytoscape.NodeSingular) =>
@@ -76,7 +72,18 @@ const style: cytoscape.Stylesheet[] = [
7672
//
7773
// @ts-ignore
7874
'target-distance-from-node': theme.paddingSizes.xs,
79-
width: 2
75+
width: 1,
76+
'source-arrow-shape': 'none'
77+
}
78+
},
79+
{
80+
selector: 'edge[bidirectional]',
81+
style: {
82+
'source-arrow-shape': 'triangle',
83+
'target-arrow-shape': 'triangle',
84+
// @ts-ignore
85+
'source-distance-from-node': theme.paddingSizes.xs,
86+
'target-distance-from-node': theme.paddingSizes.xs
8087
}
8188
}
8289
];
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License;
4+
* you may not use this file except in compliance with the Elastic License.
5+
*/
6+
import { ValuesType } from 'utility-types';
7+
import { sortBy, isEqual } from 'lodash';
8+
import { Connection, ConnectionNode } from '../../../../common/service_map';
9+
import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map';
10+
import { getAPMHref } from '../../shared/Links/apm/APMLink';
11+
12+
function getConnectionNodeId(node: ConnectionNode): string {
13+
if ('destination.address' in node) {
14+
// use a prefix to distinguish exernal destination ids from services
15+
return `>${node['destination.address']}`;
16+
}
17+
return node['service.name'];
18+
}
19+
20+
function getConnectionId(connection: Connection) {
21+
return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId(
22+
connection.destination
23+
)}`;
24+
}
25+
export function getCytoscapeElements(
26+
responses: ServiceMapAPIResponse[],
27+
search: string
28+
) {
29+
const discoveredServices = responses.flatMap(
30+
response => response.discoveredServices
31+
);
32+
33+
const serviceNodes = responses
34+
.flatMap(response => response.services)
35+
.map(service => ({
36+
...service,
37+
id: service['service.name']
38+
}));
39+
40+
// maps destination.address to service.name if possible
41+
function getConnectionNode(node: ConnectionNode) {
42+
let mappedNode: ConnectionNode | undefined;
43+
44+
if ('destination.address' in node) {
45+
mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to;
46+
}
47+
48+
if (!mappedNode) {
49+
mappedNode = node;
50+
}
51+
52+
return {
53+
...mappedNode,
54+
id: getConnectionNodeId(mappedNode)
55+
};
56+
}
57+
58+
// build connections with mapped nodes
59+
const connections = responses
60+
.flatMap(response => response.connections)
61+
.map(connection => {
62+
const source = getConnectionNode(connection.source);
63+
const destination = getConnectionNode(connection.destination);
64+
65+
return {
66+
source,
67+
destination,
68+
id: getConnectionId({ source, destination })
69+
};
70+
})
71+
.filter(connection => connection.source.id !== connection.destination.id);
72+
73+
const nodes = connections
74+
.flatMap(connection => [connection.source, connection.destination])
75+
.concat(serviceNodes);
76+
77+
type ConnectionWithId = ValuesType<typeof connections>;
78+
type ConnectionNodeWithId = ValuesType<typeof nodes>;
79+
80+
const connectionsById = connections.reduce((connectionMap, connection) => {
81+
return {
82+
...connectionMap,
83+
[connection.id]: connection
84+
};
85+
}, {} as Record<string, ConnectionWithId>);
86+
87+
const nodesById = nodes.reduce((nodeMap, node) => {
88+
return {
89+
...nodeMap,
90+
[node.id]: node
91+
};
92+
}, {} as Record<string, ConnectionNodeWithId>);
93+
94+
const cyNodes = (Object.values(nodesById) as ConnectionNodeWithId[]).map(
95+
node => {
96+
let data = {};
97+
98+
if ('service.name' in node) {
99+
data = {
100+
href: getAPMHref(
101+
`/services/${node['service.name']}/service-map`,
102+
search
103+
),
104+
agentName: node['agent.name'] || node['agent.name']
105+
};
106+
}
107+
108+
return {
109+
group: 'nodes' as const,
110+
data: {
111+
id: node.id,
112+
label:
113+
'service.name' in node
114+
? node['service.name']
115+
: node['destination.address'],
116+
...data
117+
}
118+
};
119+
}
120+
);
121+
122+
// instead of adding connections in two directions,
123+
// we add a `bidirectional` flag to use in styling
124+
const dedupedConnections = (sortBy(
125+
Object.values(connectionsById),
126+
// make sure that order is stable
127+
'id'
128+
) as ConnectionWithId[]).reduce<
129+
Array<ConnectionWithId & { bidirectional?: boolean }>
130+
>((prev, connection) => {
131+
const reversedConnection = prev.find(
132+
c =>
133+
c.destination.id === connection.source.id &&
134+
c.source.id === connection.destination.id
135+
);
136+
137+
if (reversedConnection) {
138+
reversedConnection.bidirectional = true;
139+
return prev;
140+
}
141+
142+
return prev.concat(connection);
143+
}, []);
144+
145+
const cyEdges = dedupedConnections.map(connection => {
146+
return {
147+
group: 'edges' as const,
148+
data: {
149+
id: connection.id,
150+
source: connection.source.id,
151+
target: connection.destination.id,
152+
bidirectional: connection.bidirectional ? true : undefined
153+
}
154+
};
155+
}, []);
156+
157+
return [...cyNodes, ...cyEdges];
158+
}

0 commit comments

Comments
 (0)