From 8916f83beea6f0896d4f4ea9d8b30b4366db3ebf Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 28 Apr 2026 17:55:32 +0200 Subject: [PATCH 1/8] Instrument MCP tool calls with semantically appropriate span Signed-off-by: Eric Peterson --- .../mcp-actions-backend-otel-tracing.md | 5 + docs/ai/mcp-actions.md | 36 ++++ plugins/mcp-actions-backend/config.d.ts | 17 ++ .../mcp-actions-backend/src/plugin.test.ts | 7 +- plugins/mcp-actions-backend/src/plugin.ts | 8 + .../src/services/McpService.test.ts | 178 ++++++++++++++++++ .../src/services/McpService.ts | 133 +++++++++---- 7 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 .changeset/mcp-actions-backend-otel-tracing.md diff --git a/.changeset/mcp-actions-backend-otel-tracing.md b/.changeset/mcp-actions-backend-otel-tracing.md new file mode 100644 index 0000000000..c463205370 --- /dev/null +++ b/.changeset/mcp-actions-backend-otel-tracing.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-mcp-actions-backend': patch +--- + +Trace spans are now emitted for MCP `tools/call` invocations, following OpenTelemetry server-side MCP semantic conventions. diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index 210167e8ae..fb1a805ebf 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -292,3 +292,39 @@ The MCP Actions Backend emits metrics for the following operations: - `mcp.server.session.duration`: The duration of the MCP session from the perspective of the server See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn how to make these metrics available. + +## 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 `, 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`. + +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. + +### 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**: + +```yaml title="app-config.yaml" +backend: + tracing: + capture: + endUser: true # defaults to false +``` + +This flag is honored by every plugin that creates spans through the [Tracing Service](../backend-system/core-services/tracing.md), not just MCP Actions. + +### Capturing tool arguments and results + +When `backend.tracing.capture.toolPayloads` is enabled, the tool's input arguments and output result are recorded on the span as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. + +```yaml title="app-config.yaml" +backend: + tracing: + capture: + toolPayloads: true # defaults to false +``` + +:::warning +These attributes are marked Opt-In by the OpenTelemetry GenAI semantic conventions because they may contain sensitive information — entity payloads, scaffolder inputs, free-form text, and so on. Only enable this flag if your tracing backend's data handling is appropriate for the kinds of payloads your MCP tools accept and produce. +::: + +See the [OpenTelemetry tutorial](../tutorials/setup-opentelemetry.md) to learn how to make these spans available. diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index a530f5b038..a20ab6d4b8 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -15,6 +15,23 @@ */ export interface Config { + backend?: { + tracing?: { + capture?: { + /** + * When true, the tool call's input arguments and output result are + * included on the MCP `tools/call` server span as + * `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. + * These attributes are marked Opt-In by the OpenTelemetry GenAI + * semantic conventions because they may contain sensitive + * information (entity payloads, scaffolder inputs, free-form + * text). Defaults to false. + */ + toolPayloads?: boolean; + }; + }; + }; + mcpActions?: { /** * Display name for the MCP server. Defaults to "backstage". diff --git a/plugins/mcp-actions-backend/src/plugin.test.ts b/plugins/mcp-actions-backend/src/plugin.test.ts index 31dce6bad5..e64254eb7e 100644 --- a/plugins/mcp-actions-backend/src/plugin.test.ts +++ b/plugins/mcp-actions-backend/src/plugin.test.ts @@ -14,7 +14,10 @@ * limitations under the License. */ import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; -import { metricsServiceMock } from '@backstage/backend-test-utils/alpha'; +import { + metricsServiceMock, + tracingServiceMock, +} from '@backstage/backend-test-utils/alpha'; import { mcpPlugin } from './plugin'; import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; import { createBackendPlugin } from '@backstage/backend-plugin-api'; @@ -54,6 +57,7 @@ describe('Mcp Backend', () => { mcpPlugin, mockPluginWithActions, metricsServiceMock.mock().factory, + tracingServiceMock.mock().factory, mockServices.rootConfig.factory({ data: { backend: { @@ -220,6 +224,7 @@ describe('Mcp Backend', () => { mockCatalogPlugin, mockScaffolderPlugin, metricsServiceMock.mock().factory, + tracingServiceMock.mock().factory, mockServices.rootConfig.factory({ data: { backend: { diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 6baaf8489d..e0ce76dc4d 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -26,6 +26,7 @@ import { actionsRegistryServiceRef, actionsServiceRef, metricsServiceRef, + tracingServiceRef, } from '@backstage/backend-plugin-api/alpha'; import { parseServerConfigs } from './config'; @@ -49,6 +50,7 @@ export const mcpPlugin = createBackendPlugin({ discovery: coreServices.discovery, config: coreServices.rootConfig, metrics: metricsServiceRef, + tracing: tracingServiceRef, }, async init({ actions, @@ -59,16 +61,22 @@ export const mcpPlugin = createBackendPlugin({ discovery, config, metrics, + tracing, }) { const serverConfigs = parseServerConfigs(config); const namespacedToolNames = config.getOptionalBoolean( 'mcpActions.namespacedToolNames', ); + const captureToolPayloads = + config.getOptionalBoolean('backend.tracing.capture.toolPayloads') ?? + false; const mcpService = await McpService.create({ actions, metrics, namespacedToolNames, + tracingService: tracing, + captureToolPayloads, }); const router = Router(); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 1a7dc507c3..d56bacad2e 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -19,6 +19,7 @@ import { McpService } from './McpService'; import { actionsRegistryServiceMock, metricsServiceMock, + tracingServiceMock, } from '@backstage/backend-test-utils/alpha'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; @@ -49,6 +50,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: mockMetrics, + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -121,6 +123,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: mockMetrics, + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -174,6 +177,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: mockMetrics, + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -238,6 +242,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: actionsRegistryServiceMock(), metrics: mockMetrics, + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -306,6 +311,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: mockMetrics, + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -366,6 +372,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -422,6 +429,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -509,6 +517,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: fakeActionsService, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const serverConfig: McpServerConfig = { @@ -542,6 +551,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: fakeActionsService, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const serverConfig: McpServerConfig = { @@ -583,6 +593,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: fakeActionsService, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const serverConfig: McpServerConfig = { @@ -621,6 +632,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: fakeActionsService, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const serverConfig: McpServerConfig = { @@ -659,6 +671,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: fakeActionsService, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const serverConfig: McpServerConfig = { @@ -711,6 +724,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: actionsRegistryServiceMock(), metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -734,6 +748,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: actionsRegistryServiceMock(), metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -763,6 +778,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: actionsRegistryServiceMock(), metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -805,6 +821,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -843,6 +860,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), namespacedToolNames: false, }); @@ -882,6 +900,7 @@ describe('McpService', () => { const mcpService = await McpService.create({ actions: mockActionsRegistry, metrics: metricsServiceMock.mock(), + tracingService: tracingServiceMock.mock(), }); const server = mcpService.getServer({ @@ -907,4 +926,163 @@ describe('McpService', () => { expect(result.isError).toBeUndefined(); }); }); + + describe('tracing', () => { + async function invokeMockAction(opts: { + tracing: ReturnType; + captureToolPayloads?: boolean; + credentials?: + | ReturnType + | ReturnType; + }) { + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'mock-action', + title: 'Test', + description: 'Test', + schema: { + input: z => z.object({ input: z.string() }), + output: z => z.object({ output: z.string() }), + }, + action: async () => ({ output: { output: 'test' } }), + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + tracingService: opts.tracing, + captureToolPayloads: opts.captureToolPayloads, + }); + + const server = mcpService.getServer({ + credentials: opts.credentials ?? mockCredentials.user(), + }); + + const client = new Client({ name: 'test client', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + return client.request( + { + method: 'tools/call', + params: { name: 'test.mock-action', arguments: { input: 'val' } }, + }, + CallToolResultSchema, + ); + } + + it('starts a tools/call span with spec attributes, server kind, and the request credentials', async () => { + const tracing = tracingServiceMock.mock(); + const credentials = mockCredentials.user(); + await invokeMockAction({ tracing, credentials }); + + expect(tracing.startActiveSpan).toHaveBeenCalledTimes(1); + const [name, , options] = tracing.startActiveSpan.mock.calls[0]; + expect(name).toBe('tools/call test.mock-action'); + expect(options?.kind).toBe('server'); + expect(options?.attributes).toEqual( + expect.objectContaining({ + 'mcp.method.name': 'tools/call', + 'gen_ai.tool.name': 'test.mock-action', + 'gen_ai.operation.name': 'execute_tool', + }), + ); + expect(options?.attributes).not.toHaveProperty( + 'gen_ai.tool.call.arguments', + ); + expect(options?.credentials).toBe(credentials); + expect(tracing.spans[0].setStatus).not.toHaveBeenCalled(); + }); + + it('overrides backstage.plugin.id on the span to match the action source plugin', async () => { + const tracing = tracingServiceMock.mock(); + await invokeMockAction({ tracing }); + + // The mock action is registered via actionsRegistryServiceMock(), + // which assigns pluginId 'test'. + expect(tracing.spans[0].setAttribute).toHaveBeenCalledWith( + 'backstage.plugin.id', + 'test', + ); + }); + + it('includes tool arguments in the span options and sets the result via setAttribute when captureToolPayloads is true', async () => { + const tracing = tracingServiceMock.mock(); + await invokeMockAction({ tracing, captureToolPayloads: true }); + + const [, , options] = tracing.startActiveSpan.mock.calls[0]; + expect(options?.attributes?.['gen_ai.tool.call.arguments']).toBe( + JSON.stringify({ input: 'val' }), + ); + + const span = tracing.spans[0]; + const resultCall = span.setAttribute.mock.calls.find( + ([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"'); + }); + + it('sets error.type=tool_error and ERROR status on the span when the tool returns isError', async () => { + const tracing = tracingServiceMock.mock(); + const mockActionsRegistry = actionsRegistryServiceMock(); + mockActionsRegistry.register({ + name: 'failing-action', + title: 'Failing', + description: 'Throws InputError', + schema: { + input: z => z.object({ value: z.string() }), + output: z => z.object({}), + }, + action: async () => { + throw new InputError('the value was invalid'); + }, + }); + + const mcpService = await McpService.create({ + actions: mockActionsRegistry, + metrics: metricsServiceMock.mock(), + tracingService: tracing, + }); + + const server = mcpService.getServer({ + credentials: mockCredentials.user(), + }); + const client = new Client({ name: 'test client', version: '1.0' }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.request( + { + method: 'tools/call', + params: { + name: 'test.failing-action', + arguments: { value: 'test' }, + }, + }, + CallToolResultSchema, + ); + expect(result.isError).toBe(true); + + const span = tracing.spans[0]; + expect(span.setAttribute).toHaveBeenCalledWith( + 'error.type', + 'tool_error', + ); + expect(span.setStatus).toHaveBeenCalledWith({ + code: 'error', + message: 'tool_error', + }); + }); + }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 49eea11d72..038151c5b6 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -25,6 +25,7 @@ import { ActionsServiceAction, MetricsServiceHistogram, MetricsService, + TracingService, } from '@backstage/backend-plugin-api/alpha'; import { version } from '@backstage/plugin-mcp-actions-backend/package.json'; import { NotFoundError } from '@backstage/errors'; @@ -34,18 +35,32 @@ import { handleErrors } from './handleErrors'; import { bucketBoundaries, McpServerOperationAttributes } from '../metrics'; import { FilterRule, McpServerConfig } from '../config'; +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + export class McpService { private readonly actions: ActionsService; private readonly namespacedToolNames: boolean; + private readonly tracingService: TracingService; + private readonly captureToolPayloads: boolean; private readonly operationDuration: MetricsServiceHistogram; constructor( actions: ActionsService, metrics: MetricsService, + tracingService: TracingService, namespacedToolNames?: boolean, + captureToolPayloads?: boolean, ) { this.actions = actions; this.namespacedToolNames = namespacedToolNames ?? true; + this.tracingService = tracingService; + this.captureToolPayloads = captureToolPayloads ?? false; this.operationDuration = metrics.createHistogram( 'mcp.server.operation.duration', @@ -60,13 +75,23 @@ export class McpService { static async create({ actions, metrics, + tracingService, namespacedToolNames, + captureToolPayloads, }: { actions: ActionsService; metrics: MetricsService; + tracingService: TracingService; namespacedToolNames?: boolean; + captureToolPayloads?: boolean; }) { - return new McpService(actions, metrics, namespacedToolNames); + return new McpService( + actions, + metrics, + tracingService, + namespacedToolNames, + captureToolPayloads, + ); } getServer({ @@ -136,43 +161,77 @@ export class McpService { let isError = false; try { - const result = await handleErrors(async () => { - const { actions: allActions } = await this.actions.list({ + return await this.tracingService.startActiveSpan( + `tools/call ${params.name}`, + async span => { + const result = await handleErrors(async () => { + const { actions: allActions } = await this.actions.list({ + credentials, + }); + const actions = serverConfig + ? this.filterActions(allActions, serverConfig) + : allActions; + + const action = actions.find( + a => this.getToolName(a) === params.name, + ); + + if (!action) { + throw new NotFoundError(`Action "${params.name}" not found`); + } + + // Re-attribute the span to the plugin that owns the action. + span.setAttribute('backstage.plugin.id', action.pluginId); + + const { output } = await this.actions.invoke({ + id: action.id, + input: params.arguments as JsonObject, + credentials, + }); + + return { + // todo(blam): unfortunately structuredContent is not supported by most clients yet. + // so the validation for the output happens in the default actions registry + // and we return it as json text instead for now. + content: [ + { + type: 'text', + text: [ + '```json', + JSON.stringify(output, null, 2), + '```', + ].join('\n'), + }, + ], + }; + }); + + 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' }); + } + return result; + }, + { + kind: 'server', credentials, - }); - const actions = serverConfig - ? this.filterActions(allActions, serverConfig) - : allActions; - - const action = actions.find(a => this.getToolName(a) === params.name); - - if (!action) { - throw new NotFoundError(`Action "${params.name}" not found`); - } - - const { output } = await this.actions.invoke({ - id: action.id, - input: params.arguments as JsonObject, - credentials, - }); - - return { - // todo(blam): unfortunately structuredContent is not supported by most clients yet. - // so the validation for the output happens in the default actions registry - // and we return it as json text instead for now. - content: [ - { - type: 'text', - text: ['```json', JSON.stringify(output, null, 2), '```'].join( - '\n', - ), - }, - ], - }; - }); - - isError = !!(result as { isError?: boolean })?.isError; - return result; + attributes: { + 'mcp.method.name': 'tools/call', + 'gen_ai.tool.name': params.name, + 'gen_ai.operation.name': 'execute_tool', + ...(this.captureToolPayloads && { + 'gen_ai.tool.call.arguments': safeStringify(params.arguments), + }), + }, + }, + ); } catch (err) { errorType = err instanceof Error ? err.name : 'Error'; throw err; From 6209065f007ece564ec6347f6a70509e90b49d3d Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 12 May 2026 13:35:45 +0200 Subject: [PATCH 2/8] Add support for async context propagation and baggage in tracing service. Signed-off-by: Eric Peterson --- .../tracing-service-context-and-baggage.md | 7 ++ docs/backend-system/core-services/tracing.md | 35 +++++++++ .../tracing/DefaultTracingService.test.ts | 75 ++++++++++++++++++- .../tracing/DefaultTracingService.ts | 32 +++++++- .../backend-plugin-api/report-alpha.api.md | 19 +++++ .../src/alpha/TracingService.ts | 35 +++++++++ .../backend-plugin-api/src/alpha/index.ts | 2 + packages/backend-plugin-api/src/alpha/refs.ts | 2 +- .../backend-test-utils/report-alpha.api.md | 6 ++ .../src/alpha/services/TracingServiceMock.ts | 17 ++++- 10 files changed, 226 insertions(+), 4 deletions(-) create mode 100644 .changeset/tracing-service-context-and-baggage.md diff --git a/.changeset/tracing-service-context-and-baggage.md b/.changeset/tracing-service-context-and-baggage.md new file mode 100644 index 0000000000..3f447cc339 --- /dev/null +++ b/.changeset/tracing-service-context-and-baggage.md @@ -0,0 +1,7 @@ +--- +'@backstage/backend-plugin-api': patch +'@backstage/backend-defaults': patch +'@backstage/backend-test-utils': patch +--- + +Added `withPropagatedContext` and `getActiveBaggage` to the alpha `TracingService`, enabling plugins to bridge OpenTelemetry context across async boundaries and read propagated baggage. diff --git a/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md index 66c6a35d3d..3cd9b7c00f 100644 --- a/docs/backend-system/core-services/tracing.md +++ b/docs/backend-system/core-services/tracing.md @@ -100,6 +100,41 @@ The span object exposes: | `setAttribute(key, value)` | Set a single attribute. Value is a primitive or array of primitives. | | `setStatus({ code, message })` | Set the span status. `code` is `'ok'`, `'error'`, or `'unset'`. | +## Context Propagation + +When your plugin receives requests through a protocol layer that breaks automatic OpenTelemetry context propagation (e.g. a WebSocket handler), use `withPropagatedContext` to extract the trace parent and baggage from the incoming HTTP headers and run the handler within that context: + +```ts +router.post('/', async (req, res) => { + await tracing.withPropagatedContext(req.headers, () => + transport.handleRequest(req, res, req.body), + ); +}); +``` + +Any spans created inside the callback — including those from `startActiveSpan` — will be children of the propagated trace and will have access to the propagated baggage. + +## Reading Baggage + +Use `getActiveBaggage()` to read baggage entries from the active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage: + +```ts +const baggage = tracing.getActiveBaggage(); +const tenantId = baggage?.getEntry('app.tenant.id')?.value; +if (tenantId) { + span.setAttribute('app.tenant.id', tenantId); +} +``` + +The returned object exposes: + +| Method | Description | +| ----------------- | -------------------------------------------------------- | +| `getEntry(key)` | Returns `{ value: string }` for the key, or `undefined`. | +| `getAllEntries()` | Returns all entries as `[key, { value }][]`. | + +Returns `undefined` when no baggage is present in the active context. + ## Principal Enrichment When you supply either `credentials` or a `request`, the service adds principal-derived attributes to the span: diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts index 9675ecd8ef..c7dcbd9925 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts @@ -13,7 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import { + SpanKind, + SpanStatusCode, + context, + propagation, + trace, +} from '@opentelemetry/api'; import { mockCredentials, mockServices } from '@backstage/backend-test-utils'; import { DefaultTracingService } from './DefaultTracingService'; @@ -267,4 +273,71 @@ describe('DefaultTracingService', () => { expect(value).toBe(42); expect(mocks.span.end).toHaveBeenCalledTimes(1); }); + + describe('withPropagatedContext', () => { + it('extracts context from headers and runs fn within it', async () => { + const extractSpy = jest.spyOn(propagation, 'extract'); + const withSpy = jest.spyOn(context, 'with'); + + const service = createService(); + const headers = { traceparent: '00-abc-def-01' }; + const result = await service.withPropagatedContext(headers, () => 42); + + expect(result).toBe(42); + expect(extractSpy).toHaveBeenCalledWith(expect.anything(), headers); + expect(withSpy).toHaveBeenCalledWith( + expect.anything(), + expect.any(Function), + ); + }); + + it('returns the value from an async fn', async () => { + const service = createService(); + const result = await service.withPropagatedContext( + {}, + async () => 'async-val', + ); + expect(result).toBe('async-val'); + }); + }); + + describe('getActiveBaggage', () => { + it('returns undefined when no baggage is present', () => { + jest.spyOn(propagation, 'getActiveBaggage').mockReturnValue(undefined); + const service = createService(); + expect(service.getActiveBaggage()).toBeUndefined(); + }); + + it('returns a read-only baggage wrapping the active context baggage', () => { + const mockBaggage = { + getEntry: jest.fn((key: string) => + key === 'gen_ai.conversation.id' ? { value: 'conv-1' } : undefined, + ), + getAllEntries: jest.fn(() => [ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]), + setEntry: jest.fn(), + removeEntry: jest.fn(), + removeEntries: jest.fn(), + clear: jest.fn(), + }; + jest + .spyOn(propagation, 'getActiveBaggage') + .mockReturnValue(mockBaggage as any); + + const service = createService(); + const baggage = service.getActiveBaggage(); + + expect(baggage).toBeDefined(); + expect(baggage!.getEntry('gen_ai.conversation.id')).toEqual({ + value: 'conv-1', + }); + expect(baggage!.getEntry('unknown')).toBeUndefined(); + expect(baggage!.getAllEntries()).toEqual([ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]); + }); + }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index 77049cedfd..5d99562949 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -import { SpanKind, SpanStatusCode, Tracer, trace } from '@opentelemetry/api'; +import { + SpanKind, + SpanStatusCode, + Tracer, + context, + propagation, + trace, +} from '@opentelemetry/api'; import { BackstageCredentials, HttpAuthService, @@ -117,6 +124,29 @@ export class DefaultTracingService implements TracingService { ); } + async withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise { + const otelCtx = propagation.extract(context.active(), headers); + return context.with(otelCtx, fn); + } + + getActiveBaggage() { + const baggage = propagation.getActiveBaggage(); + if (!baggage) return undefined; + return { + getEntry: (key: string) => { + const entry = baggage.getEntry(key); + return entry ? { value: entry.value } : undefined; + }, + getAllEntries: (): Array<[string, { value: string }]> => + baggage + .getAllEntries() + .map(([key, entry]) => [key, { value: entry.value }]), + }; + } + private getPrincipalAttributes( credentials: BackstageCredentials | undefined, ): TracingServiceAttributes { diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 0603f4310e..e970d85624 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -292,11 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef< // @alpha export interface TracingService { + getActiveBaggage(): TracingServiceBaggage | undefined; startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, options?: TracingServiceSpanOptions, ): Promise; + withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise; } // @alpha @@ -314,6 +319,20 @@ export type TracingServiceAttributeValue = | Array | Array; +// @alpha +export interface TracingServiceBaggage { + // (undocumented) + getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; + // (undocumented) + getEntry(key: string): TracingServiceBaggageEntry | undefined; +} + +// @alpha +export interface TracingServiceBaggageEntry { + // (undocumented) + value: string; +} + // @alpha export const tracingServiceRef: ServiceRef< TracingService, diff --git a/packages/backend-plugin-api/src/alpha/TracingService.ts b/packages/backend-plugin-api/src/alpha/TracingService.ts index e0ccf26116..8919263676 100644 --- a/packages/backend-plugin-api/src/alpha/TracingService.ts +++ b/packages/backend-plugin-api/src/alpha/TracingService.ts @@ -106,4 +106,39 @@ export interface TracingService { fn: (span: TracingServiceSpan) => T | Promise, options?: TracingServiceSpanOptions, ): Promise; + + /** + * Extracts propagated context from HTTP headers and runs `fn` within + * it. Use this to bridge context across async boundaries where + * automatic propagation is lost. + */ + withPropagatedContext( + headers: Record, + fn: () => T | Promise, + ): Promise; + + /** + * Returns the active baggage from the current context, or `undefined` + * when none is present. + */ + getActiveBaggage(): TracingServiceBaggage | undefined; +} + +/** + * A read-only view of propagated baggage entries. + * + * @alpha + */ +export interface TracingServiceBaggage { + getEntry(key: string): TracingServiceBaggageEntry | undefined; + getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; +} + +/** + * A single baggage entry. + * + * @alpha + */ +export interface TracingServiceBaggageEntry { + value: string; } diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index 4e1e73f23e..dc0624273b 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -50,6 +50,8 @@ export type { TracingService, TracingServiceAttributeValue, TracingServiceAttributes, + TracingServiceBaggage, + TracingServiceBaggageEntry, TracingServiceSpan, TracingServiceSpanKind, TracingServiceSpanOptions, diff --git a/packages/backend-plugin-api/src/alpha/refs.ts b/packages/backend-plugin-api/src/alpha/refs.ts index 967fce6344..47176645d9 100644 --- a/packages/backend-plugin-api/src/alpha/refs.ts +++ b/packages/backend-plugin-api/src/alpha/refs.ts @@ -71,7 +71,7 @@ export const metricsServiceRef = createServiceRef< /** * Service for managing trace spans. * - * See {@link TracingService} for the API surface. + * See `TracingService` for the API surface. * * @alpha */ diff --git a/packages/backend-test-utils/report-alpha.api.md b/packages/backend-test-utils/report-alpha.api.md index 71848aaaa1..b2d6b56e81 100644 --- a/packages/backend-test-utils/report-alpha.api.md +++ b/packages/backend-test-utils/report-alpha.api.md @@ -108,9 +108,15 @@ export type ServiceMock = { export interface TracingServiceMock extends TracingService { // (undocumented) factory: ServiceFactory; + // (undocumented) + getActiveBaggage: jest.MockedFunction; spans: MockedTracingServiceSpan[]; // (undocumented) startActiveSpan: jest.MockedFunction; + // (undocumented) + withPropagatedContext: jest.MockedFunction< + TracingService['withPropagatedContext'] + >; } // @alpha (undocumented) diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 6cdc1136cc..5e000b7603 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -46,6 +46,10 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan { */ export interface TracingServiceMock extends TracingService { startActiveSpan: jest.MockedFunction; + withPropagatedContext: jest.MockedFunction< + TracingService['withPropagatedContext'] + >; + getActiveBaggage: jest.MockedFunction; /** Spans created by `startActiveSpan` calls, in order. */ spans: MockedTracingServiceSpan[]; factory: ServiceFactory; @@ -75,7 +79,18 @@ export namespace tracingServiceMock { return await fn(span); }) as TracingServiceMock['startActiveSpan']; - const service: TracingService = { startActiveSpan }; + const withPropagatedContext = jest.fn(async (_headers, fn) => + fn(), + ) as TracingServiceMock['withPropagatedContext']; + const getActiveBaggage = jest.fn( + () => undefined, + ) as TracingServiceMock['getActiveBaggage']; + + const service: TracingService = { + startActiveSpan, + withPropagatedContext, + getActiveBaggage, + }; return Object.assign(service as TracingServiceMock, { spans, From d6c7805527e05adf0ab5c359f9652b6980173db1 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 12 May 2026 13:36:17 +0200 Subject: [PATCH 3/8] Propagate and forward gen_ai baggage from caller, if available Signed-off-by: Eric Peterson --- docs/ai/mcp-actions.md | 14 +++- .../alpha/services/TracingServiceMock.test.ts | 80 +++++++++++++++++++ .../src/alpha/services/TracingServiceMock.ts | 57 ++++++++++++- plugins/mcp-actions-backend/src/plugin.ts | 3 + .../src/routers/createSseRouter.ts | 7 +- .../src/routers/createStreamableRouter.ts | 11 ++- .../src/services/McpService.test.ts | 76 +++++++++++++++++- .../src/services/McpService.ts | 43 ++++++++-- 8 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index fb1a805ebf..96965f0325 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -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 `, 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 `, 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**: diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts new file mode 100644 index 0000000000..be80625932 --- /dev/null +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts @@ -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(); + }); + }); +}); diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 5e000b7603..9f43c7149f 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -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, +): 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(); + 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 = []; + 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 = { diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index e0ce76dc4d..526474e5b4 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -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, }); diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index 954d28af4e..d0f6411046 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -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) diff --git a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts index 517aa18824..1c71703ddf 100644 --- a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts @@ -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(); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index d56bacad2e..ae4a1770a4 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -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 = { + '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 = { + '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(); }); }); }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 038151c5b6..693f0b90a4 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -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 { + const baggage = tracingService.getActiveBaggage(); + if (!baggage) return {}; + const attrs: Record = {}; + 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', From b70f13990b069558d4e78c1efe125d5d472947aa Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 18 May 2026 15:20:09 +0200 Subject: [PATCH 4/8] Bring tracing service more in line with upstream APIs Signed-off-by: Eric Peterson --- .../tracing-service-context-and-baggage.md | 2 +- docs/backend-system/core-services/tracing.md | 25 +- .../tracing/DefaultTracingService.test.ts | 231 ++++++++++++------ .../tracing/DefaultTracingService.ts | 100 ++++++-- .../backend-plugin-api/report-alpha.api.md | 33 ++- .../src/alpha/TracingService.ts | 86 ++++++- .../backend-plugin-api/src/alpha/index.ts | 3 + .../backend-test-utils/report-alpha.api.md | 32 ++- .../alpha/services/TracingServiceMock.test.ts | 73 +++--- .../src/alpha/services/TracingServiceMock.ts | 147 ++++++++--- .../src/alpha/services/index.ts | 2 + .../src/routers/createSseRouter.ts | 6 +- .../src/routers/createStreamableRouter.ts | 6 +- .../src/services/McpService.test.ts | 14 +- .../src/services/McpService.ts | 28 +-- 15 files changed, 567 insertions(+), 221 deletions(-) diff --git a/.changeset/tracing-service-context-and-baggage.md b/.changeset/tracing-service-context-and-baggage.md index 3f447cc339..86d1a62168 100644 --- a/.changeset/tracing-service-context-and-baggage.md +++ b/.changeset/tracing-service-context-and-baggage.md @@ -4,4 +4,4 @@ '@backstage/backend-test-utils': patch --- -Added `withPropagatedContext` and `getActiveBaggage` to the alpha `TracingService`, enabling plugins to bridge OpenTelemetry context across async boundaries and read propagated baggage. +Added `context` and `propagation` to the alpha `TracingService`. Plugins can bridge OpenTelemetry context across async boundaries via `tracing.propagation.extract(tracing.context.active(), carrier)` followed by `tracing.context.with(ctx, fn)`, and read propagated baggage via `tracing.propagation.getActiveBaggage()` or `tracing.propagation.getBaggage(ctx)`. diff --git a/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md index 3cd9b7c00f..52d2d3069f 100644 --- a/docs/backend-system/core-services/tracing.md +++ b/docs/backend-system/core-services/tracing.md @@ -102,30 +102,45 @@ The span object exposes: ## Context Propagation -When your plugin receives requests through a protocol layer that breaks automatic OpenTelemetry context propagation (e.g. a WebSocket handler), use `withPropagatedContext` to extract the trace parent and baggage from the incoming HTTP headers and run the handler within that context: +The tracing service exposes two sub-objects that mirror the corresponding namespaces in `@opentelemetry/api`: + +- `tracing.context` for context management (`active`, `with`). +- `tracing.propagation` for context propagation (`extract`, `getBaggage`, `getActiveBaggage`). + +When your plugin handles a request through a transport or framework that doesn't automatically attach the caller's context to the work it runs (for example, a handler dispatched from a message-queue consumer, or a third-party transport like the MCP streamable HTTP transport that re-enters user code outside of Express's middleware chain), extract the trace parent and baggage from the inbound request's headers yourself and run the handler with that context active: ```ts router.post('/', async (req, res) => { - await tracing.withPropagatedContext(req.headers, () => + const ctx = tracing.propagation.extract( + tracing.context.active(), + req.headers, + ); + await tracing.context.with(ctx, () => transport.handleRequest(req, res, req.body), ); }); ``` +`propagation.extract` reads from a header-shaped record (`Record`), so any source of headers — Express's `req.headers`, a Node.js `http.IncomingMessage`, or a payload field carrying serialized headers — works the same way. + Any spans created inside the callback — including those from `startActiveSpan` — will be children of the propagated trace and will have access to the propagated baggage. +The context returned by `propagation.extract` and `context.active` is an opaque handle: consumers pass it back into the API but do not introspect it. + ## Reading Baggage -Use `getActiveBaggage()` to read baggage entries from the active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage: +Use `propagation.getActiveBaggage()` to read baggage entries from the currently active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage: ```ts -const baggage = tracing.getActiveBaggage(); +const baggage = tracing.propagation.getActiveBaggage(); const tenantId = baggage?.getEntry('app.tenant.id')?.value; if (tenantId) { span.setAttribute('app.tenant.id', tenantId); } ``` +Use `propagation.getBaggage(ctx)` when you already hold a specific context handle (for example, one returned by `propagation.extract`) and want to read its baggage without first activating the context. + The returned object exposes: | Method | Description | @@ -133,7 +148,7 @@ The returned object exposes: | `getEntry(key)` | Returns `{ value: string }` for the key, or `undefined`. | | `getAllEntries()` | Returns all entries as `[key, { value }][]`. | -Returns `undefined` when no baggage is present in the active context. +Both calls return `undefined` when no baggage is present. ## Principal Enrichment diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts index c7dcbd9925..61332a58b0 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts @@ -94,10 +94,11 @@ describe('DefaultTracingService', () => { it('passes name, kind, and caller attributes through to the tracer', async () => { const service = createService(); - await service.startActiveSpan('op', async () => undefined, { - kind: 'server', - attributes: { foo: 'bar' }, - }); + await service.startActiveSpan( + 'op', + { kind: 'server', attributes: { foo: 'bar' } }, + async () => undefined, + ); expect(mocks.tracer.startActiveSpan).toHaveBeenCalledWith( 'op', @@ -119,9 +120,11 @@ describe('DefaultTracingService', () => { it('lets caller-supplied attributes override backstage.plugin.id at start time', async () => { const service = createService({ pluginId: 'my-plugin' }); - await service.startActiveSpan('op', async () => undefined, { - attributes: { 'backstage.plugin.id': 'other-plugin' }, - }); + await service.startActiveSpan( + 'op', + { attributes: { 'backstage.plugin.id': 'other-plugin' } }, + async () => undefined, + ); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; expect(attrs['backstage.plugin.id']).toBe('other-plugin'); @@ -146,18 +149,22 @@ describe('DefaultTracingService', () => { ]; for (const [kind, expected] of cases) { mocks.tracer.startActiveSpan.mockClear(); - await service.startActiveSpan('op', async () => undefined, { - kind: kind as any, - }); + await service.startActiveSpan( + 'op', + { kind: kind as any }, + async () => undefined, + ); expect(mocks.tracer.startActiveSpan.mock.calls[0][1].kind).toBe(expected); } }); it('adds backstage.principal.type but not enduser.id when capture is off', async () => { const service = createService({ captureEndUser: false }); - await service.startActiveSpan('op', async () => undefined, { - credentials: mockCredentials.user('user:default/alice'), - }); + await service.startActiveSpan( + 'op', + { credentials: mockCredentials.user('user:default/alice') }, + async () => undefined, + ); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; expect(attrs['backstage.principal.type']).toBe('user'); @@ -166,9 +173,11 @@ describe('DefaultTracingService', () => { it('adds enduser.id from a user principal when capture is on', async () => { const service = createService({ captureEndUser: true }); - await service.startActiveSpan('op', async () => undefined, { - credentials: mockCredentials.user('user:default/alice'), - }); + await service.startActiveSpan( + 'op', + { credentials: mockCredentials.user('user:default/alice') }, + async () => undefined, + ); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; expect(attrs['enduser.id']).toBe('user:default/alice'); @@ -176,9 +185,11 @@ describe('DefaultTracingService', () => { it('adds enduser.id from a service principal subject when capture is on', async () => { const service = createService({ captureEndUser: true }); - await service.startActiveSpan('op', async () => undefined, { - credentials: mockCredentials.service('plugin:test'), - }); + await service.startActiveSpan( + 'op', + { credentials: mockCredentials.service('plugin:test') }, + async () => undefined, + ); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; expect(attrs['enduser.id']).toBe('plugin:test'); @@ -195,9 +206,11 @@ describe('DefaultTracingService', () => { httpAuth, }); - await service.startActiveSpan('op', async () => undefined, { - request: { headers: {} } as any, - }); + await service.startActiveSpan( + 'op', + { request: { headers: {} } as any }, + async () => undefined, + ); expect(credSpy).toHaveBeenCalledTimes(1); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; @@ -214,10 +227,14 @@ describe('DefaultTracingService', () => { httpAuth, }); - await service.startActiveSpan('op', async () => undefined, { - credentials: mockCredentials.user('user:default/explicit'), - request: { headers: {} } as any, - }); + await service.startActiveSpan( + 'op', + { + credentials: mockCredentials.user('user:default/explicit'), + request: { headers: {} } as any, + }, + async () => undefined, + ); expect(credSpy).not.toHaveBeenCalled(); const attrs = mocks.tracer.startActiveSpan.mock.calls[0][1].attributes; @@ -274,70 +291,124 @@ describe('DefaultTracingService', () => { expect(mocks.span.end).toHaveBeenCalledTimes(1); }); - describe('withPropagatedContext', () => { - it('extracts context from headers and runs fn within it', async () => { - const extractSpy = jest.spyOn(propagation, 'extract'); - const withSpy = jest.spyOn(context, 'with'); - - const service = createService(); - const headers = { traceparent: '00-abc-def-01' }; - const result = await service.withPropagatedContext(headers, () => 42); - - expect(result).toBe(42); - expect(extractSpy).toHaveBeenCalledWith(expect.anything(), headers); - expect(withSpy).toHaveBeenCalledWith( - expect.anything(), - expect.any(Function), - ); + describe('context', () => { + describe('active', () => { + it('returns the OTel active context as an opaque handle', () => { + const fakeCtx = { __ctx: 'active' }; + jest.spyOn(context, 'active').mockReturnValue(fakeCtx as any); + const service = createService(); + expect(service.context.active()).toBe(fakeCtx); + }); }); - it('returns the value from an async fn', async () => { - const service = createService(); - const result = await service.withPropagatedContext( - {}, - async () => 'async-val', - ); - expect(result).toBe('async-val'); + describe('with', () => { + it('delegates to OTel context.with on the supplied handle and returns the fn result', async () => { + const fakeCtx = { __ctx: 'extracted' } as any; + const withSpy = jest + .spyOn(context, 'with') + .mockImplementation((_ctx, fn) => (fn as any)()); + + const service = createService(); + const result = await service.context.with(fakeCtx, () => 99); + + expect(withSpy).toHaveBeenCalledWith(fakeCtx, expect.any(Function)); + expect(result).toBe(99); + }); + + it('awaits an async fn and returns its resolved value', async () => { + jest + .spyOn(context, 'with') + .mockImplementation((_ctx, fn) => (fn as any)()); + + const service = createService(); + const result = await service.context.with( + {} as any, + async () => 'async-val', + ); + expect(result).toBe('async-val'); + }); }); }); - describe('getActiveBaggage', () => { - it('returns undefined when no baggage is present', () => { - jest.spyOn(propagation, 'getActiveBaggage').mockReturnValue(undefined); - const service = createService(); - expect(service.getActiveBaggage()).toBeUndefined(); + describe('propagation', () => { + describe('extract', () => { + it('forwards the supplied context and headers to OTel propagation.extract', () => { + const baseCtx = { __ctx: 'base' } as any; + const extractedCtx = { __ctx: 'extracted' } as any; + const extractSpy = jest + .spyOn(propagation, 'extract') + .mockReturnValue(extractedCtx); + + const service = createService(); + const headers = { traceparent: '00-abc-def-01' }; + const result = service.propagation.extract(baseCtx, headers); + + expect(extractSpy).toHaveBeenCalledWith(baseCtx, headers); + expect(result).toBe(extractedCtx); + }); }); - it('returns a read-only baggage wrapping the active context baggage', () => { - const mockBaggage = { - getEntry: jest.fn((key: string) => - key === 'gen_ai.conversation.id' ? { value: 'conv-1' } : undefined, - ), - getAllEntries: jest.fn(() => [ + describe('getActiveBaggage', () => { + it('returns a read-only baggage wrapping the active context baggage', () => { + const mockBaggage = { + getEntry: jest.fn((key: string) => + key === 'gen_ai.conversation.id' ? { value: 'conv-1' } : undefined, + ), + getAllEntries: jest.fn(() => [ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]), + setEntry: jest.fn(), + removeEntry: jest.fn(), + removeEntries: jest.fn(), + clear: jest.fn(), + }; + jest + .spyOn(propagation, 'getActiveBaggage') + .mockReturnValue(mockBaggage as any); + + const service = createService(); + const baggage = service.propagation.getActiveBaggage(); + + expect(baggage).toBeDefined(); + expect(baggage!.getEntry('gen_ai.conversation.id')).toEqual({ + value: 'conv-1', + }); + expect(baggage!.getEntry('unknown')).toBeUndefined(); + expect(baggage!.getAllEntries()).toEqual([ ['gen_ai.conversation.id', { value: 'conv-1' }], ['gen_ai.agent.id', { value: 'agent-2' }], - ]), - setEntry: jest.fn(), - removeEntry: jest.fn(), - removeEntries: jest.fn(), - clear: jest.fn(), - }; - jest - .spyOn(propagation, 'getActiveBaggage') - .mockReturnValue(mockBaggage as any); - - const service = createService(); - const baggage = service.getActiveBaggage(); - - expect(baggage).toBeDefined(); - expect(baggage!.getEntry('gen_ai.conversation.id')).toEqual({ - value: 'conv-1', + ]); + }); + }); + + describe('getBaggage', () => { + it('returns baggage from the supplied context', () => { + const ctx = { __ctx: 'has-baggage' } as any; + const mockBaggage = { + getEntry: jest.fn(() => ({ value: 'ctx-val' })), + getAllEntries: jest.fn(() => [['k', { value: 'ctx-val' }]]), + setEntry: jest.fn(), + removeEntry: jest.fn(), + removeEntries: jest.fn(), + clear: jest.fn(), + }; + const getBaggageSpy = jest + .spyOn(propagation, 'getBaggage') + .mockReturnValue(mockBaggage as any); + + const service = createService(); + const baggage = service.propagation.getBaggage(ctx); + + expect(getBaggageSpy).toHaveBeenCalledWith(ctx); + expect(baggage!.getEntry('k')).toEqual({ value: 'ctx-val' }); + }); + + it('returns undefined when the context has no baggage', () => { + jest.spyOn(propagation, 'getBaggage').mockReturnValue(undefined); + const service = createService(); + expect(service.propagation.getBaggage({} as any)).toBeUndefined(); }); - expect(baggage!.getEntry('unknown')).toBeUndefined(); - expect(baggage!.getAllEntries()).toEqual([ - ['gen_ai.conversation.id', { value: 'conv-1' }], - ['gen_ai.agent.id', { value: 'agent-2' }], - ]); }); }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index 5d99562949..48e30fdc2c 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -15,11 +15,12 @@ */ import { + Context, SpanKind, SpanStatusCode, Tracer, - context, - propagation, + context as otelContext, + propagation as otelPropagation, trace, } from '@opentelemetry/api'; import { @@ -29,6 +30,10 @@ import { import { TracingService, TracingServiceAttributes, + TracingServiceBaggage, + TracingServiceContext, + TracingServiceContextAPI, + TracingServicePropagationAPI, TracingServiceSpan, TracingServiceSpanKind, TracingServiceSpanOptions, @@ -49,6 +54,32 @@ export interface DefaultTracingServiceOptions { httpAuth: HttpAuthService; } +// `TracingServiceContext` is an opaque handle for an OTel `Context`. Internally +// the value *is* the OTel context; we just narrow the type so consumers can't +// poke at it directly. +function toOtelContext(ctx: TracingServiceContext): Context { + return ctx as unknown as Context; +} +function fromOtelContext(ctx: Context): TracingServiceContext { + return ctx as unknown as TracingServiceContext; +} + +function wrapOtelBaggage( + baggage: ReturnType, +): TracingServiceBaggage | undefined { + if (!baggage) return undefined; + return { + getEntry: (key: string) => { + const entry = baggage.getEntry(key); + return entry ? { value: entry.value } : undefined; + }, + getAllEntries: () => + baggage + .getAllEntries() + .map(([key, entry]) => [key, { value: entry.value }]), + }; +} + /** * Default implementation of the {@link TracingService} interface. * @@ -60,6 +91,25 @@ export class DefaultTracingService implements TracingService { private readonly captureEndUser: boolean; private readonly httpAuth: HttpAuthService; + readonly context: TracingServiceContextAPI = { + active: () => fromOtelContext(otelContext.active()), + with: async ( + ctx: TracingServiceContext, + fn: () => T | Promise, + ): Promise => otelContext.with(toOtelContext(ctx), fn), + }; + + readonly propagation: TracingServicePropagationAPI = { + extract: ( + ctx: TracingServiceContext, + carrier: Record, + ): TracingServiceContext => + fromOtelContext(otelPropagation.extract(toOtelContext(ctx), carrier)), + getBaggage: (ctx: TracingServiceContext) => + wrapOtelBaggage(otelPropagation.getBaggage(toOtelContext(ctx))), + getActiveBaggage: () => wrapOtelBaggage(otelPropagation.getActiveBaggage()), + }; + private constructor(opts: DefaultTracingServiceOptions) { this.tracer = trace .getTracerProvider() @@ -73,11 +123,30 @@ export class DefaultTracingService implements TracingService { return new DefaultTracingService(opts); } - async startActiveSpan( + startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, - options: TracingServiceSpanOptions = {}, + ): Promise; + startActiveSpan( + name: string, + options: TracingServiceSpanOptions, + fn: (span: TracingServiceSpan) => T | Promise, + ): Promise; + async startActiveSpan( + name: string, + optionsOrFn: + | TracingServiceSpanOptions + | ((span: TracingServiceSpan) => T | Promise), + maybeFn?: (span: TracingServiceSpan) => T | Promise, ): Promise { + const [options, fn]: [ + TracingServiceSpanOptions, + (span: TracingServiceSpan) => T | Promise, + ] = + typeof optionsOrFn === 'function' + ? [{}, optionsOrFn] + : [optionsOrFn, maybeFn!]; + let credentials = options.credentials; if (!credentials && options.request) { credentials = await this.httpAuth.credentials(options.request); @@ -124,29 +193,6 @@ export class DefaultTracingService implements TracingService { ); } - async withPropagatedContext( - headers: Record, - fn: () => T | Promise, - ): Promise { - const otelCtx = propagation.extract(context.active(), headers); - return context.with(otelCtx, fn); - } - - getActiveBaggage() { - const baggage = propagation.getActiveBaggage(); - if (!baggage) return undefined; - return { - getEntry: (key: string) => { - const entry = baggage.getEntry(key); - return entry ? { value: entry.value } : undefined; - }, - getAllEntries: (): Array<[string, { value: string }]> => - baggage - .getAllEntries() - .map(([key, entry]) => [key, { value: entry.value }]), - }; - } - private getPrincipalAttributes( credentials: BackstageCredentials | undefined, ): TracingServiceAttributes { diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index e970d85624..41a9d6160d 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -292,15 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef< // @alpha export interface TracingService { - getActiveBaggage(): TracingServiceBaggage | undefined; + readonly context: TracingServiceContextAPI; + readonly propagation: TracingServicePropagationAPI; startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, - options?: TracingServiceSpanOptions, ): Promise; - withPropagatedContext( - headers: Record, - fn: () => T | Promise, + startActiveSpan( + name: string, + options: TracingServiceSpanOptions, + fn: (span: TracingServiceSpan) => T | Promise, ): Promise; } @@ -333,6 +334,28 @@ export interface TracingServiceBaggageEntry { value: string; } +// @alpha +export interface TracingServiceContext { + // (undocumented) + readonly [tracingServiceContextBrand]: never; +} + +// @alpha +export interface TracingServiceContextAPI { + active(): TracingServiceContext; + with(context: TracingServiceContext, fn: () => T | Promise): Promise; +} + +// @alpha +export interface TracingServicePropagationAPI { + extract( + context: TracingServiceContext, + carrier: Record, + ): TracingServiceContext; + getActiveBaggage(): TracingServiceBaggage | undefined; + getBaggage(context: TracingServiceContext): TracingServiceBaggage | undefined; +} + // @alpha export const tracingServiceRef: ServiceRef< TracingService, diff --git a/packages/backend-plugin-api/src/alpha/TracingService.ts b/packages/backend-plugin-api/src/alpha/TracingService.ts index 8919263676..f751b776bd 100644 --- a/packages/backend-plugin-api/src/alpha/TracingService.ts +++ b/packages/backend-plugin-api/src/alpha/TracingService.ts @@ -104,26 +104,92 @@ export interface TracingService { startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, - options?: TracingServiceSpanOptions, ): Promise; - /** - * Extracts propagated context from HTTP headers and runs `fn` within - * it. Use this to bridge context across async boundaries where - * automatic propagation is lost. + * Runs `fn` inside a new active span configured by `options`. The + * span is finished when `fn` resolves or throws. */ - withPropagatedContext( - headers: Record, - fn: () => T | Promise, + startActiveSpan( + name: string, + options: TracingServiceSpanOptions, + fn: (span: TracingServiceSpan) => T | Promise, ): Promise; /** - * Returns the active baggage from the current context, or `undefined` - * when none is present. + * Read the active tracing context, or run work within a specific + * one. + */ + readonly context: TracingServiceContextAPI; + + /** + * Extract a caller's tracing context from an inbound carrier, and + * read baggage from a context. Use these to bridge context across + * boundaries where automatic propagation is lost — for example, + * when a request arrives over a transport that does not + * automatically attach the caller's context. + */ + readonly propagation: TracingServicePropagationAPI; +} + +/** + * Read the active tracing context, or run work within a specific one. + * The context carries the active span and propagation fields (trace + * parent, baggage) for the current unit of work, and is automatically + * inherited by spans created via `startActiveSpan`. + * + * @alpha + */ +export interface TracingServiceContextAPI { + /** Returns the currently active context. */ + active(): TracingServiceContext; + /** Runs `fn` with the supplied context set as the active context. */ + with(context: TracingServiceContext, fn: () => T | Promise): Promise; +} + +/** + * Extract a caller's trace parent and baggage from an inbound carrier + * (typically HTTP headers), or read baggage from a context. Use these + * to bridge context across boundaries where automatic propagation is + * lost. + * + * @alpha + */ +export interface TracingServicePropagationAPI { + /** + * Returns a new context with propagation fields (trace parent, + * baggage, ...) read from the supplied carrier merged into it. + */ + extract( + context: TracingServiceContext, + carrier: Record, + ): TracingServiceContext; + /** + * Returns the baggage attached to the supplied context, or + * `undefined` when none is present. + */ + getBaggage(context: TracingServiceContext): TracingServiceBaggage | undefined; + /** + * Returns the baggage attached to the currently active context, or + * `undefined` when none is present. Equivalent to + * `getBaggage(context.active())`. */ getActiveBaggage(): TracingServiceBaggage | undefined; } +declare const tracingServiceContextBrand: unique symbol; + +/** + * Opaque handle representing a tracing context. Consumers receive + * these from {@link TracingServiceContextAPI.active} or + * {@link TracingServicePropagationAPI.extract} and pass them back into + * the API; the type carries no inspectable fields. + * + * @alpha + */ +export interface TracingServiceContext { + readonly [tracingServiceContextBrand]: never; +} + /** * A read-only view of propagated baggage entries. * diff --git a/packages/backend-plugin-api/src/alpha/index.ts b/packages/backend-plugin-api/src/alpha/index.ts index dc0624273b..1e2a03a04f 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -52,6 +52,9 @@ export type { TracingServiceAttributes, TracingServiceBaggage, TracingServiceBaggageEntry, + TracingServiceContext, + TracingServiceContextAPI, + TracingServicePropagationAPI, TracingServiceSpan, TracingServiceSpanKind, TracingServiceSpanOptions, diff --git a/packages/backend-test-utils/report-alpha.api.md b/packages/backend-test-utils/report-alpha.api.md index b2d6b56e81..f3faa4eb5e 100644 --- a/packages/backend-test-utils/report-alpha.api.md +++ b/packages/backend-test-utils/report-alpha.api.md @@ -16,6 +16,8 @@ import { MetricsService } from '@backstage/backend-plugin-api/alpha'; import { ServiceFactory } from '@backstage/backend-plugin-api'; import { TracingService } from '@backstage/backend-plugin-api/alpha'; import { TracingServiceAttributeValue } from '@backstage/backend-plugin-api/alpha'; +import { TracingServiceContextAPI } from '@backstage/backend-plugin-api/alpha'; +import { TracingServicePropagationAPI } from '@backstage/backend-plugin-api/alpha'; import { TracingServiceSpan } from '@backstage/backend-plugin-api/alpha'; import { TracingServiceSpanStatus } from '@backstage/backend-plugin-api/alpha'; @@ -85,6 +87,28 @@ export class MockActionsRegistry >(options: ActionsRegistryActionOptions): void; } +// @alpha +export interface MockedTracingServiceContextAPI + extends TracingServiceContextAPI { + // (undocumented) + active: jest.MockedFunction; + // (undocumented) + with: jest.MockedFunction; +} + +// @alpha +export interface MockedTracingServicePropagationAPI + extends TracingServicePropagationAPI { + // (undocumented) + extract: jest.MockedFunction; + // (undocumented) + getActiveBaggage: jest.MockedFunction< + TracingServicePropagationAPI['getActiveBaggage'] + >; + // (undocumented) + getBaggage: jest.MockedFunction; +} + // @alpha export interface MockedTracingServiceSpan extends TracingServiceSpan { // (undocumented) @@ -106,17 +130,15 @@ export type ServiceMock = { // @alpha export interface TracingServiceMock extends TracingService { + // (undocumented) + context: MockedTracingServiceContextAPI; // (undocumented) factory: ServiceFactory; // (undocumented) - getActiveBaggage: jest.MockedFunction; + propagation: MockedTracingServicePropagationAPI; spans: MockedTracingServiceSpan[]; // (undocumented) startActiveSpan: jest.MockedFunction; - // (undocumented) - withPropagatedContext: jest.MockedFunction< - TracingService['withPropagatedContext'] - >; } // @alpha (undocumented) diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts index be80625932..5fb711a636 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts @@ -17,37 +17,41 @@ import { tracingServiceMock } from './TracingServiceMock'; describe('tracingServiceMock', () => { - it('parses the baggage header from withPropagatedContext and exposes it via getActiveBaggage', async () => { + it('parses the baggage header via propagation.extract and exposes it via getActiveBaggage inside context.with', 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]), - }; - }, - ); + const ctx = tracing.propagation.extract(tracing.context.active(), { + baggage: + 'gen_ai.conversation.id=conv-123, gen_ai.agent.id=agent-456;property=ignored', + }); + + // Baggage is reachable directly off the extracted handle. + expect( + tracing.propagation.getBaggage(ctx)?.getEntry('gen_ai.agent.id'), + ).toEqual({ value: 'agent-456' }); + + const seen = await tracing.context.with(ctx, () => { + const baggage = tracing.propagation.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'], - ], + ['gen_ai.conversation.id', { value: 'conv-123' }], + ['gen_ai.agent.id', { value: 'agent-456' }], + ].map(([k, v]) => [k, (v as { value: string }).value]), }); - // Baggage is scoped to the propagated callback. - expect(tracing.getActiveBaggage()).toBeUndefined(); + // Baggage is scoped to the context.with callback. + expect(tracing.propagation.getActiveBaggage()).toBeUndefined(); }); it('honours mockReturnValue overrides for getActiveBaggage', async () => { @@ -59,22 +63,25 @@ describe('tracingServiceMock', () => { [string, { value: string }] >, }; - tracing.getActiveBaggage.mockReturnValue(override); + tracing.propagation.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); - }, - ); + expect(tracing.propagation.getActiveBaggage()).toBe(override); + const ctx = tracing.propagation.extract(tracing.context.active(), { + baggage: 'gen_ai.conversation.id=conv-from-header', + }); + await tracing.context.with(ctx, () => { + // mockReturnValue takes precedence over the default header parsing. + expect(tracing.propagation.getActiveBaggage()).toBe(override); + }); }); - it('returns undefined baggage when no baggage header is supplied', async () => { + it('returns undefined baggage when no baggage header is supplied to extract', async () => { const tracing = tracingServiceMock.mock(); - await tracing.withPropagatedContext({ traceparent: 'whatever' }, () => { - expect(tracing.getActiveBaggage()).toBeUndefined(); + const ctx = tracing.propagation.extract(tracing.context.active(), { + traceparent: 'whatever', + }); + await tracing.context.with(ctx, () => { + expect(tracing.propagation.getActiveBaggage()).toBeUndefined(); }); }); }); diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 9f43c7149f..69125a7a2f 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -22,22 +22,38 @@ import { TracingService, TracingServiceAttributeValue, TracingServiceBaggage, + TracingServiceContext, + TracingServiceContextAPI, + TracingServicePropagationAPI, TracingServiceSpan, TracingServiceSpanStatus, tracingServiceRef, } from '@backstage/backend-plugin-api/alpha'; import { tracingServiceFactory } from '@backstage/backend-defaults/alpha'; +// Internal context shape used by the mock. The opaque `TracingServiceContext` +// is just this object cast to the public type. +interface MockContext { + baggage?: TracingServiceBaggage; +} + +function toMockContext(ctx: TracingServiceContext): MockContext { + return ctx as unknown as MockContext; +} +function fromMockContext(ctx: MockContext): TracingServiceContext { + return ctx as unknown as TracingServiceContext; +} + // 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, + carrier: Record, ): TracingServiceBaggage | undefined { let raw: string | undefined; - for (const [name, value] of Object.entries(headers)) { + for (const [name, value] of Object.entries(carrier)) { if (name.toLowerCase() !== 'baggage') continue; raw = Array.isArray(value) ? value[0] : value; break; @@ -72,27 +88,55 @@ export interface MockedTracingServiceSpan extends TracingServiceSpan { setStatus: jest.Mock; } +/** + * Jest-mocked counterpart of the `context` member on the + * `TracingService`. + * + * @alpha + */ +export interface MockedTracingServiceContextAPI + extends TracingServiceContextAPI { + active: jest.MockedFunction; + with: jest.MockedFunction; +} + +/** + * Jest-mocked counterpart of the `propagation` member on the + * `TracingService`. + * + * @alpha + */ +export interface MockedTracingServicePropagationAPI + extends TracingServicePropagationAPI { + extract: jest.MockedFunction; + getBaggage: jest.MockedFunction; + getActiveBaggage: jest.MockedFunction< + TracingServicePropagationAPI['getActiveBaggage'] + >; +} + /** * Mock for the `TracingService`. Captures every span created via * `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. + * By default, `propagation.extract` parses the `baggage` header (W3C + * Baggage syntax) out of the supplied carrier and stashes the entries + * on the returned context handle. `context.with` activates that handle + * for the duration of the wrapped callback so + * `propagation.getActiveBaggage` (and `propagation.getBaggage` on the + * supplied handle) returns those entries. Other propagation fields + * (e.g. `traceparent`) are ignored. Tests that need fully custom + * baggage can still override `propagation.getActiveBaggage` via + * `mockReturnValue` / `mockImplementation`, which takes precedence over + * the default behaviour. * * @alpha */ export interface TracingServiceMock extends TracingService { startActiveSpan: jest.MockedFunction; - withPropagatedContext: jest.MockedFunction< - TracingService['withPropagatedContext'] - >; - getActiveBaggage: jest.MockedFunction; + context: MockedTracingServiceContextAPI; + propagation: MockedTracingServicePropagationAPI; /** Spans created by `startActiveSpan` calls, in order. */ spans: MockedTracingServiceSpan[]; factory: ServiceFactory; @@ -113,35 +157,74 @@ export namespace tracingServiceMock { */ export const mock = (): TracingServiceMock => { const spans: MockedTracingServiceSpan[] = []; - const startActiveSpan = jest.fn(async (_name, fn, _options) => { - const span: MockedTracingServiceSpan = { - setAttribute: jest.fn(), - setStatus: jest.fn(), - }; - spans.push(span); - return await fn(span); - }) as TracingServiceMock['startActiveSpan']; + const startActiveSpan = jest.fn( + async ( + _name: string, + optionsOrFn: unknown, + maybeFn?: (span: MockedTracingServiceSpan) => unknown, + ) => { + const fn = ( + typeof optionsOrFn === 'function' ? optionsOrFn : maybeFn + ) as (span: MockedTracingServiceSpan) => unknown; + const span: MockedTracingServiceSpan = { + setAttribute: jest.fn(), + setStatus: jest.fn(), + }; + spans.push(span); + return await fn(span); + }, + ) as unknown as TracingServiceMock['startActiveSpan']; - const baggageStack: Array = []; - const withPropagatedContext = jest.fn(async (headers, fn) => { - baggageStack.push(parseBaggageHeader(headers)); + const contextStack: MockContext[] = [{}]; + + const active = jest.fn(() => + fromMockContext(contextStack[contextStack.length - 1]), + ) as MockedTracingServiceContextAPI['active']; + + const withFn = jest.fn(async (ctx, fn) => { + contextStack.push(toMockContext(ctx)); try { return await fn(); } finally { - baggageStack.pop(); + contextStack.pop(); } - }) as TracingServiceMock['withPropagatedContext']; - const getActiveBaggage = jest.fn( - () => baggageStack[baggageStack.length - 1], - ) as TracingServiceMock['getActiveBaggage']; + }) as MockedTracingServiceContextAPI['with']; - const service: TracingService = { - startActiveSpan, - withPropagatedContext, + const extract = jest.fn((ctx, carrier) => { + const baggage = parseBaggageHeader(carrier); + // Carry forward the parsed baggage; preserve any baggage already on the + // supplied handle if the carrier doesn't include one. + const base = toMockContext(ctx); + return fromMockContext({ baggage: baggage ?? base.baggage }); + }) as MockedTracingServicePropagationAPI['extract']; + + const getBaggage = jest.fn( + ctx => toMockContext(ctx).baggage, + ) as MockedTracingServicePropagationAPI['getBaggage']; + + const getActiveBaggage = jest.fn( + () => contextStack[contextStack.length - 1].baggage, + ) as MockedTracingServicePropagationAPI['getActiveBaggage']; + + const context: MockedTracingServiceContextAPI = { + active, + with: withFn, + }; + const propagation: MockedTracingServicePropagationAPI = { + extract, + getBaggage, getActiveBaggage, }; + const service: TracingService = { + startActiveSpan, + context, + propagation, + }; + return Object.assign(service as TracingServiceMock, { + context, + propagation, spans, factory: createServiceFactory({ service: tracingServiceRef, diff --git a/packages/backend-test-utils/src/alpha/services/index.ts b/packages/backend-test-utils/src/alpha/services/index.ts index c8861c86a4..e4c88f0b09 100644 --- a/packages/backend-test-utils/src/alpha/services/index.ts +++ b/packages/backend-test-utils/src/alpha/services/index.ts @@ -22,5 +22,7 @@ export { tracingServiceMock, type TracingServiceMock, type MockedTracingServiceSpan, + type MockedTracingServiceContextAPI, + type MockedTracingServicePropagationAPI, } from './TracingServiceMock'; export { type ServiceMock } from './alphaCreateServiceMock'; diff --git a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts index d0f6411046..25418c69e9 100644 --- a/plugins/mcp-actions-backend/src/routers/createSseRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createSseRouter.ts @@ -68,7 +68,11 @@ export const createSseRouter = ({ const transport = transportsToSessionId.get(sessionId); if (transport) { - await tracing.withPropagatedContext(req.headers, () => + const ctx = tracing.propagation.extract( + tracing.context.active(), + req.headers, + ); + await tracing.context.with(ctx, () => transport.handlePostMessage(req, res, req.body), ); } else { diff --git a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts index 1c71703ddf..61c9951b6d 100644 --- a/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts +++ b/plugins/mcp-actions-backend/src/routers/createStreamableRouter.ts @@ -77,7 +77,11 @@ export const createStreamableRouter = ({ }); await server.connect(transport); - await tracing.withPropagatedContext(req.headers, () => + const ctx = tracing.propagation.extract( + tracing.context.active(), + req.headers, + ); + await tracing.context.with(ctx, () => transport.handleRequest(req, res, req.body), ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index ae4a1770a4..7278bf276e 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -981,7 +981,7 @@ describe('McpService', () => { await invokeMockAction({ tracing, credentials }); expect(tracing.startActiveSpan).toHaveBeenCalledTimes(1); - const [name, , options] = tracing.startActiveSpan.mock.calls[0]; + const [name, options] = tracing.startActiveSpan.mock.calls[0]; expect(name).toBe('tools/call test.mock-action'); expect(options?.kind).toBe('server'); expect(options?.attributes).toEqual( @@ -1012,7 +1012,7 @@ describe('McpService', () => { it('includes gen_ai baggage entries as span attributes when present', async () => { const tracing = tracingServiceMock.mock(); - tracing.getActiveBaggage.mockReturnValue({ + tracing.propagation.getActiveBaggage.mockReturnValue({ getEntry: (key: string) => { const entries: Record = { 'gen_ai.conversation.id': { value: 'conv-123' }, @@ -1028,14 +1028,14 @@ describe('McpService', () => { await invokeMockAction({ tracing }); - const [, , options] = tracing.startActiveSpan.mock.calls[0]; + 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({ + tracing.propagation.getActiveBaggage.mockReturnValue({ getEntry: (key: string) => { const entries: Record = { 'gen_ai.conversation.id': { value: 'conv-123' }, @@ -1055,7 +1055,7 @@ describe('McpService', () => { await invokeMockAction({ tracing }); - const [, , options] = tracing.startActiveSpan.mock.calls[0]; + 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'); @@ -1066,7 +1066,7 @@ describe('McpService', () => { const tracing = tracingServiceMock.mock(); await invokeMockAction({ tracing }); - const [, , options] = tracing.startActiveSpan.mock.calls[0]; + 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'); }); @@ -1075,7 +1075,7 @@ describe('McpService', () => { const tracing = tracingServiceMock.mock(); await invokeMockAction({ tracing, captureToolPayloads: true }); - const [, , options] = tracing.startActiveSpan.mock.calls[0]; + const [, options] = tracing.startActiveSpan.mock.calls[0]; expect(options?.attributes?.['gen_ai.tool.call.arguments']).toBe( JSON.stringify({ input: 'val' }), ); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 693f0b90a4..09685fed1d 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -57,7 +57,7 @@ const PROPAGATED_BAGGAGE_ATTRIBUTES: readonly string[] = [ function baggageAttributes( tracingService: TracingService, ): Record { - const baggage = tracingService.getActiveBaggage(); + const baggage = tracingService.propagation.getActiveBaggage(); if (!baggage) return {}; const attrs: Record = {}; for (const key of PROPAGATED_BAGGAGE_ATTRIBUTES) { @@ -189,6 +189,19 @@ export class McpService { try { return await this.tracingService.startActiveSpan( `tools/call ${params.name}`, + { + kind: 'server', + credentials, + attributes: { + ...baggageAttributes(this.tracingService), + 'mcp.method.name': 'tools/call', + 'gen_ai.tool.name': params.name, + 'gen_ai.operation.name': 'execute_tool', + ...(this.captureToolPayloads && { + 'gen_ai.tool.call.arguments': safeStringify(params.arguments), + }), + }, + }, async span => { const result = await handleErrors(async () => { const { actions: allActions } = await this.actions.list({ @@ -249,19 +262,6 @@ export class McpService { } return result; }, - { - kind: 'server', - credentials, - attributes: { - ...baggageAttributes(this.tracingService), - 'mcp.method.name': 'tools/call', - 'gen_ai.tool.name': params.name, - 'gen_ai.operation.name': 'execute_tool', - ...(this.captureToolPayloads && { - 'gen_ai.tool.call.arguments': safeStringify(params.arguments), - }), - }, - }, ); } catch (err) { errorType = err instanceof Error ? err.name : 'Error'; From 3914351b84b8f96077b2254762e55b57e396d222 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 18 May 2026 16:50:14 +0200 Subject: [PATCH 5/8] Make tool payload tracing config key more verbose Signed-off-by: Eric Peterson --- docs/ai/mcp-actions.md | 4 ++-- plugins/mcp-actions-backend/config.d.ts | 6 +++--- plugins/mcp-actions-backend/src/plugin.ts | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index 96965f0325..187f4384d1 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -326,13 +326,13 @@ This flag is honored by every plugin that creates spans through the [Tracing Ser ### Capturing tool arguments and results -When `backend.tracing.capture.toolPayloads` is enabled, the tool's input arguments and output result are recorded on the span as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. +When `backend.tracing.capture.mcpActionsToolPayloads` is enabled, the tool's input arguments and output result are recorded on the span as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. ```yaml title="app-config.yaml" backend: tracing: capture: - toolPayloads: true # defaults to false + mcpActionsToolPayloads: true # defaults to false ``` :::warning diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index a20ab6d4b8..cf70e187ad 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -19,15 +19,15 @@ export interface Config { tracing?: { capture?: { /** - * When true, the tool call's input arguments and output result are - * included on the MCP `tools/call` server span as + * When true, the MCP tool call's input arguments and output result + * are included on the MCP `tools/call` server span as * `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. * These attributes are marked Opt-In by the OpenTelemetry GenAI * semantic conventions because they may contain sensitive * information (entity payloads, scaffolder inputs, free-form * text). Defaults to false. */ - toolPayloads?: boolean; + mcpActionsToolPayloads?: boolean; }; }; }; diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index 526474e5b4..bbc2c6719d 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -68,8 +68,9 @@ export const mcpPlugin = createBackendPlugin({ 'mcpActions.namespacedToolNames', ); const captureToolPayloads = - config.getOptionalBoolean('backend.tracing.capture.toolPayloads') ?? - false; + config.getOptionalBoolean( + 'backend.tracing.capture.mcpActionsToolPayloads', + ) ?? false; const mcpService = await McpService.create({ actions, From 00bdd871a37dbc63c83f3ef840e11ff64a5252f0 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 18 May 2026 16:50:43 +0200 Subject: [PATCH 6/8] Simplify baggage getting to a single method Signed-off-by: Eric Peterson --- .../tracing/DefaultTracingService.test.ts | 12 ++----- .../tracing/DefaultTracingService.ts | 4 --- .../backend-plugin-api/report-alpha.api.md | 4 +-- .../src/alpha/TracingService.ts | 5 +-- .../alpha/services/TracingServiceMock.test.ts | 36 ++++++++----------- .../src/alpha/services/TracingServiceMock.ts | 1 - .../src/services/McpService.test.ts | 16 --------- .../src/services/McpService.ts | 9 +++-- 8 files changed, 23 insertions(+), 64 deletions(-) diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts index 61332a58b0..a03e7bc031 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.test.ts @@ -351,13 +351,11 @@ describe('DefaultTracingService', () => { describe('getActiveBaggage', () => { it('returns a read-only baggage wrapping the active context baggage', () => { const mockBaggage = { - getEntry: jest.fn((key: string) => - key === 'gen_ai.conversation.id' ? { value: 'conv-1' } : undefined, - ), getAllEntries: jest.fn(() => [ ['gen_ai.conversation.id', { value: 'conv-1' }], ['gen_ai.agent.id', { value: 'agent-2' }], ]), + getEntry: jest.fn(), setEntry: jest.fn(), removeEntry: jest.fn(), removeEntries: jest.fn(), @@ -371,10 +369,6 @@ describe('DefaultTracingService', () => { const baggage = service.propagation.getActiveBaggage(); expect(baggage).toBeDefined(); - expect(baggage!.getEntry('gen_ai.conversation.id')).toEqual({ - value: 'conv-1', - }); - expect(baggage!.getEntry('unknown')).toBeUndefined(); expect(baggage!.getAllEntries()).toEqual([ ['gen_ai.conversation.id', { value: 'conv-1' }], ['gen_ai.agent.id', { value: 'agent-2' }], @@ -386,8 +380,8 @@ describe('DefaultTracingService', () => { it('returns baggage from the supplied context', () => { const ctx = { __ctx: 'has-baggage' } as any; const mockBaggage = { - getEntry: jest.fn(() => ({ value: 'ctx-val' })), getAllEntries: jest.fn(() => [['k', { value: 'ctx-val' }]]), + getEntry: jest.fn(), setEntry: jest.fn(), removeEntry: jest.fn(), removeEntries: jest.fn(), @@ -401,7 +395,7 @@ describe('DefaultTracingService', () => { const baggage = service.propagation.getBaggage(ctx); expect(getBaggageSpy).toHaveBeenCalledWith(ctx); - expect(baggage!.getEntry('k')).toEqual({ value: 'ctx-val' }); + expect(baggage!.getAllEntries()).toEqual([['k', { value: 'ctx-val' }]]); }); it('returns undefined when the context has no baggage', () => { diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index 48e30fdc2c..c58306f8ae 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -69,10 +69,6 @@ function wrapOtelBaggage( ): TracingServiceBaggage | undefined { if (!baggage) return undefined; return { - getEntry: (key: string) => { - const entry = baggage.getEntry(key); - return entry ? { value: entry.value } : undefined; - }, getAllEntries: () => baggage .getAllEntries() diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 41a9d6160d..53ebf99f51 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -324,8 +324,6 @@ export type TracingServiceAttributeValue = export interface TracingServiceBaggage { // (undocumented) getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; - // (undocumented) - getEntry(key: string): TracingServiceBaggageEntry | undefined; } // @alpha @@ -337,7 +335,7 @@ export interface TracingServiceBaggageEntry { // @alpha export interface TracingServiceContext { // (undocumented) - readonly [tracingServiceContextBrand]: never; + readonly $$type: '@backstage/TracingServiceContext'; } // @alpha diff --git a/packages/backend-plugin-api/src/alpha/TracingService.ts b/packages/backend-plugin-api/src/alpha/TracingService.ts index f751b776bd..3b159f975c 100644 --- a/packages/backend-plugin-api/src/alpha/TracingService.ts +++ b/packages/backend-plugin-api/src/alpha/TracingService.ts @@ -176,8 +176,6 @@ export interface TracingServicePropagationAPI { getActiveBaggage(): TracingServiceBaggage | undefined; } -declare const tracingServiceContextBrand: unique symbol; - /** * Opaque handle representing a tracing context. Consumers receive * these from {@link TracingServiceContextAPI.active} or @@ -187,7 +185,7 @@ declare const tracingServiceContextBrand: unique symbol; * @alpha */ export interface TracingServiceContext { - readonly [tracingServiceContextBrand]: never; + readonly $$type: '@backstage/TracingServiceContext'; } /** @@ -196,7 +194,6 @@ export interface TracingServiceContext { * @alpha */ export interface TracingServiceBaggage { - getEntry(key: string): TracingServiceBaggageEntry | undefined; getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; } diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts index 5fb711a636..7e3cd7fa92 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts @@ -26,29 +26,22 @@ describe('tracingServiceMock', () => { }); // Baggage is reachable directly off the extracted handle. - expect( - tracing.propagation.getBaggage(ctx)?.getEntry('gen_ai.agent.id'), - ).toEqual({ value: 'agent-456' }); + expect(tracing.propagation.getBaggage(ctx)?.getAllEntries()).toEqual([ + ['gen_ai.conversation.id', { value: 'conv-123' }], + ['gen_ai.agent.id', { value: 'agent-456' }], + ]); - const seen = await tracing.context.with(ctx, () => { - const baggage = tracing.propagation.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]), - }; - }); + const seen = await tracing.context.with(ctx, () => + tracing.propagation + .getActiveBaggage() + ?.getAllEntries() + .map(([k, v]) => [k, v.value]), + ); - expect(seen).toEqual({ - conv: 'conv-123', - agent: 'agent-456', - missing: undefined, - all: [ - ['gen_ai.conversation.id', { value: 'conv-123' }], - ['gen_ai.agent.id', { value: 'agent-456' }], - ].map(([k, v]) => [k, (v as { value: string }).value]), - }); + expect(seen).toEqual([ + ['gen_ai.conversation.id', 'conv-123'], + ['gen_ai.agent.id', 'agent-456'], + ]); // Baggage is scoped to the context.with callback. expect(tracing.propagation.getActiveBaggage()).toBeUndefined(); @@ -57,7 +50,6 @@ describe('tracingServiceMock', () => { 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 }] diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index 69125a7a2f..b964e09b33 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -73,7 +73,6 @@ function parseBaggageHeader( if (entries.size === 0) return undefined; return { - getEntry: key => entries.get(key), getAllEntries: () => Array.from(entries.entries()), }; } diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 7278bf276e..8a84bd9198 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -1013,13 +1013,6 @@ describe('McpService', () => { it('includes gen_ai baggage entries as span attributes when present', async () => { const tracing = tracingServiceMock.mock(); tracing.propagation.getActiveBaggage.mockReturnValue({ - getEntry: (key: string) => { - const entries: Record = { - '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' }], @@ -1036,15 +1029,6 @@ describe('McpService', () => { it('only forwards allowlisted baggage keys onto the span', async () => { const tracing = tracingServiceMock.mock(); tracing.propagation.getActiveBaggage.mockReturnValue({ - getEntry: (key: string) => { - const entries: Record = { - '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' }], diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 09685fed1d..25e1a7002d 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -46,13 +46,13 @@ 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[] = [ +const PROPAGATED_BAGGAGE_ATTRIBUTES: ReadonlySet = new Set([ 'gen_ai.agent.id', 'gen_ai.agent.name', 'gen_ai.conversation.id', 'gen_ai.provider.name', 'gen_ai.request.model', -]; +]); function baggageAttributes( tracingService: TracingService, @@ -60,9 +60,8 @@ function baggageAttributes( const baggage = tracingService.propagation.getActiveBaggage(); if (!baggage) return {}; const attrs: Record = {}; - for (const key of PROPAGATED_BAGGAGE_ATTRIBUTES) { - const entry = baggage.getEntry(key); - if (entry) { + for (const [key, entry] of baggage.getAllEntries()) { + if (PROPAGATED_BAGGAGE_ATTRIBUTES.has(key)) { attrs[key] = entry.value; } } From 0422010a3772efa3c4b88ce178e518b2e08c3a51 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Mon, 18 May 2026 17:12:19 +0200 Subject: [PATCH 7/8] Address valid review comments Signed-off-by: Eric Peterson --- docs/backend-system/core-services/tracing.md | 18 +++++------ .../tracing/DefaultTracingService.ts | 7 +++++ .../src/alpha/services/TracingServiceMock.ts | 6 ++++ .../src/services/McpService.test.ts | 31 +++++++++++++++++++ .../src/services/McpService.ts | 13 +++++++- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md index 52d2d3069f..47dc7e6714 100644 --- a/docs/backend-system/core-services/tracing.md +++ b/docs/backend-system/core-services/tracing.md @@ -129,13 +129,14 @@ The context returned by `propagation.extract` and `context.active` is an opaque ## Reading Baggage -Use `propagation.getActiveBaggage()` to read baggage entries from the currently active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage: +Use `propagation.getActiveBaggage()` to read baggage entries from the currently active context. This is useful for forwarding caller-set metadata onto your spans — for example, a request ID, tenant identifier, or feature-flag context that the caller propagated via baggage. The baggage is exposed as a flat list of entries — iterate through them to find the keys you care about: ```ts const baggage = tracing.propagation.getActiveBaggage(); -const tenantId = baggage?.getEntry('app.tenant.id')?.value; -if (tenantId) { - span.setAttribute('app.tenant.id', tenantId); +for (const [key, entry] of baggage?.getAllEntries() ?? []) { + if (key === 'app.tenant.id') { + span.setAttribute('app.tenant.id', entry.value); + } } ``` @@ -143,12 +144,11 @@ Use `propagation.getBaggage(ctx)` when you already hold a specific context handl The returned object exposes: -| Method | Description | -| ----------------- | -------------------------------------------------------- | -| `getEntry(key)` | Returns `{ value: string }` for the key, or `undefined`. | -| `getAllEntries()` | Returns all entries as `[key, { value }][]`. | +| Method | Description | +| ----------------- | -------------------------------------------- | +| `getAllEntries()` | Returns all entries as `[key, { value }][]`. | -Both calls return `undefined` when no baggage is present. +Both calls return `undefined` when no baggage is present. Single-key lookups are intentionally not provided — baggage is meant for bridging caller metadata onto spans or metrics, not as a general-purpose key-value store. ## Principal Enrichment diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index c58306f8ae..e3d2733260 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -89,6 +89,13 @@ export class DefaultTracingService implements TracingService { readonly context: TracingServiceContextAPI = { active: () => fromOtelContext(otelContext.active()), + // `otelContext.with` is synchronous: it activates `ctx`, invokes `fn`, + // then restores the previous active context before this call returns. + // When `fn` is async, the AsyncLocalStorage context manager installed + // by the OTel SDK is what keeps `ctx` active across the callback's + // `await`s. If no context manager is registered (e.g. in a test that + // does not wire up the OTel SDK) the `await` continuations will run + // outside `ctx`. with: async ( ctx: TracingServiceContext, fn: () => T | Promise, diff --git a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts index b964e09b33..df19213022 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -130,6 +130,12 @@ export interface MockedTracingServicePropagationAPI * `mockReturnValue` / `mockImplementation`, which takes precedence over * the default behaviour. * + * Unlike the real `DefaultTracingService`, the mock's `startActiveSpan` + * does **not** resolve `options.credentials` from `options.request` via + * `httpAuth`. Tests that need principal-derived span attributes should + * supply `options.credentials` directly on the span options, or assert + * on the raw `options` captured by `startActiveSpan.mock.calls`. + * * @alpha */ export interface TracingServiceMock extends TracingService { diff --git a/plugins/mcp-actions-backend/src/services/McpService.test.ts b/plugins/mcp-actions-backend/src/services/McpService.test.ts index 8a84bd9198..d96e9c7285 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.test.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.test.ts @@ -1055,6 +1055,37 @@ describe('McpService', () => { expect(options?.attributes).not.toHaveProperty('gen_ai.agent.id'); }); + it('threads baggage end-to-end from a propagated baggage header through context.with into the tool span', async () => { + const tracing = tracingServiceMock.mock(); + // Simulate what the routers do on incoming requests: extract context + // from headers and run the handler with that context active. + const ctx = tracing.propagation.extract(tracing.context.active(), { + baggage: 'gen_ai.conversation.id=conv-end-to-end', + }); + await tracing.context.with(ctx, () => invokeMockAction({ tracing })); + + const [, options] = tracing.startActiveSpan.mock.calls[0]; + expect(options?.attributes?.['gen_ai.conversation.id']).toBe( + 'conv-end-to-end', + ); + }); + + it('truncates overlong baggage values before stamping them on the span', async () => { + const tracing = tracingServiceMock.mock(); + const longValue = 'a'.repeat(1024); + tracing.propagation.getActiveBaggage.mockReturnValue({ + getAllEntries: () => [['gen_ai.conversation.id', { value: longValue }]], + }); + + await invokeMockAction({ tracing }); + + const [, options] = tracing.startActiveSpan.mock.calls[0]; + const recorded = options?.attributes?.['gen_ai.conversation.id']; + expect(typeof recorded).toBe('string'); + expect((recorded as string).length).toBe(256); + expect(recorded).toBe('a'.repeat(256)); + }); + 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 }); diff --git a/plugins/mcp-actions-backend/src/services/McpService.ts b/plugins/mcp-actions-backend/src/services/McpService.ts index 25e1a7002d..9d907f8258 100644 --- a/plugins/mcp-actions-backend/src/services/McpService.ts +++ b/plugins/mcp-actions-backend/src/services/McpService.ts @@ -54,6 +54,12 @@ const PROPAGATED_BAGGAGE_ATTRIBUTES: ReadonlySet = new Set([ 'gen_ai.request.model', ]); +// Cap each forwarded baggage value before it lands on a span attribute. +// Baggage values are caller-controlled strings of unbounded length; +// allowlisting keys protects against arbitrary attribute names but not +// against pathologically large values inflating exported span sizes. +const BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH = 256; + function baggageAttributes( tracingService: TracingService, ): Record { @@ -62,7 +68,7 @@ function baggageAttributes( const attrs: Record = {}; for (const [key, entry] of baggage.getAllEntries()) { if (PROPAGATED_BAGGAGE_ATTRIBUTES.has(key)) { - attrs[key] = entry.value; + attrs[key] = entry.value.slice(0, BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH); } } return attrs; @@ -219,6 +225,11 @@ export class McpService { } // Re-attribute the span to the plugin that owns the action. + // This runs after the span has started, so head-based samplers + // still see the default `mcp-actions` value when deciding + // whether to record the span. The pluginId is only known after + // resolving the action via `actions.list`, so the reattribution + // is unavoidable. span.setAttribute('backstage.plugin.id', action.pluginId); const { output } = await this.actions.invoke({ From 828d3d4282a80ce091fee830945dac8ec003f648 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Tue, 19 May 2026 09:23:35 +0200 Subject: [PATCH 8/8] Put tool payload capture config under plugin namespace Signed-off-by: Eric Peterson --- docs/ai/mcp-actions.md | 6 ++--- plugins/mcp-actions-backend/config.d.ts | 32 +++++++++++------------ plugins/mcp-actions-backend/src/plugin.ts | 5 ++-- 3 files changed, 20 insertions(+), 23 deletions(-) diff --git a/docs/ai/mcp-actions.md b/docs/ai/mcp-actions.md index 187f4384d1..c22f1032ff 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -326,13 +326,13 @@ This flag is honored by every plugin that creates spans through the [Tracing Ser ### Capturing tool arguments and results -When `backend.tracing.capture.mcpActionsToolPayloads` is enabled, the tool's input arguments and output result are recorded on the span as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. +When `mcpActions.tracing.capture.toolPayload` is enabled, the tool's input arguments and output result are recorded on the span as `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. ```yaml title="app-config.yaml" -backend: +mcpActions: tracing: capture: - mcpActionsToolPayloads: true # defaults to false + toolPayload: true # defaults to false ``` :::warning diff --git a/plugins/mcp-actions-backend/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index cf70e187ad..ffb9d23571 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -15,23 +15,6 @@ */ export interface Config { - backend?: { - tracing?: { - capture?: { - /** - * When true, the MCP tool call's input arguments and output result - * are included on the MCP `tools/call` server span as - * `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. - * These attributes are marked Opt-In by the OpenTelemetry GenAI - * semantic conventions because they may contain sensitive - * information (entity payloads, scaffolder inputs, free-form - * text). Defaults to false. - */ - mcpActionsToolPayloads?: boolean; - }; - }; - }; - mcpActions?: { /** * Display name for the MCP server. Defaults to "backstage". @@ -53,6 +36,21 @@ export interface Config { */ namespacedToolNames?: boolean; + tracing?: { + capture?: { + /** + * When true, the MCP tool call's input arguments and output result + * are included on the MCP `tools/call` server span as + * `gen_ai.tool.call.arguments` and `gen_ai.tool.call.result`. + * These attributes are marked Opt-In by the OpenTelemetry GenAI + * semantic conventions because they may contain sensitive + * information (entity payloads, scaffolder inputs, free-form + * text). Defaults to false. + */ + toolPayload?: boolean; + }; + }; + /** * Named MCP servers, each exposed at /api/mcp-actions/v1/{key}. * When not configured, the plugin serves a single server at /api/mcp-actions/v1. diff --git a/plugins/mcp-actions-backend/src/plugin.ts b/plugins/mcp-actions-backend/src/plugin.ts index bbc2c6719d..65e15f504d 100644 --- a/plugins/mcp-actions-backend/src/plugin.ts +++ b/plugins/mcp-actions-backend/src/plugin.ts @@ -68,9 +68,8 @@ export const mcpPlugin = createBackendPlugin({ 'mcpActions.namespacedToolNames', ); const captureToolPayloads = - config.getOptionalBoolean( - 'backend.tracing.capture.mcpActionsToolPayloads', - ) ?? false; + config.getOptionalBoolean('mcpActions.tracing.capture.toolPayload') ?? + false; const mcpService = await McpService.create({ actions,