diff --git a/MIGRATION.md b/MIGRATION.md index f2af8d53b6f5..53db4ecef79a 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -613,53 +613,6 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp | `browser.TLS/SSL` | `browser.tls_ssl` | | `browser.DNS` | `browser.dns` | -### Span name changes - -Affected SDKs: All SDKs. - -With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/). - -If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged. - -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 | - -Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`. - -Some consequences to be aware of: - -The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. - -Because a low-cardinality name cannot say which part of request processing a span covers, every graphql span now carries a `graphql.processing.type` attribute (`parse`, `validate`, `execute` or `resolve`). Use it to tell parse, validate and resolve spans apart. The attribute is set in both trace lifecycles. - -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. - -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. - -`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later. -`ignoreSpans` filters matching a URL path no longer apply to them. -Another example where filters might need adjustments are `resource.*` spans where their name now only includes the domain the resource was taken from. - -Match on attributes instead: - -```js -Sentry.init({ - // Before - ignoreSpans: ['/health'], - - // After - ignoreSpans: [{ name: 'Pageload', attributes: { 'sentry.op': 'pageload', 'url.path': '/health' } }], -}); -``` - ### LangGraph no longer emits `create_agent` spans Affected SDKs: All server-side SDKs. diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index fc606a040f1a..0adc2ef818e9 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -743,6 +743,54 @@ These changes are not caught by TypeScript. If you filter, group, or alert on sp | `browser.TLS/SSL` | `browser.tls_ssl` | | `browser.DNS` | `browser.dns` | +### Span name changes + +Affected SDKs: All SDKs. + +With [span streaming](#span-streaming-is-now-the-default) enabled(the default), span names are now **low cardinality**, following the [Sentry span name conventions](https://getsentry.github.io/sentry-conventions/names/). + +If you [opt out of span streaming](#opting-out-of-span-streaming), span names remain unchanged. + +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 | + +Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`. + +Some consequences to be aware of: + +The graphql operation name and the resolver field path are supplied by the client, so they are no longer part of a span name. They remain available on the `graphql.operation.name` and `graphql.field.path` attributes. + +Because a low-cardinality name cannot say which part of request processing a span covers, every graphql span now carries a `graphql.processing.type` attribute (`parse`, `validate`, `execute` or `resolve`). Use it to tell parse, validate and resolve spans apart. The attribute is set in both trace lifecycles. + +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. + +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. + +`ignoreSpans` is evaluated when a span **starts**, at which point a span might not yet have its final name. For example, an unresolved pageload span name is named `'Pageload'` and might receive its final, resolved route name later. +`ignoreSpans` filters matching a URL path no longer apply to them. +Another example where filters might need adjustments are `resource.*` spans where their name now only includes the domain the resource was taken from. + +Match on attributes instead: + +```js +Sentry.init({ + // Before + ignoreSpans: ['/health'], + + // After + ignoreSpans: [{ name: 'Pageload', attributes: { 'sentry.op': 'pageload', 'url.path': '/health' } }], +}); +``` + ### AI integrations no longer trace non-inference operations Affected SDKs: All server-side SDKs. diff --git a/packages/core/src/integrations/mcp-server/spans.ts b/packages/core/src/integrations/mcp-server/spans.ts index 5faf9750d784..5fb90bc18f5a 100644 --- a/packages/core/src/integrations/mcp-server/spans.ts +++ b/packages/core/src/integrations/mcp-server/spans.ts @@ -8,6 +8,8 @@ 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 { startSpan } from '../../tracing/trace'; import { buildTransportAttributes, buildTypeSpecificAttributes } from './attributeExtraction'; import { @@ -83,6 +85,7 @@ function createMcpSpan(config: McpSpanConfig): unknown { const { type, message, transport, extra, callback, options } = config; const { method } = message; const params = message.params; + const client = getClient(); // Determine span name based on type and OTEL conventions let spanName: string; @@ -90,8 +93,9 @@ function createMcpSpan(config: McpSpanConfig): unknown { const targetInfo = extractTargetInfo(method, params || {}); spanName = createSpanName(method, targetInfo.target); } else { - // For notifications, use method name directly per OpenTelemetry conventions - spanName = method; + // 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); } const rawAttributes: Record = { @@ -101,7 +105,6 @@ function createMcpSpan(config: McpSpanConfig): unknown { ...buildSentryAttributes(type), }; - const client = getClient(); const userInfo = Boolean(client?.getDataCollectionOptions().userInfo); const attributes = filterMcpPiiFromSpanData(rawAttributes, userInfo) as Record; diff --git a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts index b2515888e073..e4ecaf8f303a 100644 --- a/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts +++ b/packages/core/test/lib/integrations/mcp-server/semanticConventions.test.ts @@ -355,6 +355,38 @@ describe('MCP Server Semantic Conventions', () => { ); }); + it('should keep the method name as the notification span name when span streaming is enabled', async () => { + getClientSpy.mockReturnValue(createMockClient(true, undefined, 'stream')); + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: {} }, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'notifications/tools/list_changed' }), + expect.any(Function), + ); + }); + + it('should fall back to a low cardinality notification span name when the method name is missing', async () => { + getClientSpy.mockReturnValue(createMockClient(true, undefined, 'stream')); + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.({ jsonrpc: '2.0', method: null, params: {} }, {}); + + expect(startSpanSpy).toHaveBeenCalledWith( + expect.objectContaining({ name: 'MCP notification' }), + expect.any(Function), + ); + }); + + it('should not fall back to a low cardinality notification span name in static trace lifecycle mode', async () => { + await wrappedMcpServer.connect(mockTransport); + + mockTransport.onmessage?.({ jsonrpc: '2.0', method: null, params: {} }, {}); + + expect(startSpanSpy).toHaveBeenCalledWith(expect.objectContaining({ name: null }), expect.any(Function)); + }); + it('should instrument tool call results and complete span with enriched attributes', async () => { await wrappedMcpServer.connect(mockTransport); diff --git a/packages/core/test/lib/integrations/mcp-server/testUtils.ts b/packages/core/test/lib/integrations/mcp-server/testUtils.ts index 5a86f78c72da..e76bd5c7cfd8 100644 --- a/packages/core/test/lib/integrations/mcp-server/testUtils.ts +++ b/packages/core/test/lib/integrations/mcp-server/testUtils.ts @@ -5,11 +5,16 @@ import { vi } from 'vitest'; * Creates a mock Sentry client with getDataCollectionOptions for use in MCP server tests. * @param userInfo - Whether user identity data (IP, port) is collected. Default: true * @param genAI - Whether AI inputs/outputs are recorded. Defaults to match userInfo. + * @param traceLifecycle - Whether spans are streamed or sent as transactions. Default: 'static' */ -export function createMockClient(userInfo = true, genAI?: { inputs: boolean; outputs: boolean }): Client { +export function createMockClient( + userInfo = true, + genAI?: { inputs: boolean; outputs: boolean }, + traceLifecycle: 'static' | 'stream' = 'static', +): Client { const genAIOptions = genAI ?? { inputs: userInfo, outputs: userInfo }; return { - getOptions: () => ({}), + getOptions: () => ({ traceLifecycle }), getDataCollectionOptions: () => ({ userInfo, cookies: true,