Tracer Version(s)
5.98.0
Node.js Version(s)
24.12.0
Operating System
Darwin 24.6.0
Bundling
No Bundling
Bug Report
When createAgent(...) from langchain@1.x (built on @langchain/langgraph) is invoked with LLM Observability enabled, the workflow-kind root span's Input/Output panels render the Pregel state as raw JSON — full HumanMessage / AIMessage class instances serialized with additional_kwargs, response_metadata, id, tool_call_id, etc. — instead of clean { content, role } shapes.
The child chat_model LLM spans and tool spans render correctly. The issue is isolated to the workflow span emitted by the LangGraph LLM Obs plugin added in #7567.
Root cause
An asymmetry between two plugins that should share formatting:
- LangChain plugin,
packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js:35-51 — getContentFromMessage already handles BaseMessage instances: reads message.content, extracts role via message.getType() / _getType(), emits { content, role }.
- LangGraph plugin,
packages/dd-trace/src/llmobs/plugins/langgraph/index.js:8-32 — formatIO is a separate, simpler implementation that only recurses plain Object / Array. BaseMessage instances are not plain Object (their constructor.name is HumanMessage / AIMessage / etc.) so they fall through to the JSON.stringify(data) branch, serializing the entire class.
Because createAgent(...).invoke({ messages: [...] }) input state and the last stream chunk both contain BaseMessage arrays, the workflow span's I/O is dominated by class-instance dumps.
Expected behavior
The workflow span's I/O should represent BaseMessage instances the same way the LangChain plugin does — as { content, role } objects — so the Input/Output panel is readable and consistent with child chat_model spans.
Suggested fix
Ideally, share a single helper between the two plugins. The cleanest path is to lift getContentFromMessage out of langchain/handlers/index.js into a shared util (e.g. llmobs/plugins/shared/messages.js) and import it from both the LangChain chain/chat_model handlers and the LangGraph formatIO — avoiding the duplication that caused this drift in the first place.
If a full refactor is heavier than desired, a minimal duck-typed check inside langgraph/index.js:formatIO would restore parity without reaching across plugin boundaries or taking a hard dependency on @langchain/core:
function formatIO (data) {
if (data == null) return ''
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') return data
// NEW: BaseMessage detection (duck-typed; mirrors getContentFromMessage in
// packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js)
if (typeof data?._getType === 'function' || typeof data?.getType === 'function') {
const role = typeof data.getType === 'function' ? data.getType() : data._getType()
return { content: data.content ?? '', role }
}
if (data.constructor?.name === 'Object') {
const formatted = {}
for (const [key, value] of Object.entries(data)) formatted[key] = formatIO(value)
return formatted
}
if (Array.isArray(data)) return data.map(item => formatIO(item))
try { return JSON.stringify(data) } catch { return String(data) }
}
Reproduction Code
// package.json: "type": "module"
// deps: dd-trace@5.98.0 @langchain/aws @langchain/core @langchain/langgraph langchain zod
import 'dd-trace/register.js';
import ddTrace from 'dd-trace';
import { ChatBedrockConverse } from '@langchain/aws';
import { createAgent } from 'langchain';
ddTrace.init({
service: 'langgraph-repro',
plugins: false,
llmobs: {
enabled: true,
mlApp: 'langgraph-repro',
agentlessEnabled: true, // requires DD_API_KEY + DD_SITE
},
});
ddTrace.use('langchain');
const agent = createAgent({
name: 'simple-demo',
model: new ChatBedrockConverse({
model: 'anthropic.claude-3-haiku-20240307-v1:0',
region: 'us-east-1',
}),
tools: [],
systemPrompt: 'You are a helpful assistant.',
});
await agent.invoke({
messages: [{ role: 'user', content: 'What is OpenTelemetry? One sentence.' }],
});
Run with DD_API_KEY=… DD_SITE=datadoghq.com AWS_REGION=us-east-1 and valid Bedrock credentials. The resulting LLM Obs trace has a workflow span named simple-demo whose Input/Output panels render verbose HumanMessage / AIMessage JSON dumps instead of the clean rendering seen on the child chat_model span.
Tracer Config
ddTrace.init({
service: '<svc>',
plugins: false,
llmobs: { enabled: true, mlApp: '<ml-app>', agentlessEnabled: true },
});
ddTrace.use('langchain');
Tracer Version(s)
5.98.0Node.js Version(s)
24.12.0Operating System
Darwin 24.6.0Bundling
No Bundling
Bug Report
When
createAgent(...)fromlangchain@1.x(built on@langchain/langgraph) is invoked with LLM Observability enabled, the workflow-kind root span's Input/Output panels render the Pregel state as raw JSON — fullHumanMessage/AIMessageclass instances serialized withadditional_kwargs,response_metadata,id,tool_call_id, etc. — instead of clean{ content, role }shapes.The child
chat_modelLLM spans andtoolspans render correctly. The issue is isolated to the workflow span emitted by the LangGraph LLM Obs plugin added in #7567.Root cause
An asymmetry between two plugins that should share formatting:
packages/dd-trace/src/llmobs/plugins/langchain/handlers/index.js:35-51—getContentFromMessagealready handlesBaseMessageinstances: readsmessage.content, extracts role viamessage.getType() / _getType(), emits{ content, role }.packages/dd-trace/src/llmobs/plugins/langgraph/index.js:8-32—formatIOis a separate, simpler implementation that only recurses plainObject/Array.BaseMessageinstances are not plainObject(theirconstructor.nameisHumanMessage/AIMessage/ etc.) so they fall through to theJSON.stringify(data)branch, serializing the entire class.Because
createAgent(...).invoke({ messages: [...] })input state and the last stream chunk both containBaseMessagearrays, the workflow span's I/O is dominated by class-instance dumps.Expected behavior
The workflow span's I/O should represent
BaseMessageinstances the same way the LangChain plugin does — as{ content, role }objects — so the Input/Output panel is readable and consistent with child chat_model spans.Suggested fix
Ideally, share a single helper between the two plugins. The cleanest path is to lift
getContentFromMessageout oflangchain/handlers/index.jsinto a shared util (e.g.llmobs/plugins/shared/messages.js) and import it from both the LangChain chain/chat_model handlers and the LangGraphformatIO— avoiding the duplication that caused this drift in the first place.If a full refactor is heavier than desired, a minimal duck-typed check inside
langgraph/index.js:formatIOwould restore parity without reaching across plugin boundaries or taking a hard dependency on@langchain/core:Reproduction Code
Run with
DD_API_KEY=… DD_SITE=datadoghq.com AWS_REGION=us-east-1and valid Bedrock credentials. The resulting LLM Obs trace has a workflow span namedsimple-demowhose Input/Output panels render verboseHumanMessage/AIMessageJSON dumps instead of the clean rendering seen on the childchat_modelspan.Tracer Config