From 2d78382a3d079077bc2201fa0aea29740dec5a80 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 9 Jul 2026 15:25:59 -0400 Subject: [PATCH] feat(core): add stringify helper and make AI-tracing serializers safe Adds an exported stringify to core (string passthrough, else JSON.stringify, never throws) and routes the SDK's span-attribute serializers through it. getJsonString and getTruncatedJsonString previously threw on circular refs / BigInt straight into span.setAttribute with no try/catch, which could crash instrumentation; the safe path returns '[unserializable]' instead. Consolidates safeStringify (server-utils) and langchain's asString too. --- packages/core/src/shared-exports.ts | 2 +- packages/core/src/tracing/ai/utils.ts | 27 +++++--------- .../core/src/tracing/anthropic-ai/utils.ts | 5 +-- .../core/src/tracing/google-genai/index.ts | 4 +-- packages/core/src/tracing/langchain/utils.ts | 36 +++++++------------ packages/core/src/tracing/langgraph/index.ts | 4 +-- packages/core/src/tracing/openai/index.ts | 4 +-- packages/core/src/tracing/vercel-ai/utils.ts | 9 +++-- packages/core/src/utils/string.ts | 24 +++++++++++++ .../lib/tracing/ai-message-truncation.test.ts | 16 +++++++++ .../vercel-ai-request-messages.test.ts | 5 +-- packages/core/test/lib/utils/string.test.ts | 34 +++++++++++++++++- packages/server-utils/src/vercel-ai/util.ts | 12 ------- .../src/vercel-ai/vercel-ai-dc-subscriber.ts | 22 ++++++------ 14 files changed, 120 insertions(+), 84 deletions(-) diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index ffd08180b202..1efdde545c76 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -320,7 +320,7 @@ export { stackParserFromStackParserOptions, stripSentryFramesAndReverse, } from './utils/stacktrace'; -export { isMatchingPattern, safeJoin, snipLine, stringMatchesSomePattern, truncate } from './utils/string'; +export { isMatchingPattern, safeJoin, stringify, snipLine, stringMatchesSomePattern, truncate } from './utils/string'; export { isNativeFunction, supportsDOMException, diff --git a/packages/core/src/tracing/ai/utils.ts b/packages/core/src/tracing/ai/utils.ts index b517049550a0..fd762e61fbe3 100644 --- a/packages/core/src/tracing/ai/utils.ts +++ b/packages/core/src/tracing/ai/utils.ts @@ -192,20 +192,9 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp } /** - * Serialize a value to a JSON string without truncation. - * Strings are returned as-is, arrays and objects are JSON-stringified. - */ -export function getJsonString(value: T | T[]): string { - if (typeof value === 'string') { - return value; - } - return JSON.stringify(value); -} - -/** - * Get the truncated JSON string for a string or array of strings. + * Get the truncated JSON string for a string, an array of messages, or an object. * - * @param value - The string or array of strings to truncate + * @param value - The value to truncate and serialize * @returns The truncated JSON string */ export function getTruncatedJsonString(value: T | T[]): string { @@ -213,13 +202,13 @@ export function getTruncatedJsonString(value: T | T[]): string { // Some values are already JSON strings, so we don't need to duplicate the JSON parsing return truncateGenAiStringInput(value); } - if (Array.isArray(value)) { - // truncateGenAiMessages returns an array of strings, so we need to stringify it - const truncatedMessages = truncateGenAiMessages(value); - return JSON.stringify(truncatedMessages); + // Both truncation (media stripping recurses the value) and `JSON.stringify` can throw on + // circular refs or non-serializable values (e.g. BigInt); never let that crash instrumentation. + try { + return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value); + } catch { + return '[unserializable]'; } - // value is an object, so we need to stringify it - return JSON.stringify(value); } /** diff --git a/packages/core/src/tracing/anthropic-ai/utils.ts b/packages/core/src/tracing/anthropic-ai/utils.ts index df1e28105d74..5fbde951343f 100644 --- a/packages/core/src/tracing/anthropic-ai/utils.ts +++ b/packages/core/src/tracing/anthropic-ai/utils.ts @@ -7,7 +7,8 @@ import { GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; +import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; +import { stringify } from '../../utils/string'; import type { AnthropicAiResponse } from './types'; /** @@ -31,7 +32,7 @@ export function setMessagesAttribute(span: Span, messages: unknown, enableTrunca span.setAttributes({ [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation ? getTruncatedJsonString(filteredMessages) - : getJsonString(filteredMessages), + : stringify(filteredMessages), [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } diff --git a/packages/core/src/tracing/google-genai/index.ts b/packages/core/src/tracing/google-genai/index.ts index ebab26706093..68e4d414586a 100644 --- a/packages/core/src/tracing/google-genai/index.ts +++ b/packages/core/src/tracing/google-genai/index.ts @@ -28,10 +28,10 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import type { InstrumentedMethodEntry } from '../ai/utils'; +import { stringify } from '../../utils/string'; import { buildMethodPath, extractSystemInstructions, - getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, shouldEnableTruncation, @@ -197,7 +197,7 @@ export function addPrivateRequestAttributes( [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation ? getTruncatedJsonString(filteredMessages) - : getJsonString(filteredMessages), + : stringify(filteredMessages), }); } } diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index 6d9a85878636..f5735c5d500a 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -1,5 +1,6 @@ import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes'; import type { SpanAttributeValue } from '../../types/span'; +import { stringify } from '../../utils/string'; import { GEN_AI_AGENT_NAME_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, @@ -27,7 +28,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import { isContentMedia, stripInlineMediaFromSingleMessage } from '../ai/mediaStripping'; -import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; +import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants'; import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types'; @@ -50,19 +51,6 @@ const setNumberIfDefined = (target: Record, key: str if (!Number.isNaN(n)) target[key] = n; }; -/** - * Converts a value to a string. Avoids double-quoted JSON strings where a plain - * string is desired, but still handles objects/arrays safely. - */ -function asString(v: unknown): string { - if (typeof v === 'string') return v; - try { - return JSON.stringify(v); - } catch { - return String(v); - } -} - /** * Converts message content to a string, stripping inline media (base64 images, audio, etc.) * from multimodal content before stringification so downstream media stripping can't miss it. @@ -78,7 +66,7 @@ function asString(v: unknown): string { * ]) * // => '[{"type":"text","text":"What color?"},{"type":"image_url","image_url":{"url":"[Blob substitute]"}}]' * - * // Without this, asString() would JSON.stringify the raw array and the base64 blob + * // Without this, stringification would JSON.stringify the raw array and the base64 blob * // would end up in span attributes, since downstream stripping only works on objects. */ function normalizeContent(v: unknown): string { @@ -92,7 +80,7 @@ function normalizeContent(v: unknown): string { return String(v); } } - return asString(v); + return stringify(v, String); } /** @@ -264,9 +252,9 @@ function baseRequestAttributes( langSmithMetadata?: Record, ): Record { return { - [GEN_AI_SYSTEM_ATTRIBUTE]: asString(system ?? 'langchain'), + [GEN_AI_SYSTEM_ATTRIBUTE]: stringify(system ?? 'langchain', String), [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', - [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: asString(modelName), + [GEN_AI_REQUEST_MODEL_ATTRIBUTE]: stringify(modelName, String), [SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN, ...extractCommonRequestAttributes(serialized, invocationParams, langSmithMetadata), }; @@ -299,7 +287,7 @@ export function extractLLMRequestAttributes( setIfDefined( attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages), + enableTruncation ? getTruncatedJsonString(messages) : stringify(messages), ); } @@ -343,7 +331,7 @@ export function extractChatModelRequestAttributes( setIfDefined( attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), + enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), ); } @@ -378,7 +366,7 @@ function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record } if (toolCalls.length > 0) { - setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, asString(toolCalls)); + setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, stringify(toolCalls, String)); } } @@ -466,7 +454,7 @@ export function extractLlmResponseAttributes( .filter((r): r is string => typeof r === 'string'); if (finishReasons.length > 0) { - setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, asString(finishReasons)); + setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, stringify(finishReasons, String)); } // Tool calls metadata (names, IDs) are not PII, so capture them regardless of recordOutputs @@ -479,7 +467,7 @@ export function extractLlmResponseAttributes( .filter(t => typeof t === 'string'); if (texts.length > 0) { - setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, asString(texts)); + setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, stringify(texts, String)); } } } @@ -506,7 +494,7 @@ export function extractLlmResponseAttributes( // Stop reason: v1 stores this in message.response_metadata.finish_reason const stopReason = llmOutput?.stop_reason ?? v1Message?.response_metadata?.finish_reason; if (stopReason) { - setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(stopReason)); + setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, stringify(stopReason, String)); } return attrs; diff --git a/packages/core/src/tracing/langgraph/index.ts b/packages/core/src/tracing/langgraph/index.ts index eaa4d719323e..d4db0de3a6f3 100644 --- a/packages/core/src/tracing/langgraph/index.ts +++ b/packages/core/src/tracing/langgraph/index.ts @@ -15,11 +15,11 @@ import { } from '../ai/gen-ai-attributes'; import { extractSystemInstructions, - getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, shouldEnableTruncation, } from '../ai/utils'; +import { stringify } from '../../utils/string'; import { createLangChainCallbackHandler } from '../langchain'; import type { BaseChatModel, LangChainMessage } from '../langchain/types'; import { normalizeLangChainMessages } from '../langchain/utils'; @@ -210,7 +210,7 @@ function instrumentCompiledGraphInvoke( span.setAttributes({ [GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation ? getTruncatedJsonString(filteredMessages) - : getJsonString(filteredMessages), + : stringify(filteredMessages), [GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength, }); } diff --git a/packages/core/src/tracing/openai/index.ts b/packages/core/src/tracing/openai/index.ts index f7577cba1aff..821e9c68e0ff 100644 --- a/packages/core/src/tracing/openai/index.ts +++ b/packages/core/src/tracing/openai/index.ts @@ -16,10 +16,10 @@ import { GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; import type { InstrumentedMethodEntry } from '../ai/utils'; +import { stringify } from '../../utils/string'; import { buildMethodPath, extractSystemInstructions, - getJsonString, getTruncatedJsonString, resolveAIRecordingOptions, shouldEnableTruncation, @@ -128,7 +128,7 @@ export function addRequestAttributes( span.setAttribute( GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), + enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), ); if (Array.isArray(filteredMessages)) { diff --git a/packages/core/src/tracing/vercel-ai/utils.ts b/packages/core/src/tracing/vercel-ai/utils.ts index 247cfa5ea3d4..b57a43666202 100644 --- a/packages/core/src/tracing/vercel-ai/utils.ts +++ b/packages/core/src/tracing/vercel-ai/utils.ts @@ -10,7 +10,8 @@ import { GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE, GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE, } from '../ai/gen-ai-attributes'; -import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils'; +import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils'; +import { stringify } from '../../utils/string'; import { toolCallSpanContextMap } from './constants'; import type { TokenSummary, ToolCallSpanContext } from './types'; import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes'; @@ -241,9 +242,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes } const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0; - const messagesJson = enableTruncation - ? getTruncatedJsonString(filteredMessages) - : getJsonString(filteredMessages); + const messagesJson = enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages); span.setAttributes({ [AI_PROMPT_ATTRIBUTE]: messagesJson, @@ -275,7 +274,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes ? originalMessagesJson : enableTruncation ? getTruncatedJsonString(filteredMessages) - : getJsonString(filteredMessages); + : stringify(filteredMessages); span.setAttributes({ [AI_PROMPT_MESSAGES_ATTRIBUTE]: messagesJson, diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 949a1bf4c6df..e56b3175a4ab 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -3,6 +3,30 @@ import { stringifyValue } from './normalize'; export { escapeStringForRegex } from '../vendor/escapeStringForRegex'; +/** + * Coerce a value to a string without ever throwing. Strings pass through unchanged (so an + * already-serialized value isn't double-encoded and a plain string isn't wrapped in quotes); + * anything else is `JSON.stringify`-ed, falling back to `fallback` if that throws (e.g. on + * circular references or `BigInt`). + * + * @param value the value to stringify + * @param fallback returned when serialization throws, or, if a function, called with `value` to + * produce the fallback. Defaults to `'[unserializable]'`. + */ +export function stringify( + value: unknown, + fallback: string | ((value: unknown) => string) = '[unserializable]', +): string { + if (typeof value === 'string') { + return value; + } + try { + return JSON.stringify(value); + } catch { + return typeof fallback === 'function' ? fallback(value) : fallback; + } +} + /** * Truncates given string to the maximum characters count * diff --git a/packages/core/test/lib/tracing/ai-message-truncation.test.ts b/packages/core/test/lib/tracing/ai-message-truncation.test.ts index f1f318e02128..a45d43d49e6c 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/core/test/lib/tracing/ai-message-truncation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { truncateGenAiMessages, truncateGenAiStringInput } from '../../../src/tracing/ai/messageTruncation'; +import { getTruncatedJsonString } from '../../../src/tracing/ai/utils'; describe('message truncation utilities', () => { describe('truncateGenAiMessages', () => { @@ -610,3 +611,18 @@ describe('message truncation utilities', () => { }); }); }); + +describe('getTruncatedJsonString', () => { + it('returns a fallback instead of throwing on circular references', () => { + const circular: Record = { role: 'user', content: 'hi' }; + circular.self = circular; + + expect(getTruncatedJsonString(circular)).toBe('[unserializable]'); + expect(() => getTruncatedJsonString([circular])).not.toThrow(); + }); + + it('serializes normal values as before', () => { + expect(getTruncatedJsonString('hello')).toBe('hello'); + expect(getTruncatedJsonString({ a: 1 })).toBe('{"a":1}'); + }); +}); diff --git a/packages/core/test/lib/tracing/vercel-ai-request-messages.test.ts b/packages/core/test/lib/tracing/vercel-ai-request-messages.test.ts index f2b1a89f961f..b2230c917344 100644 --- a/packages/core/test/lib/tracing/vercel-ai-request-messages.test.ts +++ b/packages/core/test/lib/tracing/vercel-ai-request-messages.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { getJsonString, getTruncatedJsonString } from '../../../src/tracing/ai/utils'; +import { getTruncatedJsonString } from '../../../src/tracing/ai/utils'; +import { stringify } from '../../../src/utils/string'; import { GEN_AI_INPUT_MESSAGES_ATTRIBUTE, GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE, @@ -56,7 +57,7 @@ describe('requestMessagesFromPrompt (ai.prompt.messages string branch)', () => { expect(recorded[GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE]).toBe(JSON.stringify([{ type: 'text', content: 'be nice' }])); // System message removed; output is the SDK's own serialization of just the remainder. - expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(getJsonString([{ role: 'user', content: 'hello' }])); + expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).toBe(stringify([{ role: 'user', content: 'hello' }])); expect(recorded[AI_PROMPT_MESSAGES_ATTRIBUTE]).not.toBe(original); expect(recorded[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]).toBe(1); }); diff --git a/packages/core/test/lib/utils/string.test.ts b/packages/core/test/lib/utils/string.test.ts index ea1d46d2bef7..b45df24327d0 100644 --- a/packages/core/test/lib/utils/string.test.ts +++ b/packages/core/test/lib/utils/string.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { setNormalizeStringifier } from '../../../src'; -import { isMatchingPattern, safeJoin, stringMatchesSomePattern, truncate } from '../../../src/utils/string'; +import { isMatchingPattern, safeJoin, stringify, stringMatchesSomePattern, truncate } from '../../../src/utils/string'; describe('truncate()', () => { test('it works as expected', () => { @@ -195,3 +195,35 @@ describe('safeJoin()', () => { expect(safeJoin(['a', 'b', 'c'], '')).toEqual('abc'); }); }); + +describe('stringify()', () => { + test('passes strings through unchanged (no JSON quoting)', () => { + expect(stringify('hello')).toBe('hello'); + }); + + test('JSON-stringifies non-string values', () => { + expect(stringify({ a: 1 })).toBe('{"a":1}'); + expect(stringify([1, 2])).toBe('[1,2]'); + expect(stringify(42)).toBe('42'); + expect(stringify(null)).toBe('null'); + }); + + test('returns the default fallback on circular references', () => { + const circular: Record = {}; + circular.self = circular; + expect(stringify(circular)).toBe('[unserializable]'); + }); + + test('returns a custom string fallback on failure', () => { + const circular: Record = {}; + circular.self = circular; + expect(stringify(circular, '[oops]')).toBe('[oops]'); + }); + + test('calls a function fallback with the value on failure', () => { + const circular: Record = {}; + circular.self = circular; + expect(stringify(circular, () => 'from-fn')).toBe('from-fn'); + expect(stringify(circular, String)).toBe('[object Object]'); + }); +}); diff --git a/packages/server-utils/src/vercel-ai/util.ts b/packages/server-utils/src/vercel-ai/util.ts index c64dc76a1901..e11945ee71f4 100644 --- a/packages/server-utils/src/vercel-ai/util.ts +++ b/packages/server-utils/src/vercel-ai/util.ts @@ -20,18 +20,6 @@ export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } -/** Stringify a value, passing strings through and falling back to a placeholder on circular/unserializable input. */ -export function safeStringify(value: unknown): string { - if (typeof value === 'string') { - return value; - } - try { - return JSON.stringify(value); - } catch { - return '[unserializable]'; - } -} - /* * Streaming support for the `ai:telemetry` tracing channel. * diff --git a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts index c6493486150b..eebef3d38703 100644 --- a/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts +++ b/packages/server-utils/src/vercel-ai/vercel-ai-dc-subscriber.ts @@ -35,6 +35,7 @@ import { getClient, getProviderMetadataAttributes, getTruncatedJsonString, + stringify, SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN, shouldEnableTruncation, SPAN_STATUS_ERROR, @@ -50,7 +51,6 @@ import { asString, isReadableStream, isRecord, - safeStringify, type StreamedModelCallResult, sum, tapModelCallStream, @@ -399,7 +399,7 @@ export function createSpanFromMessage( const input = type === 'embedMany' ? event.values : event.value; return startGenAiSpan(GEN_AI_EMBEDDINGS_OPERATION, modelId, { ...baseAttributes, - ...(recordInputs && input !== undefined ? { [GEN_AI_EMBEDDINGS_INPUT]: safeStringify(input) } : {}), + ...(recordInputs && input !== undefined ? { [GEN_AI_EMBEDDINGS_INPUT]: stringify(input) } : {}), }); } case 'rerank': @@ -464,9 +464,7 @@ function buildModelCallSpan( ...baseAttributes, [VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId, ...(recordInputs ? buildInputMessageAttributes(event, enableTruncation) : {}), - ...(recordInputs && Array.isArray(event.tools) - ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: safeStringify(event.tools) } - : {}), + ...(recordInputs && Array.isArray(event.tools) ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: stringify(event.tools) } : {}), }); } @@ -485,7 +483,7 @@ function buildToolSpan(event: Record, recordInputs: boolean): S ...(toolName ? { [GEN_AI_TOOL_NAME]: toolName } : {}), ...(toolCallId ? { [GEN_AI_TOOL_CALL_ID_ATTRIBUTE]: toolCallId } : {}), ...(description ? { [GEN_AI_TOOL_DESCRIPTION_ATTRIBUTE]: description } : {}), - ...(recordInputs && toolInput !== undefined ? { [GEN_AI_TOOL_INPUT]: safeStringify(toolInput) } : {}), + ...(recordInputs && toolInput !== undefined ? { [GEN_AI_TOOL_INPUT]: stringify(toolInput) } : {}), }); } @@ -508,7 +506,7 @@ export function enrichSpanOnEnd( if (type === 'executeTool') { if (recordOutputs) { - span.setAttribute(GEN_AI_TOOL_OUTPUT, safeStringify(result.output ?? result)); + span.setAttribute(GEN_AI_TOOL_OUTPUT, stringify(result.output ?? result)); } // From V5 on, tool errors are not rejected (so the `error` channel verb never fires) — they // surface as `tool-error` content on the resolved result. Mirror the OTel path by marking the @@ -542,7 +540,7 @@ export function enrichSpanOnEnd( // on the top-level `invoke_agent` span. const finishReason = getFinishReason(result); if (finishReason && type === 'languageModelCall') { - span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, safeStringify([finishReason])); + span.setAttribute(GEN_AI_RESPONSE_FINISH_REASONS, stringify([finishReason])); } const response = isRecord(result.response) ? result.response : undefined; @@ -611,7 +609,7 @@ function buildOutputMessages( if (!parts.length) { return undefined; } - return safeStringify([{ role: 'assistant', parts, finish_reason: normalizeFinishReason(finishReason) }]); + return stringify([{ role: 'assistant', parts, finish_reason: normalizeFinishReason(finishReason) }]); } function toolCallPart(toolCall: Record): Record { @@ -620,7 +618,7 @@ function toolCallPart(toolCall: Record): Record