Skip to content

Commit 3904a03

Browse files
committed
CR Comments
1 parent ca71ef3 commit 3904a03

7 files changed

Lines changed: 265 additions & 107 deletions

File tree

packages/instrumentation-utils/src/content-block-mappers.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,3 +321,188 @@ export const mapBedrockContentBlock = (block: any): object => {
321321
return { type: block.type, ...block };
322322
}
323323
};
324+
325+
// =============================================================================
326+
// Vercel AI SDK
327+
// =============================================================================
328+
// Maps a single Vercel AI SDK content part (from ai.prompt.messages span attr)
329+
// to its OTel-compliant part object. Verified against:
330+
// - AI SDK v6 ToolCallPart / ToolResultPart / ImagePart / FilePart / ReasoningPart types
331+
// - OTel gen_ai input/output messages JSON schemas (v1.40)
332+
//
333+
// text → TextPart { type:"text", content }
334+
// reasoning → ReasoningPart { type:"reasoning", content }
335+
// tool-call → ToolCallRequestPart { type:"tool_call", id?, name, arguments? }
336+
// tool-result→ ToolCallResponsePart { type:"tool_call_response", id?, response }
337+
// image (string data URI) → BlobPart { modality:"image", mime_type?, content }
338+
// image (string URL) → UriPart { modality:"image", uri }
339+
// image (URL object) → UriPart { modality:"image", uri: url.href }
340+
// image (binary data) → BlobPart { modality:"image", content (base64) }
341+
// file (inline data) → BlobPart { modality inferred from mediaType, content }
342+
// file (URL) → UriPart { modality inferred from mediaType, uri }
343+
// <unknown> → GenericPart
344+
345+
/**
346+
* Maps a single Vercel AI SDK content part to its OTel-compliant part object.
347+
*
348+
* Field names follow AI SDK v6:
349+
* ToolCallPart: toolCallId, toolName, input
350+
* ToolResultPart: toolCallId, toolName, output (ToolResultOutput union)
351+
* ImagePart: image (DataContent | URL), optional mediaType
352+
* FilePart: data (DataContent | URL), mediaType (required)
353+
* ReasoningPart: text
354+
*/
355+
export const mapAiSdkContentPart = (part: any): any => {
356+
if (!part || typeof part !== "object") {
357+
return { type: "text", content: String(part ?? "") };
358+
}
359+
360+
switch (part.type) {
361+
case "text":
362+
return { type: "text", content: part.text ?? "" };
363+
364+
// OTel type is "reasoning", AI SDK v6 field is `text`
365+
case "reasoning":
366+
return { type: "reasoning", content: part.text ?? "" };
367+
368+
case "tool-call":
369+
// AI SDK v6: toolCallId, toolName, input
370+
return {
371+
type: "tool_call",
372+
id: part.toolCallId ?? null,
373+
name: part.toolName,
374+
arguments: part.input,
375+
};
376+
377+
case "tool-result": {
378+
// AI SDK v6: output is ToolResultOutput — { type: 'text'|'json', value } union
379+
// Unwrap to the actual value for tracing; fall back to the full object if unknown shape
380+
const output = part.output;
381+
const response =
382+
output && typeof output === "object" && "value" in output
383+
? output.value
384+
: output;
385+
return {
386+
type: "tool_call_response",
387+
id: part.toolCallId ?? null,
388+
response,
389+
};
390+
}
391+
392+
case "image": {
393+
// AI SDK v6: image is DataContent | URL; optional mediaType
394+
const imgSrc = part.image ?? null;
395+
const mimeType = part.mediaType ?? null;
396+
397+
if (imgSrc instanceof URL) {
398+
return {
399+
type: "uri",
400+
modality: "image",
401+
uri: imgSrc.href,
402+
mime_type: mimeType,
403+
};
404+
}
405+
if (typeof imgSrc === "string") {
406+
if (imgSrc.startsWith("data:")) {
407+
const [header, data] = imgSrc.split(",");
408+
const detectedMime = header.match(/data:([^;]+)/)?.[1] ?? mimeType;
409+
return {
410+
type: "blob",
411+
modality: "image",
412+
...(detectedMime ? { mime_type: detectedMime } : {}),
413+
content: data,
414+
};
415+
}
416+
return {
417+
type: "uri",
418+
modality: "image",
419+
uri: imgSrc,
420+
mime_type: mimeType,
421+
};
422+
}
423+
if (imgSrc != null) {
424+
// Binary data (Uint8Array / ArrayBuffer / Buffer) — base64 encode
425+
const bytes =
426+
imgSrc instanceof ArrayBuffer ? new Uint8Array(imgSrc) : imgSrc;
427+
const b64 = Buffer.from(bytes).toString("base64");
428+
return {
429+
type: "blob",
430+
modality: "image",
431+
mime_type: mimeType,
432+
content: b64,
433+
};
434+
}
435+
return { type: "blob", modality: "image", content: "" };
436+
}
437+
438+
case "file": {
439+
// AI SDK v6: data is DataContent | URL; mediaType is required
440+
const fileSrc = part.data ?? null;
441+
const mimeType = part.mediaType ?? null;
442+
// Infer modality from MIME type (best-effort)
443+
const modality = mimeType?.startsWith("image/")
444+
? "image"
445+
: mimeType?.startsWith("audio/")
446+
? "audio"
447+
: mimeType?.startsWith("video/")
448+
? "video"
449+
: "document";
450+
451+
if (fileSrc instanceof URL) {
452+
return {
453+
type: "uri",
454+
modality,
455+
uri: fileSrc.href,
456+
mime_type: mimeType,
457+
};
458+
}
459+
if (typeof fileSrc === "string") {
460+
if (fileSrc.startsWith("data:")) {
461+
const [, data] = fileSrc.split(",");
462+
return { type: "blob", modality, mime_type: mimeType, content: data };
463+
}
464+
return { type: "uri", modality, uri: fileSrc, mime_type: mimeType };
465+
}
466+
if (fileSrc != null) {
467+
const bytes =
468+
fileSrc instanceof ArrayBuffer ? new Uint8Array(fileSrc) : fileSrc;
469+
const b64 = Buffer.from(bytes).toString("base64");
470+
return { type: "blob", modality, mime_type: mimeType, content: b64 };
471+
}
472+
return { type: "blob", modality, content: "" };
473+
}
474+
475+
default:
476+
// GenericPart — preserve unknown types as-is
477+
return { type: part.type, ...part };
478+
}
479+
};
480+
481+
/**
482+
* Converts Vercel AI SDK message content to an array of OTel parts.
483+
* Accepts: plain string, array of SDK content parts, or a JSON-serialized array
484+
* (the AI SDK serializes content arrays as JSON strings in span attributes).
485+
*/
486+
export const mapAiSdkMessageContent = (content: any): any[] => {
487+
if (typeof content === "string") {
488+
try {
489+
const parsed = JSON.parse(content);
490+
if (Array.isArray(parsed)) {
491+
return parsed.map(mapAiSdkContentPart);
492+
}
493+
} catch {
494+
// plain string
495+
}
496+
return [{ type: "text", content }];
497+
}
498+
499+
if (Array.isArray(content)) {
500+
return content.map(mapAiSdkContentPart);
501+
}
502+
503+
if (content && typeof content === "object") {
504+
return [mapAiSdkContentPart(content)];
505+
}
506+
507+
return [{ type: "text", content: String(content ?? "") }];
508+
};

