Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/shared-exports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 8 additions & 19 deletions packages/core/src/tracing/ai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,34 +192,23 @@ 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<T>(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<T>(value: T | T[]): string {
if (typeof value === '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);
}

/**
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/tracing/anthropic-ai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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,
});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tracing/google-genai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
});
}
}
Expand Down
56 changes: 25 additions & 31 deletions packages/core/src/tracing/langchain/utils.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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';

Expand All @@ -37,32 +38,23 @@ 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<string, SpanAttributeValue>, key: string, value: unknown): void => {
const setIfDefined = (target: Record<string, SpanAttributeValue | undefined>, key: string, value: unknown): void => {
if (value != null) target[key] = value as SpanAttributeValue;
};

/**
* 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<string, SpanAttributeValue>, key: string, value: unknown): void => {
const setNumberIfDefined = (
target: Record<string, SpanAttributeValue | undefined>,
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.
Expand All @@ -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 =>
Expand All @@ -92,7 +84,7 @@ function normalizeContent(v: unknown): string {
return String(v);
}
}
return asString(v);
return stringify(v, String);
}

/**
Expand Down Expand Up @@ -148,7 +140,9 @@ export function getInvocationParams(tags?: string[] | Record<string, unknown>):
* @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;
Expand Down Expand Up @@ -262,11 +256,11 @@ function baseRequestAttributes(
serialized: LangChainSerialized,
invocationParams?: Record<string, unknown>,
langSmithMetadata?: Record<string, unknown>,
): Record<string, SpanAttributeValue> {
): Record<string, SpanAttributeValue | undefined> {
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),
};
Expand All @@ -287,7 +281,7 @@ export function extractLLMRequestAttributes(
enableTruncation: boolean,
invocationParams?: Record<string, unknown>,
langSmithMetadata?: Record<string, unknown>,
): Record<string, SpanAttributeValue> {
): Record<string, SpanAttributeValue | undefined> {
const system = langSmithMetadata?.ls_provider;
const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown';

Expand All @@ -299,7 +293,7 @@ export function extractLLMRequestAttributes(
setIfDefined(
attrs,
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages),
enableTruncation ? getTruncatedJsonString(messages) : stringify(messages),
);
}

Expand All @@ -322,7 +316,7 @@ export function extractChatModelRequestAttributes(
enableTruncation: boolean,
invocationParams?: Record<string, unknown>,
langSmithMetadata?: Record<string, unknown>,
): Record<string, SpanAttributeValue> {
): Record<string, SpanAttributeValue | undefined> {
const system = langSmithMetadata?.ls_provider ?? llm.id?.[2];
const modelName = invocationParams?.model ?? langSmithMetadata?.ls_model_name ?? 'unknown';

Expand All @@ -343,7 +337,7 @@ export function extractChatModelRequestAttributes(
setIfDefined(
attrs,
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages),
enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages),
);
}

Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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));
}
}
}
Expand All @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tracing/langgraph/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
});
}
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/tracing/openai/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand Down
9 changes: 4 additions & 5 deletions packages/core/src/tracing/vercel-ai/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
sentry[bot] marked this conversation as resolved.
}
}
Comment thread
logaretm marked this conversation as resolved.
Comment thread
logaretm marked this conversation as resolved.

/**
* Truncates given string to the maximum characters count
*
Expand Down
16 changes: 16 additions & 0 deletions packages/core/test/lib/tracing/ai-message-truncation.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -610,3 +611,18 @@ describe('message truncation utilities', () => {
});
});
});

describe('getTruncatedJsonString', () => {
it('returns a fallback instead of throwing on circular references', () => {
const circular: Record<string, unknown> = { role: 'user', content: 'hi' };
circular.self = circular;

expect(getTruncatedJsonString(circular)).toBe('[unserializable]');
expect(getTruncatedJsonString([circular])).toBe('[unserializable]');
});
Comment thread
cursor[bot] marked this conversation as resolved.

it('serializes normal values as before', () => {
expect(getTruncatedJsonString('hello')).toBe('hello');
expect(getTruncatedJsonString({ a: 1 })).toBe('{"a":1}');
});
});
Loading
Loading