summaryrefslogtreecommitdiff
path: root/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
diff options
context:
space:
mode:
authorowenofbrien <[email protected]>2025-06-22 09:26:48 -0500
committerGitHub <[email protected]>2025-06-22 14:26:48 +0000
commit4cfab0a8931decca8c953de1e5715e40ee31ee9a (patch)
treedd45db52d57060058213d3fb0b7a126ab043ce4d /packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
parentc9950b3cb273246d801a5cbb04cf421d4c5e39c4 (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.ts338
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);
+ }
+}