[APM] Service map new API (#212550)

closes [#212252](https://github.com/elastic/kibana/issues/212252)

## Summary

This PR replaces the `scripted_metric` aggregation used to retrieve the
data for the service map.

The new solution relies on samples of exit spans - each representing a
unique combination of `service.name` and
`span.destination.service.resource` - along with their child
transactions. The Service Map is now built entirely on the **client
side** to reduce server-side load and prevent excessive event loop
utilization.


>[!NOTE]
> - `transform_service_map_responses.ts` was refactored to improve
readability and performance, The file was renamed to
`get_service_map_nodes.ts`
> - `group_resource_nodes.ts` was refactored to improve readability and
performance


### Consequences

- The new solution requires **all exit spans** to have the
`span.destination.service.resource` field populated — with the exception
of messaging systems, which may rely on `span.links` (not addressed in
this PR)
- A warning will be added to the trace waterfall for exit spans without
`span.destination.sevice.resource`
[#212638](https://github.com/elastic/kibana/issues/212638)
  - 
<img width="500" alt="image"
src="https://github.com/user-attachments/assets/9f056581-8dd1-403f-b831-ea615b533c07"
/>

<img width="500" alt="image"
src="https://github.com/user-attachments/assets/4c22e5d9-1c29-40aa-a18a-63c1f87fbfc1"
/>

- When multiple services point to load balancers, they will share the
same `span.destination.service.resource`. This could lead to incomplete
paths in the map, as the path is built for the **first** `service.name`
+ `span.destination.service.resource` pair returned processed.
- This can't be addressed, but we'll look into ways to inform the user
when the logic identifies this scenario
[#213124](https://github.com/elastic/kibana/issues/213124)

|  current | new |
| --------|------|
|<img width="500" alt="image"
src="https://github.com/user-attachments/assets/0bccc242-ecda-42b3-bad4-9356468a71ad"
/>|<img width="500" alt="image"
src="https://github.com/user-attachments/assets/dfa0dab7-18f4-4eb5-84e7-4cd0f8b9eedc"
/>|





### Analysis

The performance analysis below uses data from the **edge** cluster and
the **service_map_oom** synthtrace scenario, simulating long traces. The
selected date range was **24h**.


### Current solution

<img width="800" alt="image"
src="https://github.com/user-attachments/assets/aec6fdc8-d6f1-426d-a931-57bbcffb5b7c"
/>

`numeric_labels.event_loop_active`: 4085.601743
`numeric_labels.event_loop_utilization`: 0.28716


### New solution

<img width="800" alt="image"
src="https://github.com/user-attachments/assets/babd9399-e83c-4396-a01e-04fcb38086aa"
/>


`numeric_labels.event_loop_active`: 887.149512
`numeric_labels.event_loop_utilization`: 0.123929


On the **client side**, the most CPU-intensive operation is performed by
cytoscape. The creation of service connections performs efficiently.

<img width="800" alt="image"
src="https://github.com/user-attachments/assets/e346bb5b-eb27-4b54-aa44-667f61cfade3"
/>

### How to test

 - Add `xpack.apm.serviceMapV2Enabled: true` to `kibana.dev.yml`
 - Navigate to APM > Services Inventory > Service Map

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2025-03-11 16:45:02 +01:00 committed by GitHub
parent 000d859207
commit a2dbf325e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1976 additions and 1322 deletions

View file

@ -101,7 +101,12 @@ describe('serviceMap', () => {
const [transaction, ...spans] = traceDocs;
expect(transaction).toHaveProperty(['processor.event'], 'transaction');
expect(
spans.every(({ 'processor.event': processorEvent }) => processorEvent === 'span')
spans
.filter(
({ 'processor.event': processorEvent, 'span.type': spanType }) =>
processorEvent === 'span' && spanType !== 'app'
)
.every(({ 'span.destination.service.resource': spanDest }) => spanDest !== undefined)
).toBe(true);
}
});
@ -263,7 +268,9 @@ function getTraceDocsSubset(transaction: BaseSpan): ApmFields[] {
const children = transaction.getChildren();
if (children) {
const childFields = children.flatMap((child) => getTraceDocsSubset(child));
const childFields = children
.filter((p) => p.fields['processor.event'] === 'span')
.flatMap((child) => getTraceDocsSubset(child));
return [subsetFields, ...childFields];
}
return [subsetFields];

View file

@ -91,6 +91,7 @@ function getChildren(
];
}
}
const childSpan = serviceInstance
.span({
spanName: getTransactionName(transactionName, serviceInstance, index),
@ -99,10 +100,24 @@ function getChildren(
.timestamp(timestamp)
.duration(1000)
.children(...getChildren(rest, serviceInstance, timestamp, index));
if (rest[0]) {
const next = getTraceItem(rest[0]);
if (next.serviceInstance) {
return [childSpan.destination(next.serviceInstance.fields['service.name']!)];
return [
childSpan
.overrides({ 'span.type': 'external' })
.destination(next.serviceInstance.fields['service.name']!)
.children(
next.serviceInstance
.transaction({
transactionName: getTransactionName(transactionName, next.serviceInstance, index),
transactionType: 'request',
})
.timestamp(timestamp)
.duration(1000)
),
];
}
}
return [childSpan];

View file

@ -207,6 +207,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.apm.serviceMapEnabled (boolean?)',
'xpack.apm.ui.enabled (boolean?)',
'xpack.apm.ui.maxTraceItems (number?)',
'xpack.apm.ui.serviceMapApiV2Enabled (boolean?)',
'xpack.apm.managedServiceUrl (string?|never)',
'xpack.apm.serverlessOnboarding (boolean?|never)',
'xpack.apm.latestAgentVersionsUrl (string?)',

View file

@ -1,110 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type cytoscape from 'cytoscape';
import type { Coordinate } from '../typings/timeseries';
import type { ServiceAnomalyStats } from './anomaly_detection';
// These should be imported, but until TypeScript 4.2 we're inlining them here.
// All instances of "agent.name", "service.name", "service.environment", "span.type",
// "span.subtype", and "span.destination.service.resource" need to be changed
// back to using the constants.
// See https://github.com/microsoft/TypeScript/issues/37888
//
// import {
// AGENT_NAME,
// SERVICE_ENVIRONMENT,
// SERVICE_NAME,
// SPAN_DESTINATION_SERVICE_RESOURCE,
// SPAN_SUBTYPE,
// SPAN_TYPE,
// } from './es_fields/apm';
export interface ServiceConnectionNode extends cytoscape.NodeDataDefinition {
'service.name': string;
'service.environment': string | null;
'agent.name': string;
serviceAnomalyStats?: ServiceAnomalyStats;
label?: string;
}
export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition {
'span.destination.service.resource': string;
'span.type': string;
'span.subtype': string;
label?: string;
}
export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode;
export interface ConnectionEdge {
id: string;
source: ConnectionNode['id'];
target: ConnectionNode['id'];
label?: string;
bidirectional?: boolean;
isInverseEdge?: boolean;
}
export interface ConnectionElement {
data: ConnectionNode | ConnectionEdge;
}
export interface Connection {
source: ConnectionNode;
destination: ConnectionNode;
}
export interface NodeStats {
transactionStats?: {
latency?: {
value: number | null;
timeseries?: Coordinate[];
};
throughput?: {
value: number | null;
timeseries?: Coordinate[];
};
};
failedTransactionsRate?: {
value: number | null;
timeseries?: Coordinate[];
};
cpuUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
memoryUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
}
export const invalidLicenseMessage = i18n.translate('xpack.apm.serviceMap.invalidLicenseMessage', {
defaultMessage:
"In order to access Service Maps, you must be subscribed to an Elastic Platinum license. With it, you'll have the ability to visualize your entire application stack along with your APM data.",
});
const NONGROUPED_SPANS: Record<string, string[]> = {
aws: ['servicename'],
cache: ['all'],
db: ['all'],
external: ['graphql', 'grpc', 'websocket'],
messaging: ['all'],
template: ['handlebars'],
};
export function isSpanGroupingSupported(type?: string, subtype?: string) {
if (!type || !(type in NONGROUPED_SPANS)) {
return true;
}
return !NONGROUPED_SPANS[type].some(
(nongroupedSubType) => nongroupedSubType === 'all' || nongroupedSubType === subtype
);
}
export const SERVICE_MAP_TIMEOUT_ERROR = 'ServiceMapTimeoutError';

View file

@ -0,0 +1,300 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ServiceHealthStatus } from '../service_health_status';
import { SPAN_SUBTYPE, SPAN_TYPE } from '../es_fields/apm';
import type {
ServiceMapExitSpan,
ServiceMapService,
ServiceMapConnections,
GroupResourceNodesResponse,
} from './types';
import { getServiceMapNodes } from './get_service_map_nodes';
import { getExternalConnectionNode, getServiceConnectionNode } from './utils';
/**
* Helper function to generate a service connection node.
*/
const createService = (service: { serviceName: string; agentName: string }) =>
({
...service,
serviceEnvironment: 'production',
} as ServiceMapService);
/**
* Helper function to generate an external connection node.
*/
const createExitSpan = (exitSpan: {
agentName?: string;
serviceName?: string;
spanType: string;
spanSubtype: string;
spanDestinationServiceResource: string;
}) =>
({
...exitSpan,
serviceEnvironment: 'production',
} as ServiceMapExitSpan);
const nodejsService = createService({ serviceName: 'opbeans-node', agentName: 'nodejs' });
const javaService = createService({ serviceName: 'opbeans-java', agentName: 'java' });
const goService = createService({ serviceName: 'opbeans-go', agentName: 'go' });
const pythonService = createService({ serviceName: 'opbeans-python', agentName: 'python' });
const kafkaExternal = createExitSpan({
spanDestinationServiceResource: 'kafka/some-queue',
spanType: 'messaging',
spanSubtype: 'kafka',
});
const nodejsExternal = createExitSpan({
spanDestinationServiceResource: 'opbeans-node',
spanType: 'external',
spanSubtype: 'aa',
});
const httpLoadBalancer = createExitSpan({
spanDestinationServiceResource: 'opbeans:3000',
spanType: 'external',
spanSubtype: 'http',
});
// Define anomalies
const anomalies = {
mlJobIds: ['apm-test-1234-ml-module-name'],
serviceAnomalies: [
{
serviceName: 'opbeans-test',
transactionType: 'request',
actualValue: 10000,
anomalyScore: 50,
jobId: 'apm-test-1234-ml-module-name',
healthStatus: ServiceHealthStatus.warning,
},
],
};
describe('getServiceMapNodes', () => {
it('maps external destinations to internal services', () => {
const response: ServiceMapConnections = {
servicesData: [
getServiceConnectionNode(nodejsService),
getServiceConnectionNode(javaService),
],
exitSpanDestinations: [
{
from: getExternalConnectionNode(nodejsExternal),
to: getServiceConnectionNode(nodejsService),
},
],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode(nodejsExternal),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
const { edges, nodes } = partitionElements(elements);
expect(getIds(nodes)).toEqual(['opbeans-java', 'opbeans-node']);
expect(getIds(edges)).toEqual(['opbeans-java~opbeans-node']);
});
it('adds connection for messaging-based external destinations', () => {
const response: ServiceMapConnections = {
servicesData: [
getServiceConnectionNode(nodejsService),
getServiceConnectionNode(javaService),
],
exitSpanDestinations: [
{
from: getExternalConnectionNode({ ...kafkaExternal, ...javaService }),
to: getServiceConnectionNode(nodejsService),
},
],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({ ...kafkaExternal, ...javaService }),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
const { edges, nodes } = partitionElements(elements);
expect(getIds(nodes)).toEqual([
'>opbeans-java|kafka/some-queue',
'opbeans-java',
'opbeans-node',
]);
expect(getIds(edges)).toEqual([
'>opbeans-java|kafka/some-queue~opbeans-node',
'opbeans-java~>opbeans-java|kafka/some-queue',
]);
});
it('collapses external destinations based on span.destination.resource.name', () => {
const response: ServiceMapConnections = {
servicesData: [
getServiceConnectionNode(nodejsService),
getServiceConnectionNode(javaService),
],
exitSpanDestinations: [
{
from: getExternalConnectionNode({ ...nodejsExternal, ...javaService }),
to: getServiceConnectionNode(nodejsService),
},
],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({ ...nodejsExternal, ...javaService }),
},
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({
...nodejsExternal,
...javaService,
spanType: 'foo',
}),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
const { edges, nodes } = partitionElements(elements);
expect(getIds(nodes)).toEqual(['opbeans-java', 'opbeans-node']);
expect(getIds(edges)).toEqual(['opbeans-java~opbeans-node']);
});
it('picks the first span.type/subtype in an alphabetically sorted list', () => {
const response: ServiceMapConnections = {
servicesData: [getServiceConnectionNode(javaService)],
exitSpanDestinations: [],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({ ...nodejsExternal, ...javaService }),
},
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode(
createExitSpan({ ...nodejsExternal, ...javaService, spanType: 'foo' })
),
},
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({
...nodejsExternal,
...javaService,
spanSubtype: 'bb',
}),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
const { edges, nodes } = partitionElements(elements);
expect(getIds(nodes)).toEqual(['>opbeans-java|opbeans-node', 'opbeans-java']);
expect(getIds(edges)).toEqual(['opbeans-java~>opbeans-java|opbeans-node']);
const nodejsNode = elements.find((node) => node.data.id === '>opbeans-java|opbeans-node');
// @ts-expect-error
expect(nodejsNode?.data[SPAN_TYPE]).toBe('external');
// @ts-expect-error
expect(nodejsNode?.data[SPAN_SUBTYPE]).toBe('aa');
});
it('processes connections without a matching "service" aggregation', () => {
const response: ServiceMapConnections = {
servicesData: [getServiceConnectionNode(javaService)],
exitSpanDestinations: [],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getServiceConnectionNode(nodejsService),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
expect(elements.length).toBe(3);
});
it('should return connections when exit spans point to a load balancer', () => {
const response: ServiceMapConnections = {
servicesData: [getServiceConnectionNode(javaService)],
exitSpanDestinations: [
{
from: getExternalConnectionNode({ ...httpLoadBalancer, ...javaService }),
to: getServiceConnectionNode(nodejsService),
},
{
from: getExternalConnectionNode({ ...httpLoadBalancer, ...goService }),
to: getServiceConnectionNode(javaService),
},
{
from: getExternalConnectionNode({ ...httpLoadBalancer, ...pythonService }),
to: getServiceConnectionNode(javaService),
},
],
connections: [
{
source: getServiceConnectionNode(javaService),
destination: getExternalConnectionNode({ ...httpLoadBalancer, ...javaService }),
},
{
source: getServiceConnectionNode(goService),
destination: getExternalConnectionNode({ ...httpLoadBalancer, ...goService }),
},
{
source: getServiceConnectionNode(pythonService),
destination: getExternalConnectionNode({ ...httpLoadBalancer, ...pythonService }),
},
],
anomalies,
};
const { elements } = getServiceMapNodes(response);
const { edges, nodes } = partitionElements(elements);
expect(getIds(nodes)).toEqual(['opbeans-go', 'opbeans-java', 'opbeans-node', 'opbeans-python']);
expect(getIds(edges)).toEqual([
'opbeans-go~opbeans-java',
'opbeans-java~opbeans-node',
'opbeans-python~opbeans-java',
]);
});
});
type ConnectionElements = GroupResourceNodesResponse['elements'];
export function partitionElements(elements: ConnectionElements) {
const edges = elements.filter(({ data }) => 'source' in data && 'target' in data);
const nodes = elements.filter((element) => !edges.includes(element));
return { edges, nodes };
}
export function getIds(elements: ConnectionElements) {
return elements.map(({ data }) => data.id).sort();
}

View file

@ -0,0 +1,291 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sortBy } from 'lodash';
import type { ServiceAnomaliesResponse } from '../../server/routes/service_map/get_service_anomalies';
import {
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_TYPE,
SPAN_SUBTYPE,
} from '../es_fields/apm';
import type { ExitSpanDestination, ServicesResponse } from './types';
import type {
Connection,
ConnectionNode,
ServiceConnectionNode,
ExternalConnectionNode,
ConnectionElement,
ConnectionEdge,
ServiceMapConnections,
GroupResourceNodesResponse,
} from './types';
import { groupResourceNodes } from './group_resource_nodes';
import { getEdgeId, isExitSpan } from './utils';
const FORBIDDEN_SERVICE_NAMES = ['constructor'];
function addMessagingConnections(
connections: Connection[],
destinationServices: ExitSpanDestination[]
): Connection[] {
const serviceMap = new Map(
destinationServices.map(({ from, to }) => [from[SPAN_DESTINATION_SERVICE_RESOURCE], to])
);
const messagingConnections = connections.reduce<Connection[]>((acc, connection) => {
const destination = connection.destination;
if (isExitSpan(destination) && destination[SPAN_TYPE] === 'messaging') {
const matchedService = serviceMap.get(destination[SPAN_DESTINATION_SERVICE_RESOURCE]);
if (matchedService) {
acc.push({
source: destination,
destination: matchedService,
});
}
}
return acc;
}, []);
return [...connections, ...messagingConnections];
}
function getAllNodes(services: ServicesResponse[], connections: Connection[]) {
const allNodesMap = new Map<string, ConnectionNode>();
connections.forEach((connection) => {
const sourceId = connection.source.id;
const destinationId = connection.destination.id;
if (!allNodesMap.has(sourceId)) {
allNodesMap.set(sourceId, { ...connection.source, id: sourceId });
}
if (!allNodesMap.has(destinationId)) {
allNodesMap.set(destinationId, { ...connection.destination, id: destinationId });
}
});
// Derive the rest of the map nodes from the connections and add the services
// from the services data query
services.forEach((service) => {
const id = service[SERVICE_NAME];
if (!allNodesMap.has(id) && !FORBIDDEN_SERVICE_NAMES.includes(service[SERVICE_NAME])) {
allNodesMap.set(id, { ...service, id });
}
});
return allNodesMap;
}
function getAllServices(
allNodes: Map<string, ConnectionNode>,
destinationServices: ExitSpanDestination[]
) {
const serviceNodes = new Map<string, ServiceConnectionNode>();
for (const { from, to } of destinationServices) {
const fromId = from.id;
const toId = to.id;
if (allNodes.has(fromId) && !allNodes.has(toId)) {
serviceNodes.set(toId, { ...to, id: toId });
}
}
for (const node of allNodes.values()) {
if (!isExitSpan(node)) {
serviceNodes.set(node.id, node);
}
}
return serviceNodes;
}
function getExitSpans(allNodes: Map<string, ConnectionNode>) {
const exitSpans = new Map<string, ExternalConnectionNode[]>();
for (const node of allNodes.values()) {
if (isExitSpan(node)) {
const nodes = exitSpans.get(node.id) ?? [];
nodes.push(node as ExternalConnectionNode);
exitSpans.set(node.id, nodes);
}
}
return exitSpans;
}
function exitSpanDestinationsToMap(destinationServices: ExitSpanDestination[]) {
return destinationServices.reduce((acc, { from, to }) => {
acc.set(from.id, to);
return acc;
}, new Map<string, ServiceConnectionNode>());
}
function mapNodes({
allConnections,
anomalies,
nodes,
exitSpanDestinations,
services,
}: {
allConnections: Connection[];
anomalies: ServiceAnomaliesResponse;
nodes: Map<string, ConnectionNode>;
services: Map<string, ServiceConnectionNode>;
exitSpanDestinations: ExitSpanDestination[];
}) {
const exitSpanDestinationsMap = exitSpanDestinationsToMap(exitSpanDestinations);
const exitSpans = getExitSpans(nodes);
const anomaliesByServiceName = new Map(
anomalies.serviceAnomalies.map((item) => [item.serviceName, item])
);
const messagingSpanIds = new Set(
allConnections.filter(({ source }) => isExitSpan(source)).map(({ source }) => source.id)
);
// 1. Map external nodes to internal services
// 2. Collapse external nodes into one node based on span.destination.service.resource
// 3. Pick the first available span.type/span.subtype in an alphabetically sorted list
const mappedNodes = new Map<string, ConnectionNode>();
for (const [id, node] of nodes.entries()) {
if (mappedNodes.has(id)) {
continue;
}
const isMessagingSpan = messagingSpanIds.has(node.id);
const destinationService = isMessagingSpan ? undefined : exitSpanDestinationsMap.get(node.id);
const isServiceNode = !!destinationService || !isExitSpan(node);
if (isServiceNode) {
const serviceId = destinationService ? destinationService.id : node.id;
const serviceNode = services.get(serviceId);
if (serviceNode) {
const serviceAnomalyStats = anomaliesByServiceName.get(serviceNode.id);
mappedNodes.set(node.id, {
...serviceNode,
...(serviceAnomalyStats ? { serviceAnomalyStats } : null),
});
}
} else {
const exitSpanNodes = exitSpans.get(id) ?? [];
if (exitSpanNodes.length > 0) {
const exitSpanSample = exitSpanNodes[0];
mappedNodes.set(id, {
...exitSpanSample,
label: exitSpanSample[SPAN_DESTINATION_SERVICE_RESOURCE],
[SPAN_TYPE]: exitSpanNodes.map((n) => n[SPAN_TYPE]).sort()[0],
[SPAN_SUBTYPE]: exitSpanNodes.map((n) => n[SPAN_SUBTYPE]).sort()[0],
});
}
}
}
return mappedNodes;
}
function mapEdges({
allConnections,
nodes,
}: {
allConnections: Connection[];
nodes: Map<string, ConnectionNode>;
}) {
const connections = allConnections.reduce((acc, connection) => {
const sourceData = nodes.get(connection.source.id);
const targetData = nodes.get(connection.destination.id);
if (!sourceData || !targetData || sourceData.id === targetData.id) {
return acc;
}
const label = `${sourceData[SERVICE_NAME]} to ${
targetData[SERVICE_NAME] || targetData[SPAN_DESTINATION_SERVICE_RESOURCE]
}`;
const id = getEdgeId(sourceData.id, targetData.id);
acc.set(id, {
source: sourceData.id,
target: targetData.id,
label,
id,
sourceData,
targetData,
});
return acc;
}, new Map<string, ConnectionEdge & { sourceData: ConnectionNode; targetData: ConnectionNode }>());
return [...connections.values()];
}
function markBidirectionalConnections({ connections }: { connections: ConnectionEdge[] }) {
const targets = new Map<string, ConnectionEdge>();
for (const connection of connections) {
const edgeId = getEdgeId(connection.source, connection.target);
const reversedConnection = targets.get(edgeId);
if (reversedConnection) {
reversedConnection.bidirectional = true;
connection.isInverseEdge = true;
}
targets.set(edgeId, connection);
}
return targets.values();
}
export function getServiceMapNodes({
connections,
exitSpanDestinations,
servicesData,
anomalies,
}: ServiceMapConnections): GroupResourceNodesResponse {
const allConnections = addMessagingConnections(connections, exitSpanDestinations);
const allNodes = getAllNodes(servicesData, allConnections);
const allServices = getAllServices(allNodes, exitSpanDestinations);
const nodes = mapNodes({
allConnections,
nodes: allNodes,
services: allServices,
exitSpanDestinations,
anomalies,
});
// Build connections with mapped nodes
const mappedEdges = mapEdges({ allConnections, nodes });
const uniqueNodes = mappedEdges
.flatMap((connection) => [connection.sourceData, connection.targetData])
.concat(...allServices.values())
.reduce((acc, node) => {
if (!acc.has(node.id)) {
acc.set(node.id, node);
}
return acc;
}, new Map<string, ConnectionNode>())
.values();
// Instead of adding connections in two directions,
// we add a `bidirectional` flag to use in styling
const edges = markBidirectionalConnections({
connections: sortBy(mappedEdges, 'id'),
});
// Put everything together in elements, with everything in the "data" property
const elements: ConnectionElement[] = [...edges, ...uniqueNodes].map((element) => ({
data: element,
}));
return groupResourceNodes({ elements });
}

View file

@ -0,0 +1,156 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConnectionElement, ServiceMapExitSpan, ServiceMapService } from './types';
import type { GroupedNode } from './types';
import { groupResourceNodes } from './group_resource_nodes';
import { getEdgeId, getExternalConnectionNode, getServiceConnectionNode } from './utils';
describe('groupResourceNodes', () => {
const createService = (service: { serviceName: string; agentName: string }) =>
({
...service,
serviceEnvironment: 'production',
} as ServiceMapService);
/**
* Helper function to generate an external connection node.
*/
const createExitSpan = (exitSpan: {
agentName?: string;
serviceName?: string;
spanType: string;
spanSubtype: string;
spanDestinationServiceResource: string;
}) =>
({
...exitSpan,
serviceEnvironment: 'production',
} as ServiceMapExitSpan);
const nodejsService = createService({ serviceName: 'opbeans-node', agentName: 'nodejs' });
const mockServiceNode = (service: ServiceMapService): ConnectionElement => ({
data: getServiceConnectionNode(service),
});
const mockExitSpanNode = (exitSpan: ServiceMapExitSpan): ConnectionElement => ({
data: getExternalConnectionNode(exitSpan),
});
const nodeJsServiceNode = mockServiceNode(nodejsService);
const nodeJsExitSpanQuora = mockExitSpanNode(
createExitSpan({
...nodejsService,
spanType: 'http',
spanSubtype: 'external',
spanDestinationServiceResource: 'a.quora.com:443',
})
);
const nodeJsExitSpanReddit = mockExitSpanNode(
createExitSpan({
...nodejsService,
spanType: 'http',
spanSubtype: 'external',
spanDestinationServiceResource: 'alb.reddit.com:443',
})
);
const nodeJsExitSpanDoubleClick = mockExitSpanNode(
createExitSpan({
...nodejsService,
spanType: 'http',
spanSubtype: 'external',
spanDestinationServiceResource: 'ad.doubleclick.net:443',
})
);
const nodeJsExitSpanOptimizely = mockExitSpanNode(
createExitSpan({
...nodejsService,
spanType: 'http',
spanSubtype: 'external',
spanDestinationServiceResource: 'tapi.optimizely.com:443',
})
);
const createMockEdge = (source: string, target: string): ConnectionElement => ({
data: {
id: getEdgeId(source, target),
source,
target,
},
});
describe('basic grouping', () => {
it('should group external nodes', () => {
const elements: ConnectionElement[] = [
nodeJsServiceNode,
nodeJsExitSpanQuora,
nodeJsExitSpanReddit,
nodeJsExitSpanDoubleClick,
nodeJsExitSpanOptimizely,
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanQuora.data.id),
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanReddit.data.id),
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanDoubleClick.data.id),
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanOptimizely.data.id),
];
const result = groupResourceNodes({ elements });
const groupedNodes = result.elements.filter(
(p): p is GroupedNode => 'groupedConnections' in p.data
);
const groupedNode = groupedNodes.find(
(el: any) => el.data.id && el.data.id.startsWith('resourceGroup')
);
expect(groupedNode).toBeDefined();
expect(groupedNode?.data.id).toBe(`resourceGroup{opbeans-node}`);
expect(groupedNode?.data.groupedConnections.length).toBe(4);
expect(result.elements.length).toBeLessThan(elements.length);
expect(result.nodesCount).toBe(1);
});
it('should not group nodes when below minimum group size', () => {
const elements: ConnectionElement[] = [
nodeJsServiceNode,
nodeJsExitSpanQuora,
nodeJsExitSpanReddit,
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanQuora.data.id),
createMockEdge(nodeJsServiceNode.data.id, nodeJsExitSpanReddit.data.id),
];
const result = groupResourceNodes({ elements });
expect(result.elements.length).toBe(elements.length);
expect(result.nodesCount).toBe(3);
});
});
describe('edge cases', () => {
it('should handle empty input', () => {
const result = groupResourceNodes({ elements: [] });
expect(result.elements).toEqual([]);
expect(result.nodesCount).toBe(0);
});
it('should handle input with no groupable nodes', () => {
const svc1 = mockServiceNode(createService({ serviceName: 'service1', agentName: 'nodejs' }));
const svc2 = mockServiceNode(createService({ serviceName: 'service2', agentName: 'nodejs' }));
const elements: ConnectionElement[] = [
svc1,
svc2,
createMockEdge(svc1.data.id, svc2.data.id),
];
const result = groupResourceNodes({ elements });
expect(result.elements.length).toBe(elements.length);
expect(result.nodesCount).toBe(2);
});
});
});

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { SPAN_TYPE, SPAN_SUBTYPE } from '../es_fields/apm';
import type {
ConnectionEdge,
ConnectionElement,
ConnectionNode,
GroupResourceNodesResponse,
GroupedConnection,
GroupedEdge,
GroupedNode,
} from './types';
import { getEdgeId, isSpanGroupingSupported } from './utils';
const MINIMUM_GROUP_SIZE = 4;
const isEdge = (el: ConnectionElement): el is { data: ConnectionEdge } =>
Boolean(el.data.source && el.data.target);
const isNode = (el: ConnectionElement): el is { data: ConnectionNode } => !isEdge(el);
const isElligibleGroupNode = (el: ConnectionElement): el is { data: ConnectionNode } => {
if (isNode(el) && SPAN_TYPE in el.data) {
return isSpanGroupingSupported(el.data[SPAN_TYPE], el.data[SPAN_SUBTYPE]);
}
return false;
};
function groupConnections({
edgesMap,
groupableNodeIds,
}: {
edgesMap: Map<string, ConnectionElement>;
groupableNodeIds: Set<string>;
}) {
const sourcesByTarget = new Map<string, string[]>();
for (const { data } of edgesMap.values()) {
const { source, target } = data;
if (groupableNodeIds.has(target)) {
const sources = sourcesByTarget.get(target) ?? [];
sources.push(source);
sourcesByTarget.set(target, sources);
}
}
const groups = new Map<string, { id: string; sources: string[]; targets: string[] }>();
for (const [target, sources] of sourcesByTarget) {
const groupId = `resourceGroup{${[...sources].sort().join(';')}}`;
const group = groups.get(groupId) ?? { id: groupId, sources, targets: [] };
group.targets.push(target);
groups.set(groupId, group);
}
return Array.from(groups.values()).filter(({ targets }) => targets.length >= MINIMUM_GROUP_SIZE);
}
function getUngroupedNodesAndEdges({
nodesMap,
edgesMap,
groupedConnections,
}: {
nodesMap: Map<string, { data: ConnectionNode }>;
edgesMap: Map<string, { data: ConnectionEdge }>;
groupedConnections: ReturnType<typeof groupConnections>;
}) {
const ungroupedEdges = new Map(edgesMap);
const ungroupedNodes = new Map(nodesMap);
for (const { sources, targets } of groupedConnections) {
targets.forEach((target) => {
ungroupedNodes.delete(target);
sources.forEach((source) => {
ungroupedEdges.delete(getEdgeId(source, target));
});
});
}
return {
ungroupedNodes,
ungroupedEdges,
};
}
function groupNodes({
nodesMap,
groupedConnections,
}: {
nodesMap: Map<string, ConnectionElement>;
groupedConnections: ReturnType<typeof groupConnections>;
}) {
return groupedConnections.map(
({ id, targets }): GroupedNode => ({
data: {
id,
[SPAN_TYPE]: 'external',
label: i18n.translate('xpack.apm.serviceMap.resourceCountLabel', {
defaultMessage: '{count} resources',
values: { count: targets.length },
}),
groupedConnections: targets
.map((target) => {
const targetElement = nodesMap.get(target);
return targetElement
? {
...targetElement.data,
label: targetElement.data.label || targetElement.data.id,
}
: undefined;
})
.filter((target): target is GroupedConnection => !!target),
},
})
);
}
function groupEdges({
groupedConnections,
}: {
groupedConnections: ReturnType<typeof groupConnections>;
}) {
return groupedConnections.flatMap(({ id, sources }) =>
sources.map(
(source): GroupedEdge => ({
data: {
id: `${source}~>${id}`,
source,
target: id,
},
})
)
);
}
export function groupResourceNodes({
elements,
}: {
elements: ConnectionElement[];
}): GroupResourceNodesResponse {
const nodesMap = new Map(elements.filter(isNode).map((node) => [node.data.id, node]));
const edgesMap = new Map(
elements.filter(isEdge).map((edge) => [getEdgeId(edge.data.source, edge.data.target), edge])
);
const groupableNodeIds = new Set(
elements.filter(isElligibleGroupNode).map(({ data: { id } }) => id)
);
const groupedConnections = groupConnections({ edgesMap, groupableNodeIds });
const { ungroupedEdges, ungroupedNodes } = getUngroupedNodesAndEdges({
nodesMap,
edgesMap,
groupedConnections,
});
const groupedNodes = groupNodes({ nodesMap, groupedConnections });
const groupedEdges = groupEdges({ groupedConnections });
return {
elements: [
...ungroupedNodes.values(),
...groupedNodes,
...ungroupedEdges.values(),
...groupedEdges,
],
nodesCount: ungroupedNodes.size,
};
}

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
Connection,
ConnectionEdge,
ConnectionNode,
ExitSpanDestination,
ConnectionElement,
ExternalConnectionNode,
GroupResourceNodesResponse,
ServiceConnectionNode,
ServicesResponse,
ServiceMapResponse,
ServiceMapConnections,
ServiceMapTelemetry,
NodeStats,
NodeItem,
} from './types';
export * from './utils';
export { getServiceMapNodes } from './get_service_map_nodes';
export type {
Connection,
ConnectionEdge,
ConnectionNode,
ExitSpanDestination,
ConnectionElement,
GroupResourceNodesResponse,
ExternalConnectionNode,
ServiceConnectionNode,
ServicesResponse,
ServiceMapConnections,
ServiceMapResponse,
ServiceMapTelemetry,
NodeStats,
NodeItem,
};

