Merge pull request #34089 from backstage/otel/mcp-tools-call

feat: Instrument MCP tool calls with semantically appropriate span
This commit is contained in:
Patrik Oldsberg
2026-05-19 10:53:13 +02:00
committed by GitHub
21 changed files with 1221 additions and 78 deletions
@@ -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);