Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 10 additions & 7 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -784,13 +784,14 @@ If you [opt out of span streaming](#opting-out-of-span-streaming), span names re

The following span names were adjusted:

| Span op | Before | After |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |
| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name (`notifications/tools/list_changed`) | The notification method name, or `MCP notification` if the message carries none |
| Span op | Before | After |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `pageload` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Pageload` if the SDK has none |
| `router` | Framework-specific, sometimes containing the raw URL (`/users/123`, `SvelteKit Route Change`) | The span's `http.route`, or `Router` if the SDK has none |
| `graphql` | The graphql phase and, for operations, the operation name (`query GetUser`, `graphql.parse`, `graphql.resolve user.0.name`) | The operation type, or the processing type where there is none (`GraphQL query`, `GraphQL parse`, `GraphQL resolve`) |
| `resource.*` | The resource URL, relative to the page origin for same-origin resources (`/assets/app.js`) | The resource domain (`cdn.example.com`), or `Resource` if the SDK has none |
| `mcp.server` | The method and its target, including the resource URI (`resources/read file:///docs/api.md`) | The method alone for resource methods (`resources/read`). Tool and prompt names are unchanged (`tools/call get-weather`) |
| `mcp.notification.client_to_server`, `mcp.notification.server_to_client` | The notification method name (`notifications/tools/list_changed`) | The notification method name, or `MCP notification` if the message carries none |

Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`.

Expand All @@ -802,6 +803,8 @@ Because a low-cardinality name cannot say which part of request processing a spa

For the same reason, `useOperationNameForRootSpan` no longer renames the enclosing root span (`GET /graphql` stays `GET /graphql`, instead of becoming `GET /graphql (query GetUser)`). The operations are still recorded on that span's `sentry.graphql.operation` attribute, as long as the option stays enabled (the default). Disabling it skips both, as before.

Resource URIs are unbounded, so they are no longer part of an `mcp.server` span name. The URI remains available on the `mcp.resource.uri` attribute.

Only the Express, Koa and Hapi integrations resolve a route template for `router` spans. Angular, Ember and SvelteKit have none when the span starts, so their router spans are named `Router`.

Child spans of a service or root span carry its name in their `sentry.segment.name` attribute, so that changes with it. If you group or filter spans by segment name in dashboards or alerts, update those references.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ test.skip('Should record streamed spans for mcp handlers', async ({ baseURL }) =
return span.name === 'POST /messages' && getSpanOp(span) === 'http.server' && span.is_segment;
});
const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => {
return span.name === 'resources/read echo://foobar' && getSpanOp(span) === 'mcp.server' && span.is_segment;
return span.name === 'resources/read' && getSpanOp(span) === 'mcp.server' && span.is_segment;
});

const resourceResult = await client.readResource({
Expand All @@ -123,6 +123,7 @@ test.skip('Should record streamed spans for mcp handlers', async ({ baseURL }) =
expect(resourceSpan).toBeDefined();
expect(getSpanOp(resourceSpan)).toBe('mcp.server');
expect(resourceSpan.attributes['mcp.method.name']?.value).toBe('resources/read');
expect(resourceSpan.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
});

await test.step('prompt handler', async () => {
Expand Down Expand Up @@ -244,7 +245,7 @@ test('Should record streamed spans for streamable HTTP transport (wrapper transp
await test.step('resource handler', async () => {
const resourceSpanPromise = waitForStreamedSpan('node-express-streaming', span => {
return (
span.name === 'resources/read echo://streamable-test' &&
span.name === 'resources/read' &&
getSpanOp(span) === 'mcp.server' &&
String(span.attributes['mcp.transport']?.value).includes('StreamableHTTPServerTransport')
);
Expand All @@ -262,6 +263,7 @@ test('Should record streamed spans for streamable HTTP transport (wrapper transp
expect(resourceSpan).toBeDefined();
expect(getSpanOp(resourceSpan)).toBe('mcp.server');
expect(resourceSpan.attributes['mcp.method.name']?.value).toBe('resources/read');
expect(resourceSpan.attributes['mcp.resource.uri']?.value).toBe('echo://streamable-test');
});

await test.step('prompt handler', async () => {
Expand Down
2 changes: 2 additions & 0 deletions dev-packages/node-integration-tests/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
"@langchain/core": "^0.3.80",
"@langchain/openai": "^0.5.0",
"@langchain/langgraph": "^0.2.32",
"@modelcontextprotocol/client": "2.0.0",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

q: Is this pinned on purpose?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This matches our other mcp apps, I just followed suite tbh. We could loosen it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense. Loosening it wouldn't be a bad idea though.- not a PR blocker

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will do in a follow-up.

"@modelcontextprotocol/server": "2.0.0",
"@nestjs/common": "^11",
"@nestjs/core": "^11",
"@nestjs/platform-express": "^11",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as Sentry from '@sentry/node';
import { loggingTransport } from '@sentry-internal/node-integration-tests';

Sentry.init({
dsn: 'https://public@dsn.ingest.sentry.io/1337',
release: '1.0',
tracesSampleRate: 1.0,
transport: loggingTransport,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Client } from '@modelcontextprotocol/client';
import { InMemoryTransport, McpServer, ResourceTemplate } from '@modelcontextprotocol/server';
import { wrapMcpServerWithSentry } from '@sentry/node';

const server = wrapMcpServerWithSentry(new McpServer({ name: 'Echo', version: '1.0.0' }));

server.registerResource('echo', new ResourceTemplate('echo://{message}', { list: undefined }), {}, async uri => ({
contents: [{ uri: uri.href, text: 'Resource echo' }],
}));

server.registerTool('echo', {}, async () => ({ content: [{ type: 'text', text: 'Tool echo' }] }));

async function run() {
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });

await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);

await client.readResource({ uri: 'echo://foobar' });
await client.callTool({ name: 'echo', arguments: {} });

await client.close();
await server.close();
}

run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { SerializedStreamedSpanContainer } from '@sentry/core';
import { afterAll, describe, expect } from 'vitest';
import { cleanupChildProcesses, createEsmAndCjsTests } from '../../../utils/runner';

function mcpSpans(container: SerializedStreamedSpanContainer): SerializedStreamedSpanContainer['items'] {
return container.items.filter(item => item.attributes['sentry.op']?.value === 'mcp.server');
}

describe('MCP server spans (streamed)', () => {
afterAll(() => {
cleanupChildProcesses();
});

createEsmAndCjsTests(__dirname, 'scenario.mjs', 'instrument.mjs', (createTestRunner, test) => {
test('names resource spans after the method alone, keeping the URI on the attribute', async () => {
await createTestRunner()
.expect({
span: container => {
const resourceSpan = mcpSpans(container).find(
span => span.attributes['mcp.method.name']?.value === 'resources/read',
);

expect(resourceSpan?.name).toBe('resources/read');
expect(resourceSpan?.attributes['mcp.resource.uri']?.value).toBe('echo://foobar');
},
})
.start()
.completed();
});

test('keeps the tool name, which comes from a bounded registry', async () => {
await createTestRunner()
.expect({
span: container => {
const toolSpan = mcpSpans(container).find(
span => span.attributes['mcp.method.name']?.value === 'tools/call',
);

expect(toolSpan?.name).toBe('tools/call echo');
},
})
.start()
.completed();
});
Comment thread
andreiborza marked this conversation as resolved.
});
});
6 changes: 5 additions & 1 deletion packages/core/src/integrations/mcp-server/methodConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const METHOD_CONFIGS: Record<string, MethodConfig> = {
'tools/call': {
targetField: 'name',
targetAttribute: MCP_TOOL_NAME_ATTRIBUTE,
targetIsLowCardinality: true,
captureArguments: true,
argumentsField: 'arguments',
},
Expand All @@ -38,6 +39,7 @@ const METHOD_CONFIGS: Record<string, MethodConfig> = {
'prompts/get': {
targetField: 'name',
targetAttribute: MCP_PROMPT_NAME_ATTRIBUTE,
targetIsLowCardinality: true,
captureName: true,
captureArguments: true,
argumentsField: 'arguments',
Expand All @@ -55,11 +57,12 @@ export function extractTargetInfo(
params: Record<string, unknown>,
): {
target?: string;
targetIsLowCardinality: boolean;
attributes: Record<string, string>;
} {
const config = METHOD_CONFIGS[method];
if (!config) {
return { attributes: {} };
return { targetIsLowCardinality: false, attributes: {} };
}

const target =
Expand All @@ -69,6 +72,7 @@ export function extractTargetInfo(

return {
target,
targetIsLowCardinality: !!config.targetIsLowCardinality,
attributes: target && config.targetAttribute ? { [config.targetAttribute]: target } : {},
};
}
Expand Down
15 changes: 10 additions & 5 deletions packages/core/src/integrations/mcp-server/spans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getClient } from '../../currentScopes';
import { SENTRY_SEGMENT_NAME_SOURCE } from '@sentry/conventions/attributes';
import { SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
import { hasSpanStreamingEnabled } from '../../tracing/spans/hasSpanStreamingEnabled';
import { MCP_NOTIFICATION_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { MCP_NOTIFICATION_SPAN_NAME_FALLBACK, MCP_SERVER_SPAN_NAME_FALLBACK } from '../../tracing/spans/spanNames';
import { startSpan } from '../../tracing/trace';
import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction';
import {
Expand Down Expand Up @@ -86,16 +86,18 @@ function createMcpSpan(config: McpSpanConfig): unknown {
const { method } = message;
const params = message.params;
const client = getClient();
const spanStreamingEnabled = !!client && hasSpanStreamingEnabled(client);

// Determine span name based on type and OTEL conventions
let spanName: string;
if (type === 'request') {
const targetInfo = extractTargetInfo(method, params || {});
spanName = createSpanName(method, targetInfo.target);
const target = spanStreamingEnabled && !targetInfo.targetIsLowCardinality ? undefined : targetInfo.target;
spanName = method ? createSpanName(method, target) : MCP_SERVER_SPAN_NAME_FALLBACK;
} else {
// For notifications, use method name directly per OpenTelemetry conventions.
// With span streaming, span names have to be low cardinality, so a message without a method name gets a static name.
spanName = method || (!!client && hasSpanStreamingEnabled(client) ? MCP_NOTIFICATION_SPAN_NAME_FALLBACK : method);
spanName = method || (spanStreamingEnabled ? MCP_NOTIFICATION_SPAN_NAME_FALLBACK : method);
}

const rawAttributes: Record<string, string | number> = {
Expand Down Expand Up @@ -189,8 +191,12 @@ export function buildMcpServerSpanConfig(
const { method } = jsonRpcMessage;
const params = jsonRpcMessage.params;

const client = getClient();
const spanStreamingEnabled = !!client && hasSpanStreamingEnabled(client);

const targetInfo = extractTargetInfo(method, params || {});
const spanName = createSpanName(method, targetInfo.target);
const target = spanStreamingEnabled && !targetInfo.targetIsLowCardinality ? undefined : targetInfo.target;
const spanName = method ? createSpanName(method, target) : MCP_SERVER_SPAN_NAME_FALLBACK;

const rawAttributes: Record<string, string | number> = {
...buildTransportAttributes(transport, extra),
Expand All @@ -199,7 +205,6 @@ export function buildMcpServerSpanConfig(
...buildSentryAttributes('request'),
};

const client = getClient();
const userInfo = Boolean(client?.getDataCollectionOptions().userInfo);
const attributes = filterMcpPiiFromSpanData(rawAttributes, userInfo) as Record<string, string | number>;

Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/integrations/mcp-server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { Span } from '../../types/span';
export type MethodConfig = {
targetField: string;
targetAttribute: string;
/** Whether the target is drawn from a bounded set, and so may appear in a span name. */
targetIsLowCardinality?: boolean;
captureArguments?: boolean;
argumentsField?: string;
captureUri?: boolean;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,4 +616,44 @@ describe('MCP Server Semantic Conventions', () => {
expect(lastCall?.[0]?.attributes).not.toHaveProperty('mcp.logging.message');
});
});

describe('Span names with span streaming', () => {
let wrappedMcpServer: ReturnType<typeof createMockMcpServer>;
let mockTransport: ReturnType<typeof createMockTransport>;

beforeEach(() => {
getClientSpy.mockReturnValue(createMockClient(true, undefined, 'stream'));
wrappedMcpServer = wrapMcpServerWithSentry(createMockMcpServer(), { recordInputs: true, recordOutputs: true });
mockTransport = createMockTransport();
mockTransport.sessionId = 'test-session-123';
});

it('drops the resource URI from the name, keeping it on the attribute', async () => {
await wrappedMcpServer.connect(mockTransport);

mockTransport.onmessage?.(
{ jsonrpc: '2.0', method: 'resources/read', id: 'req-1', params: { uri: 'file:///docs/api.md' } },
{},
);

expect(startInactiveSpanSpy).toHaveBeenCalledWith(
expect.objectContaining({
name: 'resources/read',
attributes: expect.objectContaining({ 'mcp.resource.uri': 'file:///docs/api.md' }),
}),
);
});

it.each([
['tools/call', { name: 'get-weather' }, 'tools/call get-weather'],
['prompts/get', { name: 'analyze-code' }, 'prompts/get analyze-code'],
['initialize', {}, 'initialize'],
])('keeps the %s span name, which is already low cardinality', async (method, params, expected) => {
await wrappedMcpServer.connect(mockTransport);

mockTransport.onmessage?.({ jsonrpc: '2.0', method, id: 'req-1', params }, {});

expect(startInactiveSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: expected }));
});
});
Comment thread
cursor[bot] marked this conversation as resolved.
});
Loading
Loading