Skip to content

Commit c1ef0c7

Browse files
[8.19] [APM] Service map support for span links (#215645) (#220198)
# Backport This will backport the following commits from `main` to `8.19`: - [[APM] Service map support for span links (#215645)](#215645) <!--- Backport version: 9.6.6 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Carlos Crespo","email":"[email protected]"},"sourceCommit":{"committedDate":"2025-03-27T16:54:59Z","message":"[APM] Service map support for span links (#215645)\n\ncloses [214771](https://github.com/elastic/kibana/issues/214771)\npart of: https://github.com/elastic/kibana/issues/109209\n\n## Summary\n\nAdds support for span links to the service map. This includes both\nelastic APM and Otel data sources\n\nOn the examples below, the connection between `checkoutservice` and\n`accountingservice` is done via `kafka/orders`, which contains creates a\nspan link between these 2 services\n\n| before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/3b827119-134e-4225-91a0-ba5608bedce7\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/de0c3304-aebc-4c39-a890-841c43a50259\"\n/>|\n\n before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/14a1db6a-4c69-4683-bdca-3df66300ee1e\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/524c6332-c92d-4db1-8dff-9655135ff0a3\"\n/>|\n\n\n### How to test\n\n1. Synthtrace\n- Run `node scripts/synthtrace span_links.ts --live --uniqueIds --clean`\n - Navigate to `Applications > Service Inventory > Service map`\n\n2. Edge cluster\n- Connect to an oblt edge cluster\n- Inspect the service map for `checkoutservice` and `accountservice`\n(both otel)\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"4e3db8dd1b5993783e9f04b11019b699b92444c6","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["backport:skip","release_note:feature","Team:obs-ux-infra_services","v9.1.0","v8.19.0"],"title":"[APM] Service map support for span links","number":215645,"url":"https://github.com/elastic/kibana/pull/215645","mergeCommit":{"message":"[APM] Service map support for span links (#215645)\n\ncloses [214771](https://github.com/elastic/kibana/issues/214771)\npart of: https://github.com/elastic/kibana/issues/109209\n\n## Summary\n\nAdds support for span links to the service map. This includes both\nelastic APM and Otel data sources\n\nOn the examples below, the connection between `checkoutservice` and\n`accountingservice` is done via `kafka/orders`, which contains creates a\nspan link between these 2 services\n\n| before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/3b827119-134e-4225-91a0-ba5608bedce7\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/de0c3304-aebc-4c39-a890-841c43a50259\"\n/>|\n\n before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/14a1db6a-4c69-4683-bdca-3df66300ee1e\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/524c6332-c92d-4db1-8dff-9655135ff0a3\"\n/>|\n\n\n### How to test\n\n1. Synthtrace\n- Run `node scripts/synthtrace span_links.ts --live --uniqueIds --clean`\n - Navigate to `Applications > Service Inventory > Service map`\n\n2. Edge cluster\n- Connect to an oblt edge cluster\n- Inspect the service map for `checkoutservice` and `accountservice`\n(both otel)\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"4e3db8dd1b5993783e9f04b11019b699b92444c6"}},"sourceBranch":"main","suggestedTargetBranches":["8.19"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/215645","number":215645,"mergeCommit":{"message":"[APM] Service map support for span links (#215645)\n\ncloses [214771](https://github.com/elastic/kibana/issues/214771)\npart of: https://github.com/elastic/kibana/issues/109209\n\n## Summary\n\nAdds support for span links to the service map. This includes both\nelastic APM and Otel data sources\n\nOn the examples below, the connection between `checkoutservice` and\n`accountingservice` is done via `kafka/orders`, which contains creates a\nspan link between these 2 services\n\n| before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/3b827119-134e-4225-91a0-ba5608bedce7\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/de0c3304-aebc-4c39-a890-841c43a50259\"\n/>|\n\n before | after |\n|-------|-------|\n|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/14a1db6a-4c69-4683-bdca-3df66300ee1e\"\n/>|<img width=\"800\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/524c6332-c92d-4db1-8dff-9655135ff0a3\"\n/>|\n\n\n### How to test\n\n1. Synthtrace\n- Run `node scripts/synthtrace span_links.ts --live --uniqueIds --clean`\n - Navigate to `Applications > Service Inventory > Service map`\n\n2. Edge cluster\n- Connect to an oblt edge cluster\n- Inspect the service map for `checkoutservice` and `accountservice`\n(both otel)\n\n---------\n\nCo-authored-by: Elastic Machine <[email protected]>","sha":"4e3db8dd1b5993783e9f04b11019b699b92444c6"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}] BACKPORT--> Co-authored-by: Elastic Machine <[email protected]>
1 parent 10ecc27 commit c1ef0c7

File tree

9 files changed

+980
-294
lines changed

9 files changed

+980
-294
lines changed

src/platform/packages/shared/kbn-apm-synthtrace-client/src/lib/apm/span.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,16 @@ export function sqliteSpan(spanName: string, statement?: string): SpanParams {
130130
'span.destination.service.resource': spanSubtype,
131131
};
132132
}
133+
export function kafkaSpan(spanName: string): SpanParams {
134+
const spanType = 'messaging';
135+
const spanSubtype = 'kafka';
136+
137+
return {
138+
spanName,
139+
spanType,
140+
spanSubtype,
141+
};
142+
}
133143

