From 02f67d3c573b874aa3dd175f5f8774bb9c2cb52a Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 9 Sep 2025 12:12:56 -0400 Subject: [PATCH] Add clearcut logging for extensions install command (#8057) Co-authored-by: Bryan Morgan --- .../cli/src/commands/extensions/install.ts | 6 +- packages/cli/src/config/extension.test.ts | 31 ++++ packages/cli/src/config/extension.ts | 166 +++++++++++------- packages/core/index.ts | 2 + .../clearcut-logger/clearcut-logger.ts | 28 +++ .../clearcut-logger/event-metadata-key.ts | 16 ++ packages/core/src/telemetry/types.ts | 26 ++- 7 files changed, 209 insertions(+), 66 deletions(-) diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 9beeda870b..e33783f2ce 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -45,10 +45,8 @@ export async function handleInstall(args: InstallArgs) { throw new Error('Either --source or --path must be provided.'); } - const extensionName = await installExtension(installMetadata); - console.log( - `Extension "${extensionName}" installed successfully and enabled.`, - ); + const name = await installExtension(installMetadata); + console.log(`Extension "${name}" installed successfully and enabled.`); } catch (error) { console.error(getErrorMessage(error)); process.exit(1); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 4483a02d2c..d647bec4e3 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -26,6 +26,8 @@ import { GEMINI_DIR, type GeminiCLIExtension, type MCPServerConfig, + ClearcutLogger, + type Config, } from '@google/gemini-cli-core'; import { execSync } from 'node:child_process'; import { SettingScope, loadSettings } from './settings.js'; @@ -52,6 +54,22 @@ vi.mock('./trustedFolders.js', async (importOriginal) => { }; }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const mockLogExtensionInstallEvent = vi.fn(); + return { + ...actual, + ClearcutLogger: { + getInstance: vi.fn(() => ({ + logExtensionInstallEvent: mockLogExtensionInstallEvent, + })), + }, + Config: vi.fn(), + ExtensionInstallEvent: vi.fn(), + }; +}); + vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); return { @@ -519,6 +537,19 @@ describe('installExtension', () => { }); fs.rmSync(targetExtDir, { recursive: true, force: true }); }); + + it('should log to clearcut on successful install', async () => { + const sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await installExtension({ source: sourceExtDir, type: 'local' }); + + const logger = ClearcutLogger.getInstance({} as Config); + expect(logger?.logExtensionInstallEvent).toHaveBeenCalled(); + }); }); describe('uninstallExtension', () => { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index f1d85aa5c2..2c245b7fc6 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -8,7 +8,13 @@ import type { MCPServerConfig, GeminiCLIExtension, } from '@google/gemini-cli-core'; -import { GEMINI_DIR, Storage } from '@google/gemini-cli-core'; +import { + GEMINI_DIR, + Storage, + ClearcutLogger, + Config, + ExtensionInstallEvent, +} from '@google/gemini-cli-core'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; @@ -18,6 +24,7 @@ import { getErrorMessage } from '../utils/errors.js'; import { recursivelyHydrateStrings } from './extensions/variables.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; +import { randomUUID } from 'node:crypto'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); @@ -346,83 +353,120 @@ export async function installExtension( installMetadata: ExtensionInstallMetadata, cwd: string = process.cwd(), ): Promise { - const settings = loadSettings(cwd).merged; - if (!isWorkspaceTrusted(settings)) { - throw new Error( - `Could not install extension from untrusted folder at ${installMetadata.source}`, - ); - } - - const extensionsDir = ExtensionStorage.getUserExtensionsDir(); - await fs.promises.mkdir(extensionsDir, { recursive: true }); - - // Convert relative paths to absolute paths for the metadata file. - if ( - !path.isAbsolute(installMetadata.source) && - (installMetadata.type === 'local' || installMetadata.type === 'link') - ) { - installMetadata.source = path.resolve(cwd, installMetadata.source); - } - - let localSourcePath: string; - let tempDir: string | undefined; - let newExtensionName: string | undefined; - - if (installMetadata.type === 'git') { - tempDir = await ExtensionStorage.createTmpDir(); - await cloneFromGit(installMetadata.source, tempDir); - localSourcePath = tempDir; - } else if ( - installMetadata.type === 'local' || - installMetadata.type === 'link' - ) { - localSourcePath = installMetadata.source; - } else { - throw new Error(`Unsupported install type: ${installMetadata.type}`); - } + const config = new Config({ + sessionId: randomUUID(), + targetDir: process.cwd(), + cwd: process.cwd(), + model: '', + debugMode: false, + }); + const logger = ClearcutLogger.getInstance(config); + let newExtensionConfig: ExtensionConfig | null = null; + let localSourcePath: string | undefined; try { - const newExtensionConfig = await loadExtensionConfig(localSourcePath); - if (!newExtensionConfig) { + const settings = loadSettings(cwd).merged; + if (!isWorkspaceTrusted(settings)) { throw new Error( - `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, + `Could not install extension from untrusted folder at ${installMetadata.source}`, ); } - newExtensionName = newExtensionConfig.name; - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); + const extensionsDir = ExtensionStorage.getUserExtensionsDir(); + await fs.promises.mkdir(extensionsDir, { recursive: true }); - const installedExtensions = loadUserExtensions(); if ( - installedExtensions.some( - (installed) => installed.config.name === newExtensionName, - ) + !path.isAbsolute(installMetadata.source) && + (installMetadata.type === 'local' || installMetadata.type === 'link') ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + installMetadata.source = path.resolve(cwd, installMetadata.source); + } + + let tempDir: string | undefined; + + if (installMetadata.type === 'git') { + tempDir = await ExtensionStorage.createTmpDir(); + await cloneFromGit(installMetadata.source, tempDir); + localSourcePath = tempDir; + } else if ( + installMetadata.type === 'local' || + installMetadata.type === 'link' + ) { + localSourcePath = installMetadata.source; + } else { + throw new Error(`Unsupported install type: ${installMetadata.type}`); + } + + try { + newExtensionConfig = await loadExtensionConfig(localSourcePath); + if (!newExtensionConfig) { + throw new Error( + `Invalid extension at ${installMetadata.source}. Please make sure it has a valid gemini-extension.json file.`, + ); + } + + const newExtensionName = newExtensionConfig.name; + const extensionStorage = new ExtensionStorage(newExtensionName); + const destinationPath = extensionStorage.getExtensionDir(); + + const installedExtensions = loadUserExtensions(); + if ( + installedExtensions.some( + (installed) => installed.config.name === newExtensionName, + ) + ) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } + + await fs.promises.mkdir(destinationPath, { recursive: true }); + + if (installMetadata.type === 'local' || installMetadata.type === 'git') { + await copyExtension(localSourcePath, destinationPath); + } + + const metadataString = JSON.stringify(installMetadata, null, 2); + const metadataPath = path.join( + destinationPath, + INSTALL_METADATA_FILENAME, ); + await fs.promises.writeFile(metadataPath, metadataString); + } finally { + if (tempDir) { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } } - await fs.promises.mkdir(destinationPath, { recursive: true }); + logger?.logExtensionInstallEvent( + new ExtensionInstallEvent( + newExtensionConfig!.name, + newExtensionConfig!.version, + installMetadata.source, + 'success', + ), + ); - if (installMetadata.type === 'local' || installMetadata.type === 'git') { - await copyExtension(localSourcePath, destinationPath); - } - - const metadataString = JSON.stringify(installMetadata, null, 2); - const metadataPath = path.join(destinationPath, INSTALL_METADATA_FILENAME); - await fs.promises.writeFile(metadataPath, metadataString); - } finally { - if (tempDir) { - await fs.promises.rm(tempDir, { recursive: true, force: true }); + return newExtensionConfig!.name; + } catch (error) { + // Attempt to load config from the source path even if installation fails + // to get the name and version for logging. + if (!newExtensionConfig && localSourcePath) { + newExtensionConfig = await loadExtensionConfig(localSourcePath); } + logger?.logExtensionInstallEvent( + new ExtensionInstallEvent( + newExtensionConfig?.name ?? '', + newExtensionConfig?.version ?? '', + installMetadata.source, + 'error', + ), + ); + throw error; } - - return newExtensionName; } -async function loadExtensionConfig( +export async function loadExtensionConfig( extensionDir: string, ): Promise { const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); diff --git a/packages/core/index.ts b/packages/core/index.ts index be8ca39dd7..c6b74ba366 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -21,7 +21,9 @@ export { logIdeConnection } from './src/telemetry/loggers.js'; export { IdeConnectionEvent, IdeConnectionType, + ExtensionInstallEvent, } from './src/telemetry/types.js'; export { getIdeTrust } from './src/utils/ide-trust.js'; export { makeFakeConfig } from './src/test-utils/config.js'; export * from './src/utils/pathReader.js'; +export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 994d23cbb6..7cd25e8cf8 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -24,6 +24,7 @@ import type { InvalidChunkEvent, ContentRetryEvent, ContentRetryFailureEvent, + ExtensionInstallEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -56,6 +57,7 @@ export enum EventNames { INVALID_CHUNK = 'invalid_chunk', CONTENT_RETRY = 'content_retry', CONTENT_RETRY_FAILURE = 'content_retry_failure', + EXTENSION_INSTALL = 'extension_install', } export interface LogResponse { @@ -833,6 +835,32 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logExtensionInstallEvent(event: ExtensionInstallEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, + value: event.extension_name, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION, + value: event.extension_version, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE, + value: event.extension_source, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_INSTALL_STATUS, + value: event.status, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.EXTENSION_INSTALL, data), + ); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 31e718a466..2351c085ed 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -331,4 +331,20 @@ export enum EventMetadataKey { // Logs the current nodejs version GEMINI_CLI_NODE_VERSION = 83, + + // ========================================================================== + // Extension Install Event Keys + // =========================================================================== + + // Logs the name of the extension. + GEMINI_CLI_EXTENSION_NAME = 85, + + // Logs the version of the extension. + GEMINI_CLI_EXTENSION_VERSION = 86, + + // Logs the source of the extension. + GEMINI_CLI_EXTENSION_SOURCE = 87, + + // Logs the status of the extension install. + GEMINI_CLI_EXTENSION_INSTALL_STATUS = 88, } diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index ac816bdba8..cc35a0d421 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -531,4 +531,28 @@ export type TelemetryEvent = | FileOperationEvent | InvalidChunkEvent | ContentRetryEvent - | ContentRetryFailureEvent; + | ContentRetryFailureEvent + | ExtensionInstallEvent; + +export class ExtensionInstallEvent implements BaseTelemetryEvent { + 'event.name': 'extension_install'; + 'event.timestamp': string; + extension_name: string; + extension_version: string; + extension_source: string; + status: 'success' | 'error'; + + constructor( + extension_name: string, + extension_version: string, + extension_source: string, + status: 'success' | 'error', + ) { + this['event.name'] = 'extension_install'; + this['event.timestamp'] = new Date().toISOString(); + this.extension_name = extension_name; + this.extension_version = extension_version; + this.extension_source = extension_source; + this.status = status; + } +}