From 42fd647373aa5b0268e23d87dddd8a3cff5d5bf8 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Fri, 16 Jan 2026 14:30:48 -0500 Subject: [PATCH] fix(core): truncate large telemetry log entries (#16769) --- .../src/telemetry/semantic.truncation.test.ts | 104 ++++++++++++++++++ packages/core/src/telemetry/semantic.ts | 102 +++++++++++++++++ packages/core/src/utils/textUtils.test.ts | 24 +++- packages/core/src/utils/textUtils.ts | 18 +++ 4 files changed, 247 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/telemetry/semantic.truncation.test.ts diff --git a/packages/core/src/telemetry/semantic.truncation.test.ts b/packages/core/src/telemetry/semantic.truncation.test.ts new file mode 100644 index 0000000000..00bb6c5d57 --- /dev/null +++ b/packages/core/src/telemetry/semantic.truncation.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect } from 'vitest'; +import { toInputMessages } from './semantic.js'; +import { type Content } from '@google/genai'; + +// 160KB limit for the total size of string content in a log entry. +const GLOBAL_TEXT_LIMIT = 160 * 1024; +const SUFFIX = '...[TRUNCATED]'; + +describe('Semantic Telemetry Truncation', () => { + it('should not truncate a single part if it is within global limit', () => { + // 150KB part -> Should fit in 160KB limit + const textLen = 150 * 1024; + const longText = 'a'.repeat(textLen); + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: longText }], + }, + ]; + const result = toInputMessages(contents); + // @ts-expect-error - testing internal state + expect(result[0].parts[0].content.length).toBe(textLen); + // @ts-expect-error - testing internal state + expect(result[0].parts[0].content.endsWith(SUFFIX)).toBe(false); + }); + + it('should truncate a single part if it exceeds global limit', () => { + // 170KB part -> Should get truncated to ~160KB + const textLen = 170 * 1024; + const longText = 'a'.repeat(textLen); + const contents: Content[] = [ + { + role: 'user', + parts: [{ text: longText }], + }, + ]; + const result = toInputMessages(contents); + // @ts-expect-error - testing internal state + const content = result[0].parts[0].content; + expect(content.length).toBeLessThan(textLen); + // Because it's the only part, it gets the full budget of 160KB + expect(content.length).toBe(GLOBAL_TEXT_LIMIT + SUFFIX.length); + expect(content.endsWith(SUFFIX)).toBe(true); + }); + + it('should fairly distribute budget among multiple large parts', () => { + // Two 100KB parts (Total 200KB) -> Budget 160KB + // Each should get roughly 80KB + const partLen = 100 * 1024; + const part1 = 'a'.repeat(partLen); + const part2 = 'b'.repeat(partLen); + const contents: Content[] = [ + { role: 'user', parts: [{ text: part1 }] }, + { role: 'model', parts: [{ text: part2 }] }, + ]; + + const result = toInputMessages(contents); + + // @ts-expect-error - testing internal state + const c1 = result[0].parts[0].content; + // @ts-expect-error - testing internal state + const c2 = result[1].parts[0].content; + + expect(c1.length).toBeLessThan(partLen); + expect(c2.length).toBeLessThan(partLen); + + // Budget is split evenly + const expectedLen = Math.floor(GLOBAL_TEXT_LIMIT / 2) + SUFFIX.length; + expect(c1.length).toBe(expectedLen); + expect(c2.length).toBe(expectedLen); + }); + + it('should not truncate small parts while truncating large ones', () => { + // One 200KB part, one 1KB part. + // 1KB part is small (below average), so it keeps its size. + // 200KB part gets the remaining budget (128KB - 1KB = 127KB). + const bigLen = 200 * 1024; + const smallLen = 1 * 1024; + const bigText = 'a'.repeat(bigLen); + const smallText = 'b'.repeat(smallLen); + + const contents: Content[] = [ + { role: 'user', parts: [{ text: bigText }] }, + { role: 'model', parts: [{ text: smallText }] }, + ]; + + const result = toInputMessages(contents); + // @ts-expect-error - testing internal state + const cBig = result[0].parts[0].content; + // @ts-expect-error - testing internal state + const cSmall = result[1].parts[0].content; + + expect(cSmall.length).toBe(smallLen); // Untouched + expect(cBig.length).toBeLessThan(bigLen); + + const expectedBigLen = GLOBAL_TEXT_LIMIT - smallLen + SUFFIX.length; + expect(cBig.length).toBe(expectedBigLen); + }); +}); diff --git a/packages/core/src/telemetry/semantic.ts b/packages/core/src/telemetry/semantic.ts index 6192a6e94d..31520eb802 100644 --- a/packages/core/src/telemetry/semantic.ts +++ b/packages/core/src/telemetry/semantic.ts @@ -19,12 +19,111 @@ import type { Part, PartUnion, } from '@google/genai'; +import { truncateString } from '../utils/textUtils.js'; + +// 160KB limit for the total size of string content in a log entry. +// The total log entry size limit is 256KB. We leave ~96KB (approx 37%) for JSON overhead (escaping, structure) and other fields. +const GLOBAL_TEXT_LIMIT = 160 * 1024; + +interface StringReference { + get: () => string | undefined; + set: (val: string) => void; + len: () => number; +} + +function getStringReferences(parts: AnyPart[]): StringReference[] { + const refs: StringReference[] = []; + for (const part of parts) { + if (part instanceof TextPart) { + refs.push({ + get: () => part.content, + set: (val: string) => (part.content = val), + len: () => part.content.length, + }); + } else if (part instanceof ReasoningPart) { + refs.push({ + get: () => part.content, + set: (val: string) => (part.content = val), + len: () => part.content.length, + }); + } else if (part instanceof ToolCallRequestPart) { + if (part.arguments) { + refs.push({ + get: () => part.arguments, + set: (val: string) => (part.arguments = val), + len: () => part.arguments?.length ?? 0, + }); + } + } else if (part instanceof ToolCallResponsePart) { + if (part.response) { + refs.push({ + get: () => part.response, + set: (val: string) => (part.response = val), + len: () => part.response?.length ?? 0, + }); + } + } else if (part instanceof GenericPart) { + if (part.type === 'executableCode' && typeof part['code'] === 'string') { + refs.push({ + get: () => part['code'] as string, + set: (val: string) => (part['code'] = val), + len: () => (part['code'] as string).length, + }); + } else if ( + part.type === 'codeExecutionResult' && + typeof part['output'] === 'string' + ) { + refs.push({ + get: () => part['output'] as string, + set: (val: string) => (part['output'] = val), + len: () => (part['output'] as string).length, + }); + } + } + } + return refs; +} + +function limitTotalLength(parts: AnyPart[]): void { + const refs = getStringReferences(parts); + const totalLength = refs.reduce((sum, ref) => sum + ref.len(), 0); + + if (totalLength <= GLOBAL_TEXT_LIMIT) { + return; + } + + // Calculate the average budget per part for "large" parts. + // We identify parts that are larger than the fair share (average) and truncate them. + const averageSize = GLOBAL_TEXT_LIMIT / refs.length; + + // Filter out parts that are already small enough to not need truncation + const largeRefs = refs.filter((ref) => ref.len() > averageSize); + const smallRefsLength = refs + .filter((ref) => ref.len() <= averageSize) + .reduce((sum, ref) => sum + ref.len(), 0); + + // Distribute the remaining budget among large parts + const remainingBudget = GLOBAL_TEXT_LIMIT - smallRefsLength; + const budgetPerLargePart = Math.max( + 1, + Math.floor(remainingBudget / largeRefs.length), + ); + + for (const ref of largeRefs) { + const original = ref.get(); + if (original) { + ref.set(truncateString(original, budgetPerLargePart)); + } + } +} export function toInputMessages(contents: Content[]): InputMessages { const messages: ChatMessage[] = []; for (const content of contents) { messages.push(toChatMessage(content)); } + const allParts = messages.flatMap((m) => m.parts); + limitTotalLength(allParts); return messages; } @@ -81,6 +180,7 @@ export function toSystemInstruction( } } } + limitTotalLength(parts); return parts; } @@ -94,6 +194,8 @@ export function toOutputMessages(candidates?: Candidate[]): OutputMessages { }); } } + const allParts = messages.flatMap((m) => m.parts); + limitTotalLength(allParts); return messages; } diff --git a/packages/core/src/utils/textUtils.test.ts b/packages/core/src/utils/textUtils.test.ts index c1468c111b..4a2c319b87 100644 --- a/packages/core/src/utils/textUtils.test.ts +++ b/packages/core/src/utils/textUtils.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect } from 'vitest'; -import { safeLiteralReplace } from './textUtils.js'; +import { safeLiteralReplace, truncateString } from './textUtils.js'; describe('safeLiteralReplace', () => { it('returns original string when oldString empty or not found', () => { @@ -77,3 +77,25 @@ describe('safeLiteralReplace', () => { expect(safeLiteralReplace('abc', 'b', '$$')).toBe('a$$c'); }); }); + +describe('truncateString', () => { + it('should not truncate string shorter than maxLength', () => { + expect(truncateString('abc', 5)).toBe('abc'); + }); + + it('should not truncate string equal to maxLength', () => { + expect(truncateString('abcde', 5)).toBe('abcde'); + }); + + it('should truncate string longer than maxLength and append default suffix', () => { + expect(truncateString('abcdef', 5)).toBe('abcde...[TRUNCATED]'); + }); + + it('should truncate string longer than maxLength and append custom suffix', () => { + expect(truncateString('abcdef', 5, '...')).toBe('abcde...'); + }); + + it('should handle empty string', () => { + expect(truncateString('', 5)).toBe(''); + }); +}); diff --git a/packages/core/src/utils/textUtils.ts b/packages/core/src/utils/textUtils.ts index 693ab48fe5..c227d89e98 100644 --- a/packages/core/src/utils/textUtils.ts +++ b/packages/core/src/utils/textUtils.ts @@ -53,3 +53,21 @@ export function isBinary( // If no NULL bytes were found in the sample, we assume it's text. return false; } + +/** + * Truncates a string to a maximum length, appending a suffix if truncated. + * @param str The string to truncate. + * @param maxLength The maximum length of the string. + * @param suffix The suffix to append if truncated (default: '...[TRUNCATED]'). + * @returns The truncated string. + */ +export function truncateString( + str: string, + maxLength: number, + suffix = '...[TRUNCATED]', +): string { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength) + suffix; +}