diff options
| author | owenofbrien <[email protected]> | 2025-06-22 09:26:48 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-06-22 14:26:48 +0000 |
| commit | 4cfab0a8931decca8c953de1e5715e40ee31ee9a (patch) | |
| tree | dd45db52d57060058213d3fb0b7a126ab043ce4d /packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts | |
| parent | c9950b3cb273246d801a5cbb04cf421d4c5e39c4 (diff) | |
Clearcut logging - initial implementation (#1274)
Flag-guarded initial implementation of a clearcut logger to collect telemetry data and send it to Concord for dashboards, etc.
Diffstat (limited to 'packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts')
| -rw-r--r-- | packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts | 338 |
1 files changed, 338 insertions, 0 deletions
diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts new file mode 100644 index 00000000..8da928c7 --- /dev/null +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Buffer } from 'buffer'; +import * as https from 'https'; +import { + StartSessionEvent, + EndSessionEvent, + UserPromptEvent, + ToolCallEvent, + ApiRequestEvent, + ApiResponseEvent, + ApiErrorEvent, +} from '../types.js'; +import { EventMetadataKey } from './event-metadata-key.js'; +import { Config } from '../../config/config.js'; +import { getPersistentUserId } from '../../utils/user_id.js'; + +const start_session_event_name = 'start_session'; +const new_prompt_event_name = 'new_prompt'; +const tool_call_event_name = 'tool_call'; +const api_request_event_name = 'api_request'; +const api_response_event_name = 'api_response'; +const api_error_event_name = 'api_error'; +const end_session_event_name = 'end_session'; + +export interface LogResponse { + nextRequestWaitMs?: number; +} + +// Singleton class for batch posting log events to Clearcut. When a new event comes in, the elapsed time +// is checked and events are flushed to Clearcut if at least a minute has passed since the last flush. +export class ClearcutLogger { + private static instance: ClearcutLogger; + private config?: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Clearcut expects this format. + private readonly events: any = []; + private last_flush_time: number = Date.now(); + private flush_interval_ms: number = 1000 * 60; // Wait at least a minute before flushing events. + + private constructor(config?: Config) { + this.config = config; + } + + static getInstance(config?: Config): ClearcutLogger | undefined { + if (config === undefined || config?.getDisableDataCollection()) + return undefined; + if (!ClearcutLogger.instance) { + ClearcutLogger.instance = new ClearcutLogger(config); + } + return ClearcutLogger.instance; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Clearcut expects this format. + enqueueLogEvent(event: any): void { + this.events.push([ + { + event_time_ms: Date.now(), + source_extension_json: JSON.stringify(event), + }, + ]); + } + + createLogEvent(name: string, data: Map<EventMetadataKey, string>): object { + return { + Application: 'GEMINI_CLI', + event_name: name, + client_install_id: getPersistentUserId(), + event_metadata: [data] as object[], + }; + } + + flushIfNeeded(): void { + if (Date.now() - this.last_flush_time < this.flush_interval_ms) { + return; + } + + this.flushToClearcut(); + this.last_flush_time = Date.now(); + } + + flushToClearcut(): Promise<LogResponse> { + return new Promise<Buffer>((resolve, reject) => { + const request = [ + { + log_source_name: 'CONCORD', + request_time_ms: Date.now(), + log_event: this.events, + }, + ]; + const body = JSON.stringify(request); + const options = { + hostname: 'play.googleapis.com', + path: '/log', + method: 'POST', + headers: { 'Content-Length': Buffer.byteLength(body) }, + }; + const bufs: Buffer[] = []; + const req = https.request(options, (res) => { + res.on('data', (buf) => bufs.push(buf)); + res.on('end', () => { + resolve(Buffer.concat(bufs)); + }); + }); + req.on('error', (e) => { + reject(e); + }); + req.end(body); + }).then((buf: Buffer) => { + try { + this.events.length = 0; + return this.decodeLogResponse(buf) || {}; + } catch (error: unknown) { + console.error('Error flushing log events:', error); + return {}; + } + }); + } + + // Visible for testing. Decodes protobuf-encoded response from Clearcut server. + decodeLogResponse(buf: Buffer): LogResponse | undefined { + // TODO(obrienowen): return specific errors to facilitate debugging. + if (buf.length < 1) { + return undefined; + } + + // The first byte of the buffer is `field<<3 | type`. We're looking for field + // 1, with type varint, represented by type=0. If the first byte isn't 8, that + // means field 1 is missing or the message is corrupted. Either way, we return + // undefined. + if (buf.readUInt8(0) !== 8) { + return undefined; + } + + let ms = BigInt(0); + let cont = true; + + // In each byte, the most significant bit is the continuation bit. If it's + // set, we keep going. The lowest 7 bits, are data bits. They are concatenated + // in reverse order to form the final number. + for (let i = 1; cont && i < buf.length; i++) { + const byte = buf.readUInt8(i); + ms |= BigInt(byte & 0x7f) << BigInt(7 * (i - 1)); + cont = (byte & 0x80) !== 0; + } + + if (cont) { + // We have fallen off the buffer without seeing a terminating byte. The + // message is corrupted. + return undefined; + } + return { + nextRequestWaitMs: Number(ms), + }; + } + + logStartSessionEvent(event: StartSessionEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set(EventMetadataKey.GEMINI_CLI_START_SESSION_MODEL, event.model); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_EMBEDDING_MODEL, + event.embedding_model, + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_SANDBOX, + event.sandbox_enabled.toString(), + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_CORE_TOOLS, + event.core_tools_enabled, + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_APPROVAL_MODE, + event.approval_mode, + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_API_KEY_ENABLED, + event.api_key_enabled.toString(), + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_VERTEX_API_ENABLED, + event.vertex_ai_enabled.toString(), + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_DEBUG_MODE_ENABLED, + event.debug_enabled.toString(), + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_SERVERS, + event.mcp_servers, + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_TELEMETRY_ENABLED, + event.telemetry_enabled.toString(), + ); + data.set( + EventMetadataKey.GEMINI_CLI_START_SESSION_TELEMETRY_LOG_USER_PROMPTS_ENABLED, + event.telemetry_log_user_prompts_enabled.toString(), + ); + + this.enqueueLogEvent(this.createLogEvent(start_session_event_name, data)); + this.flushIfNeeded(); + } + + logNewPromptEvent(event: UserPromptEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set( + EventMetadataKey.GEMINI_CLI_USER_PROMPT_LENGTH, + JSON.stringify(event.prompt_length), + ); + + this.enqueueLogEvent(this.createLogEvent(new_prompt_event_name, data)); + this.flushIfNeeded(); + } + + logToolCallEvent(event: ToolCallEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set(EventMetadataKey.GEMINI_CLI_TOOL_CALL_NAME, event.function_name); + data.set( + EventMetadataKey.GEMINI_CLI_TOOL_CALL_DECISION, + JSON.stringify(event.decision), + ); + data.set( + EventMetadataKey.GEMINI_CLI_TOOL_CALL_SUCCESS, + JSON.stringify(event.success), + ); + data.set( + EventMetadataKey.GEMINI_CLI_TOOL_CALL_DURATION_MS, + JSON.stringify(event.duration_ms), + ); + data.set( + EventMetadataKey.GEMINI_CLI_TOOL_ERROR_MESSAGE, + JSON.stringify(event.error), + ); + data.set( + EventMetadataKey.GEMINI_CLI_TOOL_CALL_ERROR_TYPE, + JSON.stringify(event.error_type), + ); + + this.enqueueLogEvent(this.createLogEvent(tool_call_event_name, data)); + this.flushIfNeeded(); + } + + logApiRequestEvent(event: ApiRequestEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set(EventMetadataKey.GEMINI_CLI_API_REQUEST_MODEL, event.model); + + this.enqueueLogEvent(this.createLogEvent(api_request_event_name, data)); + this.flushIfNeeded(); + } + + logApiResponseEvent(event: ApiResponseEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set(EventMetadataKey.GEMINI_CLI_API_RESPONSE_MODEL, event.model); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_STATUS_CODE, + JSON.stringify(event.status_code), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_DURATION_MS, + JSON.stringify(event.duration_ms), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_ERROR_MESSAGE, + JSON.stringify(event.error), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_INPUT_TOKEN_COUNT, + JSON.stringify(event.input_token_count), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_OUTPUT_TOKEN_COUNT, + JSON.stringify(event.output_token_count), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_CACHED_TOKEN_COUNT, + JSON.stringify(event.cached_content_token_count), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_THINKING_TOKEN_COUNT, + JSON.stringify(event.thoughts_token_count), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT, + JSON.stringify(event.tool_token_count), + ); + + this.enqueueLogEvent(this.createLogEvent(api_response_event_name, data)); + this.flushIfNeeded(); + } + + logApiErrorEvent(event: ApiErrorEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set(EventMetadataKey.GEMINI_CLI_API_ERROR_MODEL, event.model); + data.set( + EventMetadataKey.GEMINI_CLI_API_ERROR_TYPE, + JSON.stringify(event.error_type), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_ERROR_STATUS_CODE, + JSON.stringify(event.status_code), + ); + data.set( + EventMetadataKey.GEMINI_CLI_API_ERROR_DURATION_MS, + JSON.stringify(event.duration_ms), + ); + + this.enqueueLogEvent(this.createLogEvent(api_error_event_name, data)); + this.flushIfNeeded(); + } + + logEndSessionEvent(event: EndSessionEvent): void { + const data: Map<EventMetadataKey, string> = new Map(); + + data.set( + EventMetadataKey.GEMINI_CLI_END_SESSION_ID, + event?.session_id?.toString() ?? '', + ); + + this.enqueueLogEvent(this.createLogEvent(end_session_event_name, data)); + // Flush immediately on session end. + this.flushToClearcut(); + } + + shutdown() { + const event = new EndSessionEvent(this.config); + this.logEndSessionEvent(event); + } +} |
