diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 3e479d7649..3f85015da1 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -8,12 +8,12 @@ import { describe, it, expect, vi, type MockInstance } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; -const mockInstallExtension = vi.hoisted(() => vi.fn()); +const mockInstallOrUpdateExtension = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); const mockStat = vi.hoisted(() => vi.fn()); vi.mock('../../config/extension.js', () => ({ - installExtension: mockInstallExtension, + installOrUpdateExtension: mockInstallOrUpdateExtension, requestConsentNonInteractive: mockRequestConsentNonInteractive, })); @@ -51,14 +51,14 @@ describe('handleInstall', () => { }); afterEach(() => { - mockInstallExtension.mockClear(); + mockInstallOrUpdateExtension.mockClear(); mockRequestConsentNonInteractive.mockClear(); mockStat.mockClear(); vi.resetAllMocks(); }); it('should install an extension from a http source', async () => { - mockInstallExtension.mockResolvedValue('http-extension'); + mockInstallOrUpdateExtension.mockResolvedValue('http-extension'); await handleInstall({ source: 'http://google.com', @@ -70,7 +70,7 @@ describe('handleInstall', () => { }); it('should install an extension from a https source', async () => { - mockInstallExtension.mockResolvedValue('https-extension'); + mockInstallOrUpdateExtension.mockResolvedValue('https-extension'); await handleInstall({ source: 'https://google.com', @@ -82,7 +82,7 @@ describe('handleInstall', () => { }); it('should install an extension from a git source', async () => { - mockInstallExtension.mockResolvedValue('git-extension'); + mockInstallOrUpdateExtension.mockResolvedValue('git-extension'); await handleInstall({ source: 'git@some-url', @@ -104,7 +104,7 @@ describe('handleInstall', () => { }); it('should install an extension from a sso source', async () => { - mockInstallExtension.mockResolvedValue('sso-extension'); + mockInstallOrUpdateExtension.mockResolvedValue('sso-extension'); await handleInstall({ source: 'sso://google.com', @@ -116,7 +116,7 @@ describe('handleInstall', () => { }); it('should install an extension from a local path', async () => { - mockInstallExtension.mockResolvedValue('local-extension'); + mockInstallOrUpdateExtension.mockResolvedValue('local-extension'); mockStat.mockResolvedValue({}); await handleInstall({ source: '/some/path', @@ -128,7 +128,7 @@ describe('handleInstall', () => { }); it('should throw an error if install extension fails', async () => { - mockInstallExtension.mockRejectedValue( + mockInstallOrUpdateExtension.mockRejectedValue( new Error('Install extension failed'), ); diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index eb8670dd87..e2fbcb84f7 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -6,7 +6,7 @@ import type { CommandModule } from 'yargs'; import { - installExtension, + installOrUpdateExtension, requestConsentNonInteractive, } from '../../config/extension.js'; import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; @@ -54,7 +54,7 @@ export async function handleInstall(args: InstallArgs) { } } - const name = await installExtension( + const name = await installOrUpdateExtension( installMetadata, requestConsentNonInteractive, ); diff --git a/packages/cli/src/commands/extensions/link.ts b/packages/cli/src/commands/extensions/link.ts index 330aa738bd..fad958a6bf 100644 --- a/packages/cli/src/commands/extensions/link.ts +++ b/packages/cli/src/commands/extensions/link.ts @@ -6,7 +6,7 @@ import type { CommandModule } from 'yargs'; import { - installExtension, + installOrUpdateExtension, requestConsentNonInteractive, } from '../../config/extension.js'; import type { ExtensionInstallMetadata } from '@google/gemini-cli-core'; @@ -23,7 +23,7 @@ export async function handleLink(args: InstallArgs) { source: args.path, type: 'link', }; - const extensionName = await installExtension( + const extensionName = await installOrUpdateExtension( installMetadata, requestConsentNonInteractive, ); diff --git a/packages/cli/src/commands/extensions/uninstall.ts b/packages/cli/src/commands/extensions/uninstall.ts index d7c131962b..615698e193 100644 --- a/packages/cli/src/commands/extensions/uninstall.ts +++ b/packages/cli/src/commands/extensions/uninstall.ts @@ -14,7 +14,7 @@ interface UninstallArgs { export async function handleUninstall(args: UninstallArgs) { try { - await uninstallExtension(args.name); + await uninstallExtension(args.name, false); console.log(`Extension "${args.name}" successfully uninstalled.`); } catch (error) { console.error(getErrorMessage(error)); diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index ee91582d6e..b5af3a5792 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -15,7 +15,7 @@ import { annotateActiveExtensions, disableExtension, enableExtension, - installExtension, + installOrUpdateExtension, loadExtension, loadExtensionConfig, loadExtensions, @@ -73,6 +73,7 @@ vi.mock('./trustedFolders.js', async (importOriginal) => { const mockLogExtensionEnable = vi.hoisted(() => vi.fn()); const mockLogExtensionInstallEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionUninstall = vi.hoisted(() => vi.fn()); +const mockLogExtensionUpdateEvent = vi.hoisted(() => vi.fn()); const mockLogExtensionDisable = vi.hoisted(() => vi.fn()); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -82,6 +83,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { logExtensionEnable: mockLogExtensionEnable, logExtensionInstallEvent: mockLogExtensionInstallEvent, logExtensionUninstall: mockLogExtensionUninstall, + logExtensionUpdateEvent: mockLogExtensionUpdateEvent, logExtensionDisable: mockLogExtensionDisable, ExtensionEnableEvent: vi.fn(), ExtensionInstallEvent: vi.fn(), @@ -260,7 +262,7 @@ describe('extension tests', () => { }); fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context'); - const extensionName = await installExtension( + const extensionName = await installOrUpdateExtension( { source: sourceExtDir, type: 'link', @@ -703,7 +705,7 @@ describe('extension tests', () => { const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - await installExtension( + await installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ); @@ -724,12 +726,12 @@ describe('extension tests', () => { name: 'my-local-extension', version: '1.0.0', }); - await installExtension( + await installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ), @@ -744,7 +746,7 @@ describe('extension tests', () => { const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ), @@ -761,7 +763,7 @@ describe('extension tests', () => { fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ), @@ -786,7 +788,7 @@ describe('extension tests', () => { fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' })); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ), @@ -812,7 +814,7 @@ describe('extension tests', () => { }); mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]); - await installExtension( + await installOrUpdateExtension( { source: gitUrl, type: 'git' }, async (_) => true, ); @@ -836,7 +838,7 @@ describe('extension tests', () => { const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME); - await installExtension( + await installOrUpdateExtension( { source: sourceExtDir, type: 'link' }, async (_) => true, ); @@ -854,20 +856,77 @@ describe('extension tests', () => { 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', - }); + describe.each([true, false])( + 'with previous extension config: %s', + (isUpdate: boolean) => { + let sourceExtDir: string; - await installExtension( - { source: sourceExtDir, type: 'local' }, - async (_) => true, - ); + beforeEach(async () => { + sourceExtDir = createExtension({ + extensionsDir: tempHomeDir, + name: 'my-local-extension', + version: '1.1.0', + }); + if (isUpdate) { + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + ); + } + // Clears out any calls to mocks from the above function calls. + vi.clearAllMocks(); + }); - expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); - }); + it(`should log an ${isUpdate ? 'update' : 'install'} event to clearcut on success`, async () => { + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + undefined, + isUpdate + ? { + name: 'my-local-extension', + version: '1.0.0', + } + : undefined, + ); + + if (isUpdate) { + expect(mockLogExtensionUpdateEvent).toHaveBeenCalled(); + expect(mockLogExtensionInstallEvent).not.toHaveBeenCalled(); + } else { + expect(mockLogExtensionInstallEvent).toHaveBeenCalled(); + expect(mockLogExtensionUpdateEvent).not.toHaveBeenCalled(); + } + }); + + it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { + const enablementManager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + enablementManager.enable('my-local-extension', true, '/some/scope'); + + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async (_) => true, + undefined, + isUpdate + ? { + name: 'my-local-extension', + version: '1.0.0', + } + : undefined, + ); + + const config = enablementManager.readConfig()['my-local-extension']; + if (isUpdate) { + expect(config).not.toBeUndefined(); + expect(config.overrides).toContain('/some/scope/*'); + } else { + expect(config).not.toContain('/some/scope/*'); + } + }); + }, + ); it('should show users information on their ansi escaped mcp servers when installing', async () => { const sourceExtDir = createExtension({ @@ -891,7 +950,7 @@ describe('extension tests', () => { mockRequestConsent.mockResolvedValue(true); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, mockRequestConsent, ), @@ -920,7 +979,7 @@ This extension will run the following MCP servers: }); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async () => true, ), @@ -941,7 +1000,7 @@ This extension will run the following MCP servers: }); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async () => false, ), @@ -957,7 +1016,7 @@ This extension will run the following MCP servers: const targetExtDir = path.join(userExtensionsDir, 'my-local-extension'); const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME); - await installExtension( + await installOrUpdateExtension( { source: sourceExtDir, type: 'local', @@ -991,9 +1050,15 @@ This extension will run the following MCP servers: }); const mockRequestConsent = vi.fn(); + // Install it and force consent first. + await installOrUpdateExtension( + { source: sourceExtDir, type: 'local' }, + async () => true, + ); + // Now update it without changing anything. await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, mockRequestConsent, process.cwd(), @@ -1016,7 +1081,7 @@ This extension will run the following MCP servers: }); await expect( - installExtension( + installOrUpdateExtension( { source: sourceExtDir, type: 'local' }, async (_) => true, ), @@ -1032,7 +1097,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); - await uninstallExtension('my-local-extension'); + await uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); }); @@ -1049,7 +1114,7 @@ This extension will run the following MCP servers: version: '1.0.0', }); - await uninstallExtension('my-local-extension'); + await uninstallExtension('my-local-extension', false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect( @@ -1063,25 +1128,54 @@ This extension will run the following MCP servers: }); it('should throw an error if the extension does not exist', async () => { - await expect(uninstallExtension('nonexistent-extension')).rejects.toThrow( - 'Extension not found.', - ); + await expect( + uninstallExtension('nonexistent-extension', false), + ).rejects.toThrow('Extension not found.'); }); - it('should log uninstall event', async () => { - createExtension({ - extensionsDir: userExtensionsDir, - name: 'my-local-extension', - version: '1.0.0', + describe.each([true, false])('with isUpdate: %s', (isUpdate: boolean) => { + it(`should ${isUpdate ? 'not ' : ''}log uninstall event`, async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-local-extension', + version: '1.0.0', + }); + + await uninstallExtension('my-local-extension', isUpdate); + + if (isUpdate) { + expect(mockLogExtensionUninstall).not.toHaveBeenCalled(); + expect(ExtensionUninstallEvent).not.toHaveBeenCalled(); + } else { + expect(mockLogExtensionUninstall).toHaveBeenCalled(); + expect(ExtensionUninstallEvent).toHaveBeenCalledWith( + 'my-local-extension', + 'success', + ); + } }); - await uninstallExtension('my-local-extension'); + it(`should ${isUpdate ? 'not ' : ''} alter the extension enablement configuration`, async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'test-extension', + version: '1.0.0', + }); + const enablementManager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + ); + enablementManager.enable('test-extension', true, '/some/scope'); - expect(mockLogExtensionUninstall).toHaveBeenCalled(); - expect(ExtensionUninstallEvent).toHaveBeenCalledWith( - 'my-local-extension', - 'success', - ); + await uninstallExtension('test-extension', isUpdate); + + const config = enablementManager.readConfig()['test-extension']; + if (isUpdate) { + expect(config).not.toBeUndefined(); + expect(config.overrides).toEqual(['/some/scope/*']); + } else { + expect(config).toBeUndefined(); + } + }); }); it('should uninstall an extension by its source URL', async () => { @@ -1096,7 +1190,7 @@ This extension will run the following MCP servers: }, }); - await uninstallExtension(gitUrl); + await uninstallExtension(gitUrl, false); expect(fs.existsSync(sourceExtDir)).toBe(false); expect(mockLogExtensionUninstall).toHaveBeenCalled(); @@ -1115,7 +1209,10 @@ This extension will run the following MCP servers: }); await expect( - uninstallExtension('https://github.com/google/no-metadata-extension'), + uninstallExtension( + 'https://github.com/google/no-metadata-extension', + false, + ), ).rejects.toThrow('Extension not found.'); }); }); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 5230036582..bfaed90743 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -15,11 +15,13 @@ import { Config, ExtensionInstallEvent, ExtensionUninstallEvent, + ExtensionUpdateEvent, ExtensionDisableEvent, ExtensionEnableEvent, logExtensionEnable, logExtensionInstallEvent, logExtensionUninstall, + logExtensionUpdateEvent, logExtensionDisable, } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; @@ -129,7 +131,7 @@ export async function performWorkspaceExtensionMigration( source: extension.path, type: 'local', }; - await installExtension(installMetadata, requestConsent); + await installOrUpdateExtension(installMetadata, requestConsent); } catch (_) { failedInstallNames.push(extension.name); } @@ -426,12 +428,13 @@ async function promptForConsentInteractive( }); } -export async function installExtension( +export async function installOrUpdateExtension( installMetadata: ExtensionInstallMetadata, requestConsent: (consent: string) => Promise, cwd: string = process.cwd(), previousExtensionConfig?: ExtensionConfig, ): Promise { + const isUpdate = !!previousExtensionConfig; const telemetryConfig = getTelemetryConfig(cwd); let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; @@ -489,24 +492,32 @@ export async function installExtension( }); const newExtensionName = newExtensionConfig.name; - const extensionStorage = new ExtensionStorage(newExtensionName); - const destinationPath = extensionStorage.getExtensionDir(); - - const installedExtensions = loadUserExtensions(); - if ( - installedExtensions.some( - (installed) => installed.name === newExtensionName, - ) - ) { - throw new Error( - `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, - ); + if (!isUpdate) { + const installedExtensions = loadUserExtensions(); + if ( + installedExtensions.some( + (installed) => installed.name === newExtensionName, + ) + ) { + throw new Error( + `Extension "${newExtensionName}" is already installed. Please uninstall it first.`, + ); + } } + await maybeRequestConsentOrFail( newExtensionConfig, requestConsent, previousExtensionConfig, ); + + const extensionStorage = new ExtensionStorage(newExtensionName); + const destinationPath = extensionStorage.getExtensionDir(); + + if (isUpdate) { + await uninstallExtension(newExtensionName, isUpdate, cwd); + } + await fs.promises.mkdir(destinationPath, { recursive: true }); if ( @@ -529,17 +540,30 @@ export async function installExtension( } } - logExtensionInstallEvent( - telemetryConfig, - new ExtensionInstallEvent( - newExtensionConfig!.name, - newExtensionConfig!.version, - installMetadata.source, - 'success', - ), - ); + if (isUpdate) { + logExtensionUpdateEvent( + telemetryConfig, + new ExtensionUpdateEvent( + newExtensionConfig.name, + newExtensionConfig.version, + previousExtensionConfig.version, + installMetadata.source, + 'success', + ), + ); + } else { + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig.name, + newExtensionConfig.version, + installMetadata.source, + 'success', + ), + ); + enableExtension(newExtensionConfig.name, SettingScope.User); + } - enableExtension(newExtensionConfig!.name, SettingScope.User); return newExtensionConfig!.name; } catch (error) { // Attempt to load config from the source path even if installation fails @@ -554,15 +578,28 @@ export async function installExtension( // Ignore error, this is just for logging. } } - logExtensionInstallEvent( - telemetryConfig, - new ExtensionInstallEvent( - newExtensionConfig?.name ?? '', - newExtensionConfig?.version ?? '', - installMetadata.source, - 'error', - ), - ); + if (isUpdate) { + logExtensionUpdateEvent( + telemetryConfig, + new ExtensionUpdateEvent( + newExtensionConfig?.name ?? previousExtensionConfig.name, + newExtensionConfig?.version ?? '', + previousExtensionConfig.version, + installMetadata.source, + 'error', + ), + ); + } else { + logExtensionInstallEvent( + telemetryConfig, + new ExtensionInstallEvent( + newExtensionConfig?.name ?? '', + newExtensionConfig?.version ?? '', + installMetadata.source, + 'error', + ), + ); + } throw error; } } @@ -679,9 +716,9 @@ export function loadExtensionConfig( export async function uninstallExtension( extensionIdentifier: string, + isUpdate: boolean, cwd: string = process.cwd(), ): Promise { - const telemetryConfig = getTelemetryConfig(cwd); const installedExtensions = loadUserExtensions(); const extensionName = installedExtensions.find( (installed) => @@ -692,17 +729,24 @@ export async function uninstallExtension( if (!extensionName) { throw new Error(`Extension not found.`); } - const manager = new ExtensionEnablementManager( - ExtensionStorage.getUserExtensionsDir(), - [extensionName], - ); - manager.remove(extensionName); const storage = new ExtensionStorage(extensionName); await fs.promises.rm(storage.getExtensionDir(), { recursive: true, force: true, }); + + // The rest of the cleanup below here is only for true uninstalls, not + // uninstalls related to updates. + if (isUpdate) return; + + const manager = new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + [extensionName], + ); + manager.remove(extensionName); + + const telemetryConfig = getTelemetryConfig(cwd); logExtensionUninstall( telemetryConfig, new ExtensionUninstallEvent(extensionName, 'success'), diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts index 4314c55533..5c8f615962 100644 --- a/packages/cli/src/config/extensions/update.ts +++ b/packages/cli/src/config/extensions/update.ts @@ -11,8 +11,7 @@ import { } from '../../ui/state/extensions.js'; import { copyExtension, - installExtension, - uninstallExtension, + installOrUpdateExtension, loadExtension, loadInstallMetadata, ExtensionStorage, @@ -65,13 +64,11 @@ export async function updateExtension( const tempDir = await ExtensionStorage.createTmpDir(); try { - await copyExtension(extension.path, tempDir); const previousExtensionConfig = await loadExtensionConfig({ extensionDir: extension.path, workspaceDir: cwd, }); - await uninstallExtension(extension.name, cwd); - await installExtension( + await installOrUpdateExtension( installMetadata, requestConsent, cwd, diff --git a/packages/core/index.ts b/packages/core/index.ts index 0cc2edd874..acc9743e61 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -37,6 +37,7 @@ export { ExtensionDisableEvent, ExtensionEnableEvent, ExtensionUninstallEvent, + ExtensionUpdateEvent, ModelSlashCommandEvent, } from './src/telemetry/types.js'; export { makeFakeConfig } from './src/test-utils/config.js'; diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 225513cf9d..8afeadbc7d 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -36,6 +36,7 @@ import type { AgentStartEvent, AgentFinishEvent, WebFetchFallbackAttemptEvent, + ExtensionUpdateEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -77,6 +78,7 @@ export enum EventNames { EXTENSION_DISABLE = 'extension_disable', EXTENSION_INSTALL = 'extension_install', EXTENSION_UNINSTALL = 'extension_uninstall', + EXTENSION_UPDATE = 'extension_update', TOOL_OUTPUT_TRUNCATED = 'tool_output_truncated', MODEL_ROUTING = 'model_routing', MODEL_SLASH_COMMAND = 'model_slash_command', @@ -929,6 +931,38 @@ export class ClearcutLogger { }); } + logExtensionUpdateEvent(event: ExtensionUpdateEvent): 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_PREVIOUS_VERSION, + value: event.extension_previous_version, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_SOURCE, + value: event.extension_source, + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_UPDATE_STATUS, + value: event.status, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.EXTENSION_UPDATE, data), + ); + this.flushToClearcut().catch((error) => { + console.debug('Error flushing to Clearcut:', error); + }); + } + logToolOutputTruncatedEvent(event: ToolOutputTruncatedEvent): void { const data: EventValue[] = [ { 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 2e242cb424..1615d5f755 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -367,7 +367,7 @@ export enum EventMetadataKey { GEMINI_CLI_NODE_VERSION = 83, // ========================================================================== - // Extension Install Event Keys + // Extension Event Keys // =========================================================================== // Logs the name of the extension. @@ -376,6 +376,9 @@ export enum EventMetadataKey { // Logs the version of the extension. GEMINI_CLI_EXTENSION_VERSION = 86, + // Logs the previous version of the extension. + GEMINI_CLI_EXTENSION_PREVIOUS_VERSION = 117, + // Logs the source of the extension. GEMINI_CLI_EXTENSION_SOURCE = 87, @@ -385,6 +388,9 @@ export enum EventMetadataKey { // Logs the status of the extension uninstall GEMINI_CLI_EXTENSION_UNINSTALL_STATUS = 96, + // Logs the status of the extension uninstall + GEMINI_CLI_EXTENSION_UPDATE_STATUS = 118, + // Logs the setting scope for an extension enablement. GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index da66430e71..6834c7d687 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -44,6 +44,7 @@ export { logExtensionEnable, logExtensionInstallEvent, logExtensionUninstall, + logExtensionUpdateEvent, logWebFetchFallbackAttempt, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 4145bfcb0b..97664f1298 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -42,6 +42,7 @@ import { logAgentStart, logAgentFinish, logWebFetchFallbackAttempt, + logExtensionUpdateEvent, } from './loggers.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { @@ -82,6 +83,8 @@ import { AgentStartEvent, AgentFinishEvent, WebFetchFallbackAttemptEvent, + ExtensionUpdateEvent, + EVENT_EXTENSION_UPDATE, } from './types.js'; import * as metrics from './metrics.js'; import { @@ -1292,6 +1295,9 @@ describe('loggers', () => { const mockConfig = { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => null, + getUseSmartEdit: () => null, + getUseModelRouter: () => null, } as unknown as Config; beforeEach(() => { @@ -1333,10 +1339,63 @@ describe('loggers', () => { }); }); + describe('logExtensionUpdate', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => null, + getUseSmartEdit: () => null, + getUseModelRouter: () => null, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionUpdateEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension update event', () => { + const event = new ExtensionUpdateEvent( + 'vscode', + '0.1.0', + '0.1.1', + 'git', + 'success', + ); + + logExtensionUpdateEvent(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionUpdateEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Updated extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'installation.id': 'test-installation-id', + 'event.name': EVENT_EXTENSION_UPDATE, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + extension_version: '0.1.0', + extension_previous_version: '0.1.1', + extension_source: 'git', + status: 'success', + }, + }); + }); + }); + describe('logExtensionUninstall', () => { const mockConfig = { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, + getContentGeneratorConfig: () => null, + getUseSmartEdit: () => null, + getUseModelRouter: () => null, } as unknown as Config; beforeEach(() => { diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 93b9e2446d..81f8b43029 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -47,6 +47,7 @@ import type { AgentStartEvent, AgentFinishEvent, WebFetchFallbackAttemptEvent, + ExtensionUpdateEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -531,6 +532,21 @@ export function logExtensionUninstall( logger.emit(logRecord); } +export function logExtensionUpdateEvent( + config: Config, + event: ExtensionUpdateEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionUpdateEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); +} + export function logExtensionEnable( config: Config, event: ExtensionEnableEvent, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 8a9e68f67b..9212dd665f 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -1156,6 +1156,50 @@ export class ExtensionUninstallEvent implements BaseTelemetryEvent { } } +export const EVENT_EXTENSION_UPDATE = 'gemini_cli.extension_update'; +export class ExtensionUpdateEvent implements BaseTelemetryEvent { + 'event.name': 'extension_update'; + 'event.timestamp': string; + extension_name: string; + extension_previous_version: string; + extension_version: string; + extension_source: string; + status: 'success' | 'error'; + + constructor( + extension_name: string, + extension_version: string, + extension_previous_version: string, + extension_source: string, + status: 'success' | 'error', + ) { + this['event.name'] = 'extension_update'; + this['event.timestamp'] = new Date().toISOString(); + this.extension_name = extension_name; + this.extension_version = extension_version; + this.extension_previous_version = extension_previous_version; + this.extension_source = extension_source; + this.status = status; + } + + toOpenTelemetryAttributes(config: Config): LogAttributes { + return { + ...getCommonAttributes(config), + 'event.name': EVENT_EXTENSION_UPDATE, + 'event.timestamp': this['event.timestamp'], + extension_name: this.extension_name, + extension_version: this.extension_version, + extension_previous_version: this.extension_previous_version, + extension_source: this.extension_source, + status: this.status, + }; + } + + toLogBody(): string { + return `Updated extension ${this.extension_name}`; + } +} + export const EVENT_EXTENSION_ENABLE = 'gemini_cli.extension_enable'; export class ExtensionEnableEvent implements BaseTelemetryEvent { 'event.name': 'extension_enable';