Propagate and forward gen_ai baggage from caller, if available
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
+13
-1
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user