Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ai-semantic-conventions/src/SemanticAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const SpanAttributes = {
LLM_REQUEST_TOP_P: "gen_ai.request.top_p",
LLM_PROMPTS: "gen_ai.prompt",
LLM_COMPLETIONS: "gen_ai.completion",
LLM_INPUT_MESSAGES: "gen_ai.input.messages",
LLM_OUTPUT_MESSAGES: "gen_ai.output.messages",
LLM_RESPONSE_MODEL: "gen_ai.response.model",
LLM_USAGE_PROMPT_TOKENS: "gen_ai.usage.prompt_tokens",
LLM_USAGE_COMPLETION_TOKENS: "gen_ai.usage.completion_tokens",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
{
"log": {
"_recordingName": "Test AI SDK Integration with Recording/should set LLM_INPUT_MESSAGES and LLM_OUTPUT_MESSAGES attributes for chat completions",
"creator": {
"comment": "persister:fs",
"name": "Polly.JS",
"version": "6.0.6"
},
"entries": [
{
"_id": "9e0d86c7cc2553331485a7095b7c79c7",
"_order": 0,
"cache": {},
"request": {
"bodySize": 128,
"cookies": [],
"headers": [
{
"name": "content-type",
"value": "application/json"
}
],
"headersSize": 273,
"httpVersion": "HTTP/1.1",
"method": "POST",
"postData": {
"mimeType": "application/json",
"params": [],
"text": "{\"model\":\"gpt-3.5-turbo\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is 2+2? Give a brief answer.\"}]}]}"
},
"queryString": [],
"url": "https://api.openai.com/v1/responses"
},
"response": {
"bodySize": 1368,
"content": {
"mimeType": "application/json",
"size": 1368,
"text": "{\n \"id\": \"resp_68ab8bb3a6a48196b327616ac8519242061ee2084aba21f9\",\n \"object\": \"response\",\n \"created_at\": 1756072883,\n \"status\": \"completed\",\n \"background\": false,\n \"error\": null,\n \"incomplete_details\": null,\n \"instructions\": null,\n \"max_output_tokens\": null,\n \"max_tool_calls\": null,\n \"model\": \"gpt-3.5-turbo-0125\",\n \"output\": [\n {\n \"id\": \"msg_68ab8bb44de48196bc6fd27015c46560061ee2084aba21f9\",\n \"type\": \"message\",\n \"status\": \"completed\",\n \"content\": [\n {\n \"type\": \"output_text\",\n \"annotations\": [],\n \"logprobs\": [],\n \"text\": \"2+2 equals 4.\"\n }\n ],\n \"role\": \"assistant\"\n }\n ],\n \"parallel_tool_calls\": true,\n \"previous_response_id\": null,\n \"prompt_cache_key\": null,\n \"reasoning\": {\n \"effort\": null,\n \"summary\": null\n },\n \"safety_identifier\": null,\n \"service_tier\": \"default\",\n \"store\": true,\n \"temperature\": 1.0,\n \"text\": {\n \"format\": {\n \"type\": \"text\"\n },\n \"verbosity\": \"medium\"\n },\n \"tool_choice\": \"auto\",\n \"tools\": [],\n \"top_logprobs\": 0,\n \"top_p\": 1.0,\n \"truncation\": \"disabled\",\n \"usage\": {\n \"input_tokens\": 19,\n \"input_tokens_details\": {\n \"cached_tokens\": 0\n },\n \"output_tokens\": 8,\n \"output_tokens_details\": {\n \"reasoning_tokens\": 0\n },\n \"total_tokens\": 27\n },\n \"user\": null,\n \"metadata\": {}\n}"
},
"cookies": [
{
"domain": ".api.openai.com",
"httpOnly": true,
"name": "_cfuvid",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "YvH22Dd0_.ZSsD0IiE4C_SZLye9RnIosHw8rvV.a5xI-1756072884584-0.0.1.1-604800000"
}
],
"headers": [
{
"name": "alt-svc",
"value": "h3=\":443\"; ma=86400"
},
{
"name": "cf-cache-status",
"value": "DYNAMIC"
},
{
"name": "cf-ray",
"value": "974620c03ebe7da4-TLV"
},
{
"name": "connection",
"value": "keep-alive"
},
{
"name": "content-encoding",
"value": "br"
},
{
"name": "content-type",
"value": "application/json"
},
{
"name": "date",
"value": "Sun, 24 Aug 2025 22:01:24 GMT"
},
{
"name": "openai-organization",
"value": "traceloop"
},
{
"name": "openai-processing-ms",
"value": "860"
},
{
"name": "openai-project",
"value": "proj_tzz1TbPPOXaf6j9tEkVUBIAa"
},
{
"name": "openai-version",
"value": "2020-10-01"
},
{
"name": "server",
"value": "cloudflare"
},
{
"name": "set-cookie",
"value": "_cfuvid=YvH22Dd0_.ZSsD0IiE4C_SZLye9RnIosHw8rvV.a5xI-1756072884584-0.0.1.1-604800000; path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None"
},
{
"name": "strict-transport-security",
"value": "max-age=31536000; includeSubDomains; preload"
},
{
"name": "transfer-encoding",
"value": "chunked"
},
{
"name": "x-content-type-options",
"value": "nosniff"
},
{
"name": "x-envoy-upstream-service-time",
"value": "866"
},
{
"name": "x-ratelimit-limit-requests",
"value": "10000"
},
{
"name": "x-ratelimit-limit-tokens",
"value": "50000000"
},
{
"name": "x-ratelimit-remaining-requests",
"value": "9999"
},
{
"name": "x-ratelimit-remaining-tokens",
"value": "49999976"
},
{
"name": "x-ratelimit-reset-requests",
"value": "6ms"
},
{
"name": "x-ratelimit-reset-tokens",
"value": "0s"
},
{
"name": "x-request-id",
"value": "req_ca7b8860830628b84350a7485599e844"
}
],
"headersSize": 953,
"httpVersion": "HTTP/1.1",
"redirectURL": "",
"status": 200,
"statusText": "OK"
},
"startedDateTime": "2025-08-24T22:01:23.016Z",
"time": 1409,
"timings": {
"blocked": -1,
"connect": -1,
"dns": -1,
"receive": 0,
"send": 0,
"ssl": -1,
"wait": 1409
}
}
],
"pages": [],
"version": "1.2"
}
}
102 changes: 95 additions & 7 deletions packages/traceloop-sdk/src/lib/tracing/ai-sdk-transformations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const AI_USAGE_PROMPT_TOKENS = "ai.usage.promptTokens";
const AI_USAGE_COMPLETION_TOKENS = "ai.usage.completionTokens";
const AI_MODEL_PROVIDER = "ai.model.provider";
const AI_PROMPT_TOOLS = "ai.prompt.tools";
const TYPE_TEXT = "text";
const TYPE_TOOL_CALL = "tool_call";
const ROLE_ASSISTANT = "assistant";
const ROLE_USER = "user";

// Vendor mapping from AI SDK provider prefixes to standardized LLM_SYSTEM values
// Uses prefixes to match AI SDK patterns like "openai.chat", "anthropic.messages", etc.
Expand Down Expand Up @@ -55,7 +59,21 @@ const transformResponseText = (attributes: Record<string, any>): void => {
if (AI_RESPONSE_TEXT in attributes) {
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.content`] =
attributes[AI_RESPONSE_TEXT];
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = "assistant";
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = ROLE_ASSISTANT;

const outputMessage = {
role: ROLE_ASSISTANT,
parts: [
{
type: TYPE_TEXT,
content: attributes[AI_RESPONSE_TEXT],
},
],
};
attributes[SpanAttributes.LLM_OUTPUT_MESSAGES] = JSON.stringify([
outputMessage,
]);

delete attributes[AI_RESPONSE_TEXT];
}
};
Expand All @@ -64,7 +82,21 @@ const transformResponseObject = (attributes: Record<string, any>): void => {
if (AI_RESPONSE_OBJECT in attributes) {
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.content`] =
attributes[AI_RESPONSE_OBJECT];
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = "assistant";
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = ROLE_ASSISTANT;

const outputMessage = {
role: ROLE_ASSISTANT,
parts: [
{
type: TYPE_TEXT,
content: attributes[AI_RESPONSE_OBJECT],
},
],
};
attributes[SpanAttributes.LLM_OUTPUT_MESSAGES] = JSON.stringify([
outputMessage,
]);

delete attributes[AI_RESPONSE_OBJECT];
}
};
Expand All @@ -76,8 +108,9 @@ const transformResponseToolCalls = (attributes: Record<string, any>): void => {
attributes[AI_RESPONSE_TOOL_CALLS] as string,
);

attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = "assistant";
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.role`] = ROLE_ASSISTANT;

const toolCallParts: any[] = [];
toolCalls.forEach((toolCall: any, index: number) => {
if (toolCall.toolCallType === "function") {
attributes[
Expand All @@ -86,9 +119,27 @@ const transformResponseToolCalls = (attributes: Record<string, any>): void => {
attributes[
`${SpanAttributes.LLM_COMPLETIONS}.0.tool_calls.${index}.arguments`
] = toolCall.args;

toolCallParts.push({
type: TYPE_TOOL_CALL,
tool_call: {
name: toolCall.toolName,
arguments: toolCall.args,
},
});
}
});

if (toolCallParts.length > 0) {
const outputMessage = {
role: ROLE_ASSISTANT,
parts: toolCallParts,
};
attributes[SpanAttributes.LLM_OUTPUT_MESSAGES] = JSON.stringify([
outputMessage,
]);
}

delete attributes[AI_RESPONSE_TOOL_CALLS];
} catch {
// Ignore parsing errors
Expand All @@ -100,7 +151,10 @@ const processMessageContent = (content: any): string => {
if (Array.isArray(content)) {
const textItems = content.filter(
(item: any) =>
item && typeof item === "object" && item.type === "text" && item.text,
item &&
typeof item === "object" &&
item.type === TYPE_TEXT &&
item.text,
);

if (textItems.length > 0) {
Expand All @@ -112,7 +166,7 @@ const processMessageContent = (content: any): string => {
}

if (content && typeof content === "object") {
if (content.type === "text" && content.text) {
if (content.type === TYPE_TEXT && content.text) {
return content.text;
}
return JSON.stringify(content);
Expand All @@ -126,7 +180,7 @@ const processMessageContent = (content: any): string => {
(item: any) =>
item &&
typeof item === "object" &&
item.type === "text" &&
item.type === TYPE_TEXT &&
item.text,
);

Expand Down Expand Up @@ -205,12 +259,32 @@ const transformPrompts = (attributes: Record<string, any>): void => {
}

const messages = JSON.parse(jsonString);
const inputMessages: any[] = [];

messages.forEach((msg: { role: string; content: any }, index: number) => {
const processedContent = processMessageContent(msg.content);
const contentKey = `${SpanAttributes.LLM_PROMPTS}.${index}.content`;
attributes[contentKey] = processedContent;
attributes[`${SpanAttributes.LLM_PROMPTS}.${index}.role`] = msg.role;

// Add to OpenTelemetry standard gen_ai.input.messages format
inputMessages.push({
role: msg.role,
parts: [
{
type: TYPE_TEXT,
content: processedContent,
},
],
});
});

// Set the OpenTelemetry standard input messages attribute
if (inputMessages.length > 0) {
attributes[SpanAttributes.LLM_INPUT_MESSAGES] =
JSON.stringify(inputMessages);
}

delete attributes[AI_PROMPT_MESSAGES];
} catch {
// Ignore parsing errors
Expand All @@ -223,7 +297,21 @@ const transformPrompts = (attributes: Record<string, any>): void => {
if (promptData.prompt && typeof promptData.prompt === "string") {
attributes[`${SpanAttributes.LLM_PROMPTS}.0.content`] =
promptData.prompt;
attributes[`${SpanAttributes.LLM_PROMPTS}.0.role`] = "user";
attributes[`${SpanAttributes.LLM_PROMPTS}.0.role`] = ROLE_USER;

const inputMessage = {
role: ROLE_USER,
parts: [
{
type: TYPE_TEXT,
content: promptData.prompt,
},
],
};
attributes[SpanAttributes.LLM_INPUT_MESSAGES] = JSON.stringify([
inputMessage,
]);

delete attributes[AI_PROMPT];
}
} catch {
Expand Down
Loading