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..0f95464788da 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'; @@ -37,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; }; @@ -45,24 +46,15 @@ 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; }; -/** - * 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,10 +70,10 @@ 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 { +function normalizeContent(v: unknown): string | undefined { if (Array.isArray(v)) { try { const stripped = v.map(part => @@ -92,7 +84,7 @@ function normalizeContent(v: unknown): string { return String(v); } } - return asString(v); + return stringify(v, String); } /** @@ -148,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; @@ -262,11 +256,11 @@ function baseRequestAttributes( serialized: LangChainSerialized, invocationParams?: Record, langSmithMetadata?: Record, -): 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), }; @@ -287,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'; @@ -299,7 +293,7 @@ export function extractLLMRequestAttributes( setIfDefined( attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages), + enableTruncation ? getTruncatedJsonString(messages) : stringify(messages), ); } @@ -322,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'; @@ -343,7 +337,7 @@ export function extractChatModelRequestAttributes( setIfDefined( attrs, GEN_AI_INPUT_MESSAGES_ATTRIBUTE, - enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages), + enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages), ); } @@ -378,7 +372,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 +460,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 +473,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 +500,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..46847583c6af 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -3,6 +3,31 @@ 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`). 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 + * produce the fallback. Defaults to `'[unserializable]'`. + */ +export function stringify( + value: unknown, + fallback: string | ((value: unknown) => string) = '[unserializable]', +): string | undefined { + 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..951dc9be6a15 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])).toBe('[unserializable]'); + }); + + 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..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, @@ -42,19 +42,12 @@ import { spanToJSON, spanToTraceContext, startInactiveSpan, + stringify, withScope, } from '@sentry/core'; import type { TracingChannel } from 'node:diagnostics_channel'; import { bindTracingChannelToSpan } from '../tracing-channel'; -import { - asNumber, - asString, - isReadableStream, - safeStringify, - 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 @@ -411,7 +404,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': @@ -422,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}`, @@ -435,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, @@ -462,7 +453,7 @@ function buildInvokeAgentSpan( function buildModelCallSpan( event: Record, - baseAttributes: Attributes, + baseAttributes: SpanAttributes, recordInputs: boolean, enableTruncation: boolean, callId: string | undefined, @@ -476,9 +467,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) } : {}), }); } @@ -497,7 +486,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 +509,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 +544,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 +613,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 +622,7 @@ function toolCallPart(toolCall: Record): 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 // `gen_ai.system_instructions` as `[{ type: 'text', content }]`; mirror that shape here. const instructions = asString(event.instructions); if (instructions) { - attributes[GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE] = safeStringify([{ type: 'text', content: instructions }]); + attributes[GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE] = stringify([{ type: 'text', content: instructions }]); } // The AI SDK start events extend `StandardizedPrompt`; messages live on `messages`, otherwise the // simpler `prompt` field is used. const messages = event.messages ?? event.prompt; if (messages !== undefined) { - attributes[GEN_AI_INPUT_MESSAGES] = enableTruncation ? getTruncatedJsonString(messages) : safeStringify(messages); + attributes[GEN_AI_INPUT_MESSAGES] = enableTruncation ? getTruncatedJsonString(messages) : stringify(messages); // The original (pre-truncation) message count, so the product can show how many were dropped. attributes[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE] = Array.isArray(messages) ? messages.length : 1; }