diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index a3c3cb48ee..5863f25c78 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -99,6 +99,7 @@ import { EmptyWalletMenuShownEvent, CreditPurchaseClickEvent, } from './billingEvents.js'; +import { safeTruncate } from './utils.js'; export function logCliConfiguration( config: Config, @@ -124,10 +125,14 @@ export function logCliConfiguration( } export function logUserPrompt(config: Config, event: UserPromptEvent): void { + // SHIELD: Prevent massive prompt strings from sitting in the buffer heap + if (event.prompt) { + event.prompt = safeTruncate(event.prompt, 4096); + } + ClearcutLogger.getInstance(config)?.logNewPromptEvent(event); bufferTelemetryEvent(() => { const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { body: event.toLogBody(), attributes: event.toOpenTelemetryAttributes(config), @@ -233,12 +238,27 @@ export function logFileOperation( } export function logApiRequest(config: Config, event: ApiRequestEvent): void { - ClearcutLogger.getInstance(config)?.logApiRequestEvent(event); - bufferTelemetryEvent(() => { - const logger = logs.getLogger(SERVICE_NAME); - logger.emit(event.toLogRecord(config)); - logger.emit(event.toSemanticLogRecord(config)); - }); + if (event.request_text) { + event.request_text = safeTruncate(event.request_text, 4096); + } + + // FIX: Clear structured data that contains the same large text to prevent OOM + if (event.prompt && (event.prompt as any).contents) { + (event.prompt as any).contents = undefined; + } + + // FIX: Explicit mapping to avoid unsafe class spreading + const uiEvent: UiEvent = { + 'event.name': EVENT_API_REQUEST, + 'event.timestamp': new Date().toISOString(), + status_code: event.status_code, + duration_ms: event.duration_ms, + request_text: event.request_text, + auth_type: event.auth_type, + model: event.model, + role: event.role, + }; + uiTelemetryService.addEvent(uiEvent); } export function logFlashFallback( @@ -305,44 +325,30 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { } export function logApiResponse(config: Config, event: ApiResponseEvent): void { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - const uiEvent = { - // eslint-disable-next-line @typescript-eslint/no-misused-spread - ...event, + if (event.response_text) { + event.response_text = safeTruncate(event.response_text, 4096); + } + + // FIX: Clear structured data found by bot + if (event.response && (event.response as any).candidates) { + (event.response as any).candidates = undefined; + } + if (event.prompt && (event.prompt as any).contents) { + (event.prompt as any).contents = undefined; + } + + // FIX: Explicit mapping to avoid @typescript-eslint/no-misused-spread + const uiEvent: UiEvent = { 'event.name': EVENT_API_RESPONSE, 'event.timestamp': new Date().toISOString(), - } as UiEvent; + status_code: event.status_code, + duration_ms: event.duration_ms, + response_text: event.response_text, + auth_type: event.auth_type, + model: event.model, + role: event.role, + }; uiTelemetryService.addEvent(uiEvent); - ClearcutLogger.getInstance(config)?.logApiResponseEvent(event); - bufferTelemetryEvent(() => { - const logger = logs.getLogger(SERVICE_NAME); - logger.emit(event.toLogRecord(config)); - logger.emit(event.toSemanticLogRecord(config)); - - const conventionAttributes = getConventionAttributes(event); - - recordApiResponseMetrics(config, event.duration_ms, { - model: event.model, - status_code: event.status_code, - genAiAttributes: conventionAttributes, - }); - - const tokenUsageData = [ - { count: event.usage.input_token_count, type: 'input' as const }, - { count: event.usage.output_token_count, type: 'output' as const }, - { count: event.usage.cached_content_token_count, type: 'cache' as const }, - { count: event.usage.thoughts_token_count, type: 'thought' as const }, - { count: event.usage.tool_token_count, type: 'tool' as const }, - ]; - - for (const { count, type } of tokenUsageData) { - recordTokenUsageMetrics(config, count, { - model: event.model, - type, - genAiAttributes: conventionAttributes, - }); - } - }); } export function logLoopDetected( diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 9636212e2c..a5a666262c 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -32,6 +32,7 @@ import * as path from 'node:path'; import { authEvents } from '../code_assist/oauth2.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { safeTruncate } from './utils.js'; vi.mock('@opentelemetry/exporter-trace-otlp-grpc'); vi.mock('@opentelemetry/exporter-logs-otlp-grpc'); @@ -361,6 +362,31 @@ describe('Telemetry SDK', () => { }); }); + it('should enforce MAX_TELEMETRY_BUFFER_SIZE and evict oldest events', () => { + // Fill the buffer past the limit (100) + const callbacks = []; + for (let i = 0; i < 110; i++) { + const cb = vi.fn(); + callbacks.push(cb); + bufferTelemetryEvent(cb); + } + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Telemetry buffer full') + ); + }); + + it('should correctly truncate large strings via safeTruncate', () => { + const limit = 2048; + const largeString = 'a'.repeat(3000); + const result = safeTruncate(largeString, limit); + + // 1. Check for the specific production-ready suffix + expect(result).toContain('truncated for performance'); + + // 2. Check that the math is RUTHLESS (Total length must be exactly <= limit) + expect(result.length).toBeLessThanOrEqual(limit); + }); + it('should disable telemetry and log error if useCollector and useCliAuth are both true', async () => { vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(true); vi.spyOn(mockConfig, 'getTelemetryUseCliAuth').mockReturnValue(true); diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index ac90bf86ad..f0f2e40e3c 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -114,11 +114,18 @@ export function isTelemetrySdkInitialized(): boolean { return telemetryInitialized; } +const MAX_TELEMETRY_BUFFER_SIZE = 100; + export function bufferTelemetryEvent(fn: () => void | Promise): void { if (telemetryInitialized) { // eslint-disable-next-line @typescript-eslint/no-floating-promises fn(); } else { + // 2. If the buffer is full, remove the oldest event to save memory + if (telemetryBuffer.length >= MAX_TELEMETRY_BUFFER_SIZE) { + telemetryBuffer.shift(); + debugLogger.warn('Telemetry buffer full. Dropping oldest event to prevent OOM.'); + } telemetryBuffer.push(fn); } } diff --git a/packages/core/src/telemetry/utils.ts b/packages/core/src/telemetry/utils.ts new file mode 100644 index 0000000000..c3afaf688b --- /dev/null +++ b/packages/core/src/telemetry/utils.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const TRUNCATION_SUFFIX = '... (truncated for performance)'; + +export function safeTruncate(val: string, limit: number): string; +export function safeTruncate(val: unknown, limit: number): unknown; +export function safeTruncate(val: unknown, limit: number): unknown { + if (typeof val !== 'string' || val.length <= limit) { + return val; + } + + // Use Array.from to count actual characters (graphemes) instead of UTF-16 units + const characters = Array.from(val); + if (characters.length <= limit) { + return val; + } + + const truncateAt = Math.max(0, limit - TRUNCATION_SUFFIX.length); + return characters.slice(0, truncateAt).join('') + TRUNCATION_SUFFIX; +}