import { rec, str, num, parseProviderMetadata, extractTelemetryMetadata } from "./aiHelpers"; import type { AISpanData, DisplayItem, ToolDefinition, ToolUse } from "./types"; /** * Extracts structured AI span data from unflattened OTEL span properties. * * Works with the nested object produced by `unflattenAttributes()` — expects * keys like `gen_ai.response.model`, `ai.prompt.messages`, `trigger.llm.total_cost`, etc. * * @param properties Unflattened span properties object * @param durationMs Span duration in milliseconds * @returns Structured AI data, or undefined if this isn't an AI generation span */ export function extractAISpanData( properties: Record, durationMs: number ): AISpanData | undefined { const genAi = properties.gen_ai; if (!genAi || typeof genAi !== "object") return undefined; const g = genAi as Record; const ai = rec(properties.ai); const trigger = rec(properties.trigger); const gResponse = rec(g.response); const gRequest = rec(g.request); const gUsage = rec(g.usage); const gOperation = rec(g.operation); const gProvider = rec(g.provider); const gInput = rec(g.input); const gOutput = rec(g.output); const gTool = rec(g.tool); const aiModel = rec(ai.model); const aiResponse = rec(ai.response); const aiPrompt = rec(ai.prompt); const aiUsage = rec(ai.usage); const triggerLlm = rec(trigger.llm); const model = str(gResponse.model) ?? str(gRequest.model) ?? str(aiModel.id); if (!model) return undefined; // Prefer ai.usage (richer) over gen_ai.usage. // Gateway/some providers emit promptTokens/completionTokens instead of inputTokens/outputTokens. const inputTokens = num(aiUsage.inputTokens) ?? num(aiUsage.promptTokens) ?? num(gUsage.input_tokens) ?? 0; const outputTokens = num(aiUsage.outputTokens) ?? num(aiUsage.completionTokens) ?? num(gUsage.output_tokens) ?? 0; const totalTokens = num(aiUsage.totalTokens) ?? inputTokens + outputTokens; const tokensPerSecond = num(aiResponse.avgOutputTokensPerSecond) ?? (outputTokens > 0 && durationMs > 0 ? Math.round((outputTokens / (durationMs / 1000)) * 10) / 10 : undefined); // AI SDK 7 moves span emission into `@ai-sdk/otel`, which emits OTel GenAI // semantic-convention attributes (gen_ai.input/output.messages, gen_ai.provider.name, // gen_ai.tool.definitions, gen_ai.response.finish_reasons). AI SDK 6 emits the older // `ai.*` keys (ai.prompt.messages, ai.response.text/toolCalls/finishReason). Customers // run either major, so detect the shape and read both. const isV7 = typeof gInput.messages === "string" || typeof gOutput.messages === "string" || typeof gProvider.name === "string"; const toolDefs = parseToolDefinitions(isV7 ? gTool.definitions : aiPrompt.tools); const providerMeta = parseProviderMetadata(aiResponse.providerMetadata); const aiTelemetry = rec(ai.telemetry); const telemetryMetaRaw = rec(aiTelemetry.metadata); const promptMeta = rec(telemetryMetaRaw.prompt); const promptSlug = str(promptMeta.slug); const promptVersion = str(promptMeta.version); const promptModel = str(promptMeta.model); const promptLabels = str(promptMeta.labels); const promptInput = str(promptMeta.input); const telemetryMeta = extractTelemetryMetadata(aiTelemetry.metadata); return { model, provider: str(gProvider.name) ?? str(g.system) ?? str(aiModel.provider) ?? "unknown", operationName: str(gOperation.name) ?? str(ai.operationId) ?? "", responseId: str(gResponse.id) || undefined, finishReason: isV7 ? firstFinishReason(gResponse.finish_reasons) : str(aiResponse.finishReason), serviceTier: providerMeta?.serviceTier, resolvedProvider: providerMeta?.resolvedProvider, toolChoice: parseToolChoice(aiPrompt.toolChoice), toolCount: toolDefs?.length, messageCount: isV7 ? countGenAiMessages(gInput.messages) : countMessages(aiPrompt.messages), telemetryMetadata: telemetryMeta, promptSlug: promptSlug || undefined, promptVersion: promptVersion || undefined, promptModel: promptModel || undefined, promptLabels: promptLabels || undefined, promptInput: promptInput || undefined, inputTokens, outputTokens, totalTokens, cachedTokens: num(aiUsage.cachedInputTokens) ?? num(gUsage.cache_read_input_tokens) ?? num(rec(gUsage.cache_read).input_tokens), cacheCreationTokens: num(aiUsage.cacheCreationInputTokens) ?? num(gUsage.cache_creation_input_tokens) ?? num(rec(gUsage.cache_creation).input_tokens), reasoningTokens: num(aiUsage.reasoningTokens) ?? num(gUsage.reasoning_tokens), tokensPerSecond, msToFirstChunk: num(aiResponse.msToFirstChunk), durationMs, inputCost: num(triggerLlm.input_cost), outputCost: num(triggerLlm.output_cost), totalCost: num(triggerLlm.total_cost), cachedCost: num(triggerLlm.cached_cost), cacheCreationCost: num(triggerLlm.cache_creation_cost), responseText: isV7 ? extractGenAiAssistantText(gOutput.messages) || undefined : str(aiResponse.text) || undefined, responseObject: str(aiResponse.object) || undefined, toolDefinitions: toolDefs, items: isV7 ? buildGenAiDisplayItems(g.system_instructions, gInput.messages, gOutput.messages, toolDefs) : buildDisplayItems(aiPrompt.messages, aiResponse.toolCalls, toolDefs), }; } // --------------------------------------------------------------------------- // Message → DisplayItem transformation // --------------------------------------------------------------------------- type RawMessage = { role: string; content: unknown; toolCallId?: string; name?: string; }; /** * Build display items from prompt messages and optionally response tool calls. * - Parses ai.prompt.messages and merges consecutive tool-call + tool-result pairs * - If ai.response.toolCalls is present (finishReason=tool-calls), appends those too */ function buildDisplayItems( messagesRaw: unknown, responseToolCallsRaw: unknown, toolDefs?: ToolDefinition[] ): DisplayItem[] | undefined { const items = parseMessagesToDisplayItems(messagesRaw); const responseToolCalls = parseResponseToolCalls(responseToolCallsRaw); if (!items && !responseToolCalls) return undefined; const result = items ?? []; if (responseToolCalls && responseToolCalls.length > 0) { result.push({ type: "tool-use", tools: responseToolCalls }); } if (toolDefs && toolDefs.length > 0) { const defsByName = new Map(toolDefs.map((d) => [d.name, d])); for (const item of result) { if (item.type === "tool-use") { for (const tool of item.tools) { const def = defsByName.get(tool.toolName); if (def) { tool.description = def.description; tool.parametersJson = def.parametersJson; } } } } } return result.length > 0 ? result : undefined; } function parseMessagesToDisplayItems(raw: unknown): DisplayItem[] | undefined { if (typeof raw !== "string") return undefined; let messages: RawMessage[]; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return undefined; messages = parsed.map((item: unknown) => { const m = rec(item); return { role: str(m.role) ?? "user", content: m.content, toolCallId: str(m.toolCallId), name: str(m.name), }; }); } catch { return undefined; } const items: DisplayItem[] = []; let i = 0; while (i < messages.length) { const msg = messages[i]; if (msg.role === "system") { items.push({ type: "system", text: extractTextContent(msg.content) }); i++; continue; } if (msg.role === "user") { items.push({ type: "user", text: extractTextContent(msg.content) }); i++; continue; } // Assistant message — check if it contains tool calls if (msg.role === "assistant") { const toolCalls = extractToolCalls(msg.content); if (toolCalls.length > 0) { // Collect subsequent tool result messages that match these tool calls const _toolCallIds = new Set(toolCalls.map((tc) => tc.toolCallId)); let j = i + 1; while (j < messages.length && messages[j].role === "tool") { j++; } // Gather tool result messages between i+1 and j const toolResultMsgs = messages.slice(i + 1, j); // Build ToolUse entries by pairing calls with results const tools: ToolUse[] = toolCalls.map((tc) => { const resultMsg = toolResultMsgs.find((m) => { // Match by toolCallId in the message's content parts const results = extractToolResults(m.content); return results.some((r) => r.toolCallId === tc.toolCallId); }); const result = resultMsg ? extractToolResults(resultMsg.content).find((r) => r.toolCallId === tc.toolCallId) : undefined; return { toolCallId: tc.toolCallId, toolName: tc.toolName, inputJson: JSON.stringify(tc.input, null, 2), resultSummary: result?.summary, resultOutput: result?.formattedOutput, }; }); items.push({ type: "tool-use", tools }); i = j; // skip past the tool result messages continue; } // Assistant message with just text const text = extractTextContent(msg.content); if (text) { items.push({ type: "assistant", text }); } i++; continue; } // Skip any other message types (tool messages that weren't consumed above) i++; } return items.length > 0 ? items : undefined; } // --------------------------------------------------------------------------- // Response tool calls (from ai.response.toolCalls, used when finishReason=tool-calls) // --------------------------------------------------------------------------- /** * Parse ai.response.toolCalls JSON string into ToolUse entries. * These are tool calls the model requested but haven't been executed yet in this span. */ function parseResponseToolCalls(raw: unknown): ToolUse[] | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return undefined; const tools: ToolUse[] = []; for (const item of parsed) { const tc = rec(item); if (tc.type === "tool-call" || tc.toolName || tc.toolCallId) { tools.push({ toolCallId: str(tc.toolCallId) ?? "", toolName: str(tc.toolName) ?? "", inputJson: JSON.stringify( tc.input && typeof tc.input === "object" ? tc.input : {}, null, 2 ), }); } } return tools.length > 0 ? tools : undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // Content part extraction // --------------------------------------------------------------------------- function extractTextContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const texts: string[] = []; for (const raw of content) { const p = rec(raw); if (p.type === "text" && typeof p.text === "string") { texts.push(p.text); } else if (typeof p.text === "string") { texts.push(p.text); } } return texts.join("\n"); } type ParsedToolCall = { toolCallId: string; toolName: string; input: Record; }; function extractToolCalls(content: unknown): ParsedToolCall[] { if (!Array.isArray(content)) return []; const calls: ParsedToolCall[] = []; for (const raw of content) { const p = rec(raw); if (p.type === "tool-call") { calls.push({ toolCallId: str(p.toolCallId) ?? "", toolName: str(p.toolName) ?? "", input: p.input && typeof p.input === "object" ? (p.input as Record) : {}, }); } } return calls; } type ParsedToolResult = { toolCallId: string; toolName: string; summary: string; formattedOutput: string; }; function extractToolResults(content: unknown): ParsedToolResult[] { if (!Array.isArray(content)) return []; const results: ParsedToolResult[] = []; for (const raw of content) { const p = rec(raw); if (p.type === "tool-result") { const { summary, formattedOutput } = summarizeToolOutput(p.output); results.push({ toolCallId: str(p.toolCallId) ?? "", toolName: str(p.toolName) ?? "", summary, formattedOutput, }); } } return results; } /** * Summarize a tool output into a short label and a formatted string for display. * Handles the AI SDK's `{ type: "json", value: { status, contentType, body, truncated } }` shape. */ function summarizeToolOutput(output: unknown): { summary: string; formattedOutput: string } { if (typeof output === "string") { return { summary: output.length > 80 ? output.slice(0, 80) + "..." : output, formattedOutput: output, }; } if (!output || typeof output !== "object") { return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) }; } const o = output as Record; // AI SDK wraps tool results as { type: "json", value: { status, contentType, body, ... } } if (o.type === "json" && o.value && typeof o.value === "object") { const v = o.value as Record; const parts: string[] = []; if (typeof v.status === "number") parts.push(`${v.status}`); if (typeof v.contentType === "string") parts.push(v.contentType); if (v.truncated === true) parts.push("truncated"); return { summary: parts.length > 0 ? parts.join(" · ") : "json result", formattedOutput: JSON.stringify(v, null, 2), }; } return { summary: "result", formattedOutput: JSON.stringify(output, null, 2) }; } // --------------------------------------------------------------------------- // Tool definitions (from ai.prompt.tools) // --------------------------------------------------------------------------- /** * Parse ai.prompt.tools — after the array fix, this arrives as a JSON array string * where each element is itself a JSON string of a tool definition. */ function parseToolDefinitions(raw: unknown): ToolDefinition[] | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return undefined; const defs: ToolDefinition[] = []; for (const item of parsed) { // Each item is either a JSON string or already an object const obj = typeof item === "string" ? JSON.parse(item) : item; if (!obj || typeof obj !== "object") continue; const o = obj as Record; const name = str(o.name); if (!name) continue; const schema = o.parameters ?? o.inputSchema; defs.push({ name, description: str(o.description), parametersJson: schema && typeof schema === "object" ? JSON.stringify(schema, null, 2) : undefined, }); } return defs.length > 0 ? defs : undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // Tool choice parsing // --------------------------------------------------------------------------- function parseToolChoice(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (typeof parsed === "string") return parsed; if (parsed && typeof parsed === "object") { const obj = parsed as Record; if (typeof obj.type === "string") return obj.type; } return undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // Message count // --------------------------------------------------------------------------- function countMessages(raw: unknown): number | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return undefined; return parsed.length > 0 ? parsed.length : undefined; } catch { return undefined; } } // --------------------------------------------------------------------------- // AI SDK 7 — @ai-sdk/otel GenAI semantic-convention shape // --------------------------------------------------------------------------- // // v7 moved span emission out of `ai` core into `@ai-sdk/otel`, which emits OTel // GenAI semantic-convention attributes instead of the v6 `ai.*` keys: // gen_ai.input.messages JSON string: [{ role, parts: [...] }] // gen_ai.output.messages JSON string: [{ role:"assistant", parts, finish_reason }] // gen_ai.system_instructions system prompt (plain string, or [{ type:"text", content }]) // Message parts (per @ai-sdk/otel's convertMessagePartToSemConv): // { type:"text" | "reasoning", content } // { type:"tool_call", id, name, arguments } // { type:"tool_call_response", id, response } // response already unwrapped from the AI SDK envelope // Media / approval / custom parts are not surfaced in the display yet. type GenAiMessage = { role: string; parts: Record[]; finishReason?: string }; function parseGenAiMessages(raw: unknown): GenAiMessage[] | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return undefined; return parsed.map((m) => { const o = rec(m); return { role: str(o.role) ?? "user", parts: Array.isArray(o.parts) ? o.parts.map(rec) : [], finishReason: str(o.finish_reason), }; }); } catch { return undefined; } } /** `gen_ai.response.finish_reasons` arrives as a JSON array string (e.g. `["stop"]`); take the first. */ function firstFinishReason(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { const first = parsed.find((r) => typeof r === "string"); return typeof first === "string" ? first : undefined; } if (typeof parsed === "string") return parsed || undefined; } catch { return raw || undefined; } return undefined; } function countGenAiMessages(raw: unknown): number | undefined { const msgs = parseGenAiMessages(raw); return msgs && msgs.length > 0 ? msgs.length : undefined; } /** Concatenated text of all `text` parts across the assistant output messages. */ function extractGenAiAssistantText(outputRaw: unknown): string { const msgs = parseGenAiMessages(outputRaw); if (!msgs) return ""; const texts: string[] = []; for (const m of msgs) { if (m.role !== "assistant") continue; for (const p of m.parts) { if (p.type === "text" && typeof p.content === "string") texts.push(p.content); } } return texts.join("\n"); } /** Plain text of a message's `text` parts (reasoning parts aren't surfaced yet). */ function genAiMessageText(parts: Record[]): string { const texts: string[] = []; for (const p of parts) { if (p.type === "text" && typeof p.content === "string") texts.push(p.content); } return texts.join("\n"); } /** Parse `gen_ai.system_instructions` (plain string, or a JSON array of `{ type:"text", content }`). */ function parseSystemInstructions(raw: unknown): string | undefined { if (typeof raw !== "string") return undefined; const trimmed = raw.trim(); if (trimmed.startsWith("[")) { try { const parsed = JSON.parse(trimmed); if (Array.isArray(parsed)) { const text = parsed .map((p) => str(rec(p).content)) .filter((t): t is string => Boolean(t)) .join("\n"); return text || undefined; } } catch { // fall through to raw } } return raw || undefined; } /** * Build display items from the v7 GenAI message attributes: the system prompt, * the input message history, and the assistant's output for this span. Assistant * tool_call parts are paired with tool_call_response parts from following tool messages. */ function buildGenAiDisplayItems( systemInstructionsRaw: unknown, inputMessagesRaw: unknown, outputMessagesRaw: unknown, toolDefs?: ToolDefinition[] ): DisplayItem[] | undefined { const items: DisplayItem[] = []; const systemText = parseSystemInstructions(systemInstructionsRaw); if (systemText) items.push({ type: "system", text: systemText }); const messages = [ ...(parseGenAiMessages(inputMessagesRaw) ?? []), ...(parseGenAiMessages(outputMessagesRaw) ?? []), ]; appendGenAiMessages(items, messages); if (toolDefs && toolDefs.length > 0) { const defsByName = new Map(toolDefs.map((d) => [d.name, d])); for (const item of items) { if (item.type === "tool-use") { for (const tool of item.tools) { const def = defsByName.get(tool.toolName); if (def) { tool.description = def.description; tool.parametersJson = def.parametersJson; } } } } } return items.length > 0 ? items : undefined; } function appendGenAiMessages(items: DisplayItem[], messages: GenAiMessage[]): void { let i = 0; while (i < messages.length) { const msg = messages[i]; if (msg.role === "system") { const text = genAiMessageText(msg.parts); if (text) items.push({ type: "system", text }); i++; continue; } if (msg.role === "user") { const text = genAiMessageText(msg.parts); if (text) items.push({ type: "user", text }); i++; continue; } if (msg.role === "assistant") { const text = genAiMessageText(msg.parts); if (text) items.push({ type: "assistant", text }); const toolCalls = msg.parts.filter((p) => p.type === "tool_call"); if (toolCalls.length > 0) { // Collect tool_call_response parts from the tool messages that follow. const responsesById = new Map(); let j = i + 1; while (j < messages.length && messages[j].role === "tool") { for (const p of messages[j].parts) { if (p.type === "tool_call_response") { const id = str(p.id); if (id) responsesById.set(id, p.response); } } j++; } const tools: ToolUse[] = toolCalls.map((tc) => { const id = str(tc.id) ?? ""; let resultSummary: string | undefined; let resultOutput: string | undefined; if (id && responsesById.has(id)) { const summarized = summarizeToolOutput(responsesById.get(id)); resultSummary = summarized.summary; resultOutput = summarized.formattedOutput; } return { toolCallId: id, toolName: str(tc.name) ?? "", inputJson: JSON.stringify(tc.arguments ?? {}, null, 2), resultSummary, resultOutput, }; }); items.push({ type: "tool-use", tools }); i = j; continue; } i++; continue; } // tool-role messages are consumed via the assistant pairing above; skip stragglers. i++; } }