DEV Community

Cover image for The LangGraph agent trace that quietly lost its input and output
Asuran
Asuran

Posted on

The LangGraph agent trace that quietly lost its input and output

Summer Bug Smash: Clear the Lineup 🐛🛹

This is a submission for DEV's Summer Bug Smash: Clear the Lineup powered by Sentry.

Project Overview

Sentry's JavaScript SDK can auto instrument LangGraph so every agent run shows up in the AI Agents view with its input and its output attached to a gen_ai.invoke_agent span. That is the whole reason you reach for it: when an agent misbehaves in production you open the trace and read what went in and what came out. Issue #19628 is about the case where that view is quietly empty and the fix makes the agent trace carry its data no matter how the graph state is shaped.

Bug Fix or Performance Improvement

LangGraph lets you define the graph state two ways. The common tutorials use MessagesAnnotation, where the state is a list of chat messages under a messages key. But you can also define a custom state annotation with your own keys, which is normal once an agent tracks more than a chat log:

const CustomState = Annotation.Root({
  idea: Annotation(),
  expanded: Annotation(),
  validated: Annotation(),
});

const graph = new StateGraph(CustomState)
  .addNode("expand", expandNode)
  .addEdge(START, "expand")
  .addEdge("expand", END);

Sentry.instrumentStateGraph(graph, { recordInputs: true, recordOutputs: true });
const compiled = graph.compile({ name: "my_agent" });
const result = await compiled.invoke({ idea: "test idea" });
Enter fullscreen mode Exit fullscreen mode

The instrumentation read the input as args[0].messages and the output as result.messages. For a custom state there is no messages key, so the input attribute came out as an empty array and the output attribute was never set. No error and no warning. The span looked healthy in the UI with nothing inside it, which is the worst kind of observability bug because you only notice when you already need the data and it is not there.

Code

The defect is in packages/server-utils/src/ai/langgraph. The input read collapsed a missing key into an empty array:

const inputMessages =
  args.length > 0 ? ((args[0] as { messages?: LangChainMessage[] } | null)?.messages ?? []) : [];
Enter fullscreen mode Exit fullscreen mode

and the output helper bailed out the moment there was no messages array:

const outputMessages = resultObj?.messages;
if (!outputMessages || !Array.isArray(outputMessages)) {
  return; // records nothing for a custom state
}
Enter fullscreen mode Exit fullscreen mode

The fix keeps the MessagesAnnotation path exactly as it was. When there is no messages array, it serializes the whole state and records it, wrapped as a single role and content message so the attribute stays a valid chat array:

// input, in instrumentCompiledGraphInvoke
} else if (inputState && typeof inputState === "object") {
  span.setAttributes({
    [GEN_AI_INPUT_MESSAGES]: stringify([{ role: "user", content: stringify(inputState) }]),
  });
}

// output, in setResponseAttributes
if (result && typeof result === "object") {
  span.setAttribute(GEN_AI_RESPONSE_TEXT, stringify([{ role: "assistant", content: stringify(result) }]));
}
Enter fullscreen mode Exit fullscreen mode

Wrapping the state into a role and content message is not a random choice. The same package already wraps raw string prompts that way in extractLLMRequestAttributes "to align with the chat schema used elsewhere", so this follows an existing convention rather than inventing a new shape. A bare object in gen_ai.input.messages would risk the UI failing to render it, which would trade one silent empty for another. Serialization uses the SDK's own circular safe stringify so an unusual state object cannot throw inside the span callback.

My Improvements

I added three tests in the package's own vitest suite. One drives a graph on a custom state annotation and asserts the input and output state land on the span. One is a regression test that a MessagesAnnotation graph still records its real messages and its response delta. One asserts a null input (the resume case) records nothing rather than a misleading empty array. To prove the tests actually catch the bug, I ran them against the unfixed source and they fail with expected [] to have a length of 1 but got +0, which is the empty input the issue describes. With the fix they pass.

Everything is green on the repo's own gates. The full @sentry/server-utils suite goes from 335 passing to 338 passing with the three added tests and zero failures. oxlint --type-aware is clean, oxfmt --check is clean and tsc is clean on the changed source and the changed test. The change is about 14 lines of real source across two files.

I kept the diff focused and matched the maintainer's stated direction. On the issue, a Sentry maintainer wrote that they would "have a go at serializing the entire state here", so serializing the whole state is exactly the approach taken.

Best Use of Sentry

This is a fix to Sentry's own JavaScript SDK, in the LangGraph auto instrumentation that powers the AI Agents product. The value is in what the trace now shows. Before the fix a custom state agent produced an invoke_agent span whose input and output were empty, so the AI Agents view told you an agent ran and nothing about what it did. After the fix the same span carries the input state and the full output state, so the trace is useful for the exact debugging session it exists for. It also stops emitting a misleading empty array on a resume, which reads as real but blank data.

Best Use of Google AI

I reproduced the bug end to end with real Google Gemini, because a unit test with a mock LLM proves the code path but not the product story. The reproduction builds a LangGraph StateGraph on a custom state annotation Annotation.Root({ topic, poem }) where one node calls Gemini through @langchain/google-genai ChatGoogleGenerativeAI (model gemini-flash-latest) to write a short poem into the state. The graph is instrumented with the local build of the SDK and a real Sentry client with tracing on, then the invoke_agent span is read straight off the client.

Same real agent run, two SDK builds. Against the unfixed build Gemini returned a real poem and the graph state held it, yet the invoke_agent span reported gen_ai.input.messages as [] and had no gen_ai.response.text at all. Against the fixed build the input state showed up as [{"role":"user","content":"{\"topic\":\"the sea\"}"}] and the response text carried the whole output state including the Gemini generated poem. Google Gemini produced the content and Sentry is where the missing content reappears once the SDK reads the state correctly.

A note on how this was built

AI assistance (Claude, Anthropic) was used in developing this change. The design, review and verification were done by the author. Verified locally before submitting: the full @sentry/server-utils vitest suite (338 passed, up from 335, zero failures), the new tests failing on the unfixed source to prove they catch the bug, oxlint --type-aware clean, oxfmt --check clean, tsc clean on the changed files, plus a real Google Gemini LangGraph run showing the span input and output empty before the fix and populated after.

Top comments (0)