diff --git a/MIGRATION.md b/MIGRATION.md index 1cc4f4afa550..ce6203f9af6d 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -829,22 +829,25 @@ 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 | -| `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | -| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | -| `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`) | -| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) | -| `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 | +| 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 | +| `navigation` | The parameterized route, or the raw URL path if the SDK couldn't resolve one (`/users/123`) | The parameterized route, or `Navigation` if the SDK has none | +| `http.server` | The request method and route, or the raw URL path if the SDK couldn't resolve one (`GET /users/123`) | `GET /users/:id` when a route is known, otherwise just the request method (`GET`) | +| `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`) | +| `gen_ai.chat`, `gen_ai.embeddings`, `gen_ai.generate_content` | `{operation} {model}`, or `{operation} unknown` if the model is missing (`chat unknown`) | `{operation} {model}`, or `{operation}` if the model is missing (`chat`) | +| `gen_ai.invoke_agent` | The LangChain chain name, prefixed with `chain` rather than the operation (`chain format_prompt`) | `{operation} {name}`, where the name is the span's `gen_ai.agent.name`, `gen_ai.pipeline.name` or `gen_ai.function_id`, in that order (`invoke_agent format_prompt`), or `{operation}` if the span carries none | +| `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 | `navigation.redirect` spans are started through the same code path as navigation spans, so they get the same names. Resolved low-cardinality values are kept in both lifecycles: a known model stays in the name (`chat gpt-4`). +LangChain agent spans now lead with the operation, like LangGraph and Vercel AI ones: `chain format_prompt` becomes `invoke_agent format_prompt`, and a chain the SDK cannot name becomes `invoke_agent` rather than `chain unknown_chain` — update any `ignoreSpans` filters matching the old names. The chain name remains available on `langchain.chain.name`. LangGraph agent names and Vercel AI `functionId`s are unchanged. + Resource spans now also carry a `url.domain` attribute holding that domain. The full URL remains available on `url.full`. `http.server` requests that resolve to a route are **unchanged** — those names were already low cardinality. Only requests the SDK cannot parameterize are affected. diff --git a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts index 513537ad9a26..90692575347d 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langchain/test.ts @@ -269,6 +269,7 @@ describe('LangChain integration', () => { expect(formatPromptSpan).toBeDefined(); expect(formatPromptSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); expect(formatPromptSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langchain'); + expect(formatPromptSpan!.attributes[GEN_AI_OPERATION_NAME].value).toBe('invoke_agent'); expect(formatPromptSpan!.attributes['langchain.chain.name'].value).toBe('format_prompt'); const chatSpan = container.items.find(span => span.name === 'chat claude-3-5-sonnet-20241022'); @@ -409,4 +410,34 @@ describe('LangChain integration', () => { .completed(); }); }); + + createEsmAndCjsTests(__dirname, 'scenario-chain.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => { + test('leads chain span names with the operation when span streaming is enabled', async () => { + await createRunner() + .ignore('event') + .expect({ + span: container => { + const chainSpans = container.items.filter( + span => span.attributes['sentry.op']?.value === 'gen_ai.invoke_agent', + ); + // The `unknown_chain` sentinel is dropped, so that span falls back to the bare operation. + expect(chainSpans.map(span => span.name).sort()).toEqual([ + 'invoke_agent', + 'invoke_agent format_prompt', + 'invoke_agent parse_output', + ]); + for (const span of chainSpans) { + expect(span.attributes[GEN_AI_OPERATION_NAME]?.value).toBe('invoke_agent'); + } + expect(chainSpans.map(span => span.attributes['langchain.chain.name']?.value).sort()).toEqual([ + 'format_prompt', + 'parse_output', + 'unknown_chain', + ]); + }, + }) + .start() + .completed(); + }); + }); }); diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index 02ad4fa14293..d5917b84be7c 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -228,6 +228,7 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): const chainName = runName || chain.name || 'unknown_chain'; const attributes: Record = { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain', + [GEN_AI_OPERATION_NAME]: 'invoke_agent', 'langchain.chain.name': chainName, }; @@ -235,9 +236,17 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): attributes['langchain.chain.inputs'] = JSON.stringify(inputs); } + const client = getClient(); + startSpanManual( { - name: `chain ${chainName}`, + // With span streaming, the name leads with the operation per the agent templates. The + // chain name is bounded, so it stays; the `'unknown_chain'` sentinel is dropped instead. + name: !(client && hasSpanStreamingEnabled(client)) + ? `chain ${chainName}` + : chainName === 'unknown_chain' + ? 'invoke_agent' + : `invoke_agent ${chainName}`, op: 'gen_ai.invoke_agent', attributes: { ...attributes, diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts new file mode 100644 index 000000000000..d6fb6caaf67e --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/langchain-invoke-agent.test.ts @@ -0,0 +1,79 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GEN_AI_OPERATION_NAME } from '@sentry/conventions/attributes'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { createLangChainCallbackHandler } from '../../../../src/ai/langchain'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('LangChain invoke_agent span names', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + function runChain(runName?: string, chain: { name?: string } = {}): void { + const handler = createLangChainCallbackHandler(); + handler.handleChainStart?.( + chain, + { topic: 'weather' }, + 'run-1', + undefined, + undefined, + undefined, + undefined, + runName, + ); + handler.handleChainEnd?.({ ok: true }, 'run-1'); + } + + it('keeps `chain {chainName}` in static mode', () => { + const endedSpans = setupClient('static'); + runChain('format_prompt'); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chain format_prompt'); + expect(spanToStaticSpanJSON(endedSpans[0]!).data?.[GEN_AI_OPERATION_NAME]).toBe('invoke_agent'); + }); + + it('keeps `chain unknown_chain` when the chain name is missing in static mode', () => { + const endedSpans = setupClient('static'); + runChain(); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chain unknown_chain'); + }); + + it('leads with the operation and keeps the chain name when span streaming is enabled', () => { + const endedSpans = setupClient('stream'); + runChain('format_prompt'); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('invoke_agent format_prompt'); + expect(span.data?.[GEN_AI_OPERATION_NAME]).toBe('invoke_agent'); + expect(span.data?.['langchain.chain.name']).toBe('format_prompt'); + }); + + it('drops the `unknown_chain` sentinel when span streaming is enabled', () => { + const endedSpans = setupClient('stream'); + runChain(); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent'); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph-invoke-agent.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph-invoke-agent.test.ts new file mode 100644 index 000000000000..1e8a204bd85e --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/langgraph-invoke-agent.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { instrumentCompiledGraphInvoke } from '../../../../src/ai/langgraph'; +import type { CompiledGraph } from '../../../../src/ai/langgraph/types'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('LangGraph invoke_agent span names', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + async function invokeGraph(compileOptions: Record): Promise { + const invoke = instrumentCompiledGraphInvoke( + async () => ({ messages: [] }), + {} as CompiledGraph, + compileOptions, + {}, + ); + await invoke({}); + } + + it('names the span `{operation} {agent}` when an agent name is present', async () => { + const endedSpans = setupClient('stream'); + await invokeGraph({ name: 'weather_assistant' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent weather_assistant'); + }); + + it('uses the operation name when the agent name is missing and span streaming is enabled', async () => { + const endedSpans = setupClient('stream'); + await invokeGraph({}); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent'); + }); +}); diff --git a/packages/server-utils/test/integrations/vercel-ai/invoke-agent-span-names.test.ts b/packages/server-utils/test/integrations/vercel-ai/invoke-agent-span-names.test.ts new file mode 100644 index 000000000000..be93481af515 --- /dev/null +++ b/packages/server-utils/test/integrations/vercel-ai/invoke-agent-span-names.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { GEN_AI_FUNCTION_ID } from '@sentry/conventions/attributes'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { createSpanFromMessage } from '../../../src/integrations/vercel-ai/vercel-ai-dc-subscriber'; +import { getDefaultTestClientOptions, TestClient } from '../../mocks/client'; + +describe('Vercel AI invoke_agent span names', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(traceLifecycle: 'static' | 'stream'): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + function startInvokeAgentSpan(functionId?: string): void { + const span = createSpanFromMessage( + { + type: 'generateText', + event: functionId ? { functionId } : {}, + } as Parameters[0], + {} as Parameters[1], + ); + span?.end(); + } + + // `functionId` is a developer-supplied label, so it is bounded and stays in the name in both + // lifecycles, the same way a tool name does on `gen_ai.execute_tool` spans. + it.each(['static', 'stream'] as const)('keeps `invoke_agent {functionId}` in %s mode', traceLifecycle => { + const endedSpans = setupClient(traceLifecycle); + startInvokeAgentSpan('weather_agent'); + + const span = spanToStaticSpanJSON(endedSpans[0]!); + expect(span.description).toBe('invoke_agent weather_agent'); + expect(span.data?.[GEN_AI_FUNCTION_ID]).toBe('weather_agent'); + }); + + it.each(['static', 'stream'] as const)('uses `invoke_agent` without a functionId in %s mode', traceLifecycle => { + const endedSpans = setupClient(traceLifecycle); + startInvokeAgentSpan(); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('invoke_agent'); + }); +});