Skip to content

Commit 2d78382

Browse files
committed
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.
1 parent 48b1434 commit 2d78382

14 files changed

Lines changed: 120 additions & 84 deletions

File tree

packages/core/src/shared-exports.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ export {
320320
stackParserFromStackParserOptions,
321321
stripSentryFramesAndReverse,
322322
} from './utils/stacktrace';
323-
export { isMatchingPattern, safeJoin, snipLine, stringMatchesSomePattern, truncate } from './utils/string';
323+
export { isMatchingPattern, safeJoin, stringify, snipLine, stringMatchesSomePattern, truncate } from './utils/string';
324324
export {
325325
isNativeFunction,
326326
supportsDOMException,

packages/core/src/tracing/ai/utils.ts

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -192,34 +192,23 @@ export function endStreamSpan(span: Span, state: StreamResponseState, recordOutp
192192
}
193193

194194
/**
195-
* Serialize a value to a JSON string without truncation.
196-
* Strings are returned as-is, arrays and objects are JSON-stringified.
197-
*/
198-
export function getJsonString<T>(value: T | T[]): string {
199-
if (typeof value === 'string') {
200-
return value;
201-
}
202-
return JSON.stringify(value);
203-
}
204-
205-
/**
206-
* Get the truncated JSON string for a string or array of strings.
195+
* Get the truncated JSON string for a string, an array of messages, or an object.
207196
*
208-
* @param value - The string or array of strings to truncate
197+
* @param value - The value to truncate and serialize
209198
* @returns The truncated JSON string
210199
*/
211200
export function getTruncatedJsonString<T>(value: T | T[]): string {
212201
if (typeof value === 'string') {
213202
// Some values are already JSON strings, so we don't need to duplicate the JSON parsing
214203
return truncateGenAiStringInput(value);
215204
}
216-
if (Array.isArray(value)) {
217-
// truncateGenAiMessages returns an array of strings, so we need to stringify it
218-
const truncatedMessages = truncateGenAiMessages(value);
219-
return JSON.stringify(truncatedMessages);
205+
// Both truncation (media stripping recurses the value) and `JSON.stringify` can throw on
206+
// circular refs or non-serializable values (e.g. BigInt); never let that crash instrumentation.
207+
try {
208+
return JSON.stringify(Array.isArray(value) ? truncateGenAiMessages(value) : value);
209+
} catch {
210+
return '[unserializable]';
220211
}
221-
// value is an object, so we need to stringify it
222-
return JSON.stringify(value);
223212
}
224213

225214
/**

packages/core/src/tracing/anthropic-ai/utils.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import {
77
GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE,
88
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
99
} from '../ai/gen-ai-attributes';
10-
import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils';
10+
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
11+
import { stringify } from '../../utils/string';
1112
import type { AnthropicAiResponse } from './types';
1213

1314
/**
@@ -31,7 +32,7 @@ export function setMessagesAttribute(span: Span, messages: unknown, enableTrunca
3132
span.setAttributes({
3233
[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation
3334
? getTruncatedJsonString(filteredMessages)
34-
: getJsonString(filteredMessages),
35+
: stringify(filteredMessages),
3536
[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength,
3637
});
3738
}

packages/core/src/tracing/google-genai/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,10 @@ import {
2828
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
2929
} from '../ai/gen-ai-attributes';
3030
import type { InstrumentedMethodEntry } from '../ai/utils';
31+
import { stringify } from '../../utils/string';
3132
import {
3233
buildMethodPath,
3334
extractSystemInstructions,
34-
getJsonString,
3535
getTruncatedJsonString,
3636
resolveAIRecordingOptions,
3737
shouldEnableTruncation,
@@ -197,7 +197,7 @@ export function addPrivateRequestAttributes(
197197
[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength,
198198
[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation
199199
? getTruncatedJsonString(filteredMessages)
200-
: getJsonString(filteredMessages),
200+
: stringify(filteredMessages),
201201
});
202202
}
203203
}

packages/core/src/tracing/langchain/utils.ts

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN } from '../../semanticAttributes';
22
import type { SpanAttributeValue } from '../../types/span';
3+
import { stringify } from '../../utils/string';
34
import {
45
GEN_AI_AGENT_NAME_ATTRIBUTE,
56
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
@@ -27,7 +28,7 @@ import {
2728
GEN_AI_USAGE_TOTAL_TOKENS_ATTRIBUTE,
2829
} from '../ai/gen-ai-attributes';
2930
import { isContentMedia, stripInlineMediaFromSingleMessage } from '../ai/mediaStripping';
30-
import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils';
31+
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
3132
import { LANGCHAIN_ORIGIN, ROLE_MAP } from './constants';
3233
import type { LangChainLLMResult, LangChainMessage, LangChainSerialized } from './types';
3334

@@ -50,19 +51,6 @@ const setNumberIfDefined = (target: Record<string, SpanAttributeValue>, key: str
5051
if (!Number.isNaN(n)) target[key] = n;
5152
};
5253

53-
/**
54-
* Converts a value to a string. Avoids double-quoted JSON strings where a plain
55-
* string is desired, but still handles objects/arrays safely.
56-
*/
57-
function asString(v: unknown): string {
58-
if (typeof v === 'string') return v;
59-
try {
60-
return JSON.stringify(v);
61-
} catch {
62-
return String(v);
63-
}
64-
}
65-
6654
/**
6755
* Converts message content to a string, stripping inline media (base64 images, audio, etc.)
6856
* from multimodal content before stringification so downstream media stripping can't miss it.
@@ -78,7 +66,7 @@ function asString(v: unknown): string {
7866
* ])
7967
* // => '[{"type":"text","text":"What color?"},{"type":"image_url","image_url":{"url":"[Blob substitute]"}}]'
8068
*
81-
* // Without this, asString() would JSON.stringify the raw array and the base64 blob
69+
* // Without this, stringification would JSON.stringify the raw array and the base64 blob
8270
* // would end up in span attributes, since downstream stripping only works on objects.
8371
*/
8472
function normalizeContent(v: unknown): string {
@@ -92,7 +80,7 @@ function normalizeContent(v: unknown): string {
9280
return String(v);
9381
}
9482
}
95-
return asString(v);
83+
return stringify(v, String);
9684
}
9785

9886
/**
@@ -264,9 +252,9 @@ function baseRequestAttributes(
264252
langSmithMetadata?: Record<string, unknown>,
265253
): Record<string, SpanAttributeValue> {
266254
return {
267-
[GEN_AI_SYSTEM_ATTRIBUTE]: asString(system ?? 'langchain'),
255+
[GEN_AI_SYSTEM_ATTRIBUTE]: stringify(system ?? 'langchain', String),
268256
[GEN_AI_OPERATION_NAME_ATTRIBUTE]: 'chat',
269-
[GEN_AI_REQUEST_MODEL_ATTRIBUTE]: asString(modelName),
257+
[GEN_AI_REQUEST_MODEL_ATTRIBUTE]: stringify(modelName, String),
270258
[SEMANTIC_ATTRIBUTE_SENTRY_ORIGIN]: LANGCHAIN_ORIGIN,
271259
...extractCommonRequestAttributes(serialized, invocationParams, langSmithMetadata),
272260
};
@@ -299,7 +287,7 @@ export function extractLLMRequestAttributes(
299287
setIfDefined(
300288
attrs,
301289
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
302-
enableTruncation ? getTruncatedJsonString(messages) : getJsonString(messages),
290+
enableTruncation ? getTruncatedJsonString(messages) : stringify(messages),
303291
);
304292
}
305293

@@ -343,7 +331,7 @@ export function extractChatModelRequestAttributes(
343331
setIfDefined(
344332
attrs,
345333
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
346-
enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages),
334+
enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages),
347335
);
348336
}
349337

@@ -378,7 +366,7 @@ function addToolCallsAttributes(generations: LangChainMessage[][], attrs: Record
378366
}
379367

380368
if (toolCalls.length > 0) {
381-
setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, asString(toolCalls));
369+
setIfDefined(attrs, GEN_AI_RESPONSE_TOOL_CALLS_ATTRIBUTE, stringify(toolCalls, String));
382370
}
383371
}
384372

@@ -466,7 +454,7 @@ export function extractLlmResponseAttributes(
466454
.filter((r): r is string => typeof r === 'string');
467455

468456
if (finishReasons.length > 0) {
469-
setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, asString(finishReasons));
457+
setIfDefined(attrs, GEN_AI_RESPONSE_FINISH_REASONS_ATTRIBUTE, stringify(finishReasons, String));
470458
}
471459

472460
// Tool calls metadata (names, IDs) are not PII, so capture them regardless of recordOutputs
@@ -479,7 +467,7 @@ export function extractLlmResponseAttributes(
479467
.filter(t => typeof t === 'string');
480468

481469
if (texts.length > 0) {
482-
setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, asString(texts));
470+
setIfDefined(attrs, GEN_AI_RESPONSE_TEXT_ATTRIBUTE, stringify(texts, String));
483471
}
484472
}
485473
}
@@ -506,7 +494,7 @@ export function extractLlmResponseAttributes(
506494
// Stop reason: v1 stores this in message.response_metadata.finish_reason
507495
const stopReason = llmOutput?.stop_reason ?? v1Message?.response_metadata?.finish_reason;
508496
if (stopReason) {
509-
setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, asString(stopReason));
497+
setIfDefined(attrs, GEN_AI_RESPONSE_STOP_REASON_ATTRIBUTE, stringify(stopReason, String));
510498
}
511499

512500
return attrs;

packages/core/src/tracing/langgraph/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import {
1515
} from '../ai/gen-ai-attributes';
1616
import {
1717
extractSystemInstructions,
18-
getJsonString,
1918
getTruncatedJsonString,
2019
resolveAIRecordingOptions,
2120
shouldEnableTruncation,
2221
} from '../ai/utils';
22+
import { stringify } from '../../utils/string';
2323
import { createLangChainCallbackHandler } from '../langchain';
2424
import type { BaseChatModel, LangChainMessage } from '../langchain/types';
2525
import { normalizeLangChainMessages } from '../langchain/utils';
@@ -210,7 +210,7 @@ function instrumentCompiledGraphInvoke(
210210
span.setAttributes({
211211
[GEN_AI_INPUT_MESSAGES_ATTRIBUTE]: enableTruncation
212212
? getTruncatedJsonString(filteredMessages)
213-
: getJsonString(filteredMessages),
213+
: stringify(filteredMessages),
214214
[GEN_AI_INPUT_MESSAGES_ORIGINAL_LENGTH_ATTRIBUTE]: filteredLength,
215215
});
216216
}

packages/core/src/tracing/openai/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ import {
1616
GEN_AI_SYSTEM_INSTRUCTIONS_ATTRIBUTE,
1717
} from '../ai/gen-ai-attributes';
1818
import type { InstrumentedMethodEntry } from '../ai/utils';
19+
import { stringify } from '../../utils/string';
1920
import {
2021
buildMethodPath,
2122
extractSystemInstructions,
22-
getJsonString,
2323
getTruncatedJsonString,
2424
resolveAIRecordingOptions,
2525
shouldEnableTruncation,
@@ -128,7 +128,7 @@ export function addRequestAttributes(
128128

129129
span.setAttribute(
130130
GEN_AI_INPUT_MESSAGES_ATTRIBUTE,
131-
enableTruncation ? getTruncatedJsonString(filteredMessages) : getJsonString(filteredMessages),
131+
enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages),
132132
);
133133

134134
if (Array.isArray(filteredMessages)) {

packages/core/src/tracing/vercel-ai/utils.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
GEN_AI_USAGE_INPUT_TOKENS_ATTRIBUTE,
1111
GEN_AI_USAGE_OUTPUT_TOKENS_ATTRIBUTE,
1212
} from '../ai/gen-ai-attributes';
13-
import { extractSystemInstructions, getJsonString, getTruncatedJsonString } from '../ai/utils';
13+
import { extractSystemInstructions, getTruncatedJsonString } from '../ai/utils';
14+
import { stringify } from '../../utils/string';
1415
import { toolCallSpanContextMap } from './constants';
1516
import type { TokenSummary, ToolCallSpanContext } from './types';
1617
import { AI_PROMPT_ATTRIBUTE, AI_PROMPT_MESSAGES_ATTRIBUTE } from './vercel-ai-attributes';
@@ -241,9 +242,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes
241242
}
242243

243244
const filteredLength = Array.isArray(filteredMessages) ? filteredMessages.length : 0;
244-
const messagesJson = enableTruncation
245-
? getTruncatedJsonString(filteredMessages)
246-
: getJsonString(filteredMessages);
245+
const messagesJson = enableTruncation ? getTruncatedJsonString(filteredMessages) : stringify(filteredMessages);
247246

248247
span.setAttributes({
249248
[AI_PROMPT_ATTRIBUTE]: messagesJson,
@@ -275,7 +274,7 @@ export function requestMessagesFromPrompt(span: Span, attributes: SpanAttributes
275274
? originalMessagesJson
276275
: enableTruncation
277276
? getTruncatedJsonString(filteredMessages)
278-
: getJsonString(filteredMessages);
277+
: stringify(filteredMessages);
279278

280279
span.setAttributes({
281280
[AI_PROMPT_MESSAGES_ATTRIBUTE]: messagesJson,

packages/core/src/utils/string.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,30 @@ import { stringifyValue } from './normalize';
33

44
export { escapeStringForRegex } from '../vendor/escapeStringForRegex';
55

6+
/**
7+
* Coerce a value to a string without ever throwing. Strings pass through unchanged (so an
8+
* already-serialized value isn't double-encoded and a plain string isn't wrapped in quotes);
9+
* anything else is `JSON.stringify`-ed, falling back to `fallback` if that throws (e.g. on
10+
* circular references or `BigInt`).
11+
*
12+
* @param value the value to stringify
13+
* @param fallback returned when serialization throws, or, if a function, called with `value` to
14+
* produce the fallback. Defaults to `'[unserializable]'`.
15+
*/
16+
export function stringify(
17+
value: unknown,
18+
fallback: string | ((value: unknown) => string) = '[unserializable]',
19+
): string {
20+
if (typeof value === 'string') {
21+
return value;
22+
}
23+
try {
24+
return JSON.stringify(value);
25+
} catch {
26+
return typeof fallback === 'function' ? fallback(value) : fallback;
27+
}
28+
}
29+
630
/**
731
* Truncates given string to the maximum characters count
832
*

packages/core/test/lib/tracing/ai-message-truncation.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import { truncateGenAiMessages, truncateGenAiStringInput } from '../../../src/tracing/ai/messageTruncation';
3+
import { getTruncatedJsonString } from '../../../src/tracing/ai/utils';
34

45
describe('message truncation utilities', () => {
56
describe('truncateGenAiMessages', () => {
@@ -610,3 +611,18 @@ describe('message truncation utilities', () => {
610611
});
611612
});
612613
});
614+
615+
describe('getTruncatedJsonString', () => {
616+
it('returns a fallback instead of throwing on circular references', () => {
617+
const circular: Record<string, unknown> = { role: 'user', content: 'hi' };
618+
circular.self = circular;
619+
620+
expect(getTruncatedJsonString(circular)).toBe('[unserializable]');
621+
expect(() => getTruncatedJsonString([circular])).not.toThrow();
622+
});
623+
624+
it('serializes normal values as before', () => {
625+
expect(getTruncatedJsonString('hello')).toBe('hello');
626+
expect(getTruncatedJsonString({ a: 1 })).toBe('{"a":1}');
627+
});
628+
});

0 commit comments

Comments
 (0)