From 39b3f20a2285e85f265cc0a160ff34cc4de27321 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Sun, 11 Jan 2026 21:22:49 +0800 Subject: [PATCH] feat(cli): implement passive activity logger for session analysis (#15829) --- packages/cli/src/gemini.test.tsx | 18 + packages/cli/src/gemini.tsx | 7 + packages/cli/src/nonInteractiveCli.test.ts | 3 +- packages/cli/src/utils/activityLogger.ts | 371 +++++++++++++++++++++ packages/core/src/config/storage.ts | 4 + 5 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/utils/activityLogger.ts diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 950705bfca..9619035b0d 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -142,6 +142,9 @@ vi.mock('./config/config.js', () => ({ getQuestion: vi.fn(() => ''), isInteractive: () => false, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), @@ -436,6 +439,9 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ errors: [], @@ -793,6 +799,9 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any try { @@ -875,6 +884,9 @@ describe('gemini.tsx main function kitty protocol', () => { getUsageStatisticsEnabled: () => false, getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as any); // eslint-disable-line @typescript-eslint/no-explicit-any // The mock is already set up at the top of the test @@ -1115,6 +1127,9 @@ describe('gemini.tsx main function exit codes', () => { getUsageStatisticsEnabled: () => false, getRemoteAdminSettings: () => undefined, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ merged: { security: { auth: {} }, ui: {} }, @@ -1180,6 +1195,9 @@ describe('gemini.tsx main function exit codes', () => { getExtensions: () => [], getUsageStatisticsEnabled: () => false, setTerminalBackground: vi.fn(), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'), + }, getRemoteAdminSettings: () => undefined, } as unknown as Config); vi.mocked(loadSettings).mockReturnValue({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 32284b132d..53153c2944 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -466,6 +466,13 @@ export async function main() { }); loadConfigHandle?.end(); + if (config.isInteractive() && config.storage && config.getDebugMode()) { + const { registerActivityLogger } = await import( + './utils/activityLogger.js' + ); + registerActivityLogger(config); + } + // Register config for telemetry shutdown // This ensures telemetry (including SessionEnd hooks) is properly flushed on exit registerTelemetryConfig(config); diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index c171d95a74..c4ce96452a 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -42,9 +42,10 @@ vi.mock('./ui/hooks/atCommandProcessor.js'); const mockCoreEvents = vi.hoisted(() => ({ on: vi.fn(), off: vi.fn(), - drainBacklogs: vi.fn(), emit: vi.fn(), + emitConsoleLog: vi.fn(), emitFeedback: vi.fn(), + drainBacklogs: vi.fn(), })); vi.mock('@google/gemini-cli-core', async (importOriginal) => { diff --git a/packages/cli/src/utils/activityLogger.ts b/packages/cli/src/utils/activityLogger.ts new file mode 100644 index 0000000000..486ed4858a --- /dev/null +++ b/packages/cli/src/utils/activityLogger.ts @@ -0,0 +1,371 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ +/* eslint-disable @typescript-eslint/no-this-alias */ + +import http from 'node:http'; +import https from 'node:https'; +import zlib from 'node:zlib'; +import fs from 'node:fs'; +import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { CoreEvent, coreEvents, debugLogger } from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; + +const ACTIVITY_ID_HEADER = 'x-activity-request-id'; + +export interface NetworkLog { + id: string; + timestamp: number; + method: string; + url: string; + headers: Record; + body?: string; + pending?: boolean; + response?: { + status: number; + headers: Record; + body?: string; + durationMs: number; + }; + error?: string; +} + +/** + * Capture utility for session activities (network and console). + * Provides a stream of events that can be persisted for analysis or inspection. + */ +export class ActivityLogger extends EventEmitter { + private static instance: ActivityLogger; + private isInterceptionEnabled = false; + private requestStartTimes = new Map(); + + static getInstance(): ActivityLogger { + if (!ActivityLogger.instance) { + ActivityLogger.instance = new ActivityLogger(); + } + return ActivityLogger.instance; + } + + private stringifyHeaders(headers: unknown): Record { + const result: Record = {}; + if (!headers) return result; + + if (headers instanceof Headers) { + headers.forEach((v, k) => { + result[k.toLowerCase()] = v; + }); + } else if (typeof headers === 'object' && headers !== null) { + for (const [key, val] of Object.entries(headers)) { + result[key.toLowerCase()] = Array.isArray(val) + ? val.join(', ') + : String(val); + } + } + return result; + } + + private sanitizeNetworkLog(log: any): any { + if (!log || typeof log !== 'object') return log; + + const sanitized = { ...log }; + + // Sanitize request headers + if (sanitized.headers) { + const headers = { ...sanitized.headers }; + for (const key of Object.keys(headers)) { + if ( + ['authorization', 'cookie', 'x-goog-api-key'].includes( + key.toLowerCase(), + ) + ) { + headers[key] = '[REDACTED]'; + } + } + sanitized.headers = headers; + } + + // Sanitize response headers + if (sanitized.response?.headers) { + const resHeaders = { ...sanitized.response.headers }; + for (const key of Object.keys(resHeaders)) { + if (['set-cookie'].includes(key.toLowerCase())) { + resHeaders[key] = '[REDACTED]'; + } + } + sanitized.response = { ...sanitized.response, headers: resHeaders }; + } + + return sanitized; + } + + private safeEmitNetwork(payload: any) { + this.emit('network', this.sanitizeNetworkLog(payload)); + } + + enable() { + if (this.isInterceptionEnabled) return; + this.isInterceptionEnabled = true; + + this.patchGlobalFetch(); + this.patchNodeHttp(); + } + + private patchGlobalFetch() { + if (!global.fetch) return; + const originalFetch = global.fetch; + + global.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as any).url; + if (url.includes('127.0.0.1')) return originalFetch(input, init); + + const id = Math.random().toString(36).substring(7); + const method = (init?.method || 'GET').toUpperCase(); + + const newInit = { ...init }; + const headers = new Headers(init?.headers || {}); + headers.set(ACTIVITY_ID_HEADER, id); + newInit.headers = headers; + + let reqBody = ''; + if (init?.body) { + if (typeof init.body === 'string') reqBody = init.body; + else if (init.body instanceof URLSearchParams) + reqBody = init.body.toString(); + } + + this.requestStartTimes.set(id, Date.now()); + this.safeEmitNetwork({ + id, + timestamp: Date.now(), + method, + url, + headers: this.stringifyHeaders(newInit.headers), + body: reqBody, + pending: true, + }); + + try { + const response = await originalFetch(input, newInit); + const clonedRes = response.clone(); + + clonedRes + .text() + .then((text) => { + const startTime = this.requestStartTimes.get(id); + const durationMs = startTime ? Date.now() - startTime : 0; + this.requestStartTimes.delete(id); + + this.safeEmitNetwork({ + id, + pending: false, + response: { + status: response.status, + headers: this.stringifyHeaders(response.headers), + body: text, + durationMs, + }, + }); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : String(err); + this.safeEmitNetwork({ + id, + pending: false, + error: `Failed to read response body: ${message}`, + }); + }); + + return response; + } catch (err: unknown) { + this.requestStartTimes.delete(id); + const message = err instanceof Error ? err.message : String(err); + this.safeEmitNetwork({ id, pending: false, error: message }); + throw err; + } + }; + } + + private patchNodeHttp() { + const self = this; + const originalRequest = http.request; + const originalHttpsRequest = https.request; + + const wrapRequest = (originalFn: any, args: any[], protocol: string) => { + const options = args[0]; + const url = + typeof options === 'string' + ? options + : options.href || + `${protocol}//${options.hostname || options.host || 'localhost'}${options.path || '/'}`; + + if (url.includes('127.0.0.1')) return originalFn.apply(http, args); + + const headers = + typeof options === 'object' && typeof options !== 'function' + ? (options as any).headers + : {}; + if (headers && headers[ACTIVITY_ID_HEADER]) { + delete headers[ACTIVITY_ID_HEADER]; + return originalFn.apply(http, args); + } + + const id = Math.random().toString(36).substring(7); + self.requestStartTimes.set(id, Date.now()); + const req = originalFn.apply(http, args); + const requestChunks: Buffer[] = []; + + const oldWrite = req.write; + const oldEnd = req.end; + + req.write = function (chunk: any, ...etc: any[]) { + if (chunk) { + const encoding = + typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined; + requestChunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding), + ); + } + return oldWrite.apply(this, [chunk, ...etc]); + }; + + req.end = function (this: any, chunk: any, ...etc: any[]) { + if (chunk && typeof chunk !== 'function') { + const encoding = + typeof etc[0] === 'string' ? (etc[0] as BufferEncoding) : undefined; + requestChunks.push( + Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding), + ); + } + const body = Buffer.concat(requestChunks).toString('utf8'); + + self.safeEmitNetwork({ + id, + timestamp: Date.now(), + method: req.method || 'GET', + url, + headers: self.stringifyHeaders(req.getHeaders()), + body, + pending: true, + }); + return oldEnd.apply(this, [chunk, ...etc]); + }; + + req.on('response', (res: any) => { + const responseChunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => + responseChunks.push(Buffer.from(chunk)), + ); + res.on('end', () => { + const buffer = Buffer.concat(responseChunks); + const encoding = res.headers['content-encoding']; + + const processBuffer = (finalBuffer: Buffer) => { + const resBody = finalBuffer.toString('utf8'); + const startTime = self.requestStartTimes.get(id); + const durationMs = startTime ? Date.now() - startTime : 0; + self.requestStartTimes.delete(id); + + self.safeEmitNetwork({ + id, + pending: false, + response: { + status: res.statusCode, + headers: self.stringifyHeaders(res.headers), + body: resBody, + durationMs, + }, + }); + }; + + if (encoding === 'gzip') { + zlib.gunzip(buffer, (err, decompressed) => { + processBuffer(err ? buffer : decompressed); + }); + } else if (encoding === 'deflate') { + zlib.inflate(buffer, (err, decompressed) => { + processBuffer(err ? buffer : decompressed); + }); + } else { + processBuffer(buffer); + } + }); + }); + + req.on('error', (err: any) => { + self.requestStartTimes.delete(id); + const message = err instanceof Error ? err.message : String(err); + self.safeEmitNetwork({ id, pending: false, error: message }); + }); + + return req; + }; + + http.request = (...args: any[]) => + wrapRequest(originalRequest, args, 'http:'); + https.request = (...args: any[]) => + wrapRequest(originalHttpsRequest, args, 'https:'); + } + + logConsole(payload: unknown) { + this.emit('console', payload); + } +} + +/** + * Registers the activity logger if debug mode and interactive session are enabled. + * Captures network and console logs to a session-specific JSONL file. + * + * @param config The CLI configuration + */ +export function registerActivityLogger(config: Config) { + if (config.isInteractive() && config.storage && config.getDebugMode()) { + const capture = ActivityLogger.getInstance(); + capture.enable(); + + const logsDir = config.storage.getProjectTempLogsDir(); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir, { recursive: true }); + } + + const logFile = path.join( + logsDir, + `session-${config.getSessionId()}.jsonl`, + ); + const writeToLog = (type: 'console' | 'network', payload: unknown) => { + try { + const entry = + JSON.stringify({ + type, + payload, + timestamp: Date.now(), + }) + '\n'; + + // Use asynchronous fire-and-forget to avoid blocking the event loop + fs.promises.appendFile(logFile, entry).catch((err) => { + debugLogger.error('Failed to write to activity log:', err); + }); + } catch (err) { + debugLogger.error('Failed to prepare activity log entry:', err); + } + }; + + capture.on('console', (payload) => writeToLog('console', payload)); + capture.on('network', (payload) => writeToLog('network', payload)); + + // Bridge CoreEvents to local capture + coreEvents.on(CoreEvent.ConsoleLog, (payload) => { + capture.logConsole(payload); + }); + } +} diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index bfadc2f7b7..da7142d09c 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -143,6 +143,10 @@ export class Storage { return path.join(this.getProjectTempDir(), 'checkpoints'); } + getProjectTempLogsDir(): string { + return path.join(this.getProjectTempDir(), 'logs'); + } + getExtensionsDir(): string { return path.join(this.getGeminiDir(), 'extensions'); }