Skip to content

Commit 93e3c30

Browse files
authored
fix(core): Strip inline media from multimodal content before stringification (#19540)
- Fixes a bug where base64 image/audio data in LangChain multimodal messages leaked into gen_ai.input.messages span attributes unredacted - The root cause was normalizeLangChainMessages calling asString() (which JSON.stringifies arrays) on multimodal content before the media stripping pipeline could inspect it, so stripInlineMediaFromMessages never saw structured objects to redact - Adds normalizeContent() that applies stripInlineMediaFromSingleMessage to array/object content parts before stringification, matching the [Blob substitute] behavior already working for OpenAI/Anthrop Closes #19539
1 parent 9d3ae61 commit 93e3c30

File tree

2 files changed

+287
-6
lines changed

2 files changed

+287
-6
lines changed

packages/core/src/tracing/langchain/utils.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
2626
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
2727
} from '../ai/gen-ai-attributes';
28+
import { isContentMedia, stripInlineMediaFromSingleMessage } from '../ai/mediaStripping';
2829
import { truncateGenAiMessages } from '../ai/messageTruncation';
2930
import { extractSystemInstructions } from '../ai/utils';
3031
import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants';
@@ -62,6 +63,38 @@ function asString(v: unknown): string {
6263
}
6364
}
6465

