This commit is contained in:
Abdullah Jami 2026-05-23 14:46:43 -07:00 committed by GitHub
commit f00a3fe409
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 105 additions and 42 deletions

View file

@ -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(

View file

@ -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);

View file

@ -114,11 +114,18 @@ export function isTelemetrySdkInitialized(): boolean {
return telemetryInitialized;
}
const MAX_TELEMETRY_BUFFER_SIZE = 100;
export function bufferTelemetryEvent(fn: () => void | Promise<void>): 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);
}
}

View file

@ -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;
}