View file

@ -0,0 +1,165 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type cytoscape from 'cytoscape';
import type { AgentName } from '@kbn/apm-types/src/es_schemas/ui/fields';
import type { AGENT_NAME, SERVICE_ENVIRONMENT, SERVICE_NAME } from '@kbn/apm-types';
import type { SPAN_DESTINATION_SERVICE_RESOURCE, SPAN_SUBTYPE, SPAN_TYPE } from '@kbn/apm-types';
import type { ServiceAnomaliesResponse } from '../../server/routes/service_map/get_service_anomalies';
import type { Coordinate } from '../../typings/timeseries';
import type { ServiceAnomalyStats } from '../anomaly_detection';
export interface ServiceMapTelemetry {
tracesCount: number;
nodesCount: number;
}
export type GroupedConnection = { label: string } & (ConnectionNode | ConnectionEdge);
export interface GroupedNode {
data: {
id: string;
'span.type': string;
label: string;
groupedConnections: GroupedConnection[];
};
}
export interface GroupedEdge {
data: {
id: string;
source: string;
target: string;
};
}
export interface GroupResourceNodesResponse extends Pick<ServiceMapTelemetry, 'nodesCount'> {
elements: Array<GroupedNode | GroupedEdge | ConnectionElement>;
}
export type ConnectionType = Connection | ConnectionLegacy;
export type DestinationType = ExitSpanDestination | ExitSpanDestinationLegacy;
export interface ServiceMapConnections {
servicesData: ServicesResponse[];
anomalies: ServiceAnomaliesResponse;
connections: Connection[];
exitSpanDestinations: ExitSpanDestination[];
}
export interface ServiceMapRawResponse {
spans: ServiceMapSpan[];
servicesData: ServicesResponse[];
anomalies: ServiceAnomaliesResponse;
}
export type ServiceMapResponse =
| Pick<ServiceMapTelemetry, 'tracesCount'> & (ServiceMapRawResponse | GroupResourceNodesResponse);
export interface ServicesResponse {
[SERVICE_NAME]: string;
[AGENT_NAME]: string;
[SERVICE_ENVIRONMENT]: string | null;
}
export type ServiceConnectionNode = cytoscape.NodeDataDefinition &
ServicesResponse & {
id: string;
serviceAnomalyStats?: ServiceAnomalyStats;
label?: string;
};
export interface ExternalConnectionNode extends cytoscape.NodeDataDefinition {
id: string;
[SPAN_DESTINATION_SERVICE_RESOURCE]: string;
[SPAN_TYPE]: string;
[SPAN_SUBTYPE]: string;
label?: string;
}
export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode;
export type ConnectionNodeLegacy =
| Omit<ServiceConnectionNode, 'id'>
| Omit<ExternalConnectionNode, 'id'>;
export interface ConnectionEdge {
id: string;
source: ConnectionNode['id'];
target: ConnectionNode['id'];
label?: string;
bidirectional?: boolean;
isInverseEdge?: boolean;
}
export type NodeItem = {
id: string;
} & ConnectionNode;
export interface ConnectionElement {
data: ConnectionNode | ConnectionEdge;
}
export interface Connection {
source: ConnectionNode;
destination: ConnectionNode;
}
export interface ConnectionLegacy {
source: ConnectionNodeLegacy;
destination: ConnectionNodeLegacy;
}
export interface NodeStats {
transactionStats?: {
latency?: {
value: number | null;
timeseries?: Coordinate[];
};
throughput?: {
value: number | null;
timeseries?: Coordinate[];
};
};
failedTransactionsRate?: {
value: number | null;
timeseries?: Coordinate[];
};
cpuUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
memoryUsage?: {
value?: number | null;
timeseries?: Coordinate[];
};
}
export type ExitSpanDestinationType = ExitSpanDestination | ExitSpanDestinationLegacy;
export interface ExitSpanDestination {
from: ExternalConnectionNode;
to: ServiceConnectionNode;
}
export interface ExitSpanDestinationLegacy {
from: Omit<ExternalConnectionNode, 'id'>;
to: Omit<ServiceConnectionNode, 'id'>;
}
export interface ServiceMapService {
serviceName: string;
agentName: AgentName;
serviceEnvironment?: string;
serviceNodeName?: string;
}
export interface ServiceMapExitSpan extends ServiceMapService {
spanId: string;
spanType: string;
spanSubtype: string;
spanDestinationServiceResource: string;
}
export type ServiceMapSpan = ServiceMapExitSpan & {
destinationService?: ServiceMapService;
};