packages/instrumentation-utils/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,6 @@ export {
99
mapAnthropicContentBlock,
1010
mapOpenAIContentBlock,
1111
mapBedrockContentBlock,
12+
mapAiSdkContentPart,
13+
mapAiSdkMessageContent,
1214
} from "./content-block-mappers";

packages/traceloop-sdk/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666
"@opentelemetry/sdk-trace-node": "^2.0.1",
6767
"@opentelemetry/semantic-conventions": "^1.40.0",
6868
"@traceloop/ai-semantic-conventions": "workspace:*",
69+
"@traceloop/instrumentation-utils": "workspace:*",
6970
"@traceloop/instrumentation-anthropic": "workspace:*",
7071
"@traceloop/instrumentation-bedrock": "workspace:*",
7172
"@traceloop/instrumentation-chromadb": "workspace:*",

packages/traceloop-sdk/src/lib/tracing/ai-sdk-transformations.ts

Lines changed: 8 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
SpanAttributes,
44
TraceloopSpanKindValues,
55
} from "@traceloop/ai-semantic-conventions";
6+
import { mapAiSdkMessageContent } from "@traceloop/instrumentation-utils";
67
import {
78
ATTR_GEN_AI_AGENT_NAME,
89
ATTR_GEN_AI_CONVERSATION_ID,
@@ -232,59 +233,6 @@ const transformResponseToolCalls = (attributes: Record<string, any>): void => {
232233
}
233234
};
234235

