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/.changeset/tracing-service-context-and-baggage.md b/.changeset/tracing-service-context-and-baggage.md new file mode 100644 index 0000000000..86d1a62168 --- /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 `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/ai/mcp-actions.md b/docs/ai/mcp-actions.md index b170efde5c..01db708ba3 100644 --- a/docs/ai/mcp-actions.md +++ b/docs/ai/mcp-actions.md @@ -302,3 +302,51 @@ 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`). 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**: + +```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 `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" +mcpActions: + tracing: + capture: + toolPayload: 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/docs/backend-system/core-services/tracing.md b/docs/backend-system/core-services/tracing.md index 66c6a35d3d..47dc7e6714 100644 --- a/docs/backend-system/core-services/tracing.md +++ b/docs/backend-system/core-services/tracing.md @@ -100,6 +100,56 @@ 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 + +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) => { + 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 `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(); +for (const [key, entry] of baggage?.getAllEntries() ?? []) { + if (key === 'app.tenant.id') { + span.setAttribute('app.tenant.id', entry.value); + } +} +``` + +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 | +| ----------------- | -------------------------------------------- | +| `getAllEntries()` | Returns all entries as `[key, { value }][]`. | + +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 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..a03e7bc031 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'; @@ -88,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', @@ -113,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'); @@ -140,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'); @@ -160,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'); @@ -170,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'); @@ -189,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; @@ -208,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; @@ -267,4 +290,119 @@ describe('DefaultTracingService', () => { expect(value).toBe(42); expect(mocks.span.end).toHaveBeenCalledTimes(1); }); + + 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); + }); + }); + + 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('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); + }); + }); + + describe('getActiveBaggage', () => { + it('returns a read-only baggage wrapping the active context baggage', () => { + const mockBaggage = { + 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(), + clear: jest.fn(), + }; + jest + .spyOn(propagation, 'getActiveBaggage') + .mockReturnValue(mockBaggage as any); + + const service = createService(); + const baggage = service.propagation.getActiveBaggage(); + + expect(baggage).toBeDefined(); + expect(baggage!.getAllEntries()).toEqual([ + ['gen_ai.conversation.id', { value: 'conv-1' }], + ['gen_ai.agent.id', { value: 'agent-2' }], + ]); + }); + }); + + describe('getBaggage', () => { + it('returns baggage from the supplied context', () => { + const ctx = { __ctx: 'has-baggage' } as any; + const mockBaggage = { + getAllEntries: jest.fn(() => [['k', { value: 'ctx-val' }]]), + getEntry: jest.fn(), + 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!.getAllEntries()).toEqual([['k', { 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(); + }); + }); + }); }); diff --git a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts index 77049cedfd..e3d2733260 100644 --- a/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts +++ b/packages/backend-defaults/src/alpha/entrypoints/tracing/DefaultTracingService.ts @@ -14,7 +14,15 @@ * limitations under the License. */ -import { SpanKind, SpanStatusCode, Tracer, trace } from '@opentelemetry/api'; +import { + Context, + SpanKind, + SpanStatusCode, + Tracer, + context as otelContext, + propagation as otelPropagation, + trace, +} from '@opentelemetry/api'; import { BackstageCredentials, HttpAuthService, @@ -22,6 +30,10 @@ import { import { TracingService, TracingServiceAttributes, + TracingServiceBaggage, + TracingServiceContext, + TracingServiceContextAPI, + TracingServicePropagationAPI, TracingServiceSpan, TracingServiceSpanKind, TracingServiceSpanOptions, @@ -42,6 +54,28 @@ 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 { + getAllEntries: () => + baggage + .getAllEntries() + .map(([key, entry]) => [key, { value: entry.value }]), + }; +} + /** * Default implementation of the {@link TracingService} interface. * @@ -53,6 +87,32 @@ export class DefaultTracingService implements TracingService { private readonly captureEndUser: boolean; private readonly httpAuth: HttpAuthService; + 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, + ): 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() @@ -66,11 +126,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); diff --git a/packages/backend-plugin-api/report-alpha.api.md b/packages/backend-plugin-api/report-alpha.api.md index 0603f4310e..53ebf99f51 100644 --- a/packages/backend-plugin-api/report-alpha.api.md +++ b/packages/backend-plugin-api/report-alpha.api.md @@ -292,10 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef< // @alpha export interface TracingService { + readonly context: TracingServiceContextAPI; + readonly propagation: TracingServicePropagationAPI; startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, - options?: TracingServiceSpanOptions, + ): Promise; + startActiveSpan( + name: string, + options: TracingServiceSpanOptions, + fn: (span: TracingServiceSpan) => T | Promise, ): Promise; } @@ -314,6 +320,40 @@ export type TracingServiceAttributeValue = | Array | Array; +// @alpha +export interface TracingServiceBaggage { + // (undocumented) + getAllEntries(): Array<[string, TracingServiceBaggageEntry]>; +} + +// @alpha +export interface TracingServiceBaggageEntry { + // (undocumented) + value: string; +} + +// @alpha +export interface TracingServiceContext { + // (undocumented) + readonly $$type: '@backstage/TracingServiceContext'; +} + +// @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 e0ccf26116..3b159f975c 100644 --- a/packages/backend-plugin-api/src/alpha/TracingService.ts +++ b/packages/backend-plugin-api/src/alpha/TracingService.ts @@ -104,6 +104,104 @@ export interface TracingService { startActiveSpan( name: string, fn: (span: TracingServiceSpan) => T | Promise, - options?: TracingServiceSpanOptions, ): Promise; + /** + * Runs `fn` inside a new active span configured by `options`. The + * span is finished when `fn` resolves or throws. + */ + startActiveSpan( + name: string, + options: TracingServiceSpanOptions, + fn: (span: TracingServiceSpan) => T | Promise, + ): Promise; + + /** + * 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; +} + +/** + * 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 $$type: '@backstage/TracingServiceContext'; +} + +/** + * A read-only view of propagated baggage entries. + * + * @alpha + */ +export interface TracingServiceBaggage { + 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..1e2a03a04f 100644 --- a/packages/backend-plugin-api/src/alpha/index.ts +++ b/packages/backend-plugin-api/src/alpha/index.ts @@ -50,6 +50,11 @@ export type { TracingService, TracingServiceAttributeValue, TracingServiceAttributes, + TracingServiceBaggage, + TracingServiceBaggageEntry, + TracingServiceContext, + TracingServiceContextAPI, + TracingServicePropagationAPI, 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..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,8 +130,12 @@ export type ServiceMock = { // @alpha export interface TracingServiceMock extends TracingService { + // (undocumented) + context: MockedTracingServiceContextAPI; // (undocumented) factory: ServiceFactory; + // (undocumented) + propagation: MockedTracingServicePropagationAPI; spans: MockedTracingServiceSpan[]; // (undocumented) startActiveSpan: jest.MockedFunction; 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..7e3cd7fa92 --- /dev/null +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.test.ts @@ -0,0 +1,79 @@ +/* + * 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 via propagation.extract and exposes it via getActiveBaggage inside context.with', async () => { + const tracing = tracingServiceMock.mock(); + + 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)?.getAllEntries()).toEqual([ + ['gen_ai.conversation.id', { value: 'conv-123' }], + ['gen_ai.agent.id', { value: 'agent-456' }], + ]); + + const seen = await tracing.context.with(ctx, () => + tracing.propagation + .getActiveBaggage() + ?.getAllEntries() + .map(([k, v]) => [k, v.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(); + }); + + it('honours mockReturnValue overrides for getActiveBaggage', async () => { + const tracing = tracingServiceMock.mock(); + const override = { + getAllEntries: () => + [['gen_ai.conversation.id', { value: 'override' }]] as Array< + [string, { value: string }] + >, + }; + tracing.propagation.getActiveBaggage.mockReturnValue(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 to extract', async () => { + const tracing = tracingServiceMock.mock(); + 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 6cdc1136cc..df19213022 100644 --- a/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts +++ b/packages/backend-test-utils/src/alpha/services/TracingServiceMock.ts @@ -21,12 +21,62 @@ import { 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( + carrier: Record, +): TracingServiceBaggage | undefined { + let raw: string | undefined; + for (const [name, value] of Object.entries(carrier)) { + 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 { + getAllEntries: () => Array.from(entries.entries()), + }; +} + /** * A jest-mocked span captured by {@link TracingServiceMock}. * @@ -37,15 +87,61 @@ 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, `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. + * + * 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 { startActiveSpan: jest.MockedFunction; + context: MockedTracingServiceContextAPI; + propagation: MockedTracingServicePropagationAPI; /** Spans created by `startActiveSpan` calls, in order. */ spans: MockedTracingServiceSpan[]; factory: ServiceFactory; @@ -66,18 +162,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 service: TracingService = { startActiveSpan }; + 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 { + contextStack.pop(); + } + }) as MockedTracingServiceContextAPI['with']; + + 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/config.d.ts b/plugins/mcp-actions-backend/config.d.ts index a530f5b038..ffb9d23571 100644 --- a/plugins/mcp-actions-backend/config.d.ts +++ b/plugins/mcp-actions-backend/config.d.ts @@ -36,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.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..65e15f504d 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('mcpActions.tracing.capture.toolPayload') ?? + false; const mcpService = await McpService.create({ actions, metrics, namespacedToolNames, + tracingService: tracing, + captureToolPayloads, }); const router = Router(); @@ -81,6 +89,7 @@ export const mcpPlugin = createBackendPlugin({ httpAuth, logger, metrics, + tracing, serverConfig, }); @@ -97,6 +106,7 @@ export const mcpPlugin = createBackendPlugin({ const sseRouter = createSseRouter({ mcpService, httpAuth, + tracing, serverConfig, }); @@ -105,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..25418c69e9 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,13 @@ export const createSseRouter = ({ const transport = transportsToSessionId.get(sessionId); if (transport) { - await transport.handlePostMessage(req, res, req.body); + const ctx = tracing.propagation.extract( + tracing.context.active(), + req.headers, + ); + await tracing.context.with(ctx, () => + 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..61c9951b6d 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,13 @@ export const createStreamableRouter = ({ }); await server.connect(transport); - await transport.handleRequest(req, res, req.body); + const ctx = tracing.propagation.extract( + tracing.context.active(), + req.headers, + ); + await tracing.context.with(ctx, () => + 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 1a7dc507c3..d96e9c7285 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,246 @@ 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 gen_ai baggage entries as span attributes when present', async () => { + const tracing = tracingServiceMock.mock(); + tracing.propagation.getActiveBaggage.mockReturnValue({ + 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.propagation.getActiveBaggage.mockReturnValue({ + 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('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 }); + + 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(); + // 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 () => { + 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, + captureToolPayloads: true, + }); + + 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', + }); + // 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 49eea11d72..9d907f8258 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,63 @@ 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); + } +} + +// 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: ReadonlySet = new Set([ + 'gen_ai.agent.id', + 'gen_ai.agent.name', + 'gen_ai.conversation.id', + 'gen_ai.provider.name', + '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 { + const baggage = tracingService.propagation.getActiveBaggage(); + if (!baggage) return {}; + const attrs: Record = {}; + for (const [key, entry] of baggage.getAllEntries()) { + if (PROPAGATED_BAGGAGE_ATTRIBUTES.has(key)) { + attrs[key] = entry.value.slice(0, BAGGAGE_ATTRIBUTE_VALUE_MAX_LENGTH); + } + } + return attrs; +} + 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 +106,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 +192,87 @@ 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}`, + { + kind: 'server', credentials, - }); - const actions = serverConfig - ? this.filterActions(allActions, serverConfig) - : allActions; + 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({ + credentials, + }); + const actions = serverConfig + ? this.filterActions(allActions, serverConfig) + : allActions; - const action = actions.find(a => this.getToolName(a) === params.name); + const action = actions.find( + a => this.getToolName(a) === params.name, + ); - if (!action) { - throw new NotFoundError(`Action "${params.name}" not found`); - } + 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, - }); + // 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); - 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', - ), - }, - ], - }; - }); + const { output } = await this.actions.invoke({ + id: action.id, + input: params.arguments as JsonObject, + credentials, + }); - isError = !!(result as { isError?: boolean })?.isError; - return result; + // 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 + // 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 (isError) { + span.setAttribute('error.type', 'tool_error'); + span.setStatus({ code: 'error', message: 'tool_error' }); + } + return result; + }, + ); } catch (err) { errorType = err instanceof Error ? err.name : 'Error'; throw err;