Fix rough edges around extension updates (#10926)

This commit is contained in:
Jacob MacDonald
2025-10-10 14:28:13 -07:00
committed by GitHub
parent bf0f61e656
commit a6e00d9183
14 changed files with 404 additions and 105 deletions

View File

@@ -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'),
);

View File

@@ -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,
);

View File

@@ -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,
);

View File

@@ -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));

View File

@@ -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.');
});
});

View File

@@ -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<boolean>,
cwd: string = process.cwd(),
previousExtensionConfig?: ExtensionConfig,
): Promise<string> {
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<void> {
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'),

View File

@@ -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,

View File

@@ -37,6 +37,7 @@ export {
ExtensionDisableEvent,
ExtensionEnableEvent,
ExtensionUninstallEvent,
ExtensionUpdateEvent,
ModelSlashCommandEvent,
} from './src/telemetry/types.js';
export { makeFakeConfig } from './src/test-utils/config.js';

View File

@@ -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[] = [
{

View File

@@ -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,

View File

@@ -44,6 +44,7 @@ export {
logExtensionEnable,
logExtensionInstallEvent,
logExtensionUninstall,
logExtensionUpdateEvent,
logWebFetchFallbackAttempt,
} from './loggers.js';
export type { SlashCommandEvent, ChatCompressionEvent } from './types.js';

View File

@@ -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(() => {

View File

@@ -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,

View File

@@ -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';