Skip to content

Commit 27c35f7

Browse files
authored
fix(llmobs): render BaseMessage in langgraph workflow I/O (#8097)
* fix(llmobs): render BaseMessage in langgraph workflow I/O (#8096) Extract the langchain handler's formatIO/getContentFromMessage/getRole into a shared util under and use it for both plugins, so workflow spans and child chat_model spans render messages consistently as { content, role }.
1 parent df1f326 commit 27c35f7

8 files changed

Lines changed: 138 additions & 81 deletions

File tree

packages/dd-trace/src/llmobs/plugins/langchain/handlers/chain.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
'use strict'
22

33
const { spanHasError } = require('../../../util')
4+
const { formatIO } = require('../messages')
45
const LangChainLLMObsHandler = require('.')
56

67
class LangChainLLMObsChainHandler extends LangChainLLMObsHandler {
78
setMetaTags ({ span, inputs, results }) {
89
let input
910
if (inputs) {
10-
input = this.formatIO(inputs)
11+
input = formatIO(inputs)
1112
}
1213

13-
const output = !results || spanHasError(span) ? '' : this.formatIO(results)
14+
const output = !results || spanHasError(span) ? '' : formatIO(results)
1415

1516
// chain spans will always be workflows
1617
this._tagger.tagTextIO(span, input, output)

packages/dd-trace/src/llmobs/plugins/langchain/handlers/chat_model.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const LLMObsTagger = require('../../../tagger')
44
const { spanHasError } = require('../../../util')
5+
const { getRole } = require('../messages')
56
const LangChainLLMObsHandler = require('.')
67

78
const LLM = 'llm'
@@ -22,7 +23,7 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
2223
for (const messageSet of inputs) {
2324
for (const message of messageSet) {
2425
const content = message.content || ''
25-
const role = this.getRole(message)
26+
const role = getRole(message)
2627
inputMessages.push({ content, role })
2728
}
2829
}
@@ -54,7 +55,7 @@ class LangChainLLMObsChatModelHandler extends LangChainLLMObsHandler {
5455
for (const messageSet of results.generations) {
5556
for (const chatCompletion of messageSet) {
5657
const chatCompletionMessage = chatCompletion.message
57-
const role = this.getRole(chatCompletionMessage)
58+
const role = getRole(chatCompletionMessage)
5859
const content = chatCompletionMessage.text || ''
5960
const toolCalls = this.extractToolCalls(chatCompletionMessage)
6061
outputMessages.push({ content, role, toolCalls })

packages/dd-trace/src/llmobs/plugins/langchain/handlers/embedding.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
const LLMObsTagger = require('../../../tagger')
44
const { spanHasError } = require('../../../util')
5+
const { formatIO } = require('../messages')
56
const LangChainLLMObsHandler = require('.')
67

78
class LangChainLLMObsEmbeddingHandler extends LangChainLLMObsHandler {
@@ -10,7 +11,7 @@ class LangChainLLMObsEmbeddingHandler extends LangChainLLMObsHandler {
1011
let embeddingInput, embeddingOutput
1112

1213
if (isWorkflow) {
13-
embeddingInput = this.formatIO(inputs)
14+
embeddingInput = formatIO(inputs)
1415
} else {
1516
const input = Array.isArray(inputs) ? inputs : [inputs]
1617
embeddingInput = input.map(doc => ({ text: doc }))
Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,5 @@
11
'use strict'
22

3-
const ROLE_MAPPINGS = {
4-
human: 'user',
5-
ai: 'assistant',
6-
system: 'system',
7-
}
8-
93
class LangChainLLMObsHandler {
104
constructor (tagger) {
115
/** @type {import('../../../tagger')} */
@@ -18,38 +12,6 @@ class LangChainLLMObsHandler {
1812

1913
setMetaTags () {}
2014

21-
formatIO (messages) {
22-
if (messages.constructor.name === 'Object') { // plain JSON
23-
const formatted = {}
24-
for (const [key, value] of Object.entries(messages)) {
25-
formatted[key] = this.formatIO(value)
26-
}
27-
28-
return formatted
29-
} else if (Array.isArray(messages)) {
30-
return messages.map(message => this.formatIO(message))
31-
} // either a BaseMesage type or a string
32-
return this.getContentFromMessage(messages)
33-
}
34-
35-
getContentFromMessage (message) {
36-
if (typeof message === 'string') {
37-
return message
38-
}
39-
try {
40-
const messageContent = {
41-
content: message.content || '',
42-
}
43-
44-
const role = this.getRole(message)
45-
if (role) messageContent.role = role
46-
47-
return messageContent
48-
} catch {
49-
return JSON.stringify(message)
50-
}
51-
}
52-
5315
checkTokenUsageChatOrLLMResult (results) {
5416
const llmOutput = results.llmOutput
5517
const tokens = {
@@ -90,17 +52,6 @@ class LangChainLLMObsHandler {
9052
runId: runIdBase,
9153
}
9254
}
93-
94-
getRole (message) {
95-
if (message.role) return ROLE_MAPPINGS[message.role] || message.role
96-
97-
const type = (
98-
(typeof message.getType === 'function' && message.getType()) ||
99-
(typeof message._getType === 'function' && message._getType())
100-
)
101-
102-
return ROLE_MAPPINGS[type] || type
103-
}
10455
}
10556

10657
module.exports = LangChainLLMObsHandler

packages/dd-trace/src/llmobs/plugins/langchain/handlers/vectorstore.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
'use strict'
22

33
const { spanHasError } = require('../../../util')
4+
const { formatIO } = require('../messages')
45
const LangChainLLMObsHandler = require('.')
56

67
class LangChainLLMObsVectorStoreHandler extends LangChainLLMObsHandler {
78
setMetaTags ({ span, inputs, results }) {
8-
const input = this.formatIO(inputs)
9+
const input = formatIO(inputs)
910
if (spanHasError(span)) {
1011
this._tagger.tagRetrievalIO(span, input)
1112
return
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
'use strict'
2+
3+
const ROLE_MAPPINGS = {
4+
human: 'user',
5+
ai: 'assistant',
6+
system: 'system',
7+
}
8+
9+
function getRole (message) {
10+
if (message.role) return ROLE_MAPPINGS[message.role] || message.role
11+
12+
const type = (
13+
(typeof message.getType === 'function' && message.getType()) ||
14+
(typeof message._getType === 'function' && message._getType())
15+
)
16+
17+
return ROLE_MAPPINGS[type] || type
18+
}
19+
20+
function getContentFromMessage (message) {
21+
if (typeof message === 'string') {
22+
return message
23+
}
24+
try {
25+
const messageContent = {
26+
content: message.content || '',
27+
}
28+
29+
const role = getRole(message)
30+
if (role) messageContent.role = role
31+
32+
return messageContent
33+
} catch {
34+
return JSON.stringify(message)
35+
}
36+
}
37+
38+
function isBaseMessage (data) {
39+
return typeof data._getType === 'function' || typeof data.getType === 'function'
40+
}
41+
42+
function formatIO (data) {
43+
if (data == null) return ''
44+
45+
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
46+
return data
47+
}
48+
49+
if (data.constructor?.name === 'Object') {
50+
const formatted = {}
51+
for (const [key, value] of Object.entries(data)) {
52+
formatted[key] = formatIO(value)
53+
}
54+
return formatted
55+
}
56+
57+
if (Array.isArray(data)) {
58+
return data.map(item => formatIO(item))
59+
}
60+
61+
// Only duck-typed BaseMessage instances collapse to { content, role }.
62+
// Other class instances (e.g. LangChain Document) preserve their shape via JSON.stringify,
63+
// otherwise they'd reduce to { content: '' } and lose data.
64+
if (isBaseMessage(data)) return getContentFromMessage(data)
65+
66+
try {
67+
return JSON.stringify(data)
68+
} catch {
69+
return String(data)
70+
}
71+
}
72+
73+
module.exports = {
74+
getRole,
75+
formatIO,
76+
}

packages/dd-trace/src/llmobs/plugins/langgraph/index.js

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,11 @@
11
'use strict'
22

33
const LLMObsPlugin = require('../base')
4+
const { formatIO } = require('../langchain/messages')
45
const { spanHasError } = require('../../util')
56

67
const streamDataMap = new WeakMap()
78

8-
function formatIO (data) {
9-
if (data == null) return ''
10-
11-
if (typeof data === 'string' || typeof data === 'number' || typeof data === 'boolean') {
12-
return data
13-
}
14-
15-
if (data.constructor?.name === 'Object') {
16-
const formatted = {}
17-
for (const [key, value] of Object.entries(data)) {
18-
formatted[key] = formatIO(value)
19-
}
20-
return formatted
21-
}
22-
23-
if (Array.isArray(data)) {
24-
return data.map(item => formatIO(item))
25-
}
26-
27-
try {
28-
return JSON.stringify(data)
29-
} catch {
30-
return String(data)
31-
}
32-
}
33-
349
class PregelStreamLLMObsPlugin extends LLMObsPlugin {
3510
static id = 'llmobs_langgraph_pregel_stream'
3611
static integration = 'langgraph'

packages/dd-trace/test/llmobs/plugins/langgraph/index.spec.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
describe('integrations', () => {
1414
let StateGraph
1515
let Annotation
16+
let langchainMessages
1617

1718
describe('langgraph', () => {
1819
const { getEvents } = useLlmObs({ plugin: ['langgraph', 'langchain'] })
@@ -22,6 +23,8 @@ describe('integrations', () => {
2223
const langgraph = require(`../../../../../../versions/@langchain/langgraph@${version}`).get()
2324
StateGraph = langgraph.StateGraph
2425
Annotation = langgraph.Annotation
26+
langchainMessages = require(`../../../../../../versions/@langchain/langgraph@${version}`)
27+
.get('@langchain/core/messages')
2528
})
2629

2730
describe('Pregel.stream', () => {
@@ -152,6 +155,54 @@ describe('integrations', () => {
152155
})
153156
})
154157

158+
// Regression for https://github.com/DataDog/dd-trace-js/issues/8096: BaseMessage
159+
// instances must render as { content, role } instead of full class dumps.
160+
it('renders BaseMessage input/output as clean { content, role }', async () => {
161+
const StateAnnotation = Annotation.Root({
162+
messages: Annotation({
163+
reducer: (x, y) => x.concat(y),
164+
default: () => [],
165+
}),
166+
})
167+
168+
function chatNode () {
169+
return {
170+
messages: [new langchainMessages.AIMessage('Pong')],
171+
}
172+
}
173+
174+
const workflow = new StateGraph(StateAnnotation)
175+
.addNode('chat', chatNode)
176+
.addEdge('__start__', 'chat')
177+
.addEdge('chat', '__end__')
178+
179+
const app = workflow.compile({ name: 'basemessage-graph' })
180+
181+
const chunks = []
182+
for await (const chunk of await app.stream({
183+
messages: [new langchainMessages.HumanMessage('Ping')],
184+
})) {
185+
chunks.push(chunk)
186+
}
187+
188+
assert.ok(chunks.length > 0)
189+
190+
const { llmobsSpans } = await getEvents()
191+
192+
const workflowSpan = llmobsSpans.find(s => s.name === 'basemessage-graph')
193+
assert.ok(workflowSpan, 'expected workflow span named basemessage-graph')
194+
195+
assert.strictEqual(
196+
workflowSpan.meta.input.value,
197+
JSON.stringify({ messages: [{ content: 'Ping', role: 'user' }] })
198+
)
199+
200+
const parsedOutput = JSON.parse(workflowSpan.meta.output.value)
201+
assert.ok(Array.isArray(parsedOutput.messages))
202+
const lastMessage = parsedOutput.messages[parsedOutput.messages.length - 1]
203+
assert.deepStrictEqual(lastMessage, { content: 'Pong', role: 'assistant' })
204+
})
205+
155206
it('does not tag output if streaming encounters an error', async () => {
156207
const StateAnnotation = Annotation.Root({
157208
value: Annotation({ default: () => 0 }),

0 commit comments

Comments
 (0)