235-
const processMessageContent = (content: any): string => {
236-
if (Array.isArray(content)) {
237-
const textItems = content.filter(
238-
(item: any) =>
239-
item &&
240-
typeof item === "object" &&
241-
item.type === TYPE_TEXT &&
242-
item.text,
243-
);
244-
245-
if (textItems.length > 0) {
246-
const joinedText = textItems.map((item: any) => item.text).join(" ");
247-
return joinedText;
248-
} else {
249-
return JSON.stringify(content);
250-
}
251-
}
252-
253-
if (content && typeof content === "object") {
254-
if (content.type === TYPE_TEXT && content.text) {
255-
return content.text;
256-
}
257-
return JSON.stringify(content);
258-
}
259-
260-
if (typeof content === "string") {
261-
try {
262-
const parsed = JSON.parse(content);
263-
if (Array.isArray(parsed)) {
264-
const allTextItems = parsed.every(
265-
(item: any) =>
266-
item &&
267-
typeof item === "object" &&
268-
item.type === TYPE_TEXT &&
269-
item.text,
270-
);
271-
272-
if (allTextItems && parsed.length > 0) {
273-
return parsed.map((item: any) => item.text).join(" ");
274-
} else {
275-
return content;
276-
}
277-
}
278-
} catch {
279-
// Ignore parsing errors
280-
}
281-
282-
return content;
283-
}
284-
285-
return String(content);
286-
};
287-
288236
const transformTools = (attributes: Record<string, any>): void => {
289237
if (AI_PROMPT_TOOLS in attributes) {
290238
try {
@@ -337,11 +285,9 @@ const transformPrompts = (attributes: Record<string, any>): void => {
337285
const inputMessages: any[] = [];
338286

339287
messages.forEach((msg: { role: string; content: any }) => {
340-
const processedContent = processMessageContent(msg.content);
341-
342288
inputMessages.push({
343289
role: msg.role,
344-
parts: [{ type: TYPE_TEXT, content: processedContent }],
290+
parts: mapAiSdkMessageContent(msg.content),
345291
});
346292
});
347293

@@ -368,10 +314,9 @@ const transformPrompts = (attributes: Record<string, any>): void => {
368314
const inputMessages: any[] = [];
369315

370316
messages.forEach((msg: { role: string; content: any }) => {
371-
const processedContent = processMessageContent(msg.content);
372317
inputMessages.push({
373318
role: msg.role,
374-
parts: [{ type: TYPE_TEXT, content: processedContent }],
319+
parts: mapAiSdkMessageContent(msg.content),
375320
});
376321
});
377322

@@ -535,7 +480,11 @@ const transformOperationName = (
535480
spanName.includes("streamObject")
536481
) {
537482
operationName = GEN_AI_OPERATION_NAME_VALUE_CHAT;
538-
} else if (spanName === "ai.toolCall" || spanName.endsWith(".tool")) {
483+
} else if (
484+
spanName === "ai.toolCall" ||
485+
spanName.endsWith(".tool") ||
486+
spanName.startsWith(GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL)
487+
) {
539488
operationName = GEN_AI_OPERATION_NAME_VALUE_EXECUTE_TOOL;
540489
}
541490

0 commit comments

Comments
 (0)