diff --git a/integration-tests/context-compress-interactive.test.ts b/integration-tests/context-compress-interactive.test.ts index c7e04c6c23..d5e0d957eb 100644 --- a/integration-tests/context-compress-interactive.test.ts +++ b/integration-tests/context-compress-interactive.test.ts @@ -49,7 +49,7 @@ describe('Interactive Mode', () => { ); await run.expectText('Chat history compressed', 5000); - }); + }, 60000); // TODO: Context compression is broken and doesn't include the system // instructions or tool counts, so it thinks compression is beneficial when @@ -93,6 +93,9 @@ describe('Interactive Mode', () => { }); const run = await rig.runInteractive(); + await run.expectText('tips for getting started:', 5000); + // Wait for the async command loaders to finish so /compress is recognized + await new Promise((r) => setTimeout(r, 2000)); await run.type('/compress'); await run.type('\r'); @@ -107,5 +110,5 @@ describe('Interactive Mode', () => { foundEvent, 'chat_compression telemetry event should not be found for NOOP', ).toBe(false); - }); + }, 60000); }); diff --git a/packages/core/src/telemetry/metrics.test.ts b/packages/core/src/telemetry/metrics.test.ts index 0bca699b16..4e306af406 100644 --- a/packages/core/src/telemetry/metrics.test.ts +++ b/packages/core/src/telemetry/metrics.test.ts @@ -121,8 +121,10 @@ describe('Telemetry Metrics', () => { return actualApi; }); - const { getCommonAttributes } = await import('./telemetryAttributes.js'); - (getCommonAttributes as Mock).mockReturnValue({ + const { getCommonMetricAttributes } = await import( + './telemetryAttributes.js' + ); + (getCommonMetricAttributes as Mock).mockReturnValue({ 'session.id': 'test-session-id', 'installation.id': 'test-installation-id', 'user.email': 'test@example.com', diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 377479c1e4..227f3fa858 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -24,7 +24,7 @@ import type { TokenStorageInitializationEvent, } from './types.js'; import { AuthType } from '../core/contentGenerator.js'; -import { getCommonAttributes } from './telemetryAttributes.js'; +import { getCommonMetricAttributes } from './telemetryAttributes.js'; import { sanitizeHookName } from './sanitize.js'; const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression'; @@ -104,7 +104,7 @@ const EXIT_FAIL_COUNT = 'gemini_cli.exit.fail.count'; const PLAN_EXECUTION_COUNT = 'gemini_cli.plan.execution.count'; const baseMetricDefinition = { - getCommonAttributes, + getCommonAttributes: getCommonMetricAttributes, }; const COUNTER_DEFINITIONS = { diff --git a/packages/core/src/telemetry/telemetryAttributes.test.ts b/packages/core/src/telemetry/telemetryAttributes.test.ts new file mode 100644 index 0000000000..075d55e659 --- /dev/null +++ b/packages/core/src/telemetry/telemetryAttributes.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { + getCommonAttributes, + getCommonMetricAttributes, +} from './telemetryAttributes.js'; +import type { Config } from '../config/config.js'; +import { UserAccountManager } from '../utils/userAccountManager.js'; +import { InstallationManager } from '../utils/installationManager.js'; + +vi.mock('../utils/userAccountManager.js'); +vi.mock('../utils/installationManager.js'); + +describe('telemetryAttributes', () => { + let mockConfig: Partial; + + beforeEach(() => { + vi.resetAllMocks(); + + mockConfig = { + getSessionId: vi.fn().mockReturnValue('mock-session-id'), + isInteractive: vi.fn().mockReturnValue(true), + getExperiments: vi.fn().mockReturnValue(undefined), + getContentGeneratorConfig: vi.fn().mockReturnValue(undefined), + }; + + ( + UserAccountManager.prototype.getCachedGoogleAccount as Mock + ).mockReturnValue(undefined); + (InstallationManager.prototype.getInstallationId as Mock).mockReturnValue( + 'mock-install-id', + ); + }); + + describe('getCommonMetricAttributes', () => { + it('should return interactive and auth_type when defined', () => { + mockConfig.getContentGeneratorConfig = vi + .fn() + .mockReturnValue({ authType: 'oauth-personal' }); + + const attributes = getCommonMetricAttributes(mockConfig as Config); + + expect(attributes).toEqual({ + interactive: true, + auth_type: 'oauth-personal', + }); + }); + + it('should return only interactive when auth_type is not defined', () => { + const attributes = getCommonMetricAttributes(mockConfig as Config); + + expect(attributes).toEqual({ + interactive: true, + }); + }); + }); + + describe('getCommonAttributes', () => { + it('should include all common attributes', () => { + ( + UserAccountManager.prototype.getCachedGoogleAccount as Mock + ).mockReturnValue('test@google.com'); + mockConfig.getExperiments = vi + .fn() + .mockReturnValue({ experimentIds: [123, 456] }); + mockConfig.getContentGeneratorConfig = vi + .fn() + .mockReturnValue({ authType: 'adc' }); + + const attributes = getCommonAttributes(mockConfig as Config); + + expect(attributes).toEqual({ + 'session.id': 'mock-session-id', + 'installation.id': 'mock-install-id', + interactive: true, + 'user.email': 'test@google.com', + auth_type: 'adc', + 'experiments.ids': '123,456', + }); + }); + + it('should safely truncate experiments string to not exceed 1000 characters and not cut mid-ID', () => { + // Generate a list of experiment IDs that will produce a string > 1000 chars + const expIds = []; + for (let i = 0; i < 200; i++) { + // e.g., 100000000 -> 9 chars + 1 comma = 10 chars per ID + expIds.push(100000000 + i); + } + mockConfig.getExperiments = vi + .fn() + .mockReturnValue({ experimentIds: expIds }); + + const attributes = getCommonAttributes(mockConfig as Config); + const expString = attributes['experiments.ids'] as string; + + expect(expString.length).toBeLessThanOrEqual(1000); + + // Verify the last ID is complete (not cut off) by checking if it's one of our expected IDs + const ids = expString.split(','); + const lastIdStr = ids[ids.length - 1]; + const lastIdNumber = parseInt(lastIdStr, 10); + + expect(lastIdNumber).toBeGreaterThanOrEqual(100000000); + expect(lastIdNumber).toBeLessThan(100000200); + + // Also ensure no trailing comma + expect(expString.endsWith(',')).toBe(false); + }); + }); +}); diff --git a/packages/core/src/telemetry/telemetryAttributes.ts b/packages/core/src/telemetry/telemetryAttributes.ts index d2139e32df..0632fc453c 100644 --- a/packages/core/src/telemetry/telemetryAttributes.ts +++ b/packages/core/src/telemetry/telemetryAttributes.ts @@ -12,19 +12,36 @@ import { UserAccountManager } from '../utils/userAccountManager.js'; const userAccountManager = new UserAccountManager(); const installationManager = new InstallationManager(); +export function getCommonMetricAttributes(config: Config): Attributes { + const authType = config.getContentGeneratorConfig()?.authType; + + return { + interactive: config.isInteractive(), + ...(authType && { auth_type: authType }), + }; +} + export function getCommonAttributes(config: Config): Attributes { const email = userAccountManager.getCachedGoogleAccount(); const experiments = config.getExperiments(); - const authType = config.getContentGeneratorConfig()?.authType; + + let experimentsIdsStr = ''; + if (experiments && experiments.experimentIds.length > 0) { + experimentsIdsStr = experiments.experimentIds.join(','); + if (experimentsIdsStr.length > 1000) { + experimentsIdsStr = experimentsIdsStr.substring(0, 1000); + const lastCommaIndex = experimentsIdsStr.lastIndexOf(','); + if (lastCommaIndex > 0) { + experimentsIdsStr = experimentsIdsStr.substring(0, lastCommaIndex); + } + } + } + return { + ...getCommonMetricAttributes(config), 'session.id': config.getSessionId(), 'installation.id': installationManager.getInstallationId(), - interactive: config.isInteractive(), ...(email && { 'user.email': email }), - ...(authType && { auth_type: authType }), - ...(experiments && - experiments.experimentIds.length > 0 && { - 'experiments.ids': experiments.experimentIds, - }), + ...(experimentsIdsStr && { 'experiments.ids': experimentsIdsStr }), }; } diff --git a/packages/core/src/telemetry/trace.test.ts b/packages/core/src/telemetry/trace.test.ts index 87a1419080..c5d87805f2 100644 --- a/packages/core/src/telemetry/trace.test.ts +++ b/packages/core/src/telemetry/trace.test.ts @@ -52,26 +52,95 @@ 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 surrogate pair (2 code units) 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 code units + const result = truncateForTelemetry(emojis, 2); - expect(result).toBe('👋🌍...[TRUNCATED: original length 10]'); + expect(result).toBe('👋...[TRUNCATED: original length 10]'); }); - 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]' } }, + }), + ); + }); + + it('should handle objects with a toJSON method', () => { + const date = new Date('2026-04-13T00:00:00.000Z'); + const result = truncateForTelemetry(date); + expect(result).toBe('2026-04-13T00:00:00.000Z'); + }); + + it('should handle getters via direct property access', () => { + const obj = { + get myGetter() { + return 'getter value'; + }, + get errorGetter() { + throw new Error('getter error'); + }, + }; + const result = truncateForTelemetry(obj); + expect(result).toBe( + JSON.stringify({ + myGetter: 'getter value', + errorGetter: '[ERROR: Failed to read property]', + }), + ); + }); + + it('should truncate extremely long keys', () => { + const longKey = 'a'.repeat(150); + const obj = { + [longKey]: 'value', + }; + const result = truncateForTelemetry(obj); + const expectedKey = 'a'.repeat(100) + '...[TRUNCATED_KEY]'; + expect(result).toBe( + JSON.stringify({ + [expectedKey]: 'value', + }), + ); + }); + + it('should enforce a global payload string limit without breaking JSON', () => { + const obj = { + a: 'x'.repeat(100), + b: 'y'.repeat(100), + }; + // Capping global string length to 50 + const result = truncateForTelemetry(obj, 100, 100, 4, 100, 50) as string; + + // It should replace the entire object with a valid JSON string indicating truncation + expect(result).toBe( + '"[TRUNCATED: Payload exceeded global limit of 50 characters. Original length: 215]"', + ); + + // Prove it remains perfectly parseable JSON + expect(() => JSON.parse(result)).not.toThrow(); + }); + it('should stringify objects unchanged if within maxLength', () => { const obj = { a: 1 }; expect(truncateForTelemetry(obj, 100)).toBe(JSON.stringify(obj)); diff --git a/packages/core/src/telemetry/trace.ts b/packages/core/src/telemetry/trace.ts index fd3082c3cd..94b4a9837c 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, @@ -45,6 +44,17 @@ export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => { } }); +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function hasToJSON(value: unknown): value is { toJSON: () => unknown } { + if (!isRecord(value)) return false; + if (!('toJSON' in value)) return false; + const toJSONFn = value['toJSON']; + return typeof toJSONFn === 'function'; +} + /** * Truncates a value for inclusion in telemetry attributes. * @@ -54,27 +64,98 @@ export const spanRegistry = new FinalizationRegistry((endSpan: () => void) => { */ export function truncateForTelemetry( value: unknown, - maxLength = 10000, + maxStringLength = 10000, + maxArrayLength = 100, + maxDepth = 4, + maxObjectKeys = 100, + maxGlobalStringLength = 50000, ): 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') { + if (v.length > maxStringLength) { + let truncatedStr = v.slice(0, maxStringLength); + if (truncatedStr.length > 0 && /[\uD800-\uDBFF]$/.test(truncatedStr)) { + truncatedStr = truncatedStr.slice(0, -1); + } + return truncatedStr + `...[TRUNCATED: original length ${v.length}]`; + } + return v; + } + if ( + typeof v === 'number' || + typeof v === 'boolean' || + v === null || + v === undefined + ) { + return v; + } + if (hasToJSON(v)) { + try { + return truncateObj(v.toJSON(), depth); + } catch { + // Ignore and fall back to manual structural iteration + } + } + 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 recordV = isRecord(v) ? v : {}; + for (const key in recordV) { + if (!Object.prototype.hasOwnProperty.call(recordV, key)) continue; + if (numKeys >= maxObjectKeys) { + newObj['__truncated'] = + `[TRUNCATED: Object with >${maxObjectKeys} keys]`; + break; + } + const truncatedKey = + key.length > 100 ? key.slice(0, 100) + '...[TRUNCATED_KEY]' : key; + try { + const val = recordV[key]; + newObj[truncatedKey] = truncateObj(val, depth + 1); + } catch { + newObj[truncatedKey] = '[ERROR: Failed to read property]'; + } + 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; + + const stringified = safeJsonStringify(truncated); + + if (stringified.length > maxGlobalStringLength) { + return `"[TRUNCATED: Payload exceeded global limit of ${maxGlobalStringLength} characters. Original length: ${stringified.length}]"`; } - return undefined; + + return stringified as AttributeValue; } function isAsyncIterable(value: T): value is T & AsyncIterable { diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 9d6cd08c72..1ebcef6554 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -31,6 +31,7 @@ import type { AgentTerminateMode } from '../agents/types.js'; import { getCommonAttributes } from './telemetryAttributes.js'; import { SemanticAttributes } from '@opentelemetry/semantic-conventions'; import { safeJsonStringify } from '../utils/safeJsonStringify.js'; +import { truncateForTelemetry } from './trace.js'; import { toInputMessages, toOutputMessages, @@ -444,7 +445,7 @@ export class ApiRequestEvent implements BaseTelemetryEvent { } if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) { - attributes['gen_ai.input.messages'] = JSON.stringify( + attributes['gen_ai.input.messages'] = truncateForTelemetry( toInputMessages(this.prompt.contents), ); } @@ -541,7 +542,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent { } if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) { - attributes['gen_ai.input.messages'] = JSON.stringify( + attributes['gen_ai.input.messages'] = truncateForTelemetry( toInputMessages(this.prompt.contents), ); } @@ -611,7 +612,7 @@ function toGenerateContentConfigAttributes( 'gen_ai.request.max_tokens': config.maxOutputTokens, 'gen_ai.output.type': toOutputType(config.responseMimeType), 'gen_ai.request.stop_sequences': config.stopSequences, - 'gen_ai.system_instructions': JSON.stringify( + 'gen_ai.system_instructions': truncateForTelemetry( toSystemInstruction(config.systemInstruction), ), }; @@ -707,7 +708,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent { 'event.timestamp': this['event.timestamp'], 'gen_ai.response.id': this.response.response_id, 'gen_ai.response.finish_reasons': this.finish_reasons, - 'gen_ai.output.messages': JSON.stringify( + 'gen_ai.output.messages': truncateForTelemetry( toOutputMessages(this.response.candidates), ), ...toGenerateContentConfigAttributes(this.prompt.generate_content_config), @@ -720,7 +721,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent { } if (config.getTelemetryLogPromptsEnabled() && this.prompt.contents) { - attributes['gen_ai.input.messages'] = JSON.stringify( + attributes['gen_ai.input.messages'] = truncateForTelemetry( toInputMessages(this.prompt.contents), ); } diff --git a/packages/test-utils/src/test-rig.ts b/packages/test-utils/src/test-rig.ts index 906a7760bf..6069f0fea0 100644 --- a/packages/test-utils/src/test-rig.ts +++ b/packages/test-utils/src/test-rig.ts @@ -454,6 +454,7 @@ export class TestRig { // Nightly releases sometimes becomes out of sync with local code and // triggers auto-update, which causes tests to fail. enableAutoUpdate: false, + enableAutoUpdateNotification: false, }, telemetry: { enabled: true,