Skip to content

Commit 748cc84

Browse files
authored
fix(sdk): proper formatting for vercel AI SDK tool calls (#736)
1 parent c1ee4af commit 748cc84

File tree

5 files changed

+319
-176
lines changed

5 files changed

+319
-176
lines changed

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

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import { ReadableSpan } from "@opentelemetry/sdk-trace-node";
1+
import { ReadableSpan, Span } from "@opentelemetry/sdk-trace-node";
22
import { SpanAttributes } from "@traceloop/ai-semantic-conventions";
33

4+
const AI_GENERATE_TEXT = "ai.generateText";
45
const AI_GENERATE_TEXT_DO_GENERATE = "ai.generateText.doGenerate";
56
const AI_GENERATE_OBJECT_DO_GENERATE = "ai.generateObject.doGenerate";
67
const AI_STREAM_TEXT_DO_STREAM = "ai.streamText.doStream";
78
const HANDLED_SPAN_NAMES: Record<string, string> = {
8-
[AI_GENERATE_TEXT_DO_GENERATE]: "ai.generateText.generate",
9-
[AI_GENERATE_OBJECT_DO_GENERATE]: "ai.generateObject.generate",
10-
[AI_STREAM_TEXT_DO_STREAM]: "ai.streamText.stream",
9+
[AI_GENERATE_TEXT]: "run.ai",
10+
[AI_GENERATE_TEXT_DO_GENERATE]: "text.generate",
11+
[AI_GENERATE_OBJECT_DO_GENERATE]: "object.generate",
12+
[AI_STREAM_TEXT_DO_STREAM]: "text.stream",
1113
};
1214

15+
const TOOL_SPAN_NAME = "ai.toolCall";
16+
1317
const AI_RESPONSE_TEXT = "ai.response.text";
1418
const AI_RESPONSE_OBJECT = "ai.response.object";
1519
const AI_RESPONSE_TOOL_CALLS = "ai.response.toolCalls";
@@ -19,6 +23,7 @@ const AI_USAGE_PROMPT_TOKENS = "ai.usage.promptTokens";
1923
const AI_USAGE_COMPLETION_TOKENS = "ai.usage.completionTokens";
2024
const AI_MODEL_PROVIDER = "ai.model.provider";
2125
const AI_PROMPT_TOOLS = "ai.prompt.tools";
26+
const AI_TELEMETRY_METADATA_PREFIX = "ai.telemetry.metadata.";
2227
const TYPE_TEXT = "text";
2328
const TYPE_TOOL_CALL = "tool_call";
2429
const ROLE_ASSISTANT = "assistant";
@@ -47,14 +52,6 @@ const VENDOR_MAPPING: Record<string, string> = {
4752
openrouter: "OpenRouter",
4853
};
4954

50-
export const transformAiSdkSpanName = (span: ReadableSpan): void => {
51-
// Unfortunately, the span name is not writable as this is not the intended behavior
52-
// but it is a workaround to set the correct span name
53-
if (span.name in HANDLED_SPAN_NAMES) {
54-
(span as any).name = HANDLED_SPAN_NAMES[span.name];
55-
}
56-
};
57-
5855
const transformResponseText = (attributes: Record<string, any>): void => {
5956
if (AI_RESPONSE_TEXT in attributes) {
6057
attributes[`${SpanAttributes.LLM_COMPLETIONS}.0.content`] =
@@ -367,9 +364,41 @@ const transformVendor = (attributes: Record<string, any>): void => {
367364
}
368365
};
369366

370-
export const transformAiSdkAttributes = (
371-
attributes: Record<string, any>,
372-
): void => {
367+
const transformTelemetryMetadata = (attributes: Record<string, any>): void => {
368+
const metadataAttributes: Record<string, string> = {};
369+
const keysToDelete: string[] = [];
370+
371+
// Find all ai.telemetry.metadata.* attributes
372+
for (const [key, value] of Object.entries(attributes)) {
373+
if (key.startsWith(AI_TELEMETRY_METADATA_PREFIX)) {
374+
const metadataKey = key.substring(AI_TELEMETRY_METADATA_PREFIX.length);
375+
376+
// Always mark for deletion since it's a telemetry metadata attribute
377+
keysToDelete.push(key);
378+
379+
if (metadataKey && value != null) {
380+
// Convert value to string for association properties
381+
const stringValue = typeof value === "string" ? value : String(value);
382+
metadataAttributes[metadataKey] = stringValue;
383+
384+
// Also set as traceloop association property attribute
385+
attributes[
386+
`${SpanAttributes.TRACELOOP_ASSOCIATION_PROPERTIES}.${metadataKey}`
387+
] = stringValue;
388+
}
389+
}
390+
}
391+
392+
// Remove original ai.telemetry.metadata.* attributes
393+
keysToDelete.forEach((key) => {
394+
delete attributes[key];
395+
});
396+
397+
// Note: Context setting for child span inheritance should be done before span creation,
398+
// not during transformation. Use `withTelemetryMetadataContext` function for context propagation.
399+
};
400+
401+
export const transformLLMSpans = (attributes: Record<string, any>): void => {
373402
transformResponseText(attributes);
374403
transformResponseObject(attributes);
375404
transformResponseToolCalls(attributes);
@@ -379,16 +408,40 @@ export const transformAiSdkAttributes = (
379408
transformCompletionTokens(attributes);
380409
calculateTotalTokens(attributes);
381410
transformVendor(attributes);
411+
transformTelemetryMetadata(attributes);
412+
};
413+
414+
const transformToolCalls = (span: ReadableSpan): void => {
415+
if (
416+
span.attributes["ai.toolCall.args"] &&
417+
span.attributes["ai.toolCall.result"]
418+
) {
419+
span.attributes[SpanAttributes.TRACELOOP_ENTITY_INPUT] =
420+
span.attributes["ai.toolCall.args"];
421+
delete span.attributes["ai.toolCall.args"];
422+
span.attributes[SpanAttributes.TRACELOOP_ENTITY_OUTPUT] =
423+
span.attributes["ai.toolCall.result"];
424+
delete span.attributes["ai.toolCall.result"];
425+
}
382426
};
383427

384428
const shouldHandleSpan = (span: ReadableSpan): boolean => {
385-
return span.name in HANDLED_SPAN_NAMES;
429+
return span.instrumentationScope?.name === "ai";
430+
};
431+
432+
export const transformAiSdkSpanNames = (span: Span): void => {
433+
if (span.name === TOOL_SPAN_NAME) {
434+
span.updateName(`${span.attributes["ai.toolCall.name"] as string}.tool`);
435+
}
436+
if (span.name in HANDLED_SPAN_NAMES) {
437+
span.updateName(HANDLED_SPAN_NAMES[span.name]);
438+
}
386439
};
387440

388-
export const transformAiSdkSpan = (span: ReadableSpan): void => {
441+
export const transformAiSdkSpanAttributes = (span: ReadableSpan): void => {
389442
if (!shouldHandleSpan(span)) {
390443
return;
391444
}
392-
transformAiSdkSpanName(span);
393-
transformAiSdkAttributes(span.attributes);
445+
transformLLMSpans(span.attributes);
446+
transformToolCalls(span);
394447
};

packages/traceloop-sdk/src/lib/tracing/span-processor.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import {
22
SimpleSpanProcessor,
33
BatchSpanProcessor,
44
SpanProcessor,
5+
Span,
56
ReadableSpan,
67
} from "@opentelemetry/sdk-trace-node";
7-
import { Span, context } from "@opentelemetry/api";
8+
import { context } from "@opentelemetry/api";
89
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
910
import { SpanExporter } from "@opentelemetry/sdk-trace-base";
1011
import {
@@ -13,7 +14,10 @@ import {
1314
WORKFLOW_NAME_KEY,
1415
} from "./tracing";
1516
import { SpanAttributes } from "@traceloop/ai-semantic-conventions";
16-
import { transformAiSdkSpan } from "./ai-sdk-transformations";
17+
import {
18+
transformAiSdkSpanAttributes,
19+
transformAiSdkSpanNames,
20+
} from "./ai-sdk-transformations";
1721
import { parseKeyPairsIntoRecord } from "./baggage-utils";
1822

1923
export const ALL_INSTRUMENTATION_LIBRARIES = "all" as const;
@@ -155,6 +159,8 @@ const onSpanStart = (span: Span): void => {
155159
);
156160
}
157161
}
162+
163+
transformAiSdkSpanNames(span);
158164
};
159165

160166
/**
@@ -220,7 +226,7 @@ const onSpanEnd = (
220226
}
221227

222228
// Apply AI SDK transformations (if needed)
223-
transformAiSdkSpan(span);
229+
transformAiSdkSpanAttributes(span);
224230

225231
// Ensure OTLP transformer compatibility
226232
const compatibleSpan = ensureSpanCompatibility(span);

packages/traceloop-sdk/test/ai-sdk-integration.test.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -105,17 +105,15 @@ describe("Test AI SDK Integration with Recording", function () {
105105
const spans = memoryExporter.getFinishedSpans();
106106

107107
const generateTextSpan = spans.find(
108-
(span) =>
109-
span.name === "ai.generateText.generate" ||
110-
span.name === "ai.generateText.doGenerate",
108+
(span) => span.name === "text.generate",
111109
);
112110

113111
assert.ok(result);
114112
assert.ok(result.text);
115113
assert.ok(generateTextSpan);
116114

117-
// Verify span name
118-
assert.strictEqual(generateTextSpan.name, "ai.generateText.generate");
115+
// Verify span name (should be transformed from ai.generateText.doGenerate to text.generate)
116+
assert.strictEqual(generateTextSpan.name, "text.generate");
119117

120118
// Verify vendor
121119
assert.strictEqual(generateTextSpan.attributes["gen_ai.system"], "OpenAI");
@@ -174,17 +172,16 @@ describe("Test AI SDK Integration with Recording", function () {
174172
// Find the Google span specifically (should have workflow name test_google_workflow)
175173
const generateTextSpan = spans.find(
176174
(span) =>
177-
(span.name === "ai.generateText.generate" ||
178-
span.name === "ai.generateText.doGenerate") &&
175+
span.name === "text.generate" &&
179176
span.attributes["traceloop.workflow.name"] === "test_google_workflow",
180177
);
181178

182179
assert.ok(result);
183180
assert.ok(result.text);
184181
assert.ok(generateTextSpan, "Could not find Google generateText span");
185182

186-
// Verify span name
187-
assert.strictEqual(generateTextSpan.name, "ai.generateText.generate");
183+
// Verify span name (should be transformed from ai.generateText.doGenerate to text.generate)
184+
assert.strictEqual(generateTextSpan.name, "text.generate");
188185

189186
// Verify vendor
190187
assert.strictEqual(generateTextSpan.attributes["gen_ai.system"], "Google");
@@ -236,9 +233,7 @@ describe("Test AI SDK Integration with Recording", function () {
236233
assert.ok(result.text);
237234

238235
const spans = memoryExporter.getFinishedSpans();
239-
const aiSdkSpan = spans.find((span) =>
240-
span.name.startsWith("ai.generateText"),
241-
);
236+
const aiSdkSpan = spans.find((span) => span.name === "text.generate");
242237

243238
assert.ok(aiSdkSpan);
244239

0 commit comments

Comments
 (0)