fix(telemetry): implement bounded structural truncation

This commit is contained in:
Spencer
2026-04-13 22:39:36 +00:00
parent d6f88f8720
commit dc3ab31af0
2 changed files with 92 additions and 28 deletions
+23 -9
View File
@@ -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]' } },
}),
);
});
+69 -19
View File
@@ -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<string, unknown> = {};
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<T>(value: T): value is T & AsyncIterable<unknown> {