134144
export function redisSpan(spanName: string): SpanParams {
135145
const spanType = 'db';

src/platform/packages/shared/kbn-apm-synthtrace-client/src/lib/dsl/service_map.ts

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import { AgentName } from '../../types/agent_names';
1111
import { apm } from '../apm';
1212
import { Instance } from '../apm/instance';
13-
import { elasticsearchSpan, redisSpan, sqliteSpan, Span } from '../apm/span';
13+
import { elasticsearchSpan, redisSpan, sqliteSpan, Span, kafkaSpan } from '../apm/span';
1414
import { Transaction } from '../apm/transaction';
1515
import { generateShortId } from '../utils/generate_id';
1616

@@ -22,28 +22,49 @@ function service(serviceName: string, agentName: AgentName, environment?: string
2222
.instance(serviceName);
2323
}
2424

25-
type DbSpan = 'elasticsearch' | 'redis' | 'sqlite';
26-
type ServiceMapNode = Instance | DbSpan;
25+
type SpanTypes = 'db' | 'app' | 'messaging' | 'external';
26+
type SpanSubtypes = 'elasticsearch' | 'redis' | 'sqlite' | 'kafka';
27+
28+
type ServiceMapNode = Instance | SpanSubtypes;
2729
type TransactionName = string;
28-
type TraceItem = ServiceMapNode | [ServiceMapNode, TransactionName];
30+
type TraceItem = ServiceMapNode | [ServiceMapNode, TransactionName, SpanTypes?];
2931
type TracePath = TraceItem[];
3032

3133
function getTraceItem(traceItem: TraceItem) {
3234
if (Array.isArray(traceItem)) {
33-
const transactionName = traceItem[1];
34-
if (typeof traceItem[0] === 'string') {
35-
const dbSpan = traceItem[0];
36-
return { dbSpan, transactionName, serviceInstance: undefined };
35+
const [spanSubTypeOrservice, transactionName, spanType] = traceItem;
36+
37+
if (typeof spanSubTypeOrservice === 'string') {
38+
return {
39+
spanSubtype: spanSubTypeOrservice,
40+
transactionName,
41+
serviceInstance: undefined,
42+
spanType,
43+
};
3744
} else {
38-
const serviceInstance = traceItem[0];
39-
return { dbSpan: undefined, transactionName, serviceInstance };
45+
return {
46+
spanSubtype: undefined,
47+
transactionName,
48+
serviceInstance: spanSubTypeOrservice,
49+
spanType,
50+
};
4051
}
4152
} else if (typeof traceItem === 'string') {
42-
const dbSpan = traceItem;
43-
return { dbSpan, transactionName: undefined, serviceInstance: undefined };
53+
const spanSubtype = traceItem;
54+
return {
55+
spanSubtype,
56+
transactionName: undefined,
57+
serviceInstance: undefined,
58+
spanType: undefined,
59+
};
4460
} else {
4561
const serviceInstance = traceItem;
46-
return { dbSpan: undefined, transactionName: undefined, serviceInstance };
62+
return {
63+
spanSubtype: undefined,
64+
transactionName: undefined,
65+
serviceInstance,
66+
spanType: undefined,
67+
};
4768
}
4869
}
4970

@@ -65,9 +86,9 @@ function getChildren(
6586
return [];
6687
}
6788
const [first, ...rest] = childTraceItems;
68-
const { dbSpan, serviceInstance, transactionName } = getTraceItem(first);
69-
if (dbSpan) {
70-
switch (dbSpan) {
89+
const { spanSubtype, serviceInstance, transactionName, spanType } = getTraceItem(first);
90+
if (spanSubtype) {
91+
switch (spanSubtype) {
7192
case 'elasticsearch':
7293
return [
7394
parentServiceInstance
@@ -89,6 +110,13 @@ function getChildren(
89110
.timestamp(timestamp)
90111
.duration(1000),
91112
];
113+
case 'kafka':
114+
return [
115+
parentServiceInstance
116+
.span(kafkaSpan(transactionName || 'received'))
117+
.timestamp(timestamp)
118+
.duration(1000),
119+
];
92120
}
93121
}
94122

@@ -106,7 +134,7 @@ function getChildren(
106134
if (next.serviceInstance) {
107135
return [
108136
childSpan
109-
.overrides({ 'span.type': 'external' })
137+
.overrides({ 'span.type': spanType ?? 'external' })
110138
.destination(next.serviceInstance.fields['service.name']!)
111139
.children(
112140
next.serviceInstance

src/platform/packages/shared/kbn-apm-synthtrace/src/scenarios/span_links.ts

Lines changed: 90 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99

1010
import { compact, shuffle } from 'lodash';
1111
import { Readable } from 'stream';
12-
import { apm, ApmFields, generateLongId, generateShortId } from '@kbn/apm-synthtrace-client';
12+
import {
13+
apm,
14+
ApmFields,
15+
generateLongId,
16+
generateShortId,
17+
Serializable,
18+
} from '@kbn/apm-synthtrace-client';
1319
import { Scenario } from '../cli/scenario';
1420
import { getSynthtraceEnvironment } from '../lib/utils/get_synthtrace_environment';
1521
import { withClient } from '../lib/utils/with_client';
@@ -32,90 +38,105 @@ function getSpanLinksFromEvents(events: ApmFields[]) {
3238
);
3339
}
3440

35-
const scenario: Scenario<ApmFields> = async () => {
41+
const scenario: Scenario<ApmFields> = async ({ logger }) => {
3642
return {
3743
generate: ({ range, clients: { apmEsClient } }) => {
38-
const producerInternalOnlyInstance = apm
44+
const producerTimestamps = range.ratePerMinute(1);
45+
const producerConsumerTimestamps = range.ratePerMinute(1);
46+
const consumerTimestamps = range.ratePerMinute(1);
3947

48+
const producerInternalOnlyInstance = apm
4049
.service({ name: 'producer-internal-only', environment: ENVIRONMENT, agentName: 'go' })
4150
.instance('instance-a');
42-
const producerInternalOnlyEvents = range
43-
.interval('1m')
44-
.rate(1)
45-
.generator((timestamp) => {
46-
return producerInternalOnlyInstance
47-
.transaction({ transactionName: 'Transaction A' })
48-
.timestamp(timestamp)
49-
.duration(1000)
50-
.success()
51-
.children(
52-
producerInternalOnlyInstance
53-
.span({ spanName: 'Span A', spanType: 'custom' })
54-
.timestamp(timestamp + 50)
55-
.duration(100)
56-
.success()
57-
);
58-
});
59-
60-
const spanASpanLink = getSpanLinksFromEvents(
61-
Array.from(producerInternalOnlyEvents).flatMap((event) => event.serialize())
62-
);
6351

6452
const producerConsumerInstance = apm
6553
.service({ name: 'producer-consumer', environment: ENVIRONMENT, agentName: 'java' })
6654
.instance('instance-b');
67-
const producerConsumerEvents = range
68-
.interval('1m')
69-
.rate(1)
70-
.generator((timestamp) => {
71-
return producerConsumerInstance
72-
.transaction({ transactionName: 'Transaction B' })
73-
.timestamp(timestamp)
74-
.duration(1000)
75-
.success()
76-
.children(
77-
producerConsumerInstance
78-
.span({ spanName: 'Span B', spanType: 'external' })
79-
.defaults({
80-
'span.links': shuffle([...generateExternalSpanLinks(), ...spanASpanLink]),
81-
})
82-
.timestamp(timestamp + 50)
83-
.duration(900)
84-
.success()
85-
);
86-
});
87-
88-
const producerConsumerApmFields = Array.from(producerConsumerEvents).flatMap((event) =>
89-
event.serialize()
90-
);
91-
92-
const spanBSpanLink = getSpanLinksFromEvents(producerConsumerApmFields);
9355

9456
const consumerInstance = apm
9557
.service({ name: 'consumer', environment: ENVIRONMENT, agentName: 'ruby' })
9658
.instance('instance-c');
97-
const consumerEvents = range
98-
.interval('1m')
99-
.rate(1)
100-
.generator((timestamp) => {
101-
return consumerInstance
102-
.transaction({ transactionName: 'Transaction C' })
103-
.timestamp(timestamp)
104-
.duration(1000)
105-
.success()
106-
.children(
107-
consumerInstance
108-
.span({ spanName: 'Span C', spanType: 'external' })
109-
.defaults({ 'span.links': spanBSpanLink })
110-
.timestamp(timestamp + 50)
111-
.duration(900)
112-
.success()
113-
);
114-
});
59+
60+
const producerInternalOnlyEvents = producerTimestamps.generator((timestamp) =>
61+
producerInternalOnlyInstance
62+
.transaction({ transactionName: 'Transaction A' })
63+
.timestamp(timestamp)
64+
.duration(1000)
65+
.success()
66+
.children(
67+
producerInternalOnlyInstance
68+
.span({ spanName: 'Span A', spanType: 'messaging', spanSubtype: 'kafka' })
69+
.timestamp(timestamp)
70+
.duration(100)
71+
.success()
72+
)
73+
);
74+
75+
const serializedProducerInternalOnlyEvents = Array.from(producerInternalOnlyEvents).flatMap(
76+
(event) => event.serialize()
77+
);
78+
79+
const unserializedProducerInternalOnlyEvents = serializedProducerInternalOnlyEvents.map(
80+
(event) => ({
81+
fields: event,
82+
serialize: () => {
83+
return [event];
84+
},
85+
})
86+
) as Array<Serializable<ApmFields>>;
87+
88+
const producerConsumerEvents = producerConsumerTimestamps.generator((timestamp) =>
89+
producerConsumerInstance
90+
.transaction({ transactionName: 'Transaction B' })
91+
.timestamp(timestamp)
92+
.duration(1000)
93+
.success()
94+
.children(
95+
producerConsumerInstance
96+
.span({ spanName: 'Span B', spanType: 'messaging', spanSubtype: 'kafka' })
97+
.defaults({
98+
'span.links': shuffle([
99+
...generateExternalSpanLinks(),
100+
...getSpanLinksFromEvents(serializedProducerInternalOnlyEvents),
101+
]),
102+
})
103+
.timestamp(timestamp)
104+
.duration(900)
105+
.success()
106+
)
107+
);
108+
109+
const serializedproducerConsumerEvents = Array.from(producerConsumerEvents).flatMap((event) =>
110+
event.serialize()
111+
);
112+
113+
const unserializedproducerConsumerEvents = serializedproducerConsumerEvents.map((event) => ({
114+
fields: event,
115+
serialize: () => {
116+
return [event];
117+
},
118+
})) as Array<Serializable<ApmFields>>;
119+
120+
const consumerEvents = consumerTimestamps.generator((timestamp) =>
121+
consumerInstance
122+
.transaction({ transactionName: 'Transaction C' })
123+
.timestamp(timestamp)
124+
.defaults({
125+
'span.links': getSpanLinksFromEvents(serializedproducerConsumerEvents),
126+
})
127+
.duration(1000)
128+
.success()
129+
);
115130

116131
return withClient(
117132
apmEsClient,
118-
Readable.from(Array.from(producerInternalOnlyEvents).concat(Array.from(consumerEvents)))
133+
logger.perf('generating_span_links', () =>
134+
Readable.from([
135+
...unserializedProducerInternalOnlyEvents,
136+
...unserializedproducerConsumerEvents,
137+
...Array.from(consumerEvents),
138+
])
139+
)
119140
);
120141
},
121142
};

x-pack/solutions/observability/plugins/apm/common/service_map/get_service_map_nodes.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ describe('getServiceMapNodes', () => {
114114
expect(getIds(edges)).toEqual(['opbeans-java~opbeans-node']);
115115
});
116116

117-
it('adds connection for messaging-based external destinations', () => {
117+
it('adds connections for messaging systems', () => {
118118
const response: ServiceMapConnections = {
119119
servicesData: [
120120
getServiceConnectionNode(nodejsService),
@@ -125,12 +125,20 @@ describe('getServiceMapNodes', () => {
125125
from: getExternalConnectionNode({ ...kafkaExternal, ...javaService }),
126126
to: getServiceConnectionNode(nodejsService),
127127
},
128+
{
129+
from: getExternalConnectionNode({ ...kafkaExternal, ...javaService }),
130+
to: getServiceConnectionNode(goService),
131+
},
128132
],
129133
connections: [
130134
{
131135
source: getServiceConnectionNode(javaService),
132136
destination: getExternalConnectionNode({ ...kafkaExternal, ...javaService }),
133137
},
138+
{
139+
source: getServiceConnectionNode(javaService),
140+
destination: getExternalConnectionNode({ ...kafkaExternal, ...goService }),
141+
},
134142
],
135143
anomalies,
136144
};
@@ -139,8 +147,14 @@ describe('getServiceMapNodes', () => {
139147

140148
const { edges, nodes } = partitionElements(elements);
141149

142-
expect(getIds(nodes)).toEqual(['>kafka/some-queue', 'opbeans-java', 'opbeans-node']);
150+
expect(getIds(nodes)).toEqual([
151+
'>kafka/some-queue',
152+
'opbeans-go',
153+
'opbeans-java',
154+
'opbeans-node',
155+
]);
143156
expect(getIds(edges)).toEqual([
157+
'>kafka/some-queue~opbeans-go',
144158
'>kafka/some-queue~opbeans-node',
145159
'opbeans-java~>kafka/some-queue',
146160
]);

0 commit comments

Comments
 (0)