[APM] Fix service maps when root transaction has a parent.id (#212998)

fixes [212931](https://github.com/elastic/kibana/issues/212931)

## Summary

>[!WARNING]
> This can only be merged after
https://github.com/elastic/elasticsearch-serverless/pull/3579. Service
map tests running against serverless will fail until the aforementioned
PR gets merged and deployed. It should happen Thursday/Friday next week
(13/14 Feb)

Fixes a bug on the service map causing it not to build the paths when
the root transaction of the trace had a `parent.id`

Global service map
| Before | After |
|--------|------|
|<img width="599" alt="image"
src="https://github.com/user-attachments/assets/cce72dea-822b-46e2-938c-65ec3f4600da"
/>|<img width="599" alt="image"
src="https://github.com/user-attachments/assets/68b344fb-2e75-46b8-9401-9fce08bfb860"
/>|


`Ad` service map

| Before | After |
|--------|------|
|<img width="1469" alt="image"
src="https://github.com/user-attachments/assets/e960a390-4a38-43d5-9445-853ced34bb15"
/>|<img width="1459" alt="image"
src="https://github.com/user-attachments/assets/566e3cf0-3805-4bf2-a511-fffed3480332"
/>|


### How to test
- Connect to an `edge-obl` cluster
- Navigate the Application > Services inventory > Service Map
- Inspect the service map of the `Ad` service

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2025-03-10 15:57:54 +01:00 committed by GitHub
parent 4b05bbc955
commit 4a67b8b3af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 132 additions and 51 deletions

View file

@ -12,6 +12,7 @@ import { apm } from '../apm';
import { Instance } from '../apm/instance';
import { elasticsearchSpan, redisSpan, sqliteSpan, Span } from '../apm/span';
import { Transaction } from '../apm/transaction';
import { generateShortId } from '../utils/generate_id';
const ENVIRONMENT = 'Synthtrace: service_map';
@ -116,6 +117,7 @@ export interface ServiceMapOpts {
services: Array<string | { [serviceName: string]: AgentName }>;
definePaths: (services: Instance[]) => PathDef[];
environment?: string;
rootWithParent?: boolean;
}
export function serviceMap(options: ServiceMapOpts) {
@ -145,7 +147,10 @@ export function serviceMap(options: ServiceMapOpts) {
.transaction({ transactionName, transactionType: 'request' })
.timestamp(timestamp)
.duration(1000)
.children(...getChildren(tracePath, firstTraceItem.serviceInstance, timestamp, index));
.children(...getChildren(tracePath, firstTraceItem.serviceInstance, timestamp, index))
.defaults({
'parent.id': options.rootWithParent ? generateShortId() : undefined,
});
if ('transaction' in traceDef && traceDef.transaction) {
return traceDef.transaction(transaction);

View file

@ -176,90 +176,86 @@ export async function fetchServicePathsFromTraceIds({
}
def processAndReturnEvent(def context, def eventId) {
def stack = new Stack();
def reprocessQueue = new LinkedList();
// Avoid reprocessing the same event
def pathStack = new Stack();
def visited = new HashSet();
stack.push(eventId);
def event = context.eventsById.get(eventId);
while (!stack.isEmpty()) {
def currentEventId = stack.pop();
def event = context.eventsById.get(currentEventId);
if (event == null) {
return null;
}
if (event == null || context.processedEvents.get(currentEventId) != null) {
pathStack.push(eventId);
// build a stack with the path from the current event to the root
def parentId = event['parent.id'];
while (parentId != null && !parentId.equals(eventId)) {
def parent = context.eventsById.get(parentId);
if (parent == null || visited.contains(parentId)) {
break;
}
pathStack.push(parentId);
visited.add(parentId);
parentId = parent['parent.id'];
}
// pop the stack starting from the root to current event to build the path
while (!pathStack.isEmpty()) {
def currentEventId = pathStack.pop();
def currentEvent = context.eventsById.get(currentEventId);
def basePath = new ArrayList();
if (currentEvent == null || context.processedEvents.get(currentEventId) != null) {
continue;
}
visited.add(currentEventId);
def service = new HashMap();
service['service.name'] = event['service.name'];
service['service.environment'] = event['service.environment'];
service['agent.name'] = event['agent.name'];
def basePath = new ArrayList();
def parentId = event['parent.id'];
service['service.name'] = currentEvent['service.name'];
service['service.environment'] = currentEvent['service.environment'];
service['agent.name'] = currentEvent['agent.name'];
if (parentId != null && !parentId.equals(currentEventId)) {
def parent = context.processedEvents.get(parentId);
if (parent == null) {
// Only adds the parentId to the stack if it hasn't been visited to prevent infinite loop scenarios
// if the parent is null, it means it hasn't been processed yet or it could also mean that the current event
// doesn't have a parent, in which case we should skip it
if (!visited.contains(parentId)) {
stack.push(parentId);
// Add currentEventId to be reprocessed once its parent is processed
reprocessQueue.add(currentEventId);
}
continue;
}
def currentParentId = currentEvent['parent.id'];
def parent = currentParentId != null ? context.processedEvents.get(currentParentId) : null;
if (parent != null) {
// copy the path from the parent
basePath.addAll(parent.path);
// flag parent path for removal, as it has children
context.locationsToRemove.add(parent.path);
// if the parent has 'span.destination.service.resource' set, and the service is different, we've discovered a service
if (parent['span.destination.service.resource'] != null
&& parent['span.destination.service.resource'] != ""
&& (parent['service.name'] != event['service.name']
|| parent['service.environment'] != event['service.environment'])
&& (parent['service.name'] != currentEvent['service.name']
|| parent['service.environment'] != currentEvent['service.environment'])
) {
def parentDestination = getDestination(parent);
context.externalToServiceMap.put(parentDestination, service);
}
}
def lastLocation = basePath.size() > 0 ? basePath[basePath.size() - 1] : null;
def currentLocation = service;
// only add the current location to the path if it's different from the last one
if (lastLocation == null || !lastLocation.equals(currentLocation)) {
basePath.add(currentLocation);
}
// if there is an outgoing span, create a new path
if (event['span.destination.service.resource'] != null
&& !event['span.destination.service.resource'].equals("")) {
def outgoingLocation = getDestination(event);
// if there is an outgoing span, create a new path
if (currentEvent['span.destination.service.resource'] != null
&& !currentEvent['span.destination.service.resource'].equals("")) {
def outgoingLocation = getDestination(currentEvent);
def outgoingPath = new ArrayList(basePath);
outgoingPath.add(outgoingLocation);
context.paths.add(outgoingPath);
}
event.path = basePath;
context.processedEvents[currentEventId] = event;
// reprocess events which were waiting for their parents to be processed
while (!reprocessQueue.isEmpty()) {
stack.push(reprocessQueue.remove());
}
currentEvent.path = basePath;
context.processedEvents[currentEventId] = currentEvent;
}
return null;

View file

@ -9,6 +9,7 @@ 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';
@ -152,5 +153,84 @@ export default function ({ getService }: DeploymentAgnosticFtrProviderContext) {
expect(response.body.elements.length).toBeGreaterThan(0);
});
});
describe('Root transaction with parent.id', () => {
let synthtraceEsClient: ApmSynthtraceEsClient;
before(async () => {
synthtraceEsClient = await synthtrace.createApmSynthtraceEsClient();
const events = timerange(start, end)
.interval('10s')
.rate(3)
.generator(
serviceMap({
services: [
{ 'frontend-rum': 'rum-js' },
{ 'frontend-node': 'nodejs' },
{ advertService: 'java' },
],
definePaths([rum, node, adv]) {
return [
[
[rum, 'fetchAd'],
[node, 'GET /nodejs/adTag'],
[adv, 'APIRestController#getAd'],
['elasticsearch', 'GET ad-*/_search'],
],
];
},
rootWithParent: true,
})
);
return synthtraceEsClient.index(Readable.from(Array.from(events)));
});
after(async () => {
await synthtraceEsClient.clean();
});
it('returns service map complete path', async () => {
const { body, status } = await apmApiClient.readUser({
endpoint: 'GET /internal/apm/service-map',
params: {
query: {
start: new Date(start).toISOString(),
end: new Date(end).toISOString(),
environment: 'ENVIRONMENT_ALL',
},
},
});
expect(status).toBe(200);
const { nodes, edges } = partitionElements(body.elements);
expect(getIds(nodes)).toEqual([
'>elasticsearch',
'advertService',
'frontend-node',
'frontend-rum',
]);
expect(getIds(edges)).toEqual([
'advertService~>elasticsearch',
'frontend-node~advertService',
'frontend-rum~frontend-node',
]);
});
});
});
}
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();
}