fix(telemetry): resolve OOM risk with Array.from, add surrogate pair protection, and fix try/catch key scope

This commit is contained in:
Spencer
2026-04-20 20:56:15 +00:00
parent 2bc481a3b5
commit c72ac00a53
2 changed files with 20 additions and 22 deletions
+4 -4
View File
@@ -52,13 +52,13 @@ describe('truncateForTelemetry', () => {
}); });
it('should correctly truncate strings with multi-byte unicode characters (emojis)', () => { it('should correctly truncate strings with multi-byte unicode characters (emojis)', () => {
// 5 emojis, each is a single grapheme cluster // 5 emojis, each is a surrogate pair (2 code units)
const emojis = '👋🌍🚀🔥🎉'; const emojis = '👋🌍🚀🔥🎉';
// Truncating to 2 graphemes // Truncating to 2 code units
const result = truncateForTelemetry(emojis, 2); const result = truncateForTelemetry(emojis, 2);
expect(result).toBe('👋🌍...[TRUNCATED: original length 5]'); expect(result).toBe('👋...[TRUNCATED: original length 10]');
}); });
it('should stringify and structurally truncate objects if exceeding limits', () => { it('should stringify and structurally truncate objects if exceeding limits', () => {
@@ -130,7 +130,7 @@ describe('truncateForTelemetry', () => {
b: 'y'.repeat(100), b: 'y'.repeat(100),
}; };
// Capping global string length to 50 // Capping global string length to 50
const result = truncateForTelemetry(obj, 100, 100, 4, 50) as string; const result = truncateForTelemetry(obj, 100, 100, 4, 100, 50) as string;
// It should replace the entire object with a valid JSON string indicating truncation // It should replace the entire object with a valid JSON string indicating truncation
expect(result).toBe( expect(result).toBe(
+16 -18
View File
@@ -48,7 +48,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null; return typeof value === 'object' && value !== null;
} }
function isHasToJSON(value: unknown): value is { toJSON: () => unknown } { function hasToJSON(value: unknown): value is { toJSON: () => unknown } {
if (!isRecord(value)) return false; if (!isRecord(value)) return false;
if (!('toJSON' in value)) return false; if (!('toJSON' in value)) return false;
const toJSONFn = value['toJSON']; const toJSONFn = value['toJSON'];
@@ -67,16 +67,17 @@ export function truncateForTelemetry(
maxStringLength = 10000, maxStringLength = 10000,
maxArrayLength = 100, maxArrayLength = 100,
maxDepth = 4, maxDepth = 4,
maxObjectKeys = 100,
maxGlobalStringLength = 50000, maxGlobalStringLength = 50000,
): AttributeValue | undefined { ): AttributeValue | undefined {
const truncateObj = (v: unknown, depth: number): unknown => { const truncateObj = (v: unknown, depth: number): unknown => {
if (typeof v === 'string') { if (typeof v === 'string') {
const graphemes = Array.from(v); if (v.length > maxStringLength) {
if (graphemes.length > maxStringLength) { let truncatedStr = v.slice(0, maxStringLength);
return ( if (truncatedStr.length > 0 && /[\uD800-\uDBFF]$/.test(truncatedStr)) {
graphemes.slice(0, maxStringLength).join('') + truncatedStr = truncatedStr.slice(0, -1);
`...[TRUNCATED: original length ${graphemes.length}]` }
); return truncatedStr + `...[TRUNCATED: original length ${v.length}]`;
} }
return v; return v;
} }
@@ -88,7 +89,7 @@ export function truncateForTelemetry(
) { ) {
return v; return v;
} }
if (isHasToJSON(v)) { if (hasToJSON(v)) {
try { try {
return truncateObj(v.toJSON(), depth); return truncateObj(v.toJSON(), depth);
} catch { } catch {
@@ -112,21 +113,21 @@ export function truncateForTelemetry(
const newObj: Record<string, unknown> = {}; const newObj: Record<string, unknown> = {};
let numKeys = 0; let numKeys = 0;
const MAX_KEYS = 100;
const recordV = isRecord(v) ? v : {}; const recordV = isRecord(v) ? v : {};
for (const key in recordV) { for (const key in recordV) {
if (!Object.prototype.hasOwnProperty.call(recordV, key)) continue; if (!Object.prototype.hasOwnProperty.call(recordV, key)) continue;
if (numKeys >= MAX_KEYS) { if (numKeys >= maxObjectKeys) {
newObj['__truncated'] = `[TRUNCATED: Object with >${MAX_KEYS} keys]`; newObj['__truncated'] =
`[TRUNCATED: Object with >${maxObjectKeys} keys]`;
break; break;
} }
const truncatedKey =
key.length > 100 ? key.slice(0, 100) + '...[TRUNCATED_KEY]' : key;
try { try {
const val = recordV[key]; const val = recordV[key];
const truncatedKey =
key.length > 100 ? key.slice(0, 100) + '...[TRUNCATED_KEY]' : key;
newObj[truncatedKey] = truncateObj(val, depth + 1); newObj[truncatedKey] = truncateObj(val, depth + 1);
} catch { } catch {
newObj[key] = '[ERROR: Failed to read property]'; newObj[truncatedKey] = '[ERROR: Failed to read property]';
} }
numKeys++; numKeys++;
} }
@@ -151,10 +152,7 @@ export function truncateForTelemetry(
const stringified = safeJsonStringify(truncated); const stringified = safeJsonStringify(truncated);
if (stringified.length > maxGlobalStringLength) { if (stringified.length > maxGlobalStringLength) {
const graphemes = Array.from(stringified); return `"[TRUNCATED: Payload exceeded global limit of ${maxGlobalStringLength} characters. Original length: ${stringified.length}]"`;
if (graphemes.length > maxGlobalStringLength) {
return `"[TRUNCATED: Payload exceeded global limit of ${maxGlobalStringLength} characters. Original length: ${graphemes.length}]"`;
}
} }
return stringified as AttributeValue; return stringified as AttributeValue;