View file

@ -5,8 +5,8 @@
* 2.0.
*/
import { getConnections } from './get_service_map_from_trace_ids';
import type { Connection, ConnectionNode } from '../../../common/service_map';
import { getConnections } from './utils';
import type { Connection, ConnectionNode } from './types';
function getConnectionsPairs(connections: Connection[]) {
return connections
@ -73,9 +73,7 @@ describe('getConnections', () => {
] as ConnectionNode[][];
it('includes all connections', () => {
const connections = getConnections({
paths,
});
const connections = getConnections(paths);
const connectionsPairs = getConnectionsPairs(connections);
expect(connectionsPairs).toEqual([

View file

@ -0,0 +1,115 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import type {
Connection,
ConnectionNode,
ExternalConnectionNode,
ServiceConnectionNode,
} from './types';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_SUBTYPE,
SPAN_TYPE,
} from '../es_fields/apm';
import type { ConnectionNodeLegacy, ServiceMapExitSpan, ServiceMapService } from './types';
export const invalidLicenseMessage = i18n.translate('xpack.apm.serviceMap.invalidLicenseMessage', {
defaultMessage:
"In order to access Service Maps, you must be subscribed to an Elastic Platinum license. With it, you'll have the ability to visualize your entire application stack along with your APM data.",
});
const NONGROUPED_SPANS: Record<string, string[]> = {
aws: ['servicename'],
cache: ['all'],
db: ['all'],
external: ['graphql', 'grpc', 'websocket'],
messaging: ['all'],
template: ['handlebars'],
};
export function isSpanGroupingSupported(type?: string, subtype?: string) {
if (!type || !(type in NONGROUPED_SPANS)) {
return true;
}
return !NONGROUPED_SPANS[type].some(
(nongroupedSubType) => nongroupedSubType === 'all' || nongroupedSubType === subtype
);
}
export function getConnections(
paths: Array<Array<ConnectionNode | ConnectionNodeLegacy>> | undefined
): Connection[] {
if (!paths) {
return [];
}
const connectionsById = new Set<string>();
const connections: Connection[] = [];
paths.forEach((path) => {
for (let i = 1; i < path.length; i++) {
const sourceNode = (
'id' in path[i - 1] ? path[i - 1] : { ...path[i - 1], id: getLegacyNodeId(path[i - 1]) }
) as ConnectionNode;
const destinationNode = (
'id' in path[i] ? path[i] : { ...path[i], id: getLegacyNodeId(path[i]) }
) as ConnectionNode;
const connectionId = getEdgeId(sourceNode.id, destinationNode.id);
if (!connectionsById.has(connectionId)) {
connectionsById.add(connectionId);
connections.push({ source: sourceNode, destination: destinationNode });
}
}
});
return connections;
}
export const isExitSpan = (
node: ConnectionNode | ConnectionNodeLegacy
): node is ExternalConnectionNode => {
return !!(node as ExternalConnectionNode)[SPAN_DESTINATION_SERVICE_RESOURCE];
};
// backward compatibility with scrited_metric versions
export const getLegacyNodeId = (node: ConnectionNodeLegacy) => {
if (isExitSpan(node)) {
return `>${node[SPAN_DESTINATION_SERVICE_RESOURCE]}`;
}
return `${node[SERVICE_NAME]}`;
};
export const getServiceConnectionNode = (event: ServiceMapService): ServiceConnectionNode => {
return {
id: event.serviceName,
[SERVICE_NAME]: event.serviceName,
[SERVICE_ENVIRONMENT]: event.serviceEnvironment || null,
[AGENT_NAME]: event.agentName,
};
};
export const getExternalConnectionNode = (event: ServiceMapExitSpan): ExternalConnectionNode => {
return {
id: `>${event.serviceName}|${event.spanDestinationServiceResource}`,
[SPAN_DESTINATION_SERVICE_RESOURCE]: event.spanDestinationServiceResource,
[SPAN_TYPE]: event.spanType,
[SPAN_SUBTYPE]: event.spanSubtype,
};
};
export function getEdgeId(sourceId: string, destinationId: string) {
return `${sourceId}~${destinationId}`;
}
export const SERVICE_MAP_TIMEOUT_ERROR = 'ServiceMapTimeoutError';

View file

@ -12,7 +12,7 @@ import React from 'react';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { isActivePlatinumLicense } from '../../../../common/license_check';
import { invalidLicenseMessage, SERVICE_MAP_TIMEOUT_ERROR } from '../../../../common/service_map';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
import { FETCH_STATUS } from '../../../hooks/use_fetcher';
import { useLicenseContext } from '../../../context/license/use_license_context';
import { LicensePrompt } from '../../shared/license_prompt';
import { Controls } from './controls';
@ -32,6 +32,7 @@ import { DisabledPrompt } from './disabled_prompt';
import { useApmServiceContext } from '../../../context/apm_service/use_apm_service_context';
import { isLogsOnlySignal } from '../../../utils/get_signal_type';
import { ServiceTabEmptyState } from '../service_tab_empty_state';
import { useServiceMap } from './use_service_map';
function PromptContainer({ children }: { children: ReactNode }) {
return (
@ -106,36 +107,17 @@ export function ServiceMap({
const { euiTheme } = useEuiTheme();
const license = useLicenseContext();
const serviceName = useServiceName();
const { config } = useApmPluginContext();
const { onPageReady } = usePerformanceContext();
const {
data = { elements: [], nodesCount: 0, tracesCount: 0 },
status,
error,
} = useFetcher(
(callApmApi) => {
// When we don't have a license or a valid license, don't make the request.
if (!license || !isActivePlatinumLicense(license) || !config.serviceMapEnabled) {
return;
}
return callApmApi('GET /internal/apm/service-map', {
isCachable: false,
params: {
query: {
start,
end,
environment,
serviceName,
serviceGroup: serviceGroupId,
kuery,
},
},
});
},
[license, serviceName, environment, start, end, serviceGroupId, kuery, config.serviceMapEnabled]
);
const { data, status, error } = useServiceMap({
environment,
kuery,
start,
end,
serviceGroupId,
serviceName,
});
const { ref, height } = useRefDimensions();

View file

@ -0,0 +1,176 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useEffect, useState } from 'react';
import type { IHttpFetchError, ResponseErrorBody } from '@kbn/core/public';
import type {
ServiceMapSpan,
ExitSpanDestination,
ServiceMapRawResponse,
ServiceMapTelemetry,
} from '../../../../common/service_map/types';
import { useApmPluginContext } from '../../../context/apm_plugin/use_apm_plugin_context';
import { useLicenseContext } from '../../../context/license/use_license_context';
import { isActivePlatinumLicense } from '../../../../common/license_check';
import type { Environment } from '../../../../common/environment_rt';
import {
getExternalConnectionNode,
getServiceConnectionNode,
getServiceMapNodes,
getConnections,
} from '../../../../common/service_map';
import type { ConnectionNode, GroupResourceNodesResponse } from '../../../../common/service_map';
import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher';
type SeriviceMapState = GroupResourceNodesResponse & Pick<ServiceMapTelemetry, 'tracesCount'>;
const INITIAL_SERVICE_MAP_STATE: SeriviceMapState = {
elements: [],
nodesCount: 0,
tracesCount: 0,
};
export const useServiceMap = ({
start,
end,
environment,
serviceName,
serviceGroupId,
kuery,
}: {
environment: Environment;
kuery: string;
start: string;
end: string;
serviceGroupId?: string;
serviceName?: string;
}) => {
const license = useLicenseContext();
const { config } = useApmPluginContext();
const [serviceMapNodes, setServiceMapNodes] = useState<{
data: SeriviceMapState;
error?: Error | IHttpFetchError<ResponseErrorBody>;
status: FETCH_STATUS;
}>({
data: INITIAL_SERVICE_MAP_STATE,
status: FETCH_STATUS.LOADING,
});
const { data, status, error } = useFetcher(
(callApmApi) => {
// When we don't have a license or a valid license, don't make the request.
if (!license || !isActivePlatinumLicense(license) || !config.serviceMapEnabled) {
return;
}
return callApmApi('GET /internal/apm/service-map', {
params: {
query: {
start,
end,
environment,
serviceName,
serviceGroup: serviceGroupId,
kuery,
useV2: config.ui.serviceMapApiV2Enabled,
},
},
});
},
[
license,
serviceName,
environment,
start,
end,
serviceGroupId,
kuery,
config.serviceMapEnabled,
config.ui.serviceMapApiV2Enabled,
],
{ preservePreviousData: false }
);
useEffect(() => {
if (status === FETCH_STATUS.LOADING) {
setServiceMapNodes((prevState) => ({ ...prevState, status: FETCH_STATUS.LOADING }));
return;
}
if (status === FETCH_STATUS.FAILURE || error) {
setServiceMapNodes({
data: INITIAL_SERVICE_MAP_STATE,
status: FETCH_STATUS.FAILURE,
error,
});
return;
}
if (data) {
if ('spans' in data) {
try {
const transformedData = processServiceMapData(data);
setServiceMapNodes({
data: {
elements: transformedData.elements,
nodesCount: transformedData.nodesCount,
tracesCount: data.tracesCount,
},
status: FETCH_STATUS.SUCCESS,
});
} catch (err) {
setServiceMapNodes({
data: INITIAL_SERVICE_MAP_STATE,
status: FETCH_STATUS.FAILURE,
error: err,
});
}
} else {
setServiceMapNodes({
data,
status: FETCH_STATUS.SUCCESS,
});
}
}
}, [data, status, error]);
return serviceMapNodes;
};
const processServiceMapData = (data: ServiceMapRawResponse): GroupResourceNodesResponse => {
const paths = getPaths({ spans: data.spans });
return getServiceMapNodes({
connections: getConnections(paths.connections),
exitSpanDestinations: paths.exitSpanDestinations,
servicesData: data.servicesData,
anomalies: data.anomalies,
});
};
const getPaths = ({ spans }: { spans: ServiceMapSpan[] }) => {
const connections: ConnectionNode[][] = [];
const exitSpanDestinations: ExitSpanDestination[] = [];
for (const currentNode of spans) {
const exitSpanNode = getExternalConnectionNode(currentNode);
const serviceNode = getServiceConnectionNode(currentNode);
if (currentNode.destinationService) {
// maps an exit span to its destination service
exitSpanDestinations.push({
from: exitSpanNode,
to: getServiceConnectionNode(currentNode.destinationService),
});
}
// builds a connection between a service and an exit span
connections.push([serviceNode, exitSpanNode]);
}
return {
connections,
exitSpanDestinations,
};
};

View file

@ -92,6 +92,7 @@ const mockCore = merge({}, coreStart, {
const mockConfig: ConfigSchema = {
serviceMapEnabled: true,
ui: {
serviceMapApiV2Enabled: false,
enabled: false,
},
latestAgentVersionsUrl: '',

View file

@ -12,6 +12,7 @@ import { ApmPlugin } from './plugin';
export interface ConfigSchema {
serviceMapEnabled: boolean;
ui: {
serviceMapApiV2Enabled: boolean;
enabled: boolean;
};
latestAgentVersionsUrl: string;

View file

@ -36,6 +36,7 @@ const configSchema = schema.object({
ui: schema.object({
enabled: schema.boolean({ defaultValue: true }),
maxTraceItems: schema.number({ defaultValue: 5000 }),
serviceMapApiV2Enabled: schema.boolean({ defaultValue: false }),
}),
searchAggregatedTransactions: schema.oneOf(
[

View file

@ -0,0 +1,220 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { existsQuery, rangeQuery, termsQuery } from '@kbn/observability-plugin/server';
import type { APMEventClient } from '@kbn/apm-data-access-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import { unflattenKnownApmEventFields } from '@kbn/apm-data-access-plugin/server/utils';
import { EventOutcome } from '../../../common/event_outcome';
import type { ServiceMapSpan } from '../../../common/service_map/types';
import type { AgentName } from '../../../typings/es_schemas/ui/fields/agent';
import { asMutableArray } from '../../../common/utils/as_mutable_array';
import {
AGENT_NAME,
PARENT_ID,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_ID,
SPAN_SUBTYPE,
SPAN_TYPE,
TRACE_ID,
EVENT_OUTCOME,
AT_TIMESTAMP,
} from '../../../common/es_fields/apm';
export async function fetchExitSpanSamplesFromTraceIds({
apmEventClient,
traceIds,
start,
end,
}: {
apmEventClient: APMEventClient;
traceIds: string[];
start: number;
end: number;
}) {
const exitSpansSample = await fetchExitSpanIdsFromTraceIds({
apmEventClient,
traceIds,
start,
end,
});
const transactionsFromExitSpans = await fetchTransactionsFromExitSpans({
apmEventClient,
exitSpansSample,
start,
end,
});
return transactionsFromExitSpans;
}
async function fetchExitSpanIdsFromTraceIds({
apmEventClient,
traceIds,
start,
end,
}: {
apmEventClient: APMEventClient;
traceIds: string[];
start: number;
end: number;
}) {
const sampleExitSpans = await apmEventClient.search('get_service_map_exit_span_samples', {
apm: {
events: [ProcessorEvent.span],
},
track_total_hits: false,
size: 0,
query: {
bool: {
filter: [
...rangeQuery(start, end),
...termsQuery(TRACE_ID, ...traceIds),
...existsQuery(SPAN_DESTINATION_SERVICE_RESOURCE),
],
},
},
aggs: {
exitSpans: {
composite: {
sources: asMutableArray([
{ serviceName: { terms: { field: SERVICE_NAME } } },
{
spanDestinationServiceResource: {
terms: { field: SPAN_DESTINATION_SERVICE_RESOURCE },
},
},
] as const),
size: 10000,
},
aggs: {
eventOutcomeGroup: {
filters: {
filters: {
success: {
term: {
[EVENT_OUTCOME]: EventOutcome.success as const,
},
},
others: {
bool: {
must_not: {
term: {
[EVENT_OUTCOME]: EventOutcome.success as const,
},
},
},
},
},
},
aggs: {
sample: {
top_metrics: {
size: 1,
sort: {
[AT_TIMESTAMP]: 'asc' as const,
},
metrics: asMutableArray([
{ field: SPAN_ID },
{ field: SPAN_TYPE },
{ field: SPAN_SUBTYPE },
{ field: SPAN_DESTINATION_SERVICE_RESOURCE },
{ field: SERVICE_NAME },
{ field: SERVICE_ENVIRONMENT },
{ field: AGENT_NAME },
] as const),
},
},
},
},
},
},
},
});
const destinationsBySpanId = new Map<string, ServiceMapSpan>();
sampleExitSpans.aggregations?.exitSpans.buckets.forEach((bucket) => {
const { success, others } = bucket.eventOutcomeGroup.buckets;
const eventOutcomeGroup =
success.sample.top.length > 0 ? success : others.sample.top.length > 0 ? others : undefined;
const sample = eventOutcomeGroup?.sample.top[0]?.metrics;
if (!sample) {
return;
}
const spanId = sample[SPAN_ID] as string;
destinationsBySpanId.set(spanId, {
spanId,
spanDestinationServiceResource: bucket.key.spanDestinationServiceResource as string,
spanType: sample[SPAN_TYPE] as string,
spanSubtype: sample[SPAN_SUBTYPE] as string,
agentName: sample[AGENT_NAME] as AgentName,
serviceName: bucket.key.serviceName as string,
serviceEnvironment: sample[SERVICE_ENVIRONMENT] as string,
});
});
return destinationsBySpanId;
}
async function fetchTransactionsFromExitSpans({
apmEventClient,
exitSpansSample,
start,
end,
}: {
apmEventClient: APMEventClient;
exitSpansSample: Map<string, ServiceMapSpan>;
start: number;
end: number;
}) {
const optionalFields = asMutableArray([SERVICE_ENVIRONMENT] as const);
const requiredFields = asMutableArray([SERVICE_NAME, AGENT_NAME, PARENT_ID] as const);
const servicesResponse = await apmEventClient.search('get_transactions_for_exit_spans', {
apm: {
events: [ProcessorEvent.transaction],
},
track_total_hits: false,
query: {
bool: {
filter: [...rangeQuery(start, end), ...termsQuery(PARENT_ID, ...exitSpansSample.keys())],
},
},
size: exitSpansSample.size,
fields: [...requiredFields, ...optionalFields],
});
const destinationsBySpanId = new Map(exitSpansSample);
servicesResponse.hits.hits.forEach((hit) => {
const transaction = unflattenKnownApmEventFields(hit.fields, [...requiredFields]);
const spanId = transaction.parent.id;
const destination = destinationsBySpanId.get(spanId);
if (destination) {
destinationsBySpanId.set(spanId, {
...destination,
destinationService: {
agentName: transaction.agent.name,
serviceEnvironment: transaction.service.environment,
serviceName: transaction.service.name,
},
});
}
});
return Array.from(destinationsBySpanId.values());
}

View file

@ -7,6 +7,10 @@
import { rangeQuery } from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import type {
ConnectionNodeLegacy,
ExitSpanDestinationLegacy,
} from '../../../common/service_map/types';
import {
AGENT_NAME,
PARENT_ID,
@ -18,11 +22,6 @@ import {
SPAN_TYPE,
TRACE_ID,
} from '../../../common/es_fields/apm';
import type {
ConnectionNode,
ExternalConnectionNode,
ServiceConnectionNode,
} from '../../../common/service_map';
import type { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { calculateDocsPerShard } from './calculate_docs_per_shard';
@ -328,11 +327,8 @@ export async function fetchServicePathsFromTraceIds({
aggregations?: {
service_map: {
value: {
paths: ConnectionNode[][];
discoveredServices: Array<{
from: ExternalConnectionNode;
to: ServiceConnectionNode;
}>;
paths: ConnectionNodeLegacy[][];
discoveredServices: ExitSpanDestinationLegacy[];
};
};
};

View file

@ -134,7 +134,7 @@ export async function getServiceAnomalies({
serviceName: bucket.key.serviceName as string,
jobId: bucket.key.jobId as string,
transactionType: metrics.by_field_value as string,
actualValue: metrics.actual as number | null,
actualValue: metrics.actual as number,
anomalyScore,
healthStatus,
};

View file

@ -7,16 +7,21 @@
import type { Logger } from '@kbn/core/server';
import { chunk } from 'lodash';
import type {
Connection,
ExitSpanDestination,
ServiceMapSpan,
} from '../../../common/service_map/types';
import { getServiceMapNodes, type ServiceMapResponse } from '../../../common/service_map';
import type { APMConfig } from '../..';
import type { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import type { MlClient } from '../../lib/helpers/get_ml_client';
import { withApmSpan } from '../../utils/with_apm_span';
import { DEFAULT_ANOMALIES, getServiceAnomalies } from './get_service_anomalies';
import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids';
import { getServiceStats } from './get_service_stats';
import { getTraceSampleIds } from './get_trace_sample_ids';
import type { TransformServiceMapResponse } from './transform_service_map_responses';
import { transformServiceMapResponses } from './transform_service_map_responses';
import { DEFAULT_ANOMALIES, getServiceAnomalies } from './get_service_anomalies';
import { getServiceStats } from './get_service_stats';
import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids';
import { fetchExitSpanSamplesFromTraceIds } from './fetch_exit_span_samples';
export interface IEnvOptions {
mlClient?: MlClient;
@ -30,11 +35,7 @@ export interface IEnvOptions {
end: number;
serviceGroupKuery?: string;
kuery?: string;
}
export interface ServiceMapTelemetry {
tracesCount: number;
nodesCount: number;
useV2?: boolean;
}
async function getConnectionData({
@ -47,7 +48,13 @@ async function getConnectionData({
serviceGroupKuery,
kuery,
logger,
}: IEnvOptions) {
useV2 = false,
}: IEnvOptions): Promise<
{ tracesCount: number } & (
| { connections: Connection[]; discoveredServices: ExitSpanDestination[] }
| { spans: ServiceMapSpan[] }
)
> {
return withApmSpan('get_service_map_connections', async () => {
logger.debug('Getting trace sample IDs');
const { traceIds } = await getTraceSampleIds({
@ -63,23 +70,41 @@ async function getConnectionData({
logger.debug(`Found ${traceIds.length} traces to inspect`);
const chunks = chunk(traceIds, config.serviceMapMaxTracesPerRequest);
if (useV2) {
if (traceIds.length === 0) {
return { spans: [], tracesCount: 0 };
}
const init = {
connections: [],
discoveredServices: [],
tracesCount: 0,
servicesCount: 0,
};
const spans = await withApmSpan(
'get_service_map_exit_spans_and_transactions_from_traces',
() =>
fetchExitSpanSamplesFromTraceIds({
apmEventClient,
traceIds,
start,
end,
})
);
if (!traceIds.length) {
return init;
return {
tracesCount: traceIds.length,
spans,
};
}
logger.debug(`Executing scripted metric agg (${chunks.length} chunks)`);
if (!traceIds.length) {
return {
connections: [],
discoveredServices: [],
tracesCount: 0,
};
}
const chunkedResponses = await withApmSpan('get_service_paths_from_all_trace_ids', () =>
Promise.all(
const chunkedResponses = await withApmSpan('get_service_paths_from_all_trace_ids', () => {
const chunks = chunk(traceIds, config.serviceMapMaxTracesPerRequest);
logger.debug(`Executing scripted metric agg (${chunks.length} chunks)`);
return Promise.all(
chunks.map((traceIdsChunk) =>
getServiceMapFromTraceIds({
apmEventClient,
@ -92,8 +117,8 @@ async function getConnectionData({
logger,
})
)
)
);
);
});
logger.debug('Received chunk responses');
@ -106,14 +131,14 @@ async function getConnectionData({
logger.debug('Merged responses');
return { ...mergedResponses, tracesCount: traceIds.length };
return {
connections: mergedResponses.connections,
discoveredServices: mergedResponses.discoveredServices,
tracesCount: traceIds.length,
};
});
}
export type ConnectionsResponse = Awaited<ReturnType<typeof getConnectionData>>;
export type ServicesResponse = Awaited<ReturnType<typeof getServiceStats>>;
export type ServiceMapResponse = TransformServiceMapResponse & ServiceMapTelemetry;
export function getServiceMap(
options: IEnvOptions & { maxNumberOfServices: number }
): Promise<ServiceMapResponse> {
@ -137,18 +162,26 @@ export function getServiceMap(
logger.debug('Received and parsed all responses');
const transformedResponse = transformServiceMapResponses({
response: {
...connectionData,
services: servicesData,
if ('spans' in connectionData) {
return {
spans: connectionData.spans,
tracesCount: connectionData.tracesCount,
servicesData,
anomalies,
},
};
}
const serviceMapNodes = getServiceMapNodes({
connections: connectionData.connections,
exitSpanDestinations: connectionData.discoveredServices,
servicesData,
anomalies,
});
return {
...transformedResponse,
...serviceMapNodes,
tracesCount: connectionData.tracesCount,
nodesCount: transformedResponse.nodesCount,
nodesCount: serviceMapNodes.nodesCount,
};
});
}

View file

@ -6,39 +6,11 @@
*/
import type { Logger } from '@kbn/logging';
import type { Connection, ConnectionNode } from '../../../common/service_map';
import type { ExitSpanDestination } from '../../../common/service_map/types';
import { getConnections, getLegacyNodeId } from '../../../common/service_map';
import type { APMEventClient } from '../../lib/helpers/create_es_client/create_apm_event_client';
import { fetchServicePathsFromTraceIds } from './fetch_service_paths_from_trace_ids';
import { getConnectionId } from './transform_service_map_responses';
export function getConnections({ paths }: { paths: ConnectionNode[][] | undefined }): Connection[] {
if (!paths) {
return [];
}
const connectionsById: Map<string, Connection> = new Map();
paths.forEach((path) => {
path.forEach((location, i) => {
const prev = path[i - 1];
if (prev) {
const connection = {
source: prev,
destination: location,
};
const id = getConnectionId(connection);
if (!connectionsById.has(id)) {
connectionsById.set(id, connection);
}
}
});
});
return Array.from(connectionsById.values());
}
export async function getServiceMapFromTraceIds({
apmEventClient,
@ -75,9 +47,13 @@ export async function getServiceMapFromTraceIds({
serviceMapFromTraceIdsScriptResponse.aggregations?.service_map.value;
return {
connections: getConnections({
paths: serviceMapScriptedAggValue?.paths,
}),
discoveredServices: serviceMapScriptedAggValue?.discoveredServices ?? [],
connections: getConnections(serviceMapScriptedAggValue?.paths),
discoveredServices: (serviceMapScriptedAggValue?.discoveredServices ?? []).map(
(service) =>
({
from: { ...service.from, id: getLegacyNodeId(service.from) },
to: { ...service.to, id: getLegacyNodeId(service.to) },
} as ExitSpanDestination)
),
};
}

View file

@ -7,6 +7,7 @@
import { kqlQuery, rangeQuery, termsQuery } from '@kbn/observability-plugin/server';
import { ProcessorEvent } from '@kbn/observability-plugin/common';
import type { ServicesResponse } from '../../../common/service_map/types';
import { AGENT_NAME, SERVICE_ENVIRONMENT, SERVICE_NAME } from '../../../common/es_fields/apm';
import { environmentQuery } from '../../../common/utils/environment_query';
import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values';
@ -23,7 +24,7 @@ export async function getServiceStats({
serviceGroupKuery,
serviceName,
kuery,
}: IEnvOptions & { maxNumberOfServices: number }) {
}: IEnvOptions & { maxNumberOfServices: number }): Promise<ServicesResponse[]> {
const params = {
apm: {
events: [

View file

@ -1,26 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { ConnectionElement } from '../../../common/service_map';
import { groupResourceNodes } from './group_resource_nodes';
import expectedGroupedData from './mock_responses/group_resource_nodes_grouped.json';
import preGroupedData from './mock_responses/group_resource_nodes_pregrouped.json';
describe('groupResourceNodes', () => {
it('should group external nodes', () => {
const responseWithGroups = groupResourceNodes(
preGroupedData as { elements: ConnectionElement[] }
);
expect(responseWithGroups.elements).toHaveLength(expectedGroupedData.elements.length);
for (const element of responseWithGroups.elements) {
const expectedElement = expectedGroupedData.elements.find(
({ data: { id } }: { data: { id: string } }) => id === element.data.id
)!;
expect(element).toMatchObject(expectedElement);
}
});
});

View file

@ -1,157 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { compact, groupBy } from 'lodash';
import type { ValuesType } from 'utility-types';
import { SPAN_TYPE, SPAN_SUBTYPE } from '../../../common/es_fields/apm';
import type {
ConnectionEdge,
ConnectionElement,
ConnectionNode,
} from '../../../common/service_map';
import { isSpanGroupingSupported } from '../../../common/service_map';
const MINIMUM_GROUP_SIZE = 4;
type GroupedConnection = ConnectionNode | ConnectionEdge;
interface GroupedNode {
data: {
id: string;
'span.type': string;
label: string;
groupedConnections: GroupedConnection[];
};
}
interface GroupedEdge {
data: {
id: string;
source: string;
target: string;
};
}
export interface GroupResourceNodesResponse {
elements: Array<GroupedNode | GroupedEdge | ConnectionElement>;
nodesCount: number;
}
export function groupResourceNodes(responseData: {
elements: ConnectionElement[];
}): GroupResourceNodesResponse {
type ElementDefinition = ValuesType<(typeof responseData)['elements']>;
const isEdge = (el: ElementDefinition) => Boolean(el.data.source && el.data.target);
const isNode = (el: ElementDefinition) => !isEdge(el);
const isElligibleGroupNode = (el: ElementDefinition) => {
if (isNode(el) && 'span.type' in el.data) {
return isSpanGroupingSupported(el.data[SPAN_TYPE], el.data[SPAN_SUBTYPE]);
}
return false;
};
const nodes = responseData.elements.filter(isNode);
const edges = responseData.elements.filter(isEdge);
// create adjacency list by targets
const groupNodeCandidates = responseData.elements
.filter(isElligibleGroupNode)
.map(({ data: { id } }) => id);
const adjacencyListByTargetMap = new Map<string, string[]>();
edges.forEach(({ data: { source, target } }) => {
if (groupNodeCandidates.includes(target)) {
const sources = adjacencyListByTargetMap.get(target);
if (sources) {
sources.push(source);
} else {
adjacencyListByTargetMap.set(target, [source]);
}
}
});
const adjacencyListByTarget = [...adjacencyListByTargetMap.entries()].map(
([target, sources]) => ({
target,
sources,
groupId: `resourceGroup{${sources.sort().join(';')}}`,
})
);
// group by members
const nodeGroupsById = groupBy(adjacencyListByTarget, 'groupId');
const nodeGroups = Object.keys(nodeGroupsById)
.map((id) => ({
id,
sources: nodeGroupsById[id][0].sources,
targets: nodeGroupsById[id].map(({ target }) => target),
}))
.filter(({ targets }) => targets.length > MINIMUM_GROUP_SIZE - 1);
const ungroupedEdges = [...edges];
const ungroupedNodes = [...nodes];
nodeGroups.forEach(({ sources, targets }) => {
targets.forEach((target) => {
// removes grouped nodes from original node set:
const groupedNodeIndex = ungroupedNodes.findIndex(({ data }) => data.id === target);
ungroupedNodes.splice(groupedNodeIndex, 1);
sources.forEach((source) => {
// removes edges of grouped nodes from original edge set:
const groupedEdgeIndex = ungroupedEdges.findIndex(
({ data }) => data.source === source && data.target === target
);
ungroupedEdges.splice(groupedEdgeIndex, 1);
});
});
});
// add in a composite node for each new group
const groupedNodes = nodeGroups.map(
({ id, targets }): GroupedNode => ({
data: {
id,
'span.type': 'external',
label: i18n.translate('xpack.apm.serviceMap.resourceCountLabel', {
defaultMessage: '{count} resources',
values: { count: targets.length },
}),
groupedConnections: compact(
targets.map((targetId) => {
const targetElement = nodes.find((element) => element.data.id === targetId);
if (!targetElement) {
return undefined;
}
const { data } = targetElement;
return { label: data.label || data.id, ...data };
})
),
},
})
);
// add new edges from source to new groups
const groupedEdges: Array<{
data: {
id: string;
source: string;
target: string;
};
}> = [];
nodeGroups.forEach(({ id, sources }) => {
sources.forEach((source) => {
groupedEdges.push({
data: {
id: `${source}~>${id}`,
source,
target: id,
},
});
});
});
return {
elements: [...ungroupedNodes, ...groupedNodes, ...ungroupedEdges, ...groupedEdges],
nodesCount: ungroupedNodes.length,
};
}

View file

@ -1,140 +0,0 @@
{
"elements": [
{
"data": {
"id": "opbeans-rum",
"service.environment": "test",
"service.name": "opbeans-rum",
"agent.name": "rum-js"
}
},
{
"data": {
"source": "opbeans-rum",
"target": "opbeans-node",
"id": "opbeans-rum~>opbeans-node"
}
},
{
"data": {
"id": "opbeans-node",
"service.environment": "test",
"service.name": "opbeans-node",
"agent.name": "nodejs"
}
},
{
"data": {
"source": "opbeans-node",
"target": "postgresql",
"id": "opbeans-node~>postgresql"
}
},
{
"data": {
"id": "postgresql",
"span.subtype": "postgresql",
"span.destination.service.resource": "postgresql",
"span.type": "db",
"label": "postgresql"
}
},
{
"data": {
"id": "elastic-co-rum-test",
"service.name": "elastic-co-rum-test",
"agent.name": "rum-js"
}
},
{
"data": {
"id": "elastic-co-frontend",
"service.name": "elastic-co-frontend",
"agent.name": "rum-js"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "0.cdn.example.com:443",
"id": "elastic-co-frontend~>0.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "resourceGroup{elastic-co-frontend;elastic-co-rum-test}",
"id": "elastic-co-frontend~>resourceGroup{elastic-co-frontend;elastic-co-rum-test}"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "resourceGroup{elastic-co-frontend;elastic-co-rum-test}",
"id": "elastic-co-rum-test~>resourceGroup{elastic-co-frontend;elastic-co-rum-test}"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "6.cdn.example.com:443",
"id": "elastic-co-rum-test~>6.cdn.example.com:443"
}
},
{
"data": {
"id": "resourceGroup{elastic-co-frontend;elastic-co-rum-test}",
"span.type": "external",
"label": "5 resources",
"groupedConnections": [
{
"label": "1.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "1.cdn.example.com:443"
},
{
"label": "2.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "2.cdn.example.com:443"
},
{
"label": "3.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "3.cdn.example.com:443"
},
{
"label": "4.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "4.cdn.example.com:443"
},
{
"label": "5.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "5.cdn.example.com:443"
}
]
}
},
{
"data": {
"id": "0.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "0.cdn.example.com:443"
}
},
{
"data": {
"id": "6.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "6.cdn.example.com:443"
}
}
]
}

View file

@ -1,204 +0,0 @@
{
"elements": [
{
"data": {
"id": "opbeans-rum",
"service.environment": "test",
"service.name": "opbeans-rum",
"agent.name": "rum-js"
}
},
{
"data": {
"source": "opbeans-rum",
"target": "opbeans-node",
"id": "opbeans-rum~>opbeans-node"
}
},
{
"data": {
"id": "opbeans-node",
"service.environment": "test",
"service.name": "opbeans-node",
"agent.name": "nodejs"
}
},
{
"data": {
"source": "opbeans-node",
"target": "postgresql",
"id": "opbeans-node~>postgresql"
}
},
{
"data": {
"id": "postgresql",
"span.subtype": "postgresql",
"span.destination.service.resource": "postgresql",
"span.type": "db",
"label": "postgresql"
}
},
{
"data": {
"id": "elastic-co-rum-test",
"service.name": "elastic-co-rum-test",
"agent.name": "rum-js"
}
},
{
"data": {
"id": "elastic-co-frontend",
"service.name": "elastic-co-frontend",
"agent.name": "rum-js"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "0.cdn.example.com:443",
"id": "elastic-co-frontend~>0.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "1.cdn.example.com:443",
"id": "elastic-co-frontend~>1.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "2.cdn.example.com:443",
"id": "elastic-co-frontend~>2.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "3.cdn.example.com:443",
"id": "elastic-co-frontend~>3.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "4.cdn.example.com:443",
"id": "elastic-co-frontend~>4.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-frontend",
"target": "5.cdn.example.com:443",
"id": "elastic-co-frontend~>5.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "1.cdn.example.com:443",
"id": "elastic-co-rum-test~>1.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "2.cdn.example.com:443",
"id": "elastic-co-rum-test~>2.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "3.cdn.example.com:443",
"id": "elastic-co-rum-test~>3.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "4.cdn.example.com:443",
"id": "elastic-co-rum-test~>4.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "5.cdn.example.com:443",
"id": "elastic-co-rum-test~>5.cdn.example.com:443"
}
},
{
"data": {
"source": "elastic-co-rum-test",
"target": "6.cdn.example.com:443",
"id": "elastic-co-rum-test~>6.cdn.example.com:443"
}
},
{
"data": {
"id": "0.cdn.example.com:443",
"label": "0.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "0.cdn.example.com:443"
}
},
{
"data": {
"id": "1.cdn.example.com:443",
"label": "1.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "1.cdn.example.com:443"
}
},
{
"data": {
"id": "2.cdn.example.com:443",
"label": "2.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "2.cdn.example.com:443"
}
},
{
"data": {
"id": "3.cdn.example.com:443",
"label": "3.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "3.cdn.example.com:443"
}
},
{
"data": {
"id": "4.cdn.example.com:443",
"label": "4.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "4.cdn.example.com:443"
}
},
{
"data": {
"id": "5.cdn.example.com:443",
"label": "5.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "5.cdn.example.com:443"
}
},
{
"data": {
"id": "6.cdn.example.com:443",
"label": "6.cdn.example.com:443",
"span.type": "external",
"span.subtype": "http",
"span.destination.service.resource": "6.cdn.example.com:443"
}
}
]
}

View file

@ -8,8 +8,10 @@
import Boom from '@hapi/boom';
import * as t from 'io-ts';
import { apmServiceGroupMaxNumberOfServices } from '@kbn/observability-plugin/common';
import { toBooleanRt } from '@kbn/io-ts-utils';
import type { ServiceMapResponse } from '../../../common/service_map';
import { isActivePlatinumLicense } from '../../../common/license_check';
import { invalidLicenseMessage } from '../../../common/service_map';
import { invalidLicenseMessage } from '../../../common/service_map/utils';
import { notifyFeatureUsage } from '../../feature';
import { getSearchTransactionsEvents } from '../../lib/helpers/transactions';
import { getMlClient } from '../../lib/helpers/get_ml_client';
@ -23,7 +25,6 @@ import { environmentRt, rangeRt, kueryRt } from '../default_api_types';
import { getServiceGroup } from '../service_groups/get_service_group';
import { offsetRt } from '../../../common/comparison_rt';
import { getApmEventClient } from '../../lib/helpers/get_apm_event_client';
import type { ServiceMapResponse } from './get_service_map';
const serviceMapRoute = createApmServerRoute({
endpoint: 'GET /internal/apm/service-map',
@ -33,6 +34,7 @@ const serviceMapRoute = createApmServerRoute({
serviceName: t.string,
serviceGroup: t.string,
kuery: kueryRt.props.kuery,
useV2: toBooleanRt,
}),
environmentRt,
rangeRt,
@ -56,13 +58,14 @@ const serviceMapRoute = createApmServerRoute({
});
const {
query: { serviceName, serviceGroup: serviceGroupId, environment, start, end, kuery },
query: { serviceName, serviceGroup: serviceGroupId, environment, start, end, kuery, useV2 },
} = params;
const {
savedObjects: { client: savedObjectsClient },
uiSettings: { client: uiSettingsClient },
} = await context.core;
const [mlClient, apmEventClient, serviceGroup, maxNumberOfServices] = await Promise.all([
getMlClient(resources),
getApmEventClient(resources),
@ -82,6 +85,7 @@ const serviceMapRoute = createApmServerRoute({
end,
kuery,
});
return getServiceMap({
mlClient,
config,
@ -95,6 +99,7 @@ const serviceMapRoute = createApmServerRoute({
maxNumberOfServices,
serviceGroupKuery: serviceGroup?.kuery,
kuery,
useV2,
});
},
});

View file

@ -1,242 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { ServiceHealthStatus } from '../../../common/service_health_status';
import {
AGENT_NAME,
SERVICE_ENVIRONMENT,
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_SUBTYPE,
SPAN_TYPE,
} from '../../../common/es_fields/apm';
import type { ServiceMapResponse } from './transform_service_map_responses';
import { transformServiceMapResponses } from './transform_service_map_responses';
const nodejsService = {
[SERVICE_NAME]: 'opbeans-node',
[SERVICE_ENVIRONMENT]: 'production',
[AGENT_NAME]: 'nodejs',
};
const nodejsExternal = {
[SPAN_DESTINATION_SERVICE_RESOURCE]: 'opbeans-node',
[SPAN_TYPE]: 'external',
[SPAN_SUBTYPE]: 'aa',
};
const kafkaExternal = {
[SPAN_DESTINATION_SERVICE_RESOURCE]: 'kafka/some-queue',
[SPAN_TYPE]: 'messaging',
[SPAN_SUBTYPE]: 'kafka',
};
const javaService = {
[SERVICE_NAME]: 'opbeans-java',
[SERVICE_ENVIRONMENT]: 'production',
[AGENT_NAME]: 'java',
};
const anomalies = {
mlJobIds: ['apm-test-1234-ml-module-name'],
serviceAnomalies: [
{
serviceName: 'opbeans-test',
transactionType: 'request',
actualValue: 10000,
anomalyScore: 50,
jobId: 'apm-test-1234-ml-module-name',
healthStatus: ServiceHealthStatus.warning,
},
],
};
describe('transformServiceMapResponses', () => {
it('maps external destinations to internal services', () => {
const response: ServiceMapResponse = {
services: [nodejsService, javaService],
discoveredServices: [
{
from: nodejsExternal,
to: nodejsService,
},
],
connections: [
{
source: javaService,
destination: nodejsExternal,
},
],
anomalies,
tracesCount: 10,
};
const { elements } = transformServiceMapResponses({ response });
const connection = elements.find(
(element) => 'source' in element.data && 'target' in element.data
);
expect(connection).toHaveProperty('data');
expect(connection?.data).toHaveProperty('target');
if (connection?.data && 'target' in connection.data) {
expect(connection.data.target).toBe('opbeans-node');
}
expect(elements.find((element) => element.data.id === '>opbeans-node')).toBeUndefined();
});
it('adds connection for messaging-based external destinations', () => {
const response: ServiceMapResponse = {
services: [nodejsService, javaService],
discoveredServices: [
{
from: kafkaExternal,
to: nodejsService,
},
],
connections: [
{
source: javaService,
destination: kafkaExternal,
},
],
anomalies,
tracesCount: 10,
};
const { elements } = transformServiceMapResponses({ response });
expect(elements.length).toBe(5);
const connections = elements.filter(
(element) => 'source' in element.data && 'target' in element.data
);
expect(connections.length).toBe(2);
const sendMessageConnection = connections.find(
(element) => 'source' in element.data && element.data.source === 'opbeans-java'
);
expect(sendMessageConnection).toHaveProperty('data');
expect(sendMessageConnection?.data).toHaveProperty('target');
if (sendMessageConnection?.data && 'target' in sendMessageConnection.data) {
expect(sendMessageConnection.data.target).toBe('>kafka/some-queue');
expect(sendMessageConnection.data.id).toBe('opbeans-java~>kafka/some-queue');
}
const receiveMessageConnection = connections.find(
(element) => 'target' in element.data && element.data.target === 'opbeans-node'
);
expect(receiveMessageConnection).toHaveProperty('data');
expect(receiveMessageConnection?.data).toHaveProperty('target');
if (receiveMessageConnection?.data && 'source' in receiveMessageConnection.data) {
expect(receiveMessageConnection.data.source).toBe('>kafka/some-queue');
expect(receiveMessageConnection.data.id).toBe('>kafka/some-queue~opbeans-node');
}
});
it('collapses external destinations based on span.destination.resource.name', () => {
const response: ServiceMapResponse = {
services: [nodejsService, javaService],
discoveredServices: [
{
from: nodejsExternal,
to: nodejsService,
},
],
connections: [
{
source: javaService,
destination: nodejsExternal,
},
{
source: javaService,
destination: {
...nodejsExternal,
[SPAN_TYPE]: 'foo',
},
},
],
anomalies,
tracesCount: 10,
};
const { elements } = transformServiceMapResponses({ response });
const connections = elements.filter((element) => 'source' in element.data);
expect(connections.length).toBe(1);
const nodes = elements.filter((element) => !('source' in element.data));
expect(nodes.length).toBe(2);
});
it('picks the first span.type/subtype in an alphabetically sorted list', () => {
const response: ServiceMapResponse = {
services: [javaService],
discoveredServices: [],
connections: [
{
source: javaService,
destination: nodejsExternal,
},
{
source: javaService,
destination: {
...nodejsExternal,
[SPAN_TYPE]: 'foo',
},
},
{
source: javaService,
destination: {
...nodejsExternal,
[SPAN_SUBTYPE]: 'bb',
},
},
],
anomalies,
tracesCount: 10,
};
const { elements } = transformServiceMapResponses({ response });
const nodes = elements.filter((element) => !('source' in element.data));
const nodejsNode = nodes.find((node) => node.data.id === '>opbeans-node');
// @ts-expect-error
expect(nodejsNode?.data[SPAN_TYPE]).toBe('external');
// @ts-expect-error
expect(nodejsNode?.data[SPAN_SUBTYPE]).toBe('aa');
});
it('processes connections without a matching "service" aggregation', () => {
const response: ServiceMapResponse = {
services: [javaService],
discoveredServices: [],
connections: [
{
source: javaService,
destination: nodejsService,
},
],
anomalies,
tracesCount: 10,
};
const { elements } = transformServiceMapResponses({ response });
expect(elements.length).toBe(3);
});
});

View file

@ -1,278 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { sortBy, pickBy, identity } from 'lodash';
import type { ValuesType } from 'utility-types';
import {
SERVICE_NAME,
SPAN_DESTINATION_SERVICE_RESOURCE,
SPAN_TYPE,
SPAN_SUBTYPE,
} from '../../../common/es_fields/apm';
import type {
Connection,
ConnectionNode,
ServiceConnectionNode,
ExternalConnectionNode,
ConnectionElement,
} from '../../../common/service_map';
import type { ConnectionsResponse, ServicesResponse } from './get_service_map';
import type { ServiceAnomaliesResponse } from './get_service_anomalies';
import type { GroupResourceNodesResponse } from './group_resource_nodes';
import { groupResourceNodes } from './group_resource_nodes';
function getConnectionNodeId(node: ConnectionNode): string {
if ('span.destination.service.resource' in node) {
// use a prefix to distinguish exernal destination ids from services
return `>${node[SPAN_DESTINATION_SERVICE_RESOURCE]}`;
}
return node[SERVICE_NAME];
}
export function getConnectionId(connection: Connection) {
return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId(connection.destination)}`;
}
function addMessagingConnections(
connections: Connection[],
discoveredServices: Array<{
from: ExternalConnectionNode;
to: ServiceConnectionNode;
}>
): Connection[] {
const messagingDestinations = connections
.map((connection) => connection.destination)
.filter(
(dest) => dest['span.type'] === 'messaging' && SPAN_DESTINATION_SERVICE_RESOURCE in dest
);
const newConnections = messagingDestinations
.map((node) => {
const matchedService = discoveredServices.find(
({ from }) =>
node[SPAN_DESTINATION_SERVICE_RESOURCE] === from[SPAN_DESTINATION_SERVICE_RESOURCE]
)?.to;
if (matchedService) {
return {
source: node,
destination: matchedService,
};
}
return undefined;
})
.filter((c) => c !== undefined) as Connection[];
return [...connections, ...newConnections];
}
export function getAllNodes(
services: ServiceMapResponse['services'],
connections: ServiceMapResponse['connections']
) {
// Derive the rest of the map nodes from the connections and add the services
// from the services data query
const allNodes: ConnectionNode[] = connections
.flatMap((connection) => [connection.source, connection.destination])
.map((node) => ({ ...node, id: getConnectionNodeId(node) }))
.concat(
services.map((service) => ({
...service,
id: service[SERVICE_NAME],
}))
);
return allNodes;
}
export function getServiceNodes(
allNodes: ConnectionNode[],
discoveredServices: Array<{
from: ExternalConnectionNode;
to: ServiceConnectionNode;
}>
) {
const connectionFromDiscoveredServices = discoveredServices
.filter(({ from, to }) => {
return (
allNodes.some((node) => node.id === getConnectionNodeId(from)) &&
!allNodes.some((node) => node.id === to[SERVICE_NAME])
);
})
.map(({ to }) => ({ ...to, id: getConnectionNodeId(to) }));
// List of nodes that are services
const serviceNodes = [...allNodes, ...connectionFromDiscoveredServices].filter(
(node) => SERVICE_NAME in node
) as ServiceConnectionNode[];
return serviceNodes;
}
export type ServiceMapResponse = ConnectionsResponse & {
services: ServicesResponse;
anomalies: ServiceAnomaliesResponse;
};
export type TransformServiceMapResponse = GroupResourceNodesResponse;
export function transformServiceMapResponses({
response,
}: {
response: ServiceMapResponse;
}): TransformServiceMapResponse {
const { discoveredServices, services, connections, anomalies } = response;
const allConnections = addMessagingConnections(connections, discoveredServices);
const allNodes = getAllNodes(services, allConnections);
const serviceNodes = getServiceNodes(allNodes, discoveredServices);
// List of nodes that are externals
const externalNodes = Array.from(
new Set(
allNodes.filter(
(node) => SPAN_DESTINATION_SERVICE_RESOURCE in node
) as ExternalConnectionNode[]
)
);
// 1. Map external nodes to internal services
// 2. Collapse external nodes into one node based on span.destination.service.resource
// 3. Pick the first available span.type/span.subtype in an alphabetically sorted list
const nodeMap = allNodes.reduce((map, node) => {
if (!node.id || map[node.id]) {
return map;
}
const outboundConnectionExists = allConnections.some(
(con) =>
SPAN_DESTINATION_SERVICE_RESOURCE in con.source &&
con.source[SPAN_DESTINATION_SERVICE_RESOURCE] === node[SPAN_DESTINATION_SERVICE_RESOURCE]
);
const matchedService = discoveredServices.find(({ from }) => {
if (!outboundConnectionExists && SPAN_DESTINATION_SERVICE_RESOURCE in node) {
return node[SPAN_DESTINATION_SERVICE_RESOURCE] === from[SPAN_DESTINATION_SERVICE_RESOURCE];
}
return false;
})?.to;
let serviceName: string | undefined = matchedService?.[SERVICE_NAME];
if (!serviceName && 'service.name' in node) {
serviceName = node[SERVICE_NAME];
}
const matchedServiceNodes = serviceNodes
.filter((serviceNode) => serviceNode[SERVICE_NAME] === serviceName)
.map((serviceNode) => pickBy(serviceNode, identity));
const mergedServiceNode = Object.assign({}, ...matchedServiceNodes);
const serviceAnomalyStats = serviceName
? anomalies.serviceAnomalies.find((item) => item.serviceName === serviceName)
: undefined;
if (matchedServiceNodes.length) {
return {
...map,
[node.id]: {
id: matchedServiceNodes[0][SERVICE_NAME],
...mergedServiceNode,
...(serviceAnomalyStats ? { serviceAnomalyStats } : null),
},
};
}
const allMatchedExternalNodes = externalNodes.filter((n) => n.id === node.id);
const firstMatchedNode = allMatchedExternalNodes[0];
return {
...map,
[node.id]: {
...firstMatchedNode,
label: firstMatchedNode[SPAN_DESTINATION_SERVICE_RESOURCE],
[SPAN_TYPE]: allMatchedExternalNodes.map((n) => n[SPAN_TYPE]).sort()[0],
[SPAN_SUBTYPE]: allMatchedExternalNodes.map((n) => n[SPAN_SUBTYPE]).sort()[0],
},
};
}, {} as Record<string, ConnectionNode>);
// Map destination.address to service.name if possible
function getConnectionNode(node: ConnectionNode) {
return nodeMap[getConnectionNodeId(node)];
}
// Build connections with mapped nodes
const mappedConnections = allConnections
.map((connection) => {
const sourceData = getConnectionNode(connection.source);
const targetData = getConnectionNode(connection.destination);
const label =
sourceData[SERVICE_NAME] +
' to ' +
(targetData[SERVICE_NAME] || targetData[SPAN_DESTINATION_SERVICE_RESOURCE]);
return {
source: sourceData.id,
target: targetData.id,
label,
id: getConnectionId({ source: sourceData, destination: targetData }),
sourceData,
targetData,
};
})
.filter((connection) => connection.source !== connection.target);
const nodes = mappedConnections
.flatMap((connection) => [connection.sourceData, connection.targetData])
.concat(serviceNodes);
const dedupedNodes: typeof nodes = [];
nodes.forEach((node) => {
if (!dedupedNodes.find((dedupedNode) => node.id === dedupedNode.id)) {
dedupedNodes.push(node);
}
});
type ConnectionWithId = ValuesType<typeof mappedConnections>;
const connectionsById = mappedConnections.reduce((connectionMap, connection) => {
connectionMap[connection.id] = connection;
return connectionMap;
}, {} as Record<string, ConnectionWithId>);
// Instead of adding connections in two directions,
// we add a `bidirectional` flag to use in styling
const dedupedConnections = (
sortBy(
Object.values(connectionsById),
// make sure that order is stable
'id'
) as ConnectionWithId[]
).reduce<Array<ConnectionWithId & { bidirectional?: boolean; isInverseEdge?: boolean }>>(
(prev, connection) => {
const reversedConnection = prev.find(
(c) => c.target === connection.source && c.source === connection.target
);
if (reversedConnection) {
reversedConnection.bidirectional = true;
return prev.concat({
...connection,
isInverseEdge: true,
});
}
return prev.concat(connection);
},
[]
);
// Put everything together in elements, with everything in the "data" property
const elements: ConnectionElement[] = [...dedupedConnections, ...dedupedNodes].map((element) => ({
data: element,
}));
return groupResourceNodes({ elements });
}

View file

@ -136,7 +136,7 @@
"@kbn/key-value-metadata-table",
"@kbn/event-stacktrace",
"@kbn/response-ops-rule-form",
"@kbn/fields-metadata-plugin"
"@kbn/fields-metadata-plugin",
],
"exclude": ["target/**/*"]
}

View file

@ -9,9 +9,15 @@ import type { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import expect from 'expect';
import { serviceMap, timerange } from '@kbn/apm-synthtrace-client';
import { Readable } from 'node:stream';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import type { SupertestReturnType } from '../../../../../../apm_api_integration/common/apm_api_supertest';
import type { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
import {
extractExitSpansConnections,
getElements,
getIds,
getSpans,
partitionElements,
} from './utils';
type DependencyResponse = SupertestReturnType<'GET /internal/apm/service-map/dependency'>;
type ServiceNodeResponse =
@ -39,7 +45,24 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
});
expect(response.status).toBe(200);
expect(response.body.elements.length).toBe(0);
expect(getElements(response).length).toBe(0);
});
it('returns an empty list (api v2)', async () => {
const response = await apmApiClient.readUser({
endpoint: `GET /internal/apm/service-map`,
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
useV2: true,
},
},
});
expect(response.status).toBe(200);
expect(getSpans(response).length).toBe(0);
});
describe('/internal/apm/service-map/service/{serviceName} without data', () => {
@ -150,7 +173,46 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
});
expect(response.status).toBe(200);
expect(response.body.elements.length).toBeGreaterThan(0);
expect(getElements(response).length).toBeGreaterThan(0);
});
it('returns service map spans (api v2)', async () => {
const response = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/service-map',
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
useV2: true,
},
},
});
const spans = getSpans(response);
const exitSpansConnections = extractExitSpansConnections(spans);
expect(response.status).toBe(200);
expect(exitSpansConnections).toEqual([
{
serviceName: 'advertService',
spanDestinationServiceResource: 'elasticsearch',
},
{
destinationService: {
serviceName: 'advertService',
},
serviceName: 'frontend-node',
spanDestinationServiceResource: 'advertService',
},
{
destinationService: {
serviceName: 'frontend-node',
},
serviceName: 'frontend-rum',
spanDestinationServiceResource: 'frontend-node',
},
]);
});
});
@ -205,7 +267,7 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
expect(status).toBe(200);
const { nodes, edges } = partitionElements(body.elements);
const { nodes, edges } = partitionElements(getElements({ body }));
expect(getIds(nodes)).toEqual([
'>elasticsearch',
@ -222,15 +284,3 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
});
});
}
type ConnectionElements = APIReturnType<'GET /internal/apm/service-map'>['elements'];
function partitionElements(elements: ConnectionElements) {
const edges = elements.filter(({ data }) => 'source' in data && 'target' in data);
const nodes = elements.filter((element) => !edges.includes(element));
return { edges, nodes };
}
function getIds(elements: ConnectionElements) {
return elements.map(({ data }) => data.id).sort();
}

View file

@ -6,13 +6,17 @@
*/
import expect from '@kbn/expect';
import { timerange, serviceMap } from '@kbn/apm-synthtrace-client';
import {
APIClientRequestParamsOf,
APIReturnType,
} from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { APIClientRequestParamsOf } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { RecursivePartial } from '@kbn/apm-plugin/typings/common';
import { ApmSynthtraceEsClient } from '@kbn/apm-synthtrace';
import type { DeploymentAgnosticFtrProviderContext } from '../../../../ftr_provider_context';
import {
extractExitSpansConnections,
getElements,
getIds,
getSpans,
partitionElements,
} from './utils';
export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderContext) {
const apmApiClient = getService('apmApi');
@ -80,7 +84,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
expect(status).to.be(200);
const { nodes, edges } = partitionElements(body.elements);
const { nodes, edges } = partitionElements(getElements({ body }));
expect(getIds(nodes)).to.eql([
'>elasticsearch',
@ -100,6 +104,58 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
]);
});
it('returns full service map when no kuery is defined (api v2)', async () => {
const { status, body } = await callApi({ query: { useV2: true } });
expect(status).to.be(200);
const spans = getSpans({ body });
const exitSpansConnections = extractExitSpansConnections(spans);
expect(exitSpansConnections).to.eql([
{
serviceName: 'synthbeans-go',
spanDestinationServiceResource: 'elasticsearch',
},
{
serviceName: 'synthbeans-go',
spanDestinationServiceResource: 'redis',
},
{
destinationService: {
serviceName: 'synthbeans-java',
},
serviceName: 'synthbeans-go',
spanDestinationServiceResource: 'synthbeans-java',
},
{
destinationService: {
serviceName: 'synthbeans-node',
},
serviceName: 'synthbeans-go',
spanDestinationServiceResource: 'synthbeans-node',
},
{
destinationService: {
serviceName: 'synthbeans-go',
},
serviceName: 'synthbeans-java',
spanDestinationServiceResource: 'synthbeans-go',
},
{
serviceName: 'synthbeans-node',
spanDestinationServiceResource: 'redis',
},
{
destinationService: {
serviceName: 'synthbeans-java',
},
serviceName: 'synthbeans-node',
spanDestinationServiceResource: 'synthbeans-java',
},
]);
});
it('returns only service nodes and connections filtered by given kuery', async () => {
const { status, body } = await callApi({
query: { kuery: `labels.name: "node-java-go-es"` },
@ -107,7 +163,7 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
expect(status).to.be(200);
const { nodes, edges } = partitionElements(body.elements);
const { nodes, edges } = partitionElements(getElements({ body }));
expect(getIds(nodes)).to.eql([
'>elasticsearch',
@ -123,15 +179,3 @@ export default function ApiTest({ getService }: DeploymentAgnosticFtrProviderCon
});
});
}
type ConnectionElements = APIReturnType<'GET /internal/apm/service-map'>['elements'];
function partitionElements(elements: ConnectionElements) {
const edges = elements.filter(({ data }) => 'source' in data && 'target' in data);
const nodes = elements.filter((element) => !edges.includes(element));
return { edges, nodes };
}
function getIds(elements: ConnectionElements) {
return elements.map(({ data }) => data.id).sort();
}

View file

@ -0,0 +1,55 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { GroupResourceNodesResponse } from '@kbn/apm-plugin/common/service_map';
import { ServiceMapSpan } from '@kbn/apm-plugin/common/service_map/types';
export function getElements({ body }: { body: APIReturnType<'GET /internal/apm/service-map'> }) {
if ('elements' in body) {
return body.elements;
}
return [];
}
export function getSpans({ body }: { body: APIReturnType<'GET /internal/apm/service-map'> }) {
if ('spans' in body) {
return body.spans;
}
return [];
}
export type ConnectionElements = GroupResourceNodesResponse['elements'];
export function partitionElements(elements: ConnectionElements) {
const edges = elements.filter(({ data }) => 'source' in data && 'target' in data);
const nodes = elements.filter((element) => !edges.includes(element));
return { edges, nodes };
}
export function getIds(elements: ConnectionElements) {
return elements.map(({ data }) => data.id).sort();
}
export function extractExitSpansConnections(spans: ServiceMapSpan[]) {
return spans.map((span) => {
const { serviceName, spanDestinationServiceResource, destinationService } = span;
return {
serviceName,
spanDestinationServiceResource,
...(destinationService
? {
destinationService: {
serviceName: destinationService.serviceName,
},
}
: undefined),
};
});
}

View file

@ -8,6 +8,7 @@
import expect from '@kbn/expect';
import { first, isEmpty, last, orderBy, uniq } from 'lodash';
import { ServiceConnectionNode } from '@kbn/apm-plugin/common/service_map';
import { APIReturnType } from '@kbn/apm-plugin/public/services/rest/create_call_apm_api';
import { ApmApiError, SupertestReturnType } from '../../common/apm_api_supertest';
import archives_metadata from '../../common/fixtures/es_archiver/archives_metadata';
import { FtrProviderContext } from '../../common/ftr_provider_context';
@ -24,6 +25,14 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
const archiveName = 'apm_8.0.0';
const metadata = archives_metadata[archiveName];
const getElements = ({ body }: { body: APIReturnType<'GET /internal/apm/service-map'> }) => {
if ('elements' in body) {
return body.elements;
}
return [];
};
registry.when('Service Map with a basic license', { config: 'basic', archives: [] }, () => {
describe('basic license', function () {
this.tags('skipFIPS');
@ -70,11 +79,11 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
it('returns service map elements', () => {
expect(response.status).to.be(200);
expect(response.body.elements.length).to.be.greaterThan(0);
expect(getElements(response).length).to.be.greaterThan(0);
});
it('returns the correct data', () => {
const elements: Array<{ data: Record<string, any> }> = response.body.elements;
const elements: Array<{ data: Record<string, any> }> = getElements(response);
const serviceNames = uniq(
elements
.filter((element) => element.data['service.name'] !== undefined)
@ -125,7 +134,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
it('returns service map elements with anomaly stats', () => {
expect(response.status).to.be(200);
const dataWithAnomalies = response.body.elements.filter(
const dataWithAnomalies = getElements(response).filter(
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).not.to.be.empty();
@ -136,7 +145,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
});
it('returns the correct anomaly stats', () => {
const dataWithAnomalies = response.body.elements.filter(
const dataWithAnomalies = getElements(response).filter(
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).not.to.be.empty();
@ -210,7 +219,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
it('returns service map elements without anomaly stats', () => {
expect(response.status).to.be(200);
const dataWithAnomalies = response.body.elements.filter(
const dataWithAnomalies = getElements(response).filter(
(el) => !isEmpty((el.data as ServiceConnectionNode).serviceAnomalyStats)
);
expect(dataWithAnomalies).to.be.empty();
@ -239,7 +248,7 @@ export default function serviceMapsApiTests({ getService }: FtrProviderContext)
});
it('returns some elements', () => {
expect(response.body.elements.length).to.be.greaterThan(1);
expect(getElements(response).length).to.be.greaterThan(1);
});
});
});