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
25 changes: 14 additions & 11 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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();
});
});
});
11 changes: 10 additions & 1 deletion packages/server-utils/src/ai/langchain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,16 +228,25 @@ export function createLangChainCallbackHandler(options: LangChainOptions = {}):
const chainName = runName || chain.name || 'unknown_chain';
const attributes: Record<string, SpanAttributeValue> = {
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: 'auto.ai.langchain',
[GEN_AI_OPERATION_NAME]: 'invoke_agent',
'langchain.chain.name': chainName,
};

if (recordInputs) {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown>): Promise<void> {
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');
});
});
Original file line number Diff line number Diff line change
@@ -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<typeof createSpanFromMessage>[0],
{} as Parameters<typeof createSpanFromMessage>[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');
});
});
Loading