Merge pull request #34089 from backstage/otel/mcp-tools-call
feat: Instrument MCP tool calls with semantically appropriate span
This commit is contained in:
@@ -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.
|
||||
@@ -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)`.
|
||||
@@ -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 <toolname>`, server kind, and includes the standard MCP attributes (`mcp.method.name`, `gen_ai.tool.name`, `gen_ai.operation.name`). Known Backstage errors (such as `InputError` or `NotFoundError`) are caught and returned as `isError: true` tool responses — the span is marked with `error.type=tool_error` in that case. Unhandled exceptions are recorded automatically by the Tracing Service and the span status is set to `ERROR`.
|
||||
|
||||
In addition to those attributes, the Tracing Service automatically attaches the authenticated principal's type as `backstage.principal.type` (one of `user`, `service`, or `none`). Each `tools/call` span is also attributed to the plugin that owns the invoked action via `backstage.plugin.id` (e.g. `catalog`, `scaffolder`) — overriding the default `mcp-actions` value so tracing backends can filter activity by the source plugin rather than by the MCP transport.
|
||||
|
||||
### Baggage propagation
|
||||
|
||||
The MCP Actions routers propagate OpenTelemetry context from the incoming HTTP request headers so that trace parent and baggage survive through the MCP transport layer. The following low-cardinality identifier entries from the OpenTelemetry [`gen_ai.*` attribute registry](https://opentelemetry.io/docs/specs/semconv/registry/attributes/gen-ai/), when set by the MCP client in baggage, are automatically forwarded as attributes on the `tools/call` span:
|
||||
|
||||
- `gen_ai.agent.id`
|
||||
- `gen_ai.agent.name`
|
||||
- `gen_ai.conversation.id`
|
||||
- `gen_ai.provider.name`
|
||||
- `gen_ai.request.model`
|
||||
|
||||
This enables tracing backends to correlate MCP tool invocations back to the originating agent, conversation, or model without additional configuration. Other `gen_ai.*` baggage entries are intentionally not forwarded — baggage may be set by arbitrary upstream callers, and a broad prefix filter would let clients smuggle high-cardinality or payload-shaped keys (e.g. `gen_ai.tool.call.result`, `gen_ai.prompt`) onto the span and bypass the [tool payload capture flag](#capturing-tool-arguments-and-results).
|
||||
|
||||
### Capturing the authenticated end user
|
||||
|
||||
The Tracing Service can additionally include the authenticated principal's identity as `enduser.id` (the user entity ref for a user principal, the service subject for a service principal). This is gated behind a backend-wide configuration flag and is **disabled by default**:
|
||||
|
||||
```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.
|
||||
|
||||
@@ -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<string, string | string[] | undefined>`), 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:
|
||||
|
||||
+165
-27
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof otelPropagation.getActiveBaggage>,
|
||||
): 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 <T>(
|
||||
ctx: TracingServiceContext,
|
||||
fn: () => T | Promise<T>,
|
||||
): Promise<T> => otelContext.with(toOtelContext(ctx), fn),
|
||||
};
|
||||
|
||||
readonly propagation: TracingServicePropagationAPI = {
|
||||
extract: (
|
||||
ctx: TracingServiceContext,
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): 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<T>(
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
options: TracingServiceSpanOptions = {},
|
||||
): Promise<T>;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
async startActiveSpan<T>(
|
||||
name: string,
|
||||
optionsOrFn:
|
||||
| TracingServiceSpanOptions
|
||||
| ((span: TracingServiceSpan) => T | Promise<T>),
|
||||
maybeFn?: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const [options, fn]: [
|
||||
TracingServiceSpanOptions,
|
||||
(span: TracingServiceSpan) => T | Promise<T>,
|
||||
] =
|
||||
typeof optionsOrFn === 'function'
|
||||
? [{}, optionsOrFn]
|
||||
: [optionsOrFn, maybeFn!];
|
||||
|
||||
let credentials = options.credentials;
|
||||
if (!credentials && options.request) {
|
||||
credentials = await this.httpAuth.credentials(options.request);
|
||||
|
||||
@@ -292,10 +292,16 @@ export const rootSystemMetadataServiceRef: ServiceRef<
|
||||
|
||||
// @alpha
|
||||
export interface TracingService {
|
||||
readonly context: TracingServiceContextAPI;
|
||||
readonly propagation: TracingServicePropagationAPI;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
options?: TracingServiceSpanOptions,
|
||||
): Promise<T>;
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
}
|
||||
|
||||
@@ -314,6 +320,40 @@ export type TracingServiceAttributeValue =
|
||||
| Array<null | undefined | number>
|
||||
| Array<null | undefined | boolean>;
|
||||
|
||||
// @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<T>(context: TracingServiceContext, fn: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface TracingServicePropagationAPI {
|
||||
extract(
|
||||
context: TracingServiceContext,
|
||||
carrier: Record<string, string | string[] | undefined>,
|
||||
): TracingServiceContext;
|
||||
getActiveBaggage(): TracingServiceBaggage | undefined;
|
||||
getBaggage(context: TracingServiceContext): TracingServiceBaggage | undefined;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export const tracingServiceRef: ServiceRef<
|
||||
TracingService,
|
||||
|
||||
@@ -104,6 +104,104 @@ export interface TracingService {
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
options?: TracingServiceSpanOptions,
|
||||
): Promise<T>;
|
||||
/**
|
||||
* Runs `fn` inside a new active span configured by `options`. The
|
||||
* span is finished when `fn` resolves or throws.
|
||||
*/
|
||||
startActiveSpan<T>(
|
||||
name: string,
|
||||
options: TracingServiceSpanOptions,
|
||||
fn: (span: TracingServiceSpan) => T | Promise<T>,
|
||||
): Promise<T>;
|
||||
|
||||
/**
|
||||
* 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<T>(context: TracingServiceContext, fn: () => T | Promise<T>): Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, string | string[] | undefined>,
|
||||
): 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;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,11 @@ export type {
|
||||
TracingService,
|
||||
TracingServiceAttributeValue,
|
||||
TracingServiceAttributes,
|
||||
TracingServiceBaggage,
|
||||
TracingServiceBaggageEntry,
|
||||
TracingServiceContext,
|
||||
TracingServiceContextAPI,
|
||||
TracingServicePropagationAPI,
|
||||
TracingServiceSpan,
|
||||
TracingServiceSpanKind,
|
||||
TracingServiceSpanOptions,
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<TInputSchema, TOutputSchema>): void;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServiceContextAPI
|
||||
extends TracingServiceContextAPI {
|
||||
// (undocumented)
|
||||
active: jest.MockedFunction<TracingServiceContextAPI['active']>;
|
||||
// (undocumented)
|
||||
with: jest.MockedFunction<TracingServiceContextAPI['with']>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServicePropagationAPI
|
||||
extends TracingServicePropagationAPI {
|
||||
// (undocumented)
|
||||
extract: jest.MockedFunction<TracingServicePropagationAPI['extract']>;
|
||||
// (undocumented)
|
||||
getActiveBaggage: jest.MockedFunction<
|
||||
TracingServicePropagationAPI['getActiveBaggage']
|
||||
>;
|
||||
// (undocumented)
|
||||
getBaggage: jest.MockedFunction<TracingServicePropagationAPI['getBaggage']>;
|
||||
}
|
||||
|
||||
// @alpha
|
||||
export interface MockedTracingServiceSpan extends TracingServiceSpan {
|
||||
// (undocumented)
|
||||
@@ -106,8 +130,12 @@ export type ServiceMock<TService> = {
|
||||
|
||||
// @alpha
|
||||
export interface TracingServiceMock extends TracingService {
|
||||
// (undocumented)
|
||||
context: MockedTracingServiceContextAPI;
|
||||
// (undocumented)
|
||||
factory: ServiceFactory<TracingService>;
|
||||
// (undocumented)
|
||||
propagation: MockedTracingServicePropagationAPI;
|
||||
spans: MockedTracingServiceSpan[];
|
||||
// (undocumented)
|
||||
startActiveSpan: jest.MockedFunction<TracingService['startActiveSpan']>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string | string[] | undefined>,
|
||||
): 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<string, { value: string }>();
|
||||
for (const segment of raw.split(',')) {
|
||||
const [pair] = segment.split(';');
|
||||
const eqIdx = pair.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
const key = decodeURIComponent(pair.slice(0, eqIdx).trim());
|
||||
const value = decodeURIComponent(pair.slice(eqIdx + 1).trim());
|
||||
if (!key) continue;
|
||||
entries.set(key, { value });
|
||||
}
|
||||
if (entries.size === 0) return undefined;
|
||||
|
||||
return {
|
||||
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<void, [TracingServiceSpanStatus]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jest-mocked counterpart of the `context` member on the
|
||||
* `TracingService`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface MockedTracingServiceContextAPI
|
||||
extends TracingServiceContextAPI {
|
||||
active: jest.MockedFunction<TracingServiceContextAPI['active']>;
|
||||
with: jest.MockedFunction<TracingServiceContextAPI['with']>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Jest-mocked counterpart of the `propagation` member on the
|
||||
* `TracingService`.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface MockedTracingServicePropagationAPI
|
||||
extends TracingServicePropagationAPI {
|
||||
extract: jest.MockedFunction<TracingServicePropagationAPI['extract']>;
|
||||
getBaggage: jest.MockedFunction<TracingServicePropagationAPI['getBaggage']>;
|
||||
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<TracingService['startActiveSpan']>;
|
||||
context: MockedTracingServiceContextAPI;
|
||||
propagation: MockedTracingServicePropagationAPI;
|
||||
/** Spans created by `startActiveSpan` calls, in order. */
|
||||
spans: MockedTracingServiceSpan[];
|
||||
factory: ServiceFactory<TracingService>;
|
||||
@@ -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,
|
||||
|
||||
@@ -22,5 +22,7 @@ export {
|
||||
tracingServiceMock,
|
||||
type TracingServiceMock,
|
||||
type MockedTracingServiceSpan,
|
||||
type MockedTracingServiceContextAPI,
|
||||
type MockedTracingServicePropagationAPI,
|
||||
} from './TracingServiceMock';
|
||||
export { type ServiceMock } from './alphaCreateServiceMock';
|
||||
|
||||
+15
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof tracingServiceMock.mock>;
|
||||
captureToolPayloads?: boolean;
|
||||
credentials?:
|
||||
| ReturnType<typeof mockCredentials.user>
|
||||
| ReturnType<typeof mockCredentials.service>;
|
||||
}) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string> = 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<string, string> {
|
||||
const baggage = tracingService.propagation.getActiveBaggage();
|
||||
if (!baggage) return {};
|
||||
const attrs: Record<string, string> = {};
|
||||
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<McpServerOperationAttributes>;
|
||||
|
||||
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<McpServerOperationAttributes>(
|
||||
'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;
|
||||
|
||||
Reference in New Issue
Block a user