diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts index e4276d73a0dd..f6d05506091c 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v4/test.ts @@ -114,7 +114,7 @@ describe('Vercel AI integration (streaming v4)', () => { // Sixth span - execute_tool // Note: gen_ai.tool.description is NOT present when genAI recording disabled because ai.prompt.tools is not recorded expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'ok', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), @@ -222,7 +222,7 @@ describe('Vercel AI integration (streaming v4)', () => { }), // Sixth span - execute_tool with description and input/output expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'ok', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), @@ -263,7 +263,7 @@ describe('Vercel AI integration (streaming v4)', () => { }), }), expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'error', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), diff --git a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts index 939ba08e9746..b37b3aee2de1 100644 --- a/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/vercelai/span-streaming-v6/test.ts @@ -111,7 +111,7 @@ describe('Vercel AI integration (streaming, v6)', () => { // Sixth span - execute_tool // Note: gen_ai.tool.description is NOT present when genAI recording disabled because ai.prompt.tools is not recorded expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'ok', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), @@ -219,7 +219,7 @@ describe('Vercel AI integration (streaming, v6)', () => { }), // Sixth span - execute_tool with description and input/output expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'ok', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), @@ -259,7 +259,7 @@ describe('Vercel AI integration (streaming, v6)', () => { }), }), expect.objectContaining({ - name: 'execute_tool getWeather', + name: 'execute_tool', status: 'error', attributes: expect.objectContaining({ [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: attr('call-1'), diff --git a/docs/migration/v11-end-state.md b/docs/migration/v11-end-state.md index f80b801c28b3..2ed3e610820a 100644 --- a/docs/migration/v11-end-state.md +++ b/docs/migration/v11-end-state.md @@ -475,6 +475,23 @@ Sentry.init({ In Node, Bun, Vercel Edge and Cloudflare you can also set the `SENTRY_TRACE_LIFECYCLE=static` environment variable instead. The static lifecycle only exists for backwards compatibility and is planned for removal in a future major version, so treat this as a temporary measure. +### 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. + +| 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 | +| `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`). Instrumented methods always have an operation, so the convention fallback `Generative AI model operation` is unused today. | +| `gen_ai.execute_tool` | `execute_tool {tool name}` (`execute_tool getWeather`) | `execute_tool`; the tool name stays on `gen_ai.tool.name` | + +Resolved low-cardinality values are kept in both lifecycles: a known model stays in the name (`chat gpt-4`). + +`ignoreSpans` is evaluated at span start. Filters matching `chat unknown` no longer apply to a streamed chat span named `'chat'`; match on `gen_ai.request.model` instead. Filters matching `execute_tool getWeather` no longer apply to a streamed tool span named `'execute_tool'`; match on `gen_ai.tool.name` instead. + ### The `enableLogs` option was removed Affected SDKs: All SDKs. diff --git a/packages/server-utils/src/ai/anthropic-ai/index.ts b/packages/server-utils/src/ai/anthropic-ai/index.ts index 592d37890205..0626bb220d81 100644 --- a/packages/server-utils/src/ai/anthropic-ai/index.ts +++ b/packages/server-utils/src/ai/anthropic-ai/index.ts @@ -1,6 +1,9 @@ /* eslint-disable typescript-eslint/no-deprecated */ import { captureException, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -189,8 +192,15 @@ function handleStreamingRequest( isStreamingMethod: boolean, ): R | Promise { const model = requestAttributes[GEN_AI_REQUEST_MODEL] ?? 'unknown'; + const client = getClient(); const spanConfig = { - name: `${operationName} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${model}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operationName), attributes: requestAttributes as Record, }; @@ -273,6 +283,7 @@ function instrumentMethod( const operationName = instrumentedMethod.operation || 'unknown'; const requestAttributes = extractRequestAttributes(args, operationName); const model = requestAttributes[GEN_AI_REQUEST_MODEL] ?? 'unknown'; + const client = getClient(); const params = typeof args[0] === 'object' ? (args[0] as Record) : undefined; const isStreamRequested = Boolean(params?.stream); @@ -296,7 +307,13 @@ function instrumentMethod( const instrumentedPromise = startSpan( { - name: `${operationName} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${model}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operationName), attributes: requestAttributes as Record, }, diff --git a/packages/server-utils/src/ai/google-genai/index.ts b/packages/server-utils/src/ai/google-genai/index.ts index c7ec34b5b70d..9de159e879c0 100644 --- a/packages/server-utils/src/ai/google-genai/index.ts +++ b/packages/server-utils/src/ai/google-genai/index.ts @@ -2,11 +2,14 @@ /* eslint-disable max-lines */ import { captureException, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + handleCallbackErrors, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, startSpanManual, - handleCallbackErrors, stringify, } from '@sentry/core'; import type { Span, SpanAttributeValue } from '@sentry/core'; @@ -270,13 +273,21 @@ function instrumentMethod( const params = args[0] as Record | undefined; const requestAttributes = extractRequestAttributes(operationName, params, context); const model = requestAttributes[GEN_AI_REQUEST_MODEL] ?? 'unknown'; + const client = getClient(); + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + const spanName = + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${model}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK; // Check if this is a streaming method if (instrumentedMethod.streaming) { // Use startSpanManual for streaming methods to control span lifecycle return startSpanManual( { - name: `${operationName} ${model}`, + name: spanName, op: getGenAiSpanOp(operationName), attributes: requestAttributes, }, @@ -305,7 +316,7 @@ function instrumentMethod( // Single span for both sync and async operations return startSpan( { - name: `${operationName} ${model}`, + name: spanName, op: getGenAiSpanOp(operationName), attributes: requestAttributes, }, diff --git a/packages/server-utils/src/ai/langchain/embeddings.ts b/packages/server-utils/src/ai/langchain/embeddings.ts index 23ba6fe07f69..5c735991bbf7 100644 --- a/packages/server-utils/src/ai/langchain/embeddings.ts +++ b/packages/server-utils/src/ai/langchain/embeddings.ts @@ -1,5 +1,7 @@ import { captureException, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startSpan, @@ -75,13 +77,18 @@ export function _INTERNAL_getLangChainEmbeddingsSpanOptions( const { recordInputs } = resolveAIRecordingOptions(options); const attributes = extractEmbeddingAttributes(instance); const modelName = attributes[GEN_AI_REQUEST_MODEL] || 'unknown'; + const client = getClient(); if (recordInputs && input != null) { attributes[GEN_AI_EMBEDDINGS_INPUT] = stringify(input, String); } return { - name: `embeddings ${modelName}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `embeddings ${modelName}` + : 'embeddings', op: GEN_AI_EMBEDDINGS_OPERATION_ATTRIBUTE, attributes: attributes as Record, }; diff --git a/packages/server-utils/src/ai/langchain/index.ts b/packages/server-utils/src/ai/langchain/index.ts index 1f6ba8754f79..d9a8d429ed8e 100644 --- a/packages/server-utils/src/ai/langchain/index.ts +++ b/packages/server-utils/src/ai/langchain/index.ts @@ -1,6 +1,9 @@ /* eslint-disable max-lines */ import { captureException, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -101,11 +104,19 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): metadata, ); const modelName = attributes[GEN_AI_REQUEST_MODEL]; - const operationName = attributes[GEN_AI_OPERATION_NAME]; + const operationName = + typeof attributes[GEN_AI_OPERATION_NAME] === 'string' ? attributes[GEN_AI_OPERATION_NAME] : 'unknown'; + const client = getClient(); startSpanManual( { - name: `${operationName} ${modelName}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${modelName}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: 'gen_ai.chat', attributes: { ...getAgentNameFromMetadata(metadata), @@ -146,11 +157,19 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): } const modelName = attributes[GEN_AI_REQUEST_MODEL]; - const operationName = attributes[GEN_AI_OPERATION_NAME]; + const operationName = + typeof attributes[GEN_AI_OPERATION_NAME] === 'string' ? attributes[GEN_AI_OPERATION_NAME] : 'unknown'; + const client = getClient(); startSpanManual( { - name: `${operationName} ${modelName}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${modelName}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: 'gen_ai.chat', attributes: { ...getAgentNameFromMetadata(metadata), @@ -302,9 +321,13 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}): attributes[GEN_AI_TOOL_CALL_ARGUMENTS] = input; } + const client = getClient(); + startSpanManual( { - name: `execute_tool ${toolName}`, + // With span streaming, the name follows the `{operation}` inference template. The tool + // name stays available on `gen_ai.tool.name`. + name: client && hasSpanStreamingEnabled(client) ? 'execute_tool' : `execute_tool ${toolName}`, op: 'gen_ai.execute_tool', attributes: { ...attributes, diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index 36c70cc3bd9b..1738041858dd 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -1,6 +1,8 @@ /* eslint-disable typescript-eslint/no-deprecated */ import { captureException, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_OP, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -114,10 +116,14 @@ export function wrapToolsWithSpans(tools: unknown[], options: LangGraphOptions, } } + const client = getClient(); + return startSpan( { op: GEN_AI_EXECUTE_TOOL_OPERATION_ATTRIBUTE, - name: `execute_tool ${toolName}`, + // With span streaming, the name follows the `{operation}` inference template. The tool + // name stays available on `gen_ai.tool.name`. + name: client && hasSpanStreamingEnabled(client) ? 'execute_tool' : `execute_tool ${toolName}`, attributes: spanAttributes, }, async span => { diff --git a/packages/server-utils/src/ai/openai/index.ts b/packages/server-utils/src/ai/openai/index.ts index ded2f28d755d..e929bcad7838 100644 --- a/packages/server-utils/src/ai/openai/index.ts +++ b/packages/server-utils/src/ai/openai/index.ts @@ -2,6 +2,9 @@ import { DEBUG_BUILD } from '../../debug-build'; import { captureException, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, @@ -144,9 +147,16 @@ function instrumentMethod( const params = args[0] as Record | undefined; const isStreamRequested = params && typeof params === 'object' && params.stream === true; + const client = getClient(); const spanConfig = { - name: `${operationName} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${model}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operationName), attributes: requestAttributes as Record, }; diff --git a/packages/server-utils/src/ai/workers-ai/index.ts b/packages/server-utils/src/ai/workers-ai/index.ts index cf5b0a467484..846a54f80119 100644 --- a/packages/server-utils/src/ai/workers-ai/index.ts +++ b/packages/server-utils/src/ai/workers-ai/index.ts @@ -1,5 +1,8 @@ import { _INTERNAL_shouldSkipAiProviderWrapping, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, isObjectLike, SPAN_STATUS_ERROR, startSpan, @@ -43,6 +46,7 @@ function instrumentRun( const operationName = getOperationName(inputs); const requestAttributes = extractRequestAttributes(model, inputs, operationName); const modelName = typeof model === 'string' ? model : 'unknown'; + const client = getClient(); const isStreamRequested = !!inputs && typeof inputs === 'object' && (inputs as { stream?: unknown }).stream === true; @@ -52,7 +56,13 @@ function instrumentRun( (runOptions.returnRawResponse === true || runOptions.websocket === true); const spanConfig = { - name: `${operationName} ${modelName}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof modelName === 'string' && modelName !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operationName} ${modelName}` + : operationName !== 'unknown' + ? operationName + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: `gen_ai.${operationName}`, attributes: requestAttributes, }; diff --git a/packages/server-utils/src/integrations/anthropic.ts b/packages/server-utils/src/integrations/anthropic.ts index 6717be01fe86..64692a842ba9 100644 --- a/packages/server-utils/src/integrations/anthropic.ts +++ b/packages/server-utils/src/integrations/anthropic.ts @@ -4,6 +4,9 @@ import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -99,9 +102,16 @@ function createGenAiSpan( const attributes = extractRequestAttributes(args, operation); const model = (attributes[GEN_AI_REQUEST_MODEL] as string) || 'unknown'; attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; + const client = getClient(); const span = startInactiveSpan({ - name: `${operation} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operation} ${model}` + : operation !== 'unknown' + ? operation + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operation), attributes: attributes as Record, }); diff --git a/packages/server-utils/src/integrations/google-genai.ts b/packages/server-utils/src/integrations/google-genai.ts index de03c6f190da..02033a4d4505 100644 --- a/packages/server-utils/src/integrations/google-genai.ts +++ b/packages/server-utils/src/integrations/google-genai.ts @@ -4,7 +4,10 @@ import type { IntegrationFn, Span } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, getActiveSpan, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, spanToJSON, startInactiveSpan, @@ -108,9 +111,16 @@ function createGenAiSpan( const attributes = extractRequestAttributes(operation, params, data.self); const model = (attributes[GEN_AI_REQUEST_MODEL] as string) || 'unknown'; attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; + const client = getClient(); const span = startInactiveSpan({ - name: `${operation} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operation} ${model}` + : operation !== 'unknown' + ? operation + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operation), attributes, }); diff --git a/packages/server-utils/src/integrations/openai.ts b/packages/server-utils/src/integrations/openai.ts index 4e022cb55f0c..cb6512cfc801 100644 --- a/packages/server-utils/src/integrations/openai.ts +++ b/packages/server-utils/src/integrations/openai.ts @@ -3,6 +3,9 @@ import type { IntegrationFn, Span, SpanAttributeValue } from '@sentry/core'; import { _INTERNAL_shouldSkipAiProviderWrapping, defineIntegration, + GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, + getClient, + hasSpanStreamingEnabled, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, startInactiveSpan, } from '@sentry/core'; @@ -82,9 +85,16 @@ function createGenAiSpan(data: OpenAiChatChannelContext, operation: string, opti const attributes = extractRequestAttributes(args, operation); attributes[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN] = ORIGIN; const model = (params?.model as string) || 'unknown'; + const client = getClient(); const span = startInactiveSpan({ - name: `${operation} ${model}`, + // With span streaming, omit the `'unknown'` model sentinel so the name stays low-cardinality. + name: + (typeof model === 'string' && model !== 'unknown') || !(client && hasSpanStreamingEnabled(client)) + ? `${operation} ${model}` + : operation !== 'unknown' + ? operation + : GEN_AI_INFERENCE_SPAN_NAME_FALLBACK, op: getGenAiSpanOp(operation), attributes: attributes as Record, }); diff --git a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts index 449fa12005e8..cc6a7075407c 100644 --- a/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/integrations/vercel-ai/vercel-ai-dc-subscriber.ts @@ -26,6 +26,7 @@ import { _INTERNAL_skipAiProviderWrapping, captureException, getClient, + hasSpanStreamingEnabled, isObjectLike, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, @@ -471,7 +472,12 @@ function buildToolSpan(event: Record, recordInputs: boolean): S // Gated on `recordInputs` to match the OTel path (descriptions come from the recorded tools list). const description = recordInputs && toolName ? resolveToolDescription(asString(event.callId), toolName, event.tools) : undefined; - return startGenAiSpan(GEN_AI_EXECUTE_TOOL_OPERATION, toolName, { + // With span streaming, the name follows the `{operation}` inference template. The tool name + // stays available on `gen_ai.tool.name`. Gated here rather than inside `startGenAiSpan` so the + // `embed`/`embedMany`/`rerank` callers keep passing their model as the suffix. + const client = getClient(); + const nameSuffix = client && hasSpanStreamingEnabled(client) ? undefined : toolName; + return startGenAiSpan(GEN_AI_EXECUTE_TOOL_OPERATION, nameSuffix, { [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: ORIGIN, ...(toolName ? { [GEN_AI_TOOL_NAME]: toolName } : {}), ...(toolCallId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId } : {}), diff --git a/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts b/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts new file mode 100644 index 000000000000..bd10461aa2dc --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/anthropic-ai.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { instrumentAnthropicAiClient } from '../../../../src/ai/anthropic-ai'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('instrumentAnthropicAiClient 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 fakeClient(): { + messages: { create: ReturnType }; + } { + return { + messages: { + create: vi.fn().mockResolvedValue({ id: 'msg', content: [] }), + }, + }; + } + + it('names the span `{operation} {model}` when a model is present', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentAnthropicAiClient(client); + + await instrumented.messages.create({ + model: 'claude-3-haiku-20240307', + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat claude-3-haiku-20240307'); + }); + + it('keeps `chat unknown` when the model is missing in static mode', async () => { + const endedSpans = setupClient('static'); + const client = fakeClient(); + const instrumented = instrumentAnthropicAiClient(client); + + await instrumented.messages.create({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat unknown'); + }); + + it('uses the operation name when the model is missing and span streaming is enabled', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentAnthropicAiClient(client); + + await instrumented.messages.create({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts b/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts new file mode 100644 index 000000000000..58281926d9c7 --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/google-genai.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { instrumentGoogleGenAIClient } from '../../../../src/ai/google-genai'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('instrumentGoogleGenAIClient 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 fakeClient(): { + models: { generateContent: ReturnType }; + chats: { create: ReturnType }; + } { + return { + models: { + generateContent: vi.fn().mockResolvedValue({ candidates: [] }), + }, + chats: { + create: vi.fn().mockReturnValue({ sendMessage: vi.fn() }), + }, + }; + } + + it('names the span `{operation} {model}` when a model is present', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentGoogleGenAIClient(client); + + await instrumented.models.generateContent({ model: 'gemini-1.5-pro', contents: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('generate_content gemini-1.5-pro'); + }); + + it('keeps `generate_content unknown` when the model is missing in static mode', async () => { + const endedSpans = setupClient('static'); + const client = fakeClient(); + const instrumented = instrumentGoogleGenAIClient(client); + + await instrumented.models.generateContent({ contents: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('generate_content unknown'); + }); + + it('uses the operation name when the model is missing and span streaming is enabled', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentGoogleGenAIClient(client); + + await instrumented.models.generateContent({ contents: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('generate_content'); + }); + + it('does not start a span for chats.create', () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentGoogleGenAIClient(client); + + instrumented.chats.create({ model: 'gemini-1.5-pro' }); + + expect(endedSpans).toHaveLength(0); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/langchain-embeddings.test.ts b/packages/server-utils/test/ai/lib/tracing/langchain-embeddings.test.ts index 361c47675ceb..204b4dd99420 100644 --- a/packages/server-utils/test/ai/lib/tracing/langchain-embeddings.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langchain-embeddings.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type * as AiCoreUtils from '../../../../src/ai/core/utils'; import type * as SentryCore from '@sentry/core'; import { @@ -42,11 +42,29 @@ vi.mock('@sentry/core', async importOriginal => { }; }); -import { captureException } from '@sentry/core'; +import { captureException, getMainCarrier, setCurrentClient } from '@sentry/core'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +function setupClient(traceLifecycle: 'static' | 'stream'): void { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + traceLifecycle, + }), + ); + setCurrentClient(client); + client.init(); +} describe('instrumentEmbeddingMethod', () => { beforeEach(() => { capturedSpanConfig = undefined; + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; }); it('creates a span with correct attributes', async () => { @@ -105,7 +123,8 @@ describe('instrumentEmbeddingMethod', () => { expect(capturedSpanConfig!.attributes[GEN_AI_PROVIDER_NAME]).toBe('google_genai'); }); - it('handles missing instance properties gracefully', async () => { + it('keeps `embeddings unknown` when the model is missing in static mode', async () => { + setupClient('static'); const original = vi.fn().mockResolvedValue([0.1]); const wrapped = instrumentEmbeddingMethod(original); @@ -116,11 +135,36 @@ describe('instrumentEmbeddingMethod', () => { expect(capturedSpanConfig!.attributes[GEN_AI_PROVIDER_NAME]).toBe('langchain'); expect(capturedSpanConfig!.attributes[GEN_AI_REQUEST_DIMENSIONS_ATTRIBUTE]).toBeUndefined(); }); + + it('uses the operation name when the model is missing and span streaming is enabled', async () => { + setupClient('stream'); + const original = vi.fn().mockResolvedValue([0.1]); + const wrapped = instrumentEmbeddingMethod(original); + + await wrapped.call({}, 'test'); + + expect(capturedSpanConfig!.name).toBe('embeddings'); + }); + + it('uses the operation name when the model is not a string and span streaming is enabled', async () => { + setupClient('stream'); + const original = vi.fn().mockResolvedValue([0.1]); + const wrapped = instrumentEmbeddingMethod(original); + + await wrapped.call({ model: { id: 'text-embedding-3-small' } }, 'test'); + + expect(capturedSpanConfig!.name).toBe('embeddings'); + }); }); describe('instrumentLangChainEmbeddings', () => { beforeEach(() => { capturedSpanConfig = undefined; + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; }); it('wraps both embedQuery and embedDocuments on an instance', async () => { diff --git a/packages/server-utils/test/ai/lib/tracing/openai.test.ts b/packages/server-utils/test/ai/lib/tracing/openai.test.ts new file mode 100644 index 000000000000..d25480f2bbbd --- /dev/null +++ b/packages/server-utils/test/ai/lib/tracing/openai.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToStaticSpanJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; +import { instrumentOpenAiClient } from '../../../../src/ai/openai'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; + +describe('instrumentOpenAiClient 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 fakeClient(): { + chat: { completions: { create: ReturnType } }; + } { + return { + chat: { + completions: { + create: vi.fn().mockResolvedValue({ id: 'test', choices: [] }), + }, + }, + }; + } + + it('names the span `{operation} {model}` when a model is present', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentOpenAiClient(client); + + await instrumented.chat.completions.create({ + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat gpt-4'); + }); + + it('keeps `chat unknown` when the model is missing in static mode', async () => { + const endedSpans = setupClient('static'); + const client = fakeClient(); + const instrumented = instrumentOpenAiClient(client); + + await instrumented.chat.completions.create({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat unknown'); + }); + + it('uses the operation name when the model is missing and span streaming is enabled', async () => { + const endedSpans = setupClient('stream'); + const client = fakeClient(); + const instrumented = instrumentOpenAiClient(client); + + await instrumented.chat.completions.create({ + messages: [{ role: 'user', content: 'Hello' }], + }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); + }); +}); diff --git a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts index e1183da96d1b..7cebd8b341ca 100644 --- a/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/workers-ai.test.ts @@ -159,4 +159,52 @@ describe('instrumentWorkersAiClient', () => { expect(spanToStaticSpanJSON(endedSpans[0]!).data).toEqual(expected); }); }); + + describe('span names', () => { + 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; + } + + it('names the span `{operation} {model}` when a model is present', async () => { + const endedSpans = setupClient('stream'); + const client = { run: vi.fn().mockResolvedValue({ response: 'ok' }) }; + const instrumented = instrumentWorkersAiClient(client); + + await instrumented.run(MODEL, { prompt: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe(`chat ${MODEL}`); + }); + + it('keeps `chat unknown` when the model is missing in static mode', async () => { + const endedSpans = setupClient('static'); + const client = { run: vi.fn().mockResolvedValue({ response: 'ok' }) }; + const instrumented = instrumentWorkersAiClient(client); + + await instrumented.run({ not: 'a-string' }, { prompt: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat unknown'); + }); + + it('falls back to the operation name when the model is missing and span streaming is enabled', async () => { + const endedSpans = setupClient('stream'); + const client = { run: vi.fn().mockResolvedValue({ response: 'ok' }) }; + const instrumented = instrumentWorkersAiClient(client); + + await instrumented.run({ not: 'a-string' }, { prompt: 'Hello' }); + + expect(spanToStaticSpanJSON(endedSpans[0]!).description).toBe('chat'); + }); + }); });