mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[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:
parent
000d859207
commit
a2dbf325e4
35 changed files with 1976 additions and 1322 deletions
|
@ -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];
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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?)',
|
||||
|
|
|
@ -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';
|
|
@ -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();
|
||||
}
|
|
@ -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 });
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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([
|
|
@ -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';
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -92,6 +92,7 @@ const mockCore = merge({}, coreStart, {
|
|||
const mockConfig: ConfigSchema = {
|
||||
serviceMapEnabled: true,
|
||||
ui: {
|
||||
serviceMapApiV2Enabled: false,
|
||||
enabled: false,
|
||||
},
|
||||
latestAgentVersionsUrl: '',
|
||||
|
|
|
@ -12,6 +12,7 @@ import { ApmPlugin } from './plugin';
|
|||
export interface ConfigSchema {
|
||||
serviceMapEnabled: boolean;
|
||||
ui: {
|
||||
serviceMapApiV2Enabled: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
latestAgentVersionsUrl: string;
|
||||
|
|
|
@ -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(
|
||||
[
|
||||
|
|
|
@ -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());
|
||||
}
|
|
@ -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[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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: [
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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 });
|
||||
}
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue