Skip to content

[BUG]: LLM Obs LangGraph plugin renders BaseMessage state as raw JSON — formatIO lacks the BaseMessage handling the sibling LangChain plugin already has #8096

Description

@trhinehart-attentive

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-51getContentFromMessage 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-32formatIO 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');

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions