Skip to content
Draft
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 @@ -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,
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
36 changes: 12 additions & 24 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 @@ -50,19 +51,6 @@ const setNumberIfDefined = (target: Record<string, SpanAttributeValue>, 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.
Expand All @@ -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 {
Expand All @@ -92,7 +80,7 @@ function normalizeContent(v: unknown): string {
return String(v);
}
}
return asString(v);
return stringify(v, String);
}

/**
Expand Down Expand Up @@ -264,9 +252,9 @@ function baseRequestAttributes(
langSmithMetadata?: Record<string, unknown>,
): Record<string, SpanAttributeValue> {
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 Down Expand Up @@ -299,7 +287,7 @@ export function extractLLMRequestAttributes(
setIfDefined(
attrs,
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages),
enableTruncation ? getTruncatedJsonString(messages) : stringify(messages),
);
}

Expand Down Expand Up @@ -343,7 +331,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 +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));
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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));
}
}
}
Expand All @@ -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;
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
24 changes: 24 additions & 0 deletions packages/core/src/utils/string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
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])).not.toThrow();
});

it('serializes normal values as before', () => {
expect(getTruncatedJsonString('hello')).toBe('hello');
expect(getTruncatedJsonString({ a: 1 })).toBe('{"a":1}');
});
});
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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);
});
Expand Down
Loading
Loading