From 2fd2b15412332418d7c23c2a9638456c929e71ef Mon Sep 17 00:00:00 2001 From: zkasuran Date: Tue, 11 Aug 2026 21:04:50 +0530 Subject: [PATCH 1/6] fix(server-utils): Record LangGraph span I/O for non-MessagesAnnotation state instrumentCompiledGraphInvoke read the graph input and output through `args[0].messages` and `result.messages`. Those keys only exist for graphs built on MessagesAnnotation, so a graph with a custom state annotation had its `invoke_agent` span input and output silently left empty with no error. Keep the existing message path unchanged. When there is no `messages` array, fall back to serializing the whole input and output state onto `gen_ai.input.messages` and `gen_ai.response.text`, wrapped as a single role/content message so the attribute stays a valid chat array (the same shape LangChain LLM prompts already use). A null input still records nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/langgraph/index.ts | 36 +++--- .../server-utils/src/ai/langgraph/utils.ts | 7 +- .../test/ai/lib/tracing/langgraph.test.ts | 109 +++++++++++++++++- 3 files changed, 136 insertions(+), 16 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index 00825cbd6206..b757d79bf1a3 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,21 +154,29 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input messages - const inputMessages = - args.length > 0 ? ((args[0] as { messages?: LangChainMessage[] } | null)?.messages ?? []) : []; - - if (inputMessages && recordInputs) { - const normalizedMessages = normalizeLangChainMessages(inputMessages); - const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); - - if (systemInstructions) { - span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + // Parse input state. MessagesAnnotation graphs expose a `messages` array. A custom state + // annotation exposes arbitrary keys instead, so the whole state is recorded as a fallback + // rather than dropped silently. + const inputState = args.length > 0 ? args[0] : undefined; + const inputMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages ?? []; + + if (recordInputs) { + if (inputMessages.length > 0) { + const normalizedMessages = normalizeLangChainMessages(inputMessages); + const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); + + if (systemInstructions) { + span.setAttribute(GEN_AI_SYSTEM_INSTRUCTIONS, systemInstructions); + } + + span.setAttributes({ + [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), + }); + } else if (inputState && typeof inputState === 'object') { + span.setAttributes({ + [GEN_AI_INPUT_MESSAGES]: stringify([{ role: 'user', content: stringify(inputState) }]), + }); } - - span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), - }); } // Call original invoke diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index 86e43d2d33a0..4afd0236c4b7 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -1,5 +1,5 @@ /* eslint-disable typescript-eslint/no-deprecated */ -import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan } from '@sentry/core'; +import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, SPAN_STATUS_ERROR, startSpan, stringify } from '@sentry/core'; import type { Span, SpanAttributes } from '@sentry/core'; import { GEN_AI_AGENT_NAME, @@ -276,6 +276,11 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag const outputMessages = resultObj?.messages; if (!outputMessages || !Array.isArray(outputMessages)) { + // A custom state annotation has no `messages` array. Record the whole output state as a + // fallback so it is not dropped silently. + if (result && typeof result === 'object') { + span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: stringify(result) }])); + } return; } diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index b1f79c9d184c..98fe90b77736 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from 'vitest'; +import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT } from '@sentry/conventions/attributes'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { getMainCarrier, setCurrentClient, spanToJSON } from '@sentry/core'; +import type { Span } from '@sentry/core'; import { instrumentCreateReactAgent, instrumentStateGraph, instrumentStateGraphCompile, } from '../../../../src/ai/langgraph'; +import { getDefaultTestClientOptions, TestClient } from '../../../mocks/client'; describe('langgraph double-patch guard', () => { it('instrumentStateGraphCompile returns the same wrapper when applied twice', () => { @@ -32,3 +36,106 @@ describe('instrumentStateGraph', () => { expect(stateGraph.compile).not.toBe(originalCompile); }); }); + +describe('invoke_agent input/output recording', () => { + beforeEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + afterEach(() => { + getMainCarrier().__SENTRY__ = undefined; + }); + + function setupClient(): Span[] { + const client = new TestClient( + getDefaultTestClientOptions({ + dsn: 'https://public@dsn.ingest.sentry.io/1337', + tracesSampleRate: 1, + }), + ); + setCurrentClient(client); + client.init(); + + const endedSpans: Span[] = []; + client.on('spanEnd', span => endedSpans.push(span)); + return endedSpans; + } + + // Regression test for a graph built on a custom state annotation (no `messages` key). The + // instrumentation used to read `args[0].messages` only, so the whole state was dropped. + it('records the full state for a graph that does not use MessagesAnnotation', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + const result = await graph.invoke({ idea: 'test idea' }); + + expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); + expect(endedSpans).toHaveLength(1); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + const parsedInput = JSON.parse(inputMessages!) as Array<{ role: string; content: string }>; + expect(parsedInput).toHaveLength(1); + expect(parsedInput[0]!.role).toBe('user'); + expect(JSON.parse(parsedInput[0]!.content)).toEqual({ idea: 'test idea' }); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + const parsedOutput = JSON.parse(responseText!) as Array<{ role: string; content: string }>; + expect(parsedOutput[0]!.role).toBe('assistant'); + expect(JSON.parse(parsedOutput[0]!.content)).toEqual({ + idea: 'test idea', + expanded: 'expanded idea', + validated: true, + }); + }); + + it('still records chat messages for a MessagesAnnotation graph', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (input: { messages: Array<{ role: string; content: string }> }) => ({ + messages: [...input.messages, { role: 'assistant', content: 'The weather is sunny' }], + }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + expect(JSON.parse(inputMessages!)).toEqual([{ role: 'user', content: 'What is the weather today?' }]); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + expect(responseText).toContain('The weather is sunny'); + }); + + it('does not record input messages when invoked with null input', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (_input?: unknown) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await expect(graph.invoke(null)).resolves.toBeDefined(); + + const data = spanToJSON(endedSpans[0]!).data; + expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + }); +}); From 508fded471021620d99e2c924ade3ef9e51c7b76 Mon Sep 17 00:00:00 2001 From: zkasuran Date: Thu, 13 Aug 2026 06:36:48 +0530 Subject: [PATCH 2/6] fix(server-utils): Route empty LangGraph messages array on the chat path An explicit empty `messages` array was gated by `length > 0`, so it fell through to the custom-state branch and got wrapped as a synthetic user message holding the whole input object. The output helper already routes on `Array.isArray`, so an empty array stays on the chat path there. Match that on the input read: key on `Array.isArray(state.messages)`, so an empty MessagesAnnotation input records an empty chat array while a null or resume input still records nothing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/langgraph/index.ts | 14 ++++++----- .../test/ai/lib/tracing/langgraph.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index b757d79bf1a3..a02464634193 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,14 +154,16 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input state. MessagesAnnotation graphs expose a `messages` array. A custom state - // annotation exposes arbitrary keys instead, so the whole state is recorded as a fallback - // rather than dropped silently. + // Parse input state. MessagesAnnotation graphs expose a `messages` array (possibly empty); + // a custom state annotation exposes arbitrary keys instead. Route on whether `messages` is + // an array, mirroring the output side in setResponseAttributes, so an empty chat history is + // recorded as an empty chat array rather than misread as custom state and wrapped. const inputState = args.length > 0 ? args[0] : undefined; - const inputMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages ?? []; + const stateMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages; + const inputMessages = Array.isArray(stateMessages) ? stateMessages : null; if (recordInputs) { - if (inputMessages.length > 0) { + if (inputMessages) { const normalizedMessages = normalizeLangChainMessages(inputMessages); const { systemInstructions, filteredMessages } = extractSystemInstructions(normalizedMessages); @@ -183,7 +185,7 @@ export function instrumentCompiledGraphInvoke( const result = await Reflect.apply(target, thisArg, args); if (recordOutputs) { - setResponseAttributes(span, inputMessages ?? null, result); + setResponseAttributes(span, inputMessages, result); } return result; diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index 98fe90b77736..da7f2a220ecf 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -123,6 +123,31 @@ describe('invoke_agent input/output recording', () => { expect(responseText).toContain('The weather is sunny'); }); + it('records an empty messages array on the chat path rather than wrapping it as custom state', async () => { + const endedSpans = setupClient(); + + const compiled = { + invoke: async (_input: { messages: Array<{ role: string; content: string }> }) => ({ + messages: [{ role: 'assistant', content: 'Hello' }], + }), + }; + const stateGraph = { compile: () => compiled }; + + instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); + const graph = stateGraph.compile(); + await graph.invoke({ messages: [] }); + + const data = spanToJSON(endedSpans[0]!).data; + + const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; + expect(inputMessages).toBeDefined(); + expect(JSON.parse(inputMessages!)).toEqual([]); + + const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; + expect(responseText).toBeDefined(); + expect(responseText).toContain('Hello'); + }); + it('does not record input messages when invoked with null input', async () => { const endedSpans = setupClient(); From c0df556c1ad676d606acaac45675538ba6b9ff6c Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 21 Sep 2026 16:34:50 -0400 Subject: [PATCH 3/6] test(server-utils): Read LangGraph span attributes from `attributes` `spanToJSON` on develop returns the streamed span shape, which exposes `attributes` instead of `data`. --- .../server-utils/test/ai/lib/tracing/langgraph.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index da7f2a220ecf..420c9d1fff57 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -78,7 +78,7 @@ describe('invoke_agent input/output recording', () => { expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); expect(endedSpans).toHaveLength(1); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -112,7 +112,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -137,7 +137,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await graph.invoke({ messages: [] }); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; expect(inputMessages).toBeDefined(); @@ -160,7 +160,7 @@ describe('invoke_agent input/output recording', () => { const graph = stateGraph.compile(); await expect(graph.invoke(null)).resolves.toBeDefined(); - const data = spanToJSON(endedSpans[0]!).data; + const data = spanToJSON(endedSpans[0]!).attributes; expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); }); }); From d34ea302d6e8637330d51ff4de57ea4f52b4136e Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 21 Sep 2026 16:39:51 -0400 Subject: [PATCH 4/6] ref(server-utils): Tighten LangGraph state recording and its tests --- .../server-utils/src/ai/langgraph/index.ts | 14 +- .../server-utils/src/ai/langgraph/utils.ts | 3 +- .../test/ai/lib/tracing/langgraph.test.ts | 122 ++++++------------ 3 files changed, 45 insertions(+), 94 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index a02464634193..0d6a6fab985b 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -154,13 +154,9 @@ export function instrumentCompiledGraphInvoke( span.setAttribute(GEN_AI_TOOL_DEFINITIONS, JSON.stringify(tools)); } - // Parse input state. MessagesAnnotation graphs expose a `messages` array (possibly empty); - // a custom state annotation exposes arbitrary keys instead. Route on whether `messages` is - // an array, mirroring the output side in setResponseAttributes, so an empty chat history is - // recorded as an empty chat array rather than misread as custom state and wrapped. - const inputState = args.length > 0 ? args[0] : undefined; - const stateMessages = (inputState as { messages?: LangChainMessage[] } | null)?.messages; - const inputMessages = Array.isArray(stateMessages) ? stateMessages : null; + // Custom state annotations have no `messages` array, the whole state is recorded instead. + const inputState = args[0] as { messages?: LangChainMessage[] } | null | undefined; + const inputMessages = Array.isArray(inputState?.messages) ? inputState.messages : null; if (recordInputs) { if (inputMessages) { @@ -175,9 +171,7 @@ export function instrumentCompiledGraphInvoke( [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), }); } else if (inputState && typeof inputState === 'object') { - span.setAttributes({ - [GEN_AI_INPUT_MESSAGES]: stringify([{ role: 'user', content: stringify(inputState) }]), - }); + span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify([{ role: 'user', content: stringify(inputState) }])); } } diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index 4afd0236c4b7..e9a39186d827 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -276,8 +276,7 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag const outputMessages = resultObj?.messages; if (!outputMessages || !Array.isArray(outputMessages)) { - // A custom state annotation has no `messages` array. Record the whole output state as a - // fallback so it is not dropped silently. + // Custom state annotations have no `messages` array, the whole state is recorded instead. if (result && typeof result === 'object') { span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: stringify(result) }])); } diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index 420c9d1fff57..02c7c4b95978 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -61,106 +61,64 @@ describe('invoke_agent input/output recording', () => { return endedSpans; } - // Regression test for a graph built on a custom state annotation (no `messages` key). The - // instrumentation used to read `args[0].messages` only, so the whole state was dropped. - it('records the full state for a graph that does not use MessagesAnnotation', async () => { + async function getInvokeAttributes(invoke: (input: T) => Promise, input: T) { const endedSpans = setupClient(); - - const compiled = { - invoke: async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), - }; - const stateGraph = { compile: () => compiled }; + const stateGraph = { compile: () => ({ invoke }) }; instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - const result = await graph.invoke({ idea: 'test idea' }); + await stateGraph.compile().invoke(input); - expect(result).toEqual({ idea: 'test idea', expanded: 'expanded idea', validated: true }); expect(endedSpans).toHaveLength(1); + return spanToJSON(endedSpans[0]!).attributes; + } - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - const parsedInput = JSON.parse(inputMessages!) as Array<{ role: string; content: string }>; - expect(parsedInput).toHaveLength(1); - expect(parsedInput[0]!.role).toBe('user'); - expect(JSON.parse(parsedInput[0]!.content)).toEqual({ idea: 'test idea' }); - - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - const parsedOutput = JSON.parse(responseText!) as Array<{ role: string; content: string }>; - expect(parsedOutput[0]!.role).toBe('assistant'); - expect(JSON.parse(parsedOutput[0]!.content)).toEqual({ - idea: 'test idea', - expanded: 'expanded idea', - validated: true, - }); + it('records the full state for a graph that does not use MessagesAnnotation', async () => { + const attributes = await getInvokeAttributes( + async (input: Record) => ({ ...input, expanded: 'expanded idea', validated: true }), + { idea: 'test idea' }, + ); + + expect(JSON.parse(attributes[GEN_AI_INPUT_MESSAGES] as string)).toEqual([ + { role: 'user', content: JSON.stringify({ idea: 'test idea' }) }, + ]); + expect(JSON.parse(attributes[GEN_AI_RESPONSE_TEXT] as string)).toEqual([ + { + role: 'assistant', + content: JSON.stringify({ idea: 'test idea', expanded: 'expanded idea', validated: true }), + }, + ]); }); it('still records chat messages for a MessagesAnnotation graph', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (input: { messages: Array<{ role: string; content: string }> }) => ({ + const attributes = await getInvokeAttributes( + async (input: { messages: Array<{ role: string; content: string }> }) => ({ messages: [...input.messages, { role: 'assistant', content: 'The weather is sunny' }], }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await graph.invoke({ messages: [{ role: 'user', content: 'What is the weather today?' }] }); - - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - expect(JSON.parse(inputMessages!)).toEqual([{ role: 'user', content: 'What is the weather today?' }]); + { messages: [{ role: 'user', content: 'What is the weather today?' }] }, + ); - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - expect(responseText).toContain('The weather is sunny'); + expect(JSON.parse(attributes[GEN_AI_INPUT_MESSAGES] as string)).toEqual([ + { role: 'user', content: 'What is the weather today?' }, + ]); + expect(attributes[GEN_AI_RESPONSE_TEXT]).toContain('The weather is sunny'); }); - it('records an empty messages array on the chat path rather than wrapping it as custom state', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (_input: { messages: Array<{ role: string; content: string }> }) => ({ - messages: [{ role: 'assistant', content: 'Hello' }], - }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await graph.invoke({ messages: [] }); - - const data = spanToJSON(endedSpans[0]!).attributes; - - const inputMessages = data[GEN_AI_INPUT_MESSAGES] as string | undefined; - expect(inputMessages).toBeDefined(); - expect(JSON.parse(inputMessages!)).toEqual([]); + it('records an empty messages array as an empty chat array', async () => { + const attributes = await getInvokeAttributes( + async (_input: { messages: unknown[] }) => ({ messages: [{ role: 'assistant', content: 'Hello' }] }), + { messages: [] }, + ); - const responseText = data[GEN_AI_RESPONSE_TEXT] as string | undefined; - expect(responseText).toBeDefined(); - expect(responseText).toContain('Hello'); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBe('[]'); + expect(attributes[GEN_AI_RESPONSE_TEXT]).toContain('Hello'); }); it('does not record input messages when invoked with null input', async () => { - const endedSpans = setupClient(); - - const compiled = { - invoke: async (_input?: unknown) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), - }; - const stateGraph = { compile: () => compiled }; - - instrumentStateGraph(stateGraph, { recordInputs: true, recordOutputs: true }); - const graph = stateGraph.compile(); - await expect(graph.invoke(null)).resolves.toBeDefined(); + const attributes = await getInvokeAttributes( + async (_input: null) => ({ messages: [{ role: 'assistant', content: 'resumed' }] }), + null, + ); - const data = spanToJSON(endedSpans[0]!).attributes; - expect(data[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); }); }); From 9ab5235e8240a781222fcd0abdda8f0dfa2ae22b Mon Sep 17 00:00:00 2001 From: zkasuran Date: Thu, 1 Oct 2026 17:38:27 +0530 Subject: [PATCH 5/6] fix(server-utils): Skip Command resumes and emit output.messages for LangGraph custom state Two fixes from review of the custom-state recording. A `new Command({ resume })` resumes an interrupted run and carries no user turn, so recording it in the custom-state branch produced a `gen_ai.input.messages` turn nobody wrote. It is now skipped like a `null` resume. Detection keys on `lg_name === 'Command'`, which is what LangGraph's own `isCommand` checks, so server-utils takes no dependency on `@langchain/langgraph`. Custom-state output now also sets `gen_ai.output.messages` through the shared `setOutputMessagesAttribute` helper. The deprecated `gen_ai.response.text` is kept for back-compat, matching the dual-set the helper documents. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../server-utils/src/ai/langgraph/index.ts | 8 +++-- .../server-utils/src/ai/langgraph/utils.ts | 7 ++++- .../test/ai/lib/tracing/langgraph.test.ts | 31 ++++++++++++++++++- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/server-utils/src/ai/langgraph/index.ts b/packages/server-utils/src/ai/langgraph/index.ts index 0d6a6fab985b..59b792b4a704 100644 --- a/packages/server-utils/src/ai/langgraph/index.ts +++ b/packages/server-utils/src/ai/langgraph/index.ts @@ -155,8 +155,12 @@ export function instrumentCompiledGraphInvoke( } // Custom state annotations have no `messages` array, the whole state is recorded instead. - const inputState = args[0] as { messages?: LangChainMessage[] } | null | undefined; + const inputState = args[0] as { messages?: LangChainMessage[]; lg_name?: string } | null | undefined; const inputMessages = Array.isArray(inputState?.messages) ? inputState.messages : null; + // `new Command({ resume })` resumes an interrupted run and carries no user turn (LangGraph + // tags it `lg_name: 'Command'`), so skip it like a `null` resume rather than recording the + // control object as a message nobody wrote. + const isResumeCommand = inputState?.lg_name === 'Command'; if (recordInputs) { if (inputMessages) { @@ -170,7 +174,7 @@ export function instrumentCompiledGraphInvoke( span.setAttributes({ [GEN_AI_INPUT_MESSAGES]: stringify(filteredMessages), }); - } else if (inputState && typeof inputState === 'object') { + } else if (inputState && typeof inputState === 'object' && !isResumeCommand) { span.setAttribute(GEN_AI_INPUT_MESSAGES, stringify([{ role: 'user', content: stringify(inputState) }])); } } diff --git a/packages/server-utils/src/ai/langgraph/utils.ts b/packages/server-utils/src/ai/langgraph/utils.ts index e9a39186d827..3d068da8d218 100644 --- a/packages/server-utils/src/ai/langgraph/utils.ts +++ b/packages/server-utils/src/ai/langgraph/utils.ts @@ -19,6 +19,7 @@ import { } from '@sentry/conventions/attributes'; import { GEN_AI_EXECUTE_TOOL } from '@sentry/conventions/op'; import { GEN_AI_TOOL_CALL_ID_ATTRIBUTE } from '../core/gen-ai-attributes'; +import { setOutputMessagesAttribute } from '../core/utils'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; import { LANGGRAPH_ORIGIN } from './constants'; @@ -278,7 +279,11 @@ export function setResponseAttributes(span: Span, inputMessages: LangChainMessag if (!outputMessages || !Array.isArray(outputMessages)) { // Custom state annotations have no `messages` array, the whole state is recorded instead. if (result && typeof result === 'object') { - span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: stringify(result) }])); + const serializedState = stringify(result); + // `gen_ai.output.messages` is what the product reads first; `gen_ai.response.text` is kept for + // back-compat (Relay still migrates it). + setOutputMessagesAttribute(span, { responseText: serializedState }); + span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: 'assistant', content: serializedState }])); } return; } diff --git a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts index 02c7c4b95978..786fd45ff6df 100644 --- a/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts +++ b/packages/server-utils/test/ai/lib/tracing/langgraph.test.ts @@ -1,4 +1,4 @@ -import { GEN_AI_INPUT_MESSAGES, GEN_AI_RESPONSE_TEXT } from '@sentry/conventions/attributes'; +import { GEN_AI_INPUT_MESSAGES, GEN_AI_OUTPUT_MESSAGES, GEN_AI_RESPONSE_TEXT } from '@sentry/conventions/attributes'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { getMainCarrier, setCurrentClient, spanToJSON } from '@sentry/core'; import type { Span } from '@sentry/core'; @@ -121,4 +121,33 @@ describe('invoke_agent input/output recording', () => { expect(attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); }); + + it('does not record a Command resume as input (skips it like a null resume)', async () => { + const attributes = await getInvokeAttributes( + async (_input: { lg_name: string; resume: string; goto: unknown[] }) => ({ + messages: [{ role: 'assistant', content: 'resumed' }], + }), + { lg_name: 'Command', resume: 'approved', goto: [] }, + ); + + expect(attributes[GEN_AI_INPUT_MESSAGES]).toBeUndefined(); + expect(attributes[GEN_AI_RESPONSE_TEXT]).toContain('resumed'); + }); + + it('records custom state output as gen_ai.output.messages alongside the deprecated response.text', async () => { + const attributes = await getInvokeAttributes( + async (input: Record) => ({ ...input, summary: 'done' }), + { topic: 'weather' }, + ); + + expect(JSON.parse(attributes[GEN_AI_OUTPUT_MESSAGES] as string)).toEqual([ + { + role: 'assistant', + parts: [{ type: 'text', content: JSON.stringify({ topic: 'weather', summary: 'done' }) }], + }, + ]); + expect(JSON.parse(attributes[GEN_AI_RESPONSE_TEXT] as string)).toEqual([ + { role: 'assistant', content: JSON.stringify({ topic: 'weather', summary: 'done' }) }, + ]); + }); }); From e50b970b9c1ded609de5660af4d09edbc49301c6 Mon Sep 17 00:00:00 2001 From: zkasuran Date: Thu, 1 Oct 2026 17:38:40 +0530 Subject: [PATCH 6/6] test(node-integration-tests): Add LangGraph custom Annotation.Root scenario Covers the custom-state path end to end, which the existing MessagesAnnotation scenarios did not. The graph uses `Annotation.Root({ topic, summary })`, runs under span streaming, and the test asserts `gen_ai.input.messages` and `gen_ai.output.messages` on the `invoke_agent` span. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../langgraph/scenario-custom-state.mjs | 30 ++++++++++++++++ .../suites/tracing/langgraph/test.ts | 35 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs new file mode 100644 index 000000000000..91537c9e33f0 --- /dev/null +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/scenario-custom-state.mjs @@ -0,0 +1,30 @@ +import { Annotation, END, START, StateGraph } from '@langchain/langgraph'; +import * as Sentry from '@sentry/node'; + +// A graph built on a custom `Annotation.Root` state (no `messages` channel). The invoke_agent span +// records the whole state object on both the input and the output side, unlike the MessagesAnnotation +// graphs the other scenarios use. +const CustomState = Annotation.Root({ + topic: Annotation(), + summary: Annotation(), +}); + +async function run() { + await Sentry.startSpan({ op: 'function', name: 'main' }, async () => { + const summarize = state => { + return { summary: `Summary of ${state.topic}` }; + }; + + const graph = new StateGraph(CustomState) + .addNode('summarize', summarize) + .addEdge(START, 'summarize') + .addEdge('summarize', END) + .compile({ name: 'custom_state_agent' }); + + await graph.invoke({ topic: 'weather' }); + }); + + await Sentry.flush(2000); +} + +run(); diff --git a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts index 3f85267b154e..b8d381a1d77b 100644 --- a/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts +++ b/dev-packages/node-integration-tests/suites/tracing/langgraph/test.ts @@ -5,6 +5,7 @@ import { GEN_AI_CONVERSATION_ID, GEN_AI_INPUT_MESSAGES, GEN_AI_OPERATION_NAME, + GEN_AI_OUTPUT_MESSAGES, GEN_AI_PIPELINE_NAME, GEN_AI_RESPONSE_MODEL, GEN_AI_RESPONSE_TEXT, @@ -251,6 +252,40 @@ describe('LangGraph integration', () => { }); }); + // Custom `Annotation.Root` state has no `messages` channel, so the whole state object is recorded on + // both the input and the output side of the invoke_agent span. + createEsmAndCjsTests( + __dirname, + 'scenario-custom-state.mjs', + 'instrument-span-streaming.mjs', + (createRunner, test) => { + test('records custom Annotation.Root state as input and output on the invoke_agent span', async () => { + await createRunner() + .expect({ + span: container => { + const invokeAgentSpan = container.items.find(span => span.name === 'invoke_agent custom_state_agent'); + expect(invokeAgentSpan).toBeDefined(); + expect(invokeAgentSpan!.status).toBe('ok'); + expect(invokeAgentSpan!.attributes['sentry.op'].value).toBe('gen_ai.invoke_agent'); + expect(invokeAgentSpan!.attributes['sentry.origin'].value).toBe('auto.ai.langgraph'); + + const inputMessages = getStringAttributeValue(invokeAgentSpan!.attributes[GEN_AI_INPUT_MESSAGES]?.value); + expect(inputMessages).toContain('"role":"user"'); + expect(inputMessages).toContain('weather'); + + const outputMessages = getStringAttributeValue( + invokeAgentSpan!.attributes[GEN_AI_OUTPUT_MESSAGES]?.value, + ); + expect(outputMessages).toContain('"role":"assistant"'); + expect(outputMessages).toContain('Summary of weather'); + }, + }) + .start() + .completed(); + }); + }, + ); + // createReactAgent tests. // Spans are asserted order-independently: the span-array order is not a protocol guarantee (Sentry // rebuilds the tree from `parent_span_id`), and the provider emits tree order while the OTel exporter