From b6cc26f8298e2af665f61df0b68164ae38f6888a Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Thu, 9 Jul 2026 15:25:59 -0400 Subject: [PATCH 1/4] 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 | 20 +++++------ 14 files changed, 120 insertions(+), 82 deletions(-) diff --git a/packages/core/src/shared-exports.ts b/packages/core/src/shared-exports.ts index 143fca90a5c6..647886d41199 100644 --- a/packages/core/src/shared-exports.ts +++ b/packages/core/src/shared-exports.ts @@ -322,7 +322,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 88edb7b5c6a6..230e6cd26eba 100644 --- a/packages/server-utils/src/vercel-ai/util.ts +++ b/packages/server-utils/src/vercel-ai/util.ts @@ -17,18 +17,6 @@ export function sum(a: number | undefined, b: number | undefined): number | unde return a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0); } -/** 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 52cb19a5ea1d..0de4e4f118c7 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 @@ -42,6 +42,7 @@ import { spanToJSON, spanToTraceContext, startInactiveSpan, + stringify, withScope, } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; @@ -50,7 +51,6 @@ import { asNumber, asString, isReadableStream, - safeStringify, type StreamedModelCallResult, sum, tapModelCallStream, @@ -411,7 +411,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': @@ -477,7 +477,7 @@ function buildModelCallSpan( [VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId, ...(recordInputs ? buildInputMessageAttributes(event, enableTruncation) : {}), ...(recordInputs && Array.isArray(event.tools) - ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: safeStringify(event.tools) } + ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: stringify(event.tools) } : {}), }); } @@ -497,7 +497,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) } : {}), }); } @@ -520,7 +520,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 @@ -555,7 +555,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 = isObjectLike(result.response) ? result.response : undefined; @@ -624,7 +624,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 { @@ -633,7 +633,7 @@ function toolCallPart(toolCall: Record): Record Date: Mon, 13 Jul 2026 11:40:34 -0400 Subject: [PATCH 2/4] fix: reformat --- .../src/vercel-ai/vercel-ai-dc-subscriber.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) 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 0de4e4f118c7..d92b1e64e480 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 @@ -47,14 +47,7 @@ import { } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { bindTracingChannelToSpan } from '../tracing-channel'; -import { - asNumber, - asString, - isReadableStream, - type StreamedModelCallResult, - sum, - tapModelCallStream, -} from './util'; +import { asNumber, asString, isReadableStream, type StreamedModelCallResult, sum, tapModelCallStream } from './util'; /** * The single tracing channel the `ai` package (>= 7) publishes all telemetry lifecycle events to @@ -476,9 +469,7 @@ function buildModelCallSpan( ...baseAttributes, [VERCEL_AI_OPERATION_ID_ATTRIBUTE]: operationId, ...(recordInputs ? buildInputMessageAttributes(event, enableTruncation) : {}), - ...(recordInputs && Array.isArray(event.tools) - ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: stringify(event.tools) } - : {}), + ...(recordInputs && Array.isArray(event.tools) ? { [GEN_AI_REQUEST_AVAILABLE_TOOLS]: stringify(event.tools) } : {}), }); } From 8dab3c0b258b05e32a85bc6dadd22fa0698b6df3 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Jul 2026 13:40:23 -0400 Subject: [PATCH 3/4] fix(core): make stringify return type honest (string | undefined) JSON.stringify returns undefined (not a throw) for top-level undefined, functions, and symbols, so stringify could return undefined despite its string annotation. Widen the return type to string | undefined and let it propagate: undefined means "nothing to serialize", which naturally omits the attribute, while the fallback stays reserved for real throws (circular refs, BigInt). Propagates through the langchain attribute builders and swaps the vercel-ai subscriber's bespoke Attributes type for the shared SpanAttributes, which already permits undefined. No runtime behavior change. --- packages/core/src/tracing/langchain/utils.ts | 20 ++++++++++++------- packages/core/src/utils/string.ts | 5 +++-- .../src/vercel-ai/vercel-ai-dc-subscriber.ts | 14 ++++++------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/core/src/tracing/langchain/utils.ts b/packages/core/src/tracing/langchain/utils.ts index f5735c5d500a..0f95464788da 100644 --- a/packages/core/src/tracing/langchain/utils.ts +++ b/packages/core/src/tracing/langchain/utils.ts @@ -38,7 +38,7 @@ import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from ' * We keep this tiny helper because call sites are repetitive and easy to miswrite. * It also preserves falsy-but-valid values like `0` and `""`. */ -const setIfDefined = (target: Record, key: string, value: unknown): void => { +const setIfDefined = (target: Record, key: string, value: unknown): void => { if (value != null) target[key] = value as SpanAttributeValue; }; @@ -46,7 +46,11 @@ const setIfDefined = (target: Record, key: string, v * Like `setIfDefined`, but converts the value with `Number()` and skips only when the * result is `NaN`. This ensures numeric 0 makes it through (unlike truthy checks). */ -const setNumberIfDefined = (target: Record, key: string, value: unknown): void => { +const setNumberIfDefined = ( + target: Record, + key: string, + value: unknown, +): void => { const n = Number(value); if (!Number.isNaN(n)) target[key] = n; }; @@ -69,7 +73,7 @@ const setNumberIfDefined = (target: Record, key: str * // 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 { +function normalizeContent(v: unknown): string | undefined { if (Array.isArray(v)) { try { const stripped = v.map(part => @@ -136,7 +140,9 @@ export function getInvocationParams(tags?: string[] | Record): * @param messages Mixed LangChain messages * @returns Array of normalized `{ role, content }` */ -export function normalizeLangChainMessages(messages: LangChainMessage[]): Array<{ role: string; content: string }> { +export function normalizeLangChainMessages( + messages: LangChainMessage[], +): Array<{ role: string; content: string | undefined }> { return messages.map(message => { // 1) Prefer _getType() when present const maybeGetType = (message as { _getType?: () => string })._getType; @@ -250,7 +256,7 @@ function baseRequestAttributes( serialized: LangChainSerialized, invocationParams?: Record, langSmithMetadata?: Record, -): Record { +): Record { return { [GEN_AI_SYSTEM_ATTRIBUTE]: stringify(system ?? 'langchain', String), [GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat', @@ -275,7 +281,7 @@ export function extractLLMRequestAttributes( enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, -): Record { +): Record { const system = langSmithMetadata?.ls_provider; const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; @@ -310,7 +316,7 @@ export function extractChatModelRequestAttributes( enableTruncation: boolean, invocationParams?: Record, langSmithMetadata?: Record, -): Record { +): Record { const system = langSmithMetadata?.ls_provider ?? llm.id?.[2]; const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown'; diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index e56b3175a4ab..46847583c6af 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -7,7 +7,8 @@ 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`). + * circular references or `BigInt`). Returns `undefined` for values `JSON.stringify` itself drops + * (top-level `undefined`, functions, symbols), matching `JSON.stringify`'s own runtime behavior. * * @param value the value to stringify * @param fallback returned when serialization throws, or, if a function, called with `value` to @@ -16,7 +17,7 @@ export { escapeStringForRegex } from '../vendor/escapeStringForRegex'; export function stringify( value: unknown, fallback: string | ((value: unknown) => string) = '[unserializable]', -): string { +): string | undefined { if (typeof value === 'string') { return value; } 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 d92b1e64e480..0b9e7f39e101 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 @@ -26,7 +26,7 @@ import { GEN_AI_USAGE_TOTAL_TOKENS, } from '@sentry/conventions/attributes'; import { GEN_AI_EXECUTE_TOOL_SPAN_OP, GEN_AI_INVOKE_AGENT_SPAN_OP } from '@sentry/conventions/op'; -import type { Span } from '@sentry/core'; +import type { Span, SpanAttributes } from '@sentry/core'; import { captureException, GEN_AI_CONVERSATION_ID_ATTRIBUTE, @@ -415,10 +415,8 @@ export function createSpanFromMessage( } } -type Attributes = Record; - /** Start a `gen_ai.` span named ` ` (or just `` when no suffix). */ -function startGenAiSpan(operation: string, suffix: string | undefined, attributes: Attributes): Span { +function startGenAiSpan(operation: string, suffix: string | undefined, attributes: SpanAttributes): Span { return startInactiveSpan({ name: suffix ? `${operation} ${suffix}` : operation, op: `gen_ai.${operation}`, @@ -428,7 +426,7 @@ function startGenAiSpan(operation: string, suffix: string | undefined, attribute function buildInvokeAgentSpan( event: Record, - baseAttributes: Attributes, + baseAttributes: SpanAttributes, recordInputs: boolean, enableTruncation: boolean, callId: string | undefined, @@ -455,7 +453,7 @@ function buildInvokeAgentSpan( function buildModelCallSpan( event: Record, - baseAttributes: Attributes, + baseAttributes: SpanAttributes, recordInputs: boolean, enableTruncation: boolean, callId: string | undefined, @@ -726,8 +724,8 @@ function resolveRecording(integrationOption: unknown, perCallOption: unknown, gl function buildInputMessageAttributes( event: Record, enableTruncation: boolean, -): Record { - const attributes: Record = {}; +): Record { + const attributes: Record = {}; // `ai` >= 7 forbids system messages in `messages`/`prompt` and exposes the system prompt as a // separate `instructions` field. The OTel path lifts the system message out of the prompt into From 4ab1ca0889be567275f0b0e7cb758764e0426204 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 13 Jul 2026 13:52:18 -0400 Subject: [PATCH 4/4] test(core): assert the circular-array path returns the fallback The array case routes through truncateGenAiMessages (a different code path from a bare object) and only asserted it didn't throw, leaving the actual fallback value unchecked. Assert it returns '[unserializable]' too. --- packages/core/test/lib/tracing/ai-message-truncation.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a45d43d49e6c..951dc9be6a15 100644 --- a/packages/core/test/lib/tracing/ai-message-truncation.test.ts +++ b/packages/core/test/lib/tracing/ai-message-truncation.test.ts @@ -618,7 +618,7 @@ describe('getTruncatedJsonString', () => { circular.self = circular; expect(getTruncatedJsonString(circular)).toBe('[unserializable]'); - expect(() => getTruncatedJsonString([circular])).not.toThrow(); + expect(getTruncatedJsonString([circular])).toBe('[unserializable]'); }); it('serializes normal values as before', () => {