66+
/**
67+
* Converts message content to a string, stripping inline media (base64 images, audio, etc.)
68+
* from multimodal content before stringification so downstream media stripping can't miss it.
69+
*
70+
* @example
71+
* // String content passes through unchanged:
72+
* normalizeContent("Hello") // => "Hello"
73+
*
74+
* // Multimodal array content — media is replaced with "[Blob substitute]" before JSON.stringify:
75+
* normalizeContent([
76+
* { type: "text", text: "What color?" },
77+
* { type: "image_url", image_url: { url: "data:image/png;base64,iVBOR..." } }
78+
* ])
79+
* // => '[{"type":"text","text":"What color?"},{"type":"image_url","image_url":{"url":"[Blob substitute]"}}]'
80+
*
81+
* // Without this, asString() would JSON.stringify the raw array and the base64 blob
82+
* // would end up in span attributes, since downstream stripping only works on objects.
83+
*/
84+
function normalizeContent(v: unknown): string {
85+
if (Array.isArray(v)) {
86+
try {
87+
const stripped = v.map(part =>
88+
part && typeof part === 'object' && isContentMedia(part) ? stripInlineMediaFromSingleMessage(part) : part,
89+
);
90+
return JSON.stringify(stripped);
91+
} catch {
92+
return String(v);
93+
}
94+
}
95+
return asString(v);
96+
}
97+
6598
/**
6699
* Normalizes a single role token to our canonical set.
67100
*
@@ -123,7 +156,7 @@ export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<
123156
const messageType = maybeGetType.call(message);
124157
return {
125158
role: normalizeMessageRole(messageType),
126-
content: asString(message.content),
159+
content: normalizeContent(message.content),
127160
};
128161
}
129162

@@ -136,7 +169,7 @@ export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<
136169

137170
return {
138171
role: normalizeMessageRole(role),
139-
content: asString(message.kwargs?.content),
172+
content: normalizeContent(message.kwargs?.content),
140173
};
141174
}
142175

@@ -145,7 +178,7 @@ export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<
145178
const role = String(message.type).toLowerCase();
146179
return {
147180
role: normalizeMessageRole(role),
148-
content: asString(message.content),
181+
content: normalizeContent(message.content),
149182
};
150183
}
151184

@@ -154,7 +187,7 @@ export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<
154187
if (message.role) {
155188
return {
156189
role: normalizeMessageRole(String(message.role)),
157-
content: asString(message.content),
190+
content: normalizeContent(message.content),
158191
};
159192
}
160193

@@ -164,14 +197,14 @@ export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<
164197
if (ctor && ctor !== 'Object') {
165198
return {
166199
role: normalizeMessageRole(normalizeRoleNameFromCtor(ctor)),
167-
content: asString(message.content),
200+
content: normalizeContent(message.content),
168201
};
169202
}
170203

171204
// 6) Fallback: treat as user text
172205
return {
173206
role: 'user',
174-
content: asString(message.content),
207+
content: normalizeContent(message.content),
175208
};
176209
});
177210
}
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE } from '../../../src/tracing/ai/gen-ai-attributes';
3+
import type { LangChainMessage } from '../../../src/tracing/langchain/types';
4+
import { extractChatModelRequestAttributes, normalizeLangChainMessages } from '../../../src/tracing/langchain/utils';
5+
6+
describe('normalizeLangChainMessages', () => {
7+
it('normalizes messages with _getType()', () => {
8+
const messages = [
9+
{
10+
_getType: () => 'human',
11+
content: 'Hello',
12+
},
13+
{
14+
_getType: () => 'ai',
15+
content: 'Hi there!',
16+
},
17+
] as unknown as LangChainMessage[];
18+
19+
const result = normalizeLangChainMessages(messages);
20+
expect(result).toEqual([
21+
{ role: 'user', content: 'Hello' },
22+
{ role: 'assistant', content: 'Hi there!' },
23+
]);
24+
});
25+
26+
it('normalizes messages with type property', () => {
27+
const messages: LangChainMessage[] = [
28+
{ type: 'human', content: 'Hello' },
29+
{ type: 'ai', content: 'Hi!' },
30+
];
31+
32+
const result = normalizeLangChainMessages(messages);
33+
expect(result).toEqual([
34+
{ role: 'user', content: 'Hello' },
35+
{ role: 'assistant', content: 'Hi!' },
36+
]);
37+
});
38+
39+
it('normalizes messages with role property', () => {
40+
const messages: LangChainMessage[] = [
41+
{ role: 'user', content: 'Hello' },
42+
{ role: 'assistant', content: 'Hi!' },
43+
];
44+
45+
const result = normalizeLangChainMessages(messages);
46+
expect(result).toEqual([
47+
{ role: 'user', content: 'Hello' },
48+
{ role: 'assistant', content: 'Hi!' },
49+
]);
50+
});
51+
52+
it('normalizes serialized LangChain format', () => {
53+
const messages: LangChainMessage[] = [
54+
{
55+
lc: 1,
56+
id: ['langchain_core', 'messages', 'HumanMessage'],
57+
kwargs: { content: 'Hello from serialized' },
58+
},
59+
];
60+
61+
const result = normalizeLangChainMessages(messages);
62+
expect(result).toEqual([{ role: 'user', content: 'Hello from serialized' }]);
63+
});
64+
65+
describe('multimodal content media stripping', () => {
66+
const b64Data = `iVBORw0KGgoAAAANSUhEUgAAAAUA${'A'.repeat(200)}`;
67+
const BLOB_SUBSTITUTE = '[Blob substitute]';
68+
69+
it('strips base64 image_url from multimodal array content via _getType()', () => {
70+
const messages = [
71+
{
72+
_getType: () => 'human',
73+
content: [
74+
{ type: 'text', text: 'What color is in this image?' },
75+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } },
76+
],
77+
},
78+
] as unknown as LangChainMessage[];
79+
80+
const result = normalizeLangChainMessages(messages);
81+
expect(result).toHaveLength(1);
82+
expect(result[0]!.role).toBe('user');
83+
84+
const parsed = JSON.parse(result[0]!.content);
85+
expect(parsed).toHaveLength(2);
86+
expect(parsed[0]).toEqual({ type: 'text', text: 'What color is in this image?' });
87+
expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE);
88+
expect(result[0]!.content).not.toContain(b64Data);
89+
});
90+
91+
it('strips base64 data from Anthropic-style source blocks', () => {
92+
const messages = [
93+
{
94+
_getType: () => 'human',
95+
content: [
96+
{ type: 'text', text: 'Describe this image' },
97+
{
98+
type: 'image',
99+
source: {
100+
type: 'base64',
101+
media_type: 'image/png',
102+
data: b64Data,
103+
},
104+
},
105+
],
106+
},
107+
] as unknown as LangChainMessage[];
108+
109+
const result = normalizeLangChainMessages(messages);
110+
const parsed = JSON.parse(result[0]!.content);
111+
expect(parsed[1].source.data).toBe(BLOB_SUBSTITUTE);
112+
expect(result[0]!.content).not.toContain(b64Data);
113+
});
114+
115+
it('strips base64 from inline_data (Google GenAI style)', () => {
116+
const messages: LangChainMessage[] = [
117+
{
118+
type: 'human',
119+
content: [
120+
{ type: 'text', text: 'Describe' },
121+
{ inlineData: { mimeType: 'image/png', data: b64Data } },
122+
] as unknown as string,
123+
},
124+
];
125+
126+
const result = normalizeLangChainMessages(messages);
127+
const parsed = JSON.parse(result[0]!.content);
128+
expect(parsed[1].inlineData.data).toBe(BLOB_SUBSTITUTE);
129+
expect(result[0]!.content).not.toContain(b64Data);
130+
});
131+
132+
it('strips base64 from input_audio content parts', () => {
133+
const messages = [
134+
{
135+
_getType: () => 'human',
136+
content: [
137+
{ type: 'text', text: 'What do you hear?' },
138+
{ type: 'input_audio', input_audio: { data: b64Data } },
139+
],
140+
},
141+
] as unknown as LangChainMessage[];
142+
143+
const result = normalizeLangChainMessages(messages);
144+
const parsed = JSON.parse(result[0]!.content);
145+
expect(parsed[1].input_audio.data).toBe(BLOB_SUBSTITUTE);
146+
expect(result[0]!.content).not.toContain(b64Data);
147+
});
148+
149+
it('preserves text-only array content without modification', () => {
150+
const messages = [
151+
{
152+
_getType: () => 'human',
153+
content: [
154+
{ type: 'text', text: 'First part' },
155+
{ type: 'text', text: 'Second part' },
156+
],
157+
},
158+
] as unknown as LangChainMessage[];
159+
160+
const result = normalizeLangChainMessages(messages);
161+
const parsed = JSON.parse(result[0]!.content);
162+
expect(parsed).toEqual([
163+
{ type: 'text', text: 'First part' },
164+
{ type: 'text', text: 'Second part' },
165+
]);
166+
});
167+
168+
it('strips media from serialized LangChain format with array content', () => {
169+
const messages: LangChainMessage[] = [
170+
{
171+
lc: 1,
172+
id: ['langchain_core', 'messages', 'HumanMessage'],
173+
kwargs: {
174+
content: [
175+
{ type: 'text', text: 'Describe this' },
176+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } },
177+
] as unknown as string,
178+
},
179+
},
180+
];
181+
182+
const result = normalizeLangChainMessages(messages);
183+
const parsed = JSON.parse(result[0]!.content);
184+
expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE);
185+
expect(result[0]!.content).not.toContain(b64Data);
186+
});
187+
188+
it('strips media from messages with role property and array content', () => {
189+
const messages: LangChainMessage[] = [
190+
{
191+
role: 'user',
192+
content: [
193+
{ type: 'text', text: 'Look at this' },
194+
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${b64Data}` } },
195+
] as unknown as string,
196+
},
197+
];
198+
199+
const result = normalizeLangChainMessages(messages);
200+
const parsed = JSON.parse(result[0]!.content);
201+
expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE);
202+
expect(result[0]!.content).not.toContain(b64Data);
203+
});
204+
205+
it('strips media from messages with type property and array content', () => {
206+
const messages: LangChainMessage[] = [
207+
{
208+
type: 'human',
209+
content: [
210+
{ type: 'text', text: 'Check this' },
211+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } },
212+
] as unknown as string,
213+
},
214+
];
215+
216+
const result = normalizeLangChainMessages(messages);
217+
const parsed = JSON.parse(result[0]!.content);
218+
expect(parsed[1].image_url.url).toBe(BLOB_SUBSTITUTE);
219+
});
220+
});
221+
});
222+
223+
describe('extractChatModelRequestAttributes with multimodal content', () => {
224+
const b64Data = `iVBORw0KGgoAAAANSUhEUgAAAAUA${'A'.repeat(200)}`;
225+
226+
it('strips base64 from input messages attribute', () => {
227+
const serialized = { id: ['langchain', 'chat_models', 'openai'], name: 'ChatOpenAI' };
228+
const messages: LangChainMessage[][] = [
229+
[
230+
{
231+
_getType: () => 'human',
232+
content: [
233+
{ type: 'text', text: 'What is in this image?' },
234+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${b64Data}` } },
235+
],
236+
} as unknown as LangChainMessage,
237+
],
238+
];
239+
240+
const attrs = extractChatModelRequestAttributes(serialized, messages, true);
241+
const inputMessages = attrs[GEN_AI_INPUT_MESSAGES_ATTRIBUTE] as string | undefined;
242+
243+
expect(inputMessages).toBeDefined();
244+
expect(inputMessages).not.toContain(b64Data);
245+
expect(inputMessages).toContain('[Blob substitute]');
246+
expect(inputMessages).toContain('What is in this image?');
247+
});
248+
});

0 commit comments

Comments
 (0)