Skip to content
Open
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
26 changes: 15 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} {agent}`, where the agent is the LangGraph agent name, the LangChain chain name or the Vercel AI `functionId` (`invoke_agent format_prompt`), or `{operation}` if the SDK has 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`).

Only the `chain` prefix changes: LangChain agent spans now lead with the operation, like every other agent span. The chain name itself is bounded, so it stays in the name and remains available on `langchain.chain.name`. LangGraph agent names and Vercel AI `functionId`s are unchanged in both lifecycles. A chain the SDK cannot name falls back to `invoke_agent` rather than `chain unknown_chain`.

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 All @@ -867,6 +870,7 @@ Child spans of a service or root span carry its name in their `sentry.segment.na
`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.
Likewise, filters matching `chat unknown` no longer apply to a streamed chat span (`'chat'`).
The same goes for filters matching `chain format_prompt`, which no longer apply to a streamed LangChain agent span (`'invoke_agent format_prompt'`).

Match on attributes instead:

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