Propagate and forward gen_ai baggage from caller, if available

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2026-05-12 13:36:17 +02:00
parent 6209065f00
commit d6c7805527
8 changed files with 273 additions and 18 deletions
+13 -1
View File
@@ -295,10 +295,22 @@ See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn h
## Tracing
The MCP Actions Backend emits a trace span for each `tools/call` invocation via the [Tracing Service](../backend-system/core-services/tracing.md), following the [OpenTelemetry server-side MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#server). Each span uses the name `tools/call <toolname>`, server kind, and includes the standard MCP attributes (`mcp.method.name`, `gen_ai.tool.name`, `gen_ai.operation.name`). When a tool result returns `isError: true`, the span is marked with `error.type=tool_error`; thrown handler errors are recorded and the span status is set to `ERROR`.
The MCP Actions Backend emits a trace span for each `tools/call` invocation via the [Tracing Service](../backend-system/core-services/tracing.md), following the [OpenTelemetry server-side MCP semantic conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/mcp/#server). Each span uses the name `tools/call <toolname>`, server kind, and includes the standard MCP attributes (`mcp.method.name`, `gen_ai.tool.name`, `gen_ai.operation.name`). Known Backstage errors (such as `InputError` or `NotFoundError`) are caught and returned as `isError: true` tool responses — the span is marked with `error.type=tool_error` in that case. Unhandled exceptions are recorded automatically by the Tracing Service and the span status is set to `ERROR`.
In addition to those attributes, the Tracing Service automatically attaches the authenticated principal's type as `backstage.principal.type` (one of `user`, `service`, or `none`). Each `tools/call` span is also attributed to the plugin that owns the invoked action via `backstage.plugin.id` (e.g. `catalog`, `scaffolder`) — overriding the default `mcp-actions` value so tracing backends can filter activity by the source plugin rather than by the MCP transport.
### Baggage propagation
The MCP Actions routers propagate OpenTelemetry context from the incoming HTTP request headers so that trace parent and baggage survive through the MCP transport layer. The following low-cardinality identifier entries from the OpenTelemetry [`gen_ai.*` attribute registry](https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/), when set by the MCP client in baggage, are automatically forwarded as attributes on the `tools/call` span:
- `gen_ai.agent.id`
- `gen_ai.agent.name`
- `gen_ai.conversation.id`
- `gen_ai.provider.name`
- `gen_ai.request.model`
This enables tracing backends to correlate MCP tool invocations back to the originating agent, conversation, or model without additional configuration. Other `gen_ai.*` baggage entries are intentionally not forwarded — baggage may be set by arbitrary upstream callers, and a broad prefix filter would let clients smuggle high-cardinality or payload-shaped keys (e.g. `gen_ai.tool.call.result`, `gen_ai.prompt`) onto the span and bypass the [tool payload capture flag](#capturing-tool-arguments-and-results).
### Capturing the authenticated end user
The Tracing Service can additionally include the authenticated principal's identity as `enduser.id` (the user entity ref for a user principal, the service subject for a service principal). This is gated behind a backend-wide configuration flag and is **disabled by default**:
@@ -0,0 +1,80 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { tracingServiceMock } from './TracingServiceMock';
describe('tracingServiceMock', () => {
it('parses the baggage header from withPropagatedContext and exposes it via getActiveBaggage', async () => {
const tracing = tracingServiceMock.mock();
const seen = await tracing.withPropagatedContext(
{
baggage:
'gen_ai.conversation.id=conv-123, gen_ai.agent.id=agent-456;property=ignored',
},
() => {
const baggage = tracing.getActiveBaggage();
return {
conv: baggage?.getEntry('gen_ai.conversation.id')?.value,
agent: baggage?.getEntry('gen_ai.agent.id')?.value,
missing: baggage?.getEntry('gen_ai.missing'),
all: baggage?.getAllEntries().map(([k, v]) => [k, v.value]),
};
},
);
expect(seen).toEqual({
conv: 'conv-123',
agent: 'agent-456',
missing: undefined,
all: [
['gen_ai.conversation.id', 'conv-123'],
['gen_ai.agent.id', 'agent-456'],
],
});
// Baggage is scoped to the propagated callback.
expect(tracing.getActiveBaggage()).toBeUndefined();
});
it('honours mockReturnValue overrides for getActiveBaggage', async () => {
const tracing = tracingServiceMock.mock();
const override = {
getEntry: () => ({ value: 'override' }),
getAllEntries: () =>
[['gen_ai.conversation.id', { value: 'override' }]] as Array<
[string, { value: string }]
>,
};
tracing.getActiveBaggage.mockReturnValue(override);
expect(tracing.getActiveBaggage()).toBe(override);
await tracing.withPropagatedContext(
{ baggage: 'gen_ai.conversation.id=conv-from-header' },
() => {
// mockReturnValue takes precedence over the default header parsing.
expect(tracing.getActiveBaggage()).toBe(override);
},
);
});
it('returns undefined baggage when no baggage header is supplied', async () => {
const tracing = tracingServiceMock.mock();
await tracing.withPropagatedContext({ traceparent: 'whatever' }, () => {
expect(tracing.getActiveBaggage()).toBeUndefined();
});
});
});
@@ -21,12 +21,47 @@ import {
import {
TracingService,
TracingServiceAttributeValue,
TracingServiceBaggage,
TracingServiceSpan,
TracingServiceSpanStatus,
tracingServiceRef,
} from '@backstage/backend-plugin-api/alpha';
import { tracingServiceFactory } from '@backstage/backend-defaults/alpha';
// Parses the `baggage` header per the W3C Baggage member syntax,
// dropping value properties (`;property=value`). This mirrors what
// `propagation.extract` does in the real tracing service, just enough
// for tests to assert end-to-end behaviour between propagated headers
// and `getActiveBaggage()`.
function parseBaggageHeader(
headers: Record<string, string | string[] | undefined>,
): TracingServiceBaggage | undefined {
let raw: string | undefined;
for (const [name, value] of Object.entries(headers)) {
if (name.toLowerCase() !== 'baggage') continue;
raw = Array.isArray(value) ? value[0] : value;
break;
}
if (!raw) return undefined;
const entries = new Map<string, { value: string }>();
for (const segment of raw.split(',')) {
const [pair] = segment.split(';');
const eqIdx = pair.indexOf('=');
if (eqIdx === -1) continue;
const key = decodeURIComponent(pair.slice(0, eqIdx).trim());
const value = decodeURIComponent(pair.slice(eqIdx + 1).trim());
if (!key) continue;
entries.set(key, { value });
}
if (entries.size === 0) return undefined;
return {
getEntry: key => entries.get(key),
getAllEntries: () => Array.from(entries.entries()),
};
}
/**
* A jest-mocked span captured by {@link TracingServiceMock}.
*
@@ -42,6 +77,14 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan {
* `startActiveSpan` so tests can assert on the options passed in and the
* methods called on the span inside the callback.
*
* By default, `withPropagatedContext` parses the `baggage` header (W3C
* Baggage syntax) out of the supplied headers and makes those entries
* available via `getActiveBaggage` for the duration of the wrapped
* callback. Other propagation headers (e.g. `traceparent`) are ignored.
* Tests that need fully custom baggage can still override
* `getActiveBaggage` via `mockReturnValue` / `mockImplementation`, which
* takes precedence over the default behaviour.
*
* @alpha
*/
export interface TracingServiceMock extends TracingService {
@@ -79,11 +122,17 @@ export namespace tracingServiceMock {
return await fn(span);
}) as TracingServiceMock['startActiveSpan'];
const withPropagatedContext = jest.fn(async (_headers, fn) =>
fn(),
) as TracingServiceMock['withPropagatedContext'];
const baggageStack: Array<TracingServiceBaggage | undefined> = [];
const withPropagatedContext = jest.fn(async (headers, fn) => {
baggageStack.push(parseBaggageHeader(headers));
try {
return await fn();
} finally {
baggageStack.pop();
}
}) as TracingServiceMock['withPropagatedContext'];
const getActiveBaggage = jest.fn(
() => undefined,
() => baggageStack[baggageStack.length - 1],
) as TracingServiceMock['getActiveBaggage'];
const service: TracingService = {
@@ -89,6 +89,7 @@ export const mcpPlugin = createBackendPlugin({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
});
@@ -105,6 +106,7 @@ export const mcpPlugin = createBackendPlugin({
const sseRouter = createSseRouter({
mcpService,
httpAuth,
tracing,
serverConfig,
});
@@ -113,6 +115,7 @@ export const mcpPlugin = createBackendPlugin({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
});
@@ -18,6 +18,7 @@ import { Router } from 'express';
import { McpService } from '../services/McpService';
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { HttpAuthService } from '@backstage/backend-plugin-api';
import { TracingService } from '@backstage/backend-plugin-api/alpha';
import { McpServerConfig } from '../config';
/**
@@ -26,10 +27,12 @@ import { McpServerConfig } from '../config';
export const createSseRouter = ({
mcpService,
httpAuth,
tracing,
serverConfig,
}: {
mcpService: McpService;
httpAuth: HttpAuthService;
tracing: TracingService;
serverConfig?: McpServerConfig;
}): Router => {
const router = PromiseRouter();
@@ -65,7 +68,9 @@ export const createSseRouter = ({
const transport = transportsToSessionId.get(sessionId);
if (transport) {
await transport.handlePostMessage(req, res, req.body);
await tracing.withPropagatedContext(req.headers, () =>
transport.handlePostMessage(req, res, req.body),
);
} else {
res
.status(400)
@@ -21,7 +21,10 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
import { LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/sdk/types.js';
import { HttpAuthService, LoggerService } from '@backstage/backend-plugin-api';
import { toError } from '@backstage/errors';
import { MetricsService } from '@backstage/backend-plugin-api/alpha';
import {
MetricsService,
TracingService,
} from '@backstage/backend-plugin-api/alpha';
import { bucketBoundaries, McpServerSessionAttributes } from '../metrics';
import { McpServerConfig } from '../config';
@@ -30,12 +33,14 @@ export const createStreamableRouter = ({
httpAuth,
logger,
metrics,
tracing,
serverConfig,
}: {
mcpService: McpService;
logger: LoggerService;
httpAuth: HttpAuthService;
metrics: MetricsService;
tracing: TracingService;
serverConfig?: McpServerConfig;
}): Router => {
const router = PromiseRouter();
@@ -72,7 +77,9 @@ export const createStreamableRouter = ({
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
await tracing.withPropagatedContext(req.headers, () =>
transport.handleRequest(req, res, req.body),
);
res.on('close', () => {
transport.close();
@@ -1010,7 +1010,68 @@ describe('McpService', () => {
);
});
it('includes tool arguments in the span options and sets the result via setAttribute when captureToolPayloads is true', async () => {
it('includes gen_ai baggage entries as span attributes when present', async () => {
const tracing = tracingServiceMock.mock();
tracing.getActiveBaggage.mockReturnValue({
getEntry: (key: string) => {
const entries: Record<string, { value: string }> = {
'gen_ai.conversation.id': { value: 'conv-123' },
'gen_ai.agent.id': { value: 'agent-456' },
};
return entries[key];
},
getAllEntries: () => [
['gen_ai.conversation.id', { value: 'conv-123' }],
['gen_ai.agent.id', { value: 'agent-456' }],
],
});
await invokeMockAction({ tracing });
const [, , options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.conversation.id']).toBe('conv-123');
expect(options?.attributes?.['gen_ai.agent.id']).toBe('agent-456');
});
it('only forwards allowlisted baggage keys onto the span', async () => {
const tracing = tracingServiceMock.mock();
tracing.getActiveBaggage.mockReturnValue({
getEntry: (key: string) => {
const entries: Record<string, { value: string }> = {
'gen_ai.conversation.id': { value: 'conv-123' },
'gen_ai.tool.call.result': { value: 'injected-result' },
'gen_ai.prompt': { value: 'injected-prompt' },
'gen_ai.user.message': { value: 'injected-user-message' },
};
return entries[key];
},
getAllEntries: () => [
['gen_ai.conversation.id', { value: 'conv-123' }],
['gen_ai.tool.call.result', { value: 'injected-result' }],
['gen_ai.prompt', { value: 'injected-prompt' }],
['gen_ai.user.message', { value: 'injected-user-message' }],
],
});
await invokeMockAction({ tracing });
const [, , options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes?.['gen_ai.conversation.id']).toBe('conv-123');
expect(options?.attributes).not.toHaveProperty('gen_ai.tool.call.result');
expect(options?.attributes).not.toHaveProperty('gen_ai.prompt');
expect(options?.attributes).not.toHaveProperty('gen_ai.user.message');
});
it('omits gen_ai baggage attributes when no baggage is present', async () => {
const tracing = tracingServiceMock.mock();
await invokeMockAction({ tracing });
const [, , options] = tracing.startActiveSpan.mock.calls[0];
expect(options?.attributes).not.toHaveProperty('gen_ai.conversation.id');
expect(options?.attributes).not.toHaveProperty('gen_ai.agent.id');
});
it('includes tool arguments in the span options and sets the structured action output as the result attribute when captureToolPayloads is true', async () => {
const tracing = tracingServiceMock.mock();
await invokeMockAction({ tracing, captureToolPayloads: true });
@@ -1024,9 +1085,9 @@ describe('McpService', () => {
([key]) => key === 'gen_ai.tool.call.result',
);
expect(resultCall).toBeDefined();
const parsed = JSON.parse(resultCall![1] as string);
expect(parsed.content[0].type).toBe('text');
expect(parsed.content[0].text).toContain('"output": "test"');
// The recorded result should be the structured action output, not the
// CallToolResult envelope wrapping a fenced JSON block.
expect(JSON.parse(resultCall![1] as string)).toEqual({ output: 'test' });
});
it('sets error.type=tool_error and ERROR status on the span when the tool returns isError', async () => {
@@ -1049,6 +1110,7 @@ describe('McpService', () => {
actions: mockActionsRegistry,
metrics: metricsServiceMock.mock(),
tracingService: tracing,
captureToolPayloads: true,
});
const server = mcpService.getServer({
@@ -1083,6 +1145,12 @@ describe('McpService', () => {
code: 'error',
message: 'tool_error',
});
// The error is signalled via error.type + status; the result attribute
// should be omitted even when captureToolPayloads is enabled.
const resultCall = span.setAttribute.mock.calls.find(
([key]) => key === 'gen_ai.tool.call.result',
);
expect(resultCall).toBeUndefined();
});
});
});
@@ -43,6 +43,32 @@ function safeStringify(value: unknown): string {
}
}
// Baggage is propagated from untrusted callers, so we forward only an
// explicit allowlist of low-cardinality identifier keys from the OTel
// `gen_ai.*` registry.
const PROPAGATED_BAGGAGE_ATTRIBUTES: readonly string[] = [
'gen_ai.agent.id',
'gen_ai.agent.name',
'gen_ai.conversation.id',
'gen_ai.provider.name',
'gen_ai.request.model',
];
function baggageAttributes(
tracingService: TracingService,
): Record<string, string> {
const baggage = tracingService.getActiveBaggage();
if (!baggage) return {};
const attrs: Record<string, string> = {};
for (const key of PROPAGATED_BAGGAGE_ATTRIBUTES) {
const entry = baggage.getEntry(key);
if (entry) {
attrs[key] = entry.value;
}
}
return attrs;
}
export class McpService {
private readonly actions: ActionsService;
private readonly namespacedToolNames: boolean;
@@ -189,6 +215,16 @@ export class McpService {
credentials,
});
// Record the structured action output directly rather than the
// CallToolResult envelope below, which wraps an already-
// stringified markdown-fenced JSON block.
if (this.captureToolPayloads) {
span.setAttribute(
'gen_ai.tool.call.result',
safeStringify(output),
);
}
return {
// todo(blam): unfortunately structuredContent is not supported by most clients yet.
// so the validation for the output happens in the default actions registry
@@ -207,12 +243,6 @@ export class McpService {
});
isError = !!(result as { isError?: boolean })?.isError;
if (this.captureToolPayloads) {
span.setAttribute(
'gen_ai.tool.call.result',
safeStringify(result),
);
}
if (isError) {
span.setAttribute('error.type', 'tool_error');
span.setStatus({ code: 'error', message: 'tool_error' });
@@ -223,6 +253,7 @@ export class McpService {
kind: 'server',
credentials,
attributes: {
...baggageAttributes(this.tracingService),
'mcp.method.name': 'tools/call',
'gen_ai.tool.name': params.name,
'gen_ai.operation.name': 'execute_tool',