-
-
Notifications
You must be signed in to change notification settings - Fork 349
fix(ai): keep multimodal parts structured in otel gen_ai.input.messages #1527
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@tanstack/ai': patch | ||
| --- | ||
|
|
||
| `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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -221,6 +221,51 @@ function serializeContent(content: unknown): string { | |
| return parts.join(' ') | ||
| } | ||
|
|
||
| type InputPart = | ||
| | { type: 'text'; content: string } | ||
| | { type: 'uri'; modality: string; uri: string; mime_type?: string } | ||
| | { type: 'file'; modality: string; file_id: string; mime_type?: string } | ||
|
|
||
| /** | ||
| * Structured form of `ContentPart[]` for `gen_ai.input.messages`, using the | ||
| * OTel GenAI semconv part shapes. URL and file-handle media keep their | ||
| * reference; inline bytes (and `data:` URLs) stay a `[type]` placeholder so | ||
| * they never blow attribute size limits. `redact` runs on text parts only. | ||
| */ | ||
| function serializeParts( | ||
| content: Array<unknown>, | ||
| redact: (text: string) => string, | ||
| ): Array<InputPart> { | ||
| const parts: Array<InputPart> = [] | ||
| for (const part of content) { | ||
| if (!part || typeof part !== 'object') continue | ||
| const p = part as { | ||
| type?: string | ||
| text?: string | ||
| content?: string | ||
| source?: { type?: string; value?: string; mimeType?: string } | ||
| } | ||
| if (p.type === 'text') { | ||
| parts.push({ | ||
| type: 'text', | ||
| content: redact((p.text ?? p.content ?? '').toString()), | ||
| }) | ||
| continue | ||
| } | ||
| const modality = p.type ?? 'unknown' | ||
| const { type, value, mimeType } = p.source ?? {} | ||
| const mime = mimeType ? { mime_type: mimeType } : {} | ||
| if (type === 'url' && value && !value.startsWith('data:')) { | ||
| parts.push({ type: 'uri', modality, uri: value, ...mime }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win Sensitive Data Exposure Reachability: External Scrub sensitive media URLs before recording them. When a media URL contains a signed query parameter or embedded credentials, 🤖 Prompt for AI Agents |
||
| } else if (type === 'file' && value) { | ||
| parts.push({ type: 'file', modality, file_id: value, ...mime }) | ||
| } else { | ||
| parts.push({ type: 'text', content: `[${modality}]` }) | ||
| } | ||
| } | ||
| return parts | ||
| } | ||
|
|
||
| function messageEventName(role: string): string { | ||
| switch (role) { | ||
| case 'user': | ||
|
|
@@ -581,14 +626,25 @@ export function otelMiddleware( | |
| // Also emit the current GenAI-semconv attribute form | ||
| // (`gen_ai.input.messages`) — backends like PostHog read prompt | ||
| // content from this attribute, not from span events. | ||
| const inputMessages: Array<{ role: string; content: string }> = [] | ||
| // Multimodal messages keep their parts structured so image / audio / | ||
| // video / document references survive into the trace (#1525). | ||
| const inputMessages: Array<{ | ||
| role: string | ||
| content: string | Array<InputPart> | ||
| }> = [] | ||
| for (const sys of systemPromptContents) { | ||
| inputMessages.push({ | ||
| role: 'system', | ||
| content: redactContent(sys), | ||
| }) | ||
| } | ||
| for (const m of config.messages) { | ||
| if (Array.isArray(m.content)) { | ||
| const parts = serializeParts(m.content, redactContent) | ||
| if (parts.length === 0) continue | ||
| inputMessages.push({ role: m.role, content: parts }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Put structured input under the OpenTelemetry For array content, this branch emits 🤖 Prompt for AI Agents |
||
| continue | ||
| } | ||
| const body = serializeContent(m.content) | ||
| if (body.length === 0) continue | ||
| inputMessages.push({ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Detected with Advanced Tier | 🟠 Major | ⚡ Quick win
Sensitive Data Exposure
Reachability: External
Exploitability: Moderate
CWE: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
Reject data URLs without a case-sensitive scheme check.
When
source.valuestarts withDATA:or another mixed-case spelling, this check treats the inline bytes as a URI. The bytes then enter the span attributes instead of the intended[image]placeholder. URI schemes are case-insensitive. Normalize or parse the scheme before deciding whether to record the value. (rfc-editor.org)View in Security blast radius
🤖 Prompt for AI Agents