Skip to content

Commit d632d41

Browse files
authored
fix(ai): keep multimodal parts structured in otel gen_ai.input.messages (#1527)
captureContent flattened image/audio/video/document parts to '[image]' placeholders, dropping the media reference from traces. Array content now serializes to OTel GenAI semconv parts: text (redacted), uri for URL sources, file for provider handles. Inline data and data: URLs keep the placeholder. Span events stay flat strings. Closes #1525
1 parent 0eb8f0b commit d632d41

6 files changed

Lines changed: 189 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/ai': patch
3+
---
4+
5+
`otelMiddleware` with `captureContent: true` now keeps multimodal parts structured in `gen_ai.input.messages` (OTel GenAI part shapes). URL media becomes a `uri` part and provider file handles become a `file` part, so traces show what the model looked at. Inline base64 data still records a `[image]`-style placeholder.

‎docs/advanced/otel.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,14 @@ If `redact` throws, the middleware writes the literal sentinel `"[redaction_fail
142142

143143
Accumulated assistant text (the `gen_ai.choice` event) is capped at `maxContentLength` characters (default `100 000`); longer completions are truncated with a trailing `"…"` marker.
144144

145-
Multimodal content (images, audio, video, documents) is represented as placeholder strings (`[image]`, `[audio]`, ...) to preserve message order without dumping binary data onto spans. Use `onSpanEnd` if you need richer multimodal capture.
145+
Multimodal messages (images, audio, video, documents) keep their parts in `gen_ai.input.messages`, in the OTel GenAI part shapes:
146+
147+
- Text: `{ "type": "text", "content": "..." }`. `redact` runs on it.
148+
- URL source: `{ "type": "uri", "modality": "image", "uri": "https://...", "mime_type": "image/png" }`.
149+
- Provider file handle: `{ "type": "file", "modality": "image", "file_id": "..." }`.
150+
- Inline base64 data or a `data:` URL: a `[image]` text placeholder, so the bytes do not go onto the span.
151+
152+
Span events stay flat strings, with the same placeholders for every media part (`look at this [image]`).
146153

147154
Prompt/system/user message events fire from `onConfig` at the start of every iteration, which means the full conversation history (as the adapter will re-send it) is re-emitted on each iteration span. This mirrors what the provider actually sees on the wire.
148155

‎packages/ai/src/middlewares/otel.ts‎

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,51 @@ function serializeContent(content: unknown): string {
221221
return parts.join(' ')
222222
}
223223

224+
type InputPart =
225+
| { type: 'text'; content: string }
226+
| { type: 'uri'; modality: string; uri: string; mime_type?: string }
227+
| { type: 'file'; modality: string; file_id: string; mime_type?: string }
228+
229+
/**
230+
* Structured form of `ContentPart[]` for `gen_ai.input.messages`, using the
231+
* OTel GenAI semconv part shapes. URL and file-handle media keep their
232+
* reference; inline bytes (and `data:` URLs) stay a `[type]` placeholder so
233+
* they never blow attribute size limits. `redact` runs on text parts only.
234+
*/
235+
function serializeParts(
236+
content: Array<unknown>,
237+
redact: (text: string) => string,
238+
): Array<InputPart> {
239+
const parts: Array<InputPart> = []
240+
for (const part of content) {
241+
if (!part || typeof part !== 'object') continue
242+
const p = part as {
243+
type?: string
244+
text?: string
245+
content?: string
246+
source?: { type?: string; value?: string; mimeType?: string }
247+
}
248+
if (p.type === 'text') {
249+
parts.push({
250+
type: 'text',
251+
content: redact((p.text ?? p.content ?? '').toString()),
252+
})
253+
continue
254+
}
255+
const modality = p.type ?? 'unknown'
256+
const { type, value, mimeType } = p.source ?? {}
257+
const mime = mimeType ? { mime_type: mimeType } : {}
258+
if (type === 'url' && value && !value.startsWith('data:')) {
259+
parts.push({ type: 'uri', modality, uri: value, ...mime })
260+
} else if (type === 'file' && value) {
261+
parts.push({ type: 'file', modality, file_id: value, ...mime })
262+
} else {
263+
parts.push({ type: 'text', content: `[${modality}]` })
264+
}
265+
}
266+
return parts
267+
}
268+
224269
function messageEventName(role: string): string {
225270
switch (role) {
226271
case 'user':
@@ -581,14 +626,25 @@ export function otelMiddleware(
581626
// Also emit the current GenAI-semconv attribute form
582627
// (`gen_ai.input.messages`) — backends like PostHog read prompt
583628
// content from this attribute, not from span events.
584-
const inputMessages: Array<{ role: string; content: string }> = []
629+
// Multimodal messages keep their parts structured so image / audio /
630+
// video / document references survive into the trace (#1525).
631+
const inputMessages: Array<{
632+
role: string
633+
content: string | Array<InputPart>
634+
}> = []
585635
for (const sys of systemPromptContents) {
586636
inputMessages.push({
587637
role: 'system',
588638
content: redactContent(sys),
589639
})
590640
}
591641
for (const m of config.messages) {
642+
if (Array.isArray(m.content)) {
643+
const parts = serializeParts(m.content, redactContent)
644+
if (parts.length === 0) continue
645+
inputMessages.push({ role: m.role, content: parts })
646+
continue
647+
}
592648
const body = serializeContent(m.content)
593649
if (body.length === 0) continue
594650
inputMessages.push({

‎packages/ai/tests/middlewares/otel.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -965,6 +965,64 @@ describe('otelMiddleware — captureContent', () => {
965965
expect(userEvt.attributes!['content']).toBe('look at this [image]')
966966
})
967967

968+
it('keeps multimodal parts structured in gen_ai.input.messages', async () => {
969+
const { tracer, spans } = createFakeTracer()
970+
const mw = otelMiddleware({
971+
tracer,
972+
captureContent: true,
973+
redact: (s) => s.replace('secret', '***'),
974+
})
975+
const ctx = makeCtx()
976+
977+
await runToIterationStart(mw, ctx, {
978+
messages: [
979+
{
980+
role: 'user',
981+
content: [
982+
{ type: 'text', content: 'describe secret' },
983+
{
984+
type: 'image',
985+
source: {
986+
type: 'url',
987+
value: 'https://x.test/a.png',
988+
mimeType: 'image/png',
989+
},
990+
},
991+
{ type: 'audio', source: { type: 'file', value: 'file_123' } },
992+
{
993+
type: 'video',
994+
source: { type: 'data', value: 'AAAA', mimeType: 'video/mp4' },
995+
},
996+
{
997+
type: 'image',
998+
source: { type: 'url', value: 'data:image/png;base64,AAAA' },
999+
},
1000+
],
1001+
},
1002+
],
1003+
})
1004+
1005+
expect(
1006+
JSON.parse(spans[1]!.attributes['gen_ai.input.messages'] as string),
1007+
).toEqual([
1008+
{
1009+
role: 'user',
1010+
content: [
1011+
{ type: 'text', content: 'describe ***' },
1012+
{
1013+
type: 'uri',
1014+
modality: 'image',
1015+
uri: 'https://x.test/a.png',
1016+
mime_type: 'image/png',
1017+
},
1018+
{ type: 'file', modality: 'audio', file_id: 'file_123' },
1019+
{ type: 'text', content: '[video]' },
1020+
{ type: 'text', content: '[image]' },
1021+
],
1022+
},
1023+
])
1024+
})
1025+
9681026
it('emits redaction sentinel and never raw content when redact throws', async () => {
9691027
const { tracer, spans } = createFakeTracer()
9701028
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

‎testing/e2e/src/routes/api.otel-usage.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const weatherTool = toolDefinition({
2525
* `completion_tokens_details.reasoning_tokens`.
2626
* - `provider: 'openrouter'` → `/openrouter-cost` mount, whose trailing usage
2727
* chunk carries `cost` / `cost_details`.
28+
* - `provider: 'multimodal'` → the `/openai-usage-details` mount with an
29+
* image part and `captureContent: true`, so the spec can check the image
30+
* URL survives into `gen_ai.input.messages` (#1525).
2831
*
2932
* The spec asserts the corresponding `gen_ai.usage.*` / `tanstack.ai.usage.*`
3033
* attributes land on the iteration and root spans.
@@ -63,14 +66,34 @@ export const Route = createFileRoute('/api/otel-usage')({
6366
for await (const _chunk of chat({
6467
...createChatOptions({ adapter }),
6568
messages: [
66-
{
67-
role: 'user',
68-
content:
69-
provider === 'tool-loop' ? '[with-tool] run test' : 'hi',
70-
},
69+
provider === 'multimodal'
70+
? {
71+
role: 'user',
72+
content: [
73+
{ type: 'text', content: 'describe this' },
74+
{
75+
type: 'image',
76+
source: {
77+
type: 'url',
78+
value: 'https://example.com/cat.png',
79+
mimeType: 'image/png',
80+
},
81+
},
82+
],
83+
}
84+
: {
85+
role: 'user',
86+
content:
87+
provider === 'tool-loop' ? '[with-tool] run test' : 'hi',
88+
},
7189
],
7290
...(provider === 'tool-loop' ? { tools: [weatherTool] } : {}),
73-
middleware: [otelMiddleware({ tracer })],
91+
middleware: [
92+
otelMiddleware({
93+
tracer,
94+
captureContent: provider === 'multimodal',
95+
}),
96+
],
7497
})) {
7598
// Drain — the assertions live on the captured spans.
7699
}

‎testing/e2e/tests/middleware.spec.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,38 @@ test.describe('Middleware Lifecycle', () => {
413413
})
414414
})
415415

416+
test('otel middleware keeps image parts structured in gen_ai.input.messages', async ({
417+
request,
418+
}) => {
419+
// #1525: captureContent used to flatten image parts to "[image]". The
420+
// URL reference must survive as an OTel semconv `uri` part.
421+
const res = await request.post('/api/otel-usage', {
422+
data: { provider: 'multimodal' },
423+
})
424+
expect(res.ok()).toBe(true)
425+
const { ok, error, spans } = await res.json()
426+
expect(error ?? null).toBeNull()
427+
expect(ok).toBe(true)
428+
429+
const iterationSpan = spans.find((s: any) => s.kind === SpanKind.CLIENT)
430+
expect(
431+
JSON.parse(iterationSpan.attributes['gen_ai.input.messages']),
432+
).toEqual([
433+
{
434+
role: 'user',
435+
content: [
436+
{ type: 'text', content: 'describe this' },
437+
{
438+
type: 'uri',
439+
modality: 'image',
440+
uri: 'https://example.com/cat.png',
441+
mime_type: 'image/png',
442+
},
443+
],
444+
},
445+
])
446+
})
447+
416448
test('otel middleware emits provider-reported cost on spans', async ({
417449
request,
418450
}) => {

0 commit comments

Comments
 (0)