Add extensions logging (#11261)

This commit is contained in:
christine betts
2025-10-21 16:55:16 -04:00
committed by GitHub
parent e9e80b054d
commit c6a59896f3
16 changed files with 230 additions and 65 deletions

View File

@@ -603,6 +603,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
contextFiles: ['/path/to/ext1/GEMINI.md'],
isActive: true,
@@ -610,6 +611,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
{
path: '/path/to/ext2',
name: 'ext2',
id: 'ext2-id',
version: '1.0.0',
contextFiles: [],
isActive: true,
@@ -617,6 +619,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
{
path: '/path/to/ext3',
name: 'ext3',
id: 'ext3-id',
version: '1.0.0',
contextFiles: [
'/path/to/ext3/context1.md',
@@ -690,6 +693,8 @@ describe('mergeMcpServers', () => {
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
mcpServers: {
'ext1-server': {
@@ -730,6 +735,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
contextFiles: [],
@@ -738,6 +744,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext2',
name: 'ext2',
id: 'ext2-id',
version: '1.0.0',
excludeTools: ['tool5'],
contextFiles: [],
@@ -764,6 +771,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
contextFiles: [],
@@ -790,6 +798,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext1',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
excludeTools: ['tool2', 'tool3'],
contextFiles: [],
@@ -798,6 +807,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext2',
name: 'ext2',
id: 'ext2-id',
version: '1.0.0',
excludeTools: ['tool3', 'tool4'],
contextFiles: [],
@@ -871,6 +881,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
excludeTools: ['tool1', 'tool2'],
contextFiles: [],
@@ -897,6 +908,7 @@ describe('mergeExcludeTools', () => {
{
path: '/path/to/ext',
name: 'ext1',
id: 'ext1-id',
version: '1.0.0',
excludeTools: ['tool2'],
contextFiles: [],

View File

@@ -21,6 +21,7 @@ import {
loadExtensionConfig,
loadExtensions,
uninstallExtension,
hashValue,
} from './extension.js';
import {
GEMINI_DIR,
@@ -1259,6 +1260,10 @@ This extension will run the following MCP servers:
extensionsDir: userExtensionsDir,
name: 'my-local-extension',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
await uninstallExtension('my-local-extension', isUpdate);
@@ -1269,7 +1274,8 @@ This extension will run the following MCP servers:
} else {
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'my-local-extension',
hashValue('my-local-extension'),
hashValue(userExtensionsDir),
'success',
);
}
@@ -1313,7 +1319,8 @@ This extension will run the following MCP servers:
expect(fs.existsSync(sourceExtDir)).toBe(false);
expect(mockLogExtensionUninstall).toHaveBeenCalled();
expect(ExtensionUninstallEvent).toHaveBeenCalledWith(
'gemini-sql-extension',
hashValue('gemini-sql-extension'),
hashValue('https://github.com/google/gemini-sql-extension'),
'success',
);
});
@@ -1423,6 +1430,10 @@ This extension will run the following MCP servers:
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
disableExtension(
@@ -1433,7 +1444,8 @@ This extension will run the following MCP servers:
expect(mockLogExtensionDisable).toHaveBeenCalled();
expect(ExtensionDisableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
);
});
@@ -1497,6 +1509,10 @@ This extension will run the following MCP servers:
extensionsDir: userExtensionsDir,
name: 'ext1',
version: '1.0.0',
installMetadata: {
source: userExtensionsDir,
type: 'local',
},
});
const extensionEnablementManager = new ExtensionEnablementManager();
disableExtension(
@@ -1512,7 +1528,8 @@ This extension will run the following MCP servers:
expect(mockLogExtensionEnable).toHaveBeenCalled();
expect(ExtensionEnableEvent).toHaveBeenCalledWith(
'ext1',
hashValue('ext1'),
hashValue(userExtensionsDir),
SettingScope.Workspace,
);
});

View File

@@ -199,28 +199,6 @@ export function loadExtension(
)
.filter((contextFilePath) => fs.existsSync(contextFilePath));
// IDs are created by hashing details of the installation source in order to
// deduplicate extensions with conflicting names and also obfuscate any
// potentially sensitive information such as private git urls, system paths,
// or project names.
const hash = createHash('sha256');
const githubUrlParts =
installMetadata &&
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release')
? tryParseGithubUrl(installMetadata.source)
: null;
if (githubUrlParts) {
// For github repos, we use the https URI to the repo as the ID.
hash.update(
`https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`,
);
} else {
hash.update(installMetadata?.source ?? config.name);
}
const id = hash.digest('hex');
return {
name: config.name,
version: config.version,
@@ -230,7 +208,7 @@ export function loadExtension(
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
isActive: extensionEnablementManager.isEnabled(config.name, workspaceDir),
id,
id: getExtensionId(config, installMetadata),
};
} catch (e) {
debugLogger.error(
@@ -384,6 +362,10 @@ async function promptForConsentInteractive(
});
}
export function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export async function installOrUpdateExtension(
installMetadata: ExtensionInstallMetadata,
requestConsent: (consent: string) => Promise<boolean>,
@@ -520,15 +502,15 @@ export async function installOrUpdateExtension(
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
}
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
previousExtensionConfig.version,
installMetadata.source,
installMetadata.type,
'success',
),
);
@@ -536,9 +518,10 @@ export async function installOrUpdateExtension(
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig.name,
hashValue(newExtensionConfig.name),
getExtensionId(newExtensionConfig, installMetadata),
newExtensionConfig.version,
installMetadata.source,
installMetadata.type,
'success',
),
);
@@ -564,14 +547,19 @@ export async function installOrUpdateExtension(
// Ignore error, this is just for logging.
}
}
const config = newExtensionConfig ?? previousExtensionConfig;
const extensionId = config
? getExtensionId(config, installMetadata)
: undefined;
if (isUpdate) {
logExtensionUpdateEvent(
telemetryConfig,
new ExtensionUpdateEvent(
newExtensionConfig?.name ?? previousExtensionConfig.name,
hashValue(config?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
previousExtensionConfig.version,
installMetadata.source,
installMetadata.type,
'error',
),
);
@@ -579,9 +567,10 @@ export async function installOrUpdateExtension(
logExtensionInstallEvent(
telemetryConfig,
new ExtensionInstallEvent(
newExtensionConfig?.name ?? '',
hashValue(newExtensionConfig?.name ?? ''),
extensionId ?? '',
newExtensionConfig?.version ?? '',
installMetadata.source,
installMetadata.type,
'error',
),
);
@@ -707,16 +696,16 @@ export async function uninstallExtension(
new ExtensionEnablementManager(),
cwd,
);
const extensionName = installedExtensions.find(
const extension = installedExtensions.find(
(installed) =>
installed.name.toLowerCase() === extensionIdentifier.toLowerCase() ||
installed.installMetadata?.source.toLowerCase() ===
extensionIdentifier.toLowerCase(),
)?.name;
if (!extensionName) {
);
if (!extension) {
throw new Error(`Extension not found.`);
}
const storage = new ExtensionStorage(extensionName);
const storage = new ExtensionStorage(extension.name);
await fs.promises.rm(storage.getExtensionDir(), {
recursive: true,
@@ -727,13 +716,17 @@ export async function uninstallExtension(
// uninstalls related to updates.
if (isUpdate) return;
const manager = new ExtensionEnablementManager([extensionName]);
manager.remove(extensionName);
const manager = new ExtensionEnablementManager([extension.name]);
manager.remove(extension.name);
const telemetryConfig = getTelemetryConfig(cwd);
logExtensionUninstall(
telemetryConfig,
new ExtensionUninstallEvent(extensionName, 'success'),
new ExtensionUninstallEvent(
hashValue(extension.name),
extension.id,
'success',
),
);
}
@@ -747,6 +740,7 @@ export function toOutputString(
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${status} ${extension.name} (${extension.version})`;
output += `\n ID: ${extension.id}`;
output += `\n Path: ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
@@ -797,7 +791,10 @@ export function disableExtension(
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
extensionEnablementManager.disable(name, true, scopePath);
logExtensionDisable(config, new ExtensionDisableEvent(name, scope));
logExtensionDisable(
config,
new ExtensionDisableEvent(hashValue(name), extension.id, scope),
);
}
export function enableExtension(
@@ -816,5 +813,32 @@ export function enableExtension(
const scopePath = scope === SettingScope.Workspace ? cwd : os.homedir();
extensionEnablementManager.enable(name, true, scopePath);
const config = getTelemetryConfig(cwd);
logExtensionEnable(config, new ExtensionEnableEvent(name, scope));
logExtensionEnable(
config,
new ExtensionEnableEvent(hashValue(name), extension.id, scope),
);
}
function getExtensionId(
config: ExtensionConfig,
installMetadata?: ExtensionInstallMetadata,
): string {
// IDs are created by hashing details of the installation source in order to
// deduplicate extensions with conflicting names and also obfuscate any
// potentially sensitive information such as private git urls, system paths,
// or project names.
let idValue = config.name;
const githubUrlParts =
installMetadata &&
(installMetadata.type === 'git' ||
installMetadata.type === 'github-release')
? tryParseGithubUrl(installMetadata.source)
: null;
if (githubUrlParts) {
// For github repos, we use the https URI to the repo as the ID.
idValue = `https://github.com/${githubUrlParts.owner}/${githubUrlParts.repo}`;
} else {
idValue = installMetadata?.source ?? config.name;
}
return hashValue(idValue);
}

View File

@@ -141,6 +141,7 @@ describe('git extension helpers', () => {
it('should return NOT_UPDATABLE for non-git extensions', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
@@ -160,6 +161,7 @@ describe('git extension helpers', () => {
it('should return ERROR if no remotes found', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
@@ -180,6 +182,7 @@ describe('git extension helpers', () => {
it('should return UPDATE_AVAILABLE when remote hash is different', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
@@ -205,6 +208,7 @@ describe('git extension helpers', () => {
it('should return UP_TO_DATE when remote and local hashes are the same', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,
@@ -230,6 +234,7 @@ describe('git extension helpers', () => {
it('should return ERROR on git error', async () => {
const extension: GeminiCLIExtension = {
name: 'test',
id: 'test-id',
path: '/ext',
version: '1.0.0',
isActive: true,

View File

@@ -223,6 +223,7 @@ describe('extensionsCommand', () => {
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
id: 'ext-one-id',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
@@ -235,6 +236,7 @@ describe('extensionsCommand', () => {
};
const extensionTwo: GeminiCLIExtension = {
name: 'another-ext',
id: 'another-ext-id',
version: '1.0.0',
isActive: true,
path: '/test/dir/another-ext',
@@ -247,6 +249,7 @@ describe('extensionsCommand', () => {
};
const allExt: GeminiCLIExtension = {
name: 'all-ext',
id: 'all-ext-id',
version: '1.0.0',
isActive: true,
path: '/test/dir/all-ext',

View File

@@ -196,6 +196,7 @@ export interface SlashCommand {
// Optional metadata for extension commands
extensionName?: string;
extensionId?: string;
// The action to run. Optional for parent commands that only group sub-commands.
action?: (

View File

@@ -518,6 +518,7 @@ export const useSlashCommandProcessor = (
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.ERROR,
extension_id: commandToExecute?.extensionId,
});
logSlashCommand(config, event);
}
@@ -535,6 +536,7 @@ export const useSlashCommandProcessor = (
command: resolvedCommandPath[0],
subcommand,
status: SlashCommandStatus.SUCCESS,
extension_id: commandToExecute?.extensionId,
});
logSlashCommand(config, event);
}

View File

@@ -57,6 +57,7 @@ describe('useExtensionUpdates', () => {
const extensions = [
{
name: 'test-extension',
id: 'test-extension-id',
type: 'git',
version: '1.0.0',
path: '/some/path',
@@ -269,6 +270,7 @@ describe('useExtensionUpdates', () => {
const extensions = [
{
name: 'test-extension-1',
id: 'test-extension-1-id',
type: 'git',
version: '1.0.0',
path: '/some/path1',
@@ -282,6 +284,8 @@ describe('useExtensionUpdates', () => {
},
{
name: 'test-extension-2',
id: 'test-extension-2-id',
type: 'git',
version: '2.0.0',
path: '/some/path2',