Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,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 @@ -420,4 +421,29 @@ describe('LangChain integration', () => {
.completed();
});
});

createEsmAndCjsTests(__dirname, 'scenario-chain.mjs', 'instrument-span-streaming.mjs', (createRunner, test) => {
test('uses invoke_agent for chain spans 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',
);
expect(chainSpans.map(span => span.name).sort()).toEqual(['invoke_agent', 'invoke_agent', 'invoke_agent']);
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();
});
});
});
14 changes: 14 additions & 0 deletions docs/migration/v11-end-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,20 @@ 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 |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `gen_ai.invoke_agent`, `gen_ai.handoff` | `{operation} {agent}` (`invoke_agent weather_assistant`), `chain {chainName}` (`chain format_prompt`), or `{operation} {functionId}` (`invoke_agent weather_agent`) | `{operation} {agent}` when the agent name is known, or `{operation}` if the SDK has none (`invoke_agent`). Chain names stay on `langchain.chain.name`; function ids stay on `gen_ai.function.id` |

Resolved low-cardinality values are kept in both lifecycles: a known agent name stays in the name (`invoke_agent weather_assistant`).

`ignoreSpans` is evaluated at span start. Filters matching `chain format_prompt` or `invoke_agent weather_agent` no longer apply to a streamed agent span named `'invoke_agent'`; match on `langchain.chain.name` or `gen_ai.function.id` instead.

### The `enableLogs` option was removed

Affected SDKs: All SDKs.
Expand Down
9 changes: 8 additions & 1 deletion packages/server-utils/src/ai/langchain/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable max-lines */
import {
captureException,
getClient,
hasSpanStreamingEnabled,
SEMANTIC_ATTRIBUTE_SENTRY_OP,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SPAN_STATUS_ERROR,
Expand Down Expand Up @@ -220,16 +222,21 @@ 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 follows the `{operation}` agent template. The chain
// name stays available on `langchain.chain.name`.
name: client && hasSpanStreamingEnabled(client) ? 'invoke_agent' : `chain ${chainName}`,
op: 'gen_ai.invoke_agent',
attributes: {
...attributes,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
_INTERNAL_skipAiProviderWrapping,
captureException,
getClient,
hasSpanStreamingEnabled,
isObjectLike,
SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN,
SPAN_STATUS_ERROR,
Expand Down Expand Up @@ -429,7 +430,11 @@ function buildInvokeAgentSpan(
if (callId) {
operationIdByCallId.set(callId, { operationId, isStream });
}
const span = startGenAiSpan(GEN_AI_INVOKE_AGENT_OPERATION, functionId, {
// With span streaming, the name follows the `{operation}` agent template. `functionId` is not
// `gen_ai.agent.name` — it stays available on `gen_ai.function.id`.
const client = getClient();
const nameSuffix = client && hasSpanStreamingEnabled(client) ? undefined : functionId;
const span = startGenAiSpan(GEN_AI_INVOKE_AGENT_OPERATION, nameSuffix, {
...baseAttributes,
[VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId,
[GEN_AI_RESPONSE_STREAMING]: isStream,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
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('uses `invoke_agent` when span streaming is enabled', () => {
const endedSpans = setupClient('stream');
runChain('format_prompt');

const span = spanToStaticSpanJSON(endedSpans[0]!);
expect(span.description).toBe('invoke_agent');
expect(span.data?.[GEN_AI_OPERATION_NAME]).toBe('invoke_agent');
expect(span.data?.['langchain.chain.name']).toBe('format_prompt');
});
});
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();
}

it('keeps `invoke_agent {functionId}` in static mode', () => {
const endedSpans = setupClient('static');
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('uses `invoke_agent` when span streaming is enabled', () => {
const endedSpans = setupClient('stream');
startInvokeAgentSpan('weather_agent');

const span = spanToStaticSpanJSON(endedSpans[0]!);
expect(span.description).toBe('invoke_agent');
expect(span.data?.[GEN_AI_FUNCTION_ID]).toBe('weather_agent');
});
});
Loading