From dc3ab31af0da482f0457a47bb405a0faed148a6e Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 13 Apr 2026 22:39:36 +0000 Subject: [PATCH] fix(telemetry): implement bounded structural truncation --- packages/core/src/telemetry/trace.test.ts | 32 ++++++--- packages/core/src/telemetry/trace.ts | 88 ++++++++++++++++++----- 2 files changed, 92 insertions(+), 28 deletions(-) diff --git a/packages/core/src/telemetry/trace.test.ts b/packages/core/src/telemetry/trace.test.ts index 25812cc9e3..6bb790ea8e 100644 --- a/packages/core/src/telemetry/trace.test.ts +++ b/packages/core/src/telemetry/trace.test.ts @@ -52,23 +52,37 @@ describe('truncateForTelemetry', () => { }); it('should correctly truncate strings with multi-byte unicode characters (emojis)', () => { - // 5 emojis, each is multiple bytes in UTF-16 + // 5 emojis, each is a single grapheme cluster const emojis = '👋🌍🚀🔥🎉'; - // Truncating to length 5 (which is 2.5 emojis in UTF-16 length terms) - // truncateString will stop after the full grapheme clusters that fit within 5 - const result = truncateForTelemetry(emojis, 5); + // Truncating to 2 graphemes + const result = truncateForTelemetry(emojis, 2); - expect(result).toBe('👋🌍...[TRUNCATED: original length 10]'); + expect(result).toBe('👋🌍...[TRUNCATED: original length 5]'); }); - it('should stringify and truncate objects if exceeding maxLength', () => { + it('should stringify and structurally truncate objects if exceeding limits', () => { const obj = { message: 'hello world', nested: { a: 1 } }; - const stringified = JSON.stringify(obj); const result = truncateForTelemetry(obj, 10); expect(result).toBe( - stringified.substring(0, 10) + - `...[TRUNCATED: original length ${stringified.length}]`, + JSON.stringify({ + message: 'hello worl...[TRUNCATED: original length 11]', + nested: { a: 1 }, + }), + ); + }); + + it('should structurally truncate arrays and depth', () => { + const obj = { + arr: [1, 2, 3], + deep: { level1: { level2: { level3: { a: 1 } } } }, + }; + const result = truncateForTelemetry(obj, 100, 2, 3); + expect(result).toBe( + JSON.stringify({ + arr: [1, 2, '[TRUNCATED: Array of length 3]'], + deep: { level1: { level2: '[TRUNCATED: Max Depth Reached]' } }, + }), ); }); diff --git a/packages/core/src/telemetry/trace.ts b/packages/core/src/telemetry/trace.ts index 768dd26060..c3600dafbf 100644 --- a/packages/core/src/telemetry/trace.ts +++ b/packages/core/src/telemetry/trace.ts @@ -14,7 +14,6 @@ import { import { debugLogger } from '../utils/debugLogger.js'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; -import { truncateString } from '../utils/textUtils.js'; import { GEN_AI_AGENT_DESCRIPTION, GEN_AI_AGENT_NAME, @@ -54,27 +53,78 @@ export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => { */ export function truncateForTelemetry( value: unknown, - maxLength = 10000, + maxStringLength = 10000, + maxArrayLength = 100, + maxDepth = 4, ): AttributeValue | undefined { - if (typeof value === 'string') { - return truncateString( - value, - maxLength, - `...[TRUNCATED: original length ${value.length}]`, - ) as AttributeValue; + const truncateObj = (v: unknown, depth: number): unknown => { + if (typeof v === 'string') { + const graphemes = Array.from(v); + if (graphemes.length > maxStringLength) { + return ( + graphemes.slice(0, maxStringLength).join('') + + `...[TRUNCATED: original length ${graphemes.length}]` + ); + } + return v; + } + if ( + typeof v === 'number' || + typeof v === 'boolean' || + v === null || + v === undefined + ) { + return v; + } + if (typeof v === 'object') { + if (depth >= maxDepth) { + return `[TRUNCATED: Max Depth Reached]`; + } + if (Array.isArray(v)) { + if (v.length > maxArrayLength) { + const truncatedArray = v + .slice(0, maxArrayLength) + .map((item) => truncateObj(item, depth + 1)); + truncatedArray.push(`[TRUNCATED: Array of length ${v.length}]`); + return truncatedArray; + } + return v.map((item) => truncateObj(item, depth + 1)); + } + + const newObj: Record = {}; + let numKeys = 0; + const MAX_KEYS = 100; + for (const key in v) { + if (!Object.prototype.hasOwnProperty.call(v, key)) continue; + if (numKeys >= MAX_KEYS) { + newObj['__truncated'] = `[TRUNCATED: Object with >${MAX_KEYS} keys]`; + break; + } + const descriptor = Object.getOwnPropertyDescriptor(v, key); + if (descriptor) { + newObj[key] = truncateObj(descriptor.value, depth + 1); + } + numKeys++; + } + return newObj; + } + return undefined; + }; + + const truncated = truncateObj(value, 0); + + if ( + typeof truncated === 'string' || + typeof truncated === 'number' || + typeof truncated === 'boolean' + ) { + return truncated as AttributeValue; } - if (typeof value === 'object' && value !== null) { - const stringified = safeJsonStringify(value); - return truncateString( - stringified, - maxLength, - `...[TRUNCATED: original length ${stringified.length}]`, - ) as AttributeValue; + if (truncated === null || truncated === undefined) { + return undefined; } - if (typeof value === 'number' || typeof value === 'boolean') { - return value as AttributeValue; - } - return undefined; + + return safeJsonStringify(truncated) as AttributeValue; } function isAsyncIterable(value: T): value is T & AsyncIterable {