From c6a59896f304b82ef5a4ab2ef7cd26f4dfb0fbea Mon Sep 17 00:00:00 2001 From: christine betts Date: Tue, 21 Oct 2025 16:55:16 -0400 Subject: [PATCH] Add extensions logging (#11261) --- packages/cli/src/config/config.test.ts | 12 ++ packages/cli/src/config/extension.test.ts | 25 ++++- packages/cli/src/config/extension.ts | 106 +++++++++++------- .../cli/src/config/extensions/github.test.ts | 5 + .../src/ui/commands/extensionsCommand.test.ts | 3 + packages/cli/src/ui/commands/types.ts | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 + .../src/ui/hooks/useExtensionUpdates.test.ts | 4 + packages/core/src/config/config.ts | 2 +- .../clearcut-logger/clearcut-logger.ts | 29 +++++ .../clearcut-logger/event-metadata-key.ts | 11 +- packages/core/src/telemetry/loggers.test.ts | 45 +++++--- packages/core/src/telemetry/types.ts | 44 +++++++- packages/core/src/tools/mcp-client.ts | 1 + packages/core/src/tools/mcp-tool.ts | 4 + packages/core/src/tools/tools.ts | 1 + 16 files changed, 230 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 95f3e6c778..5490afb678 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -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: [], diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 18b92fc36c..d87df818c9 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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, ); }); diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 2328f5426f..e5a1cff5bc 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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, @@ -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); } diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts index 43c69e4302..74f6992286 100644 --- a/packages/cli/src/config/extensions/github.test.ts +++ b/packages/cli/src/config/extensions/github.test.ts @@ -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, diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index a15cc70c60..130a452e1a 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -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', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4d48132721..44080cbf61 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -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?: ( diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index c5b5d5db4e..ec5ee1609a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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); } diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts index 8ec0689cc6..8270bdf789 100644 --- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.ts @@ -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', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index b615c048e5..172fc52fa3 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -140,7 +140,7 @@ export interface GeminiCLIExtension { mcpServers?: Record; contextFiles: string[]; excludeTools?: string[]; - id?: string; + id: string; } export interface ExtensionInstallMetadata { diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 5bce4b8edf..cede1516ce 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -446,6 +446,15 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_MCP_TOOLS, value: event.mcp_tools ? event.mcp_tools : '', }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_START_SESSION_EXTENSIONS_COUNT, + value: event.extensions_count.toString(), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_START_SESSION_EXTENSION_IDS, + value: event.extension_ids.toString(), + }, ]; this.sessionData = data; @@ -893,6 +902,10 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, value: event.extension_name, }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ID, + value: event.extension_id, + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION, value: event.extension_version, @@ -921,6 +934,10 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, value: event.extension_name, }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ID, + value: event.extension_id, + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_UNINSTALL_STATUS, value: event.status, @@ -941,6 +958,10 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, value: event.extension_name, }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ID, + value: event.extension_id, + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_VERSION, value: event.extension_version, @@ -1037,6 +1058,10 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, value: event.extension_name, }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ID, + value: event.extension_id, + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE, @@ -1072,6 +1097,10 @@ export class ClearcutLogger { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, value: event.extension_name, }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_ID, + value: event.extension_id, + }, { gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_DISABLE_SETTING_SCOPE, 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 1615d5f755..5a99db8001 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -7,7 +7,7 @@ // Defines valid event metadata keys for Clearcut logging. export enum EventMetadataKey { // Deleted enums: 24 - // Next ID: 117 + // Next ID: 122 GEMINI_CLI_KEY_UNKNOWN = 0, @@ -373,6 +373,9 @@ export enum EventMetadataKey { // Logs the name of the extension. GEMINI_CLI_EXTENSION_NAME = 85, + // Logs the name of the extension. + GEMINI_CLI_EXTENSION_ID = 121, + // Logs the version of the extension. GEMINI_CLI_EXTENSION_VERSION = 86, @@ -391,6 +394,12 @@ export enum EventMetadataKey { // Logs the status of the extension uninstall GEMINI_CLI_EXTENSION_UPDATE_STATUS = 118, + // Logs the count of extensions in Start Session Event + GEMINI_CLI_START_SESSION_EXTENSIONS_COUNT = 119, + + // Logs the name of extensions as a comma-separated string + GEMINI_CLI_START_SESSION_EXTENSION_IDS = 120, + // Logs the setting scope for an extension enablement. GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 397f01bba8..b6f52af8f1 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -201,6 +201,7 @@ describe('loggers', () => { getTargetDir: () => 'target-dir', getProxy: () => 'http://test.proxy.com:8080', getOutputFormat: () => OutputFormat.JSON, + getExtensions: () => [], } as unknown as Config; const startSessionEvent = new StartSessionEvent(mockConfig); @@ -229,6 +230,8 @@ describe('loggers', () => { mcp_tools: undefined, mcp_tools_count: undefined, output_format: 'json', + extension_ids: '', + extensions_count: 0, }, }); }); @@ -1042,6 +1045,10 @@ describe('loggers', () => { }, required: ['arg1', 'arg2'], }, + false, + undefined, + undefined, + 'test-extension', ); const call: CompletedToolCall = { @@ -1076,6 +1083,7 @@ describe('loggers', () => { 'installation.id': 'test-installation-id', 'event.name': EVENT_TOOL_CALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_id: 'test-extension', function_name: 'mock_mcp_tool', function_args: JSON.stringify( { @@ -1094,6 +1102,7 @@ describe('loggers', () => { error: undefined, error_type: undefined, metadata: undefined, + content_length: undefined, }, }); }); @@ -1310,7 +1319,8 @@ describe('loggers', () => { it('should log extension install event', () => { const event = new ExtensionInstallEvent( - 'vscode', + 'testing', + 'testing-id', '0.1.0', 'git', 'success', @@ -1323,14 +1333,14 @@ describe('loggers', () => { ).toHaveBeenCalledWith(event); expect(mockLogger.emit).toHaveBeenCalledWith({ - body: 'Installed extension vscode', + body: 'Installed extension testing', attributes: { 'session.id': 'test-session-id', 'user.email': 'test-user@example.com', 'installation.id': 'test-installation-id', 'event.name': EVENT_EXTENSION_INSTALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', - extension_name: 'vscode', + extension_name: 'testing', extension_version: '0.1.0', extension_source: 'git', status: 'success', @@ -1358,7 +1368,8 @@ describe('loggers', () => { it('should log extension update event', () => { const event = new ExtensionUpdateEvent( - 'vscode', + 'testing', + 'testing-id', '0.1.0', '0.1.1', 'git', @@ -1372,14 +1383,14 @@ describe('loggers', () => { ).toHaveBeenCalledWith(event); expect(mockLogger.emit).toHaveBeenCalledWith({ - body: 'Updated extension vscode', + body: 'Updated extension testing', 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_name: 'testing', extension_version: '0.1.0', extension_previous_version: '0.1.1', extension_source: 'git', @@ -1407,7 +1418,11 @@ describe('loggers', () => { }); it('should log extension uninstall event', () => { - const event = new ExtensionUninstallEvent('vscode', 'success'); + const event = new ExtensionUninstallEvent( + 'testing', + 'testing-id', + 'success', + ); logExtensionUninstall(mockConfig, event); @@ -1416,14 +1431,14 @@ describe('loggers', () => { ).toHaveBeenCalledWith(event); expect(mockLogger.emit).toHaveBeenCalledWith({ - body: 'Uninstalled extension vscode', + body: 'Uninstalled extension testing', attributes: { 'session.id': 'test-session-id', 'user.email': 'test-user@example.com', 'installation.id': 'test-installation-id', 'event.name': EVENT_EXTENSION_UNINSTALL, 'event.timestamp': '2025-01-01T00:00:00.000Z', - extension_name: 'vscode', + extension_name: 'testing', status: 'success', }, }); @@ -1445,7 +1460,7 @@ describe('loggers', () => { }); it('should log extension enable event', () => { - const event = new ExtensionEnableEvent('vscode', 'user'); + const event = new ExtensionEnableEvent('testing', 'testing-id', 'user'); logExtensionEnable(mockConfig, event); @@ -1454,14 +1469,14 @@ describe('loggers', () => { ).toHaveBeenCalledWith(event); expect(mockLogger.emit).toHaveBeenCalledWith({ - body: 'Enabled extension vscode', + body: 'Enabled extension testing', attributes: { 'session.id': 'test-session-id', 'user.email': 'test-user@example.com', 'installation.id': 'test-installation-id', 'event.name': EVENT_EXTENSION_ENABLE, 'event.timestamp': '2025-01-01T00:00:00.000Z', - extension_name: 'vscode', + extension_name: 'testing', setting_scope: 'user', }, }); @@ -1483,7 +1498,7 @@ describe('loggers', () => { }); it('should log extension disable event', () => { - const event = new ExtensionDisableEvent('vscode', 'user'); + const event = new ExtensionDisableEvent('testing', 'testing-id', 'user'); logExtensionDisable(mockConfig, event); @@ -1492,14 +1507,14 @@ describe('loggers', () => { ).toHaveBeenCalledWith(event); expect(mockLogger.emit).toHaveBeenCalledWith({ - body: 'Disabled extension vscode', + body: 'Disabled extension testing', attributes: { 'session.id': 'test-session-id', 'user.email': 'test-user@example.com', 'installation.id': 'test-installation-id', 'event.name': EVENT_EXTENSION_DISABLE, 'event.timestamp': '2025-01-01T00:00:00.000Z', - extension_name: 'vscode', + extension_name: 'testing', setting_scope: 'user', }, }); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 9212dd665f..38e636b128 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -54,6 +54,8 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_tools_count?: number; mcp_tools?: string; output_format: OutputFormat; + extensions_count: number; + extension_ids: string; constructor(config: Config, toolRegistry?: ToolRegistry) { const generatorConfig = config.getContentGeneratorConfig(); @@ -85,6 +87,9 @@ export class StartSessionEvent implements BaseTelemetryEvent { config.getFileFilteringRespectGitIgnore(); this.mcp_servers_count = mcpServers ? Object.keys(mcpServers).length : 0; this.output_format = config.getOutputFormat(); + const extensions = config.getExtensions(); + this.extensions_count = extensions.length; + this.extension_ids = extensions.map((e) => e.id).join(','); if (toolRegistry) { const mcpTools = toolRegistry .getAllTools() @@ -116,6 +121,8 @@ export class StartSessionEvent implements BaseTelemetryEvent { mcp_tools: this.mcp_tools, mcp_tools_count: this.mcp_tools_count, output_format: this.output_format, + extensions_count: this.extensions_count, + extension_ids: this.extension_ids, }; } @@ -198,6 +205,7 @@ export class ToolCallEvent implements BaseTelemetryEvent { tool_type: 'native' | 'mcp'; content_length?: number; mcp_server_name?: string; + extension_id?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any metadata?: { [key: string]: any }; @@ -243,6 +251,7 @@ export class ToolCallEvent implements BaseTelemetryEvent { ) { this.tool_type = 'mcp'; this.mcp_server_name = call.tool.serverName; + this.extension_id = call.tool.extensionId; } else { this.tool_type = 'native'; } @@ -292,6 +301,7 @@ export class ToolCallEvent implements BaseTelemetryEvent { tool_type: this.tool_type, content_length: this.content_length, mcp_server_name: this.mcp_server_name, + extension_id: this.extension_id, metadata: this.metadata, }; @@ -627,6 +637,7 @@ export interface SlashCommandEvent extends BaseTelemetryEvent { command: string; subcommand?: string; status?: SlashCommandStatus; + extension_id?: string; toOpenTelemetryAttributes(config: Config): LogAttributes; toLogBody(): string; } @@ -635,6 +646,7 @@ export function makeSlashCommandEvent({ command, subcommand, status, + extension_id, }: Omit< SlashCommandEvent, CommonFields | 'toOpenTelemetryAttributes' | 'toLogBody' @@ -645,6 +657,7 @@ export function makeSlashCommandEvent({ command, subcommand, status, + extension_id, toOpenTelemetryAttributes(config: Config): LogAttributes { return { ...getCommonAttributes(config), @@ -653,6 +666,7 @@ export function makeSlashCommandEvent({ command: this.command, subcommand: this.subcommand, status: this.status, + extension_id: this.extension_id, }; }, toLogBody(): string { @@ -1041,12 +1055,14 @@ export class ExtensionInstallEvent implements BaseTelemetryEvent { 'event.name': 'extension_install'; 'event.timestamp': string; extension_name: string; + extension_id: string; extension_version: string; extension_source: string; status: 'success' | 'error'; constructor( extension_name: string, + extension_id: string, extension_version: string, extension_source: string, status: 'success' | 'error', @@ -1054,6 +1070,7 @@ export class ExtensionInstallEvent implements BaseTelemetryEvent { this['event.name'] = 'extension_install'; this['event.timestamp'] = new Date().toISOString(); this.extension_name = extension_name; + this.extension_id = extension_id; this.extension_version = extension_version; this.extension_source = extension_source; this.status = status; @@ -1132,12 +1149,18 @@ export class ExtensionUninstallEvent implements BaseTelemetryEvent { 'event.name': 'extension_uninstall'; 'event.timestamp': string; extension_name: string; + extension_id: string; status: 'success' | 'error'; - constructor(extension_name: string, status: 'success' | 'error') { + constructor( + extension_name: string, + extension_id: string, + status: 'success' | 'error', + ) { this['event.name'] = 'extension_uninstall'; this['event.timestamp'] = new Date().toISOString(); this.extension_name = extension_name; + this.extension_id = extension_id; this.status = status; } @@ -1161,6 +1184,7 @@ export class ExtensionUpdateEvent implements BaseTelemetryEvent { 'event.name': 'extension_update'; 'event.timestamp': string; extension_name: string; + extension_id: string; extension_previous_version: string; extension_version: string; extension_source: string; @@ -1168,6 +1192,7 @@ export class ExtensionUpdateEvent implements BaseTelemetryEvent { constructor( extension_name: string, + extension_id: string, extension_version: string, extension_previous_version: string, extension_source: string, @@ -1176,6 +1201,7 @@ export class ExtensionUpdateEvent implements BaseTelemetryEvent { this['event.name'] = 'extension_update'; this['event.timestamp'] = new Date().toISOString(); this.extension_name = extension_name; + this.extension_id = extension_id; this.extension_version = extension_version; this.extension_previous_version = extension_previous_version; this.extension_source = extension_source; @@ -1205,12 +1231,18 @@ export class ExtensionEnableEvent implements BaseTelemetryEvent { 'event.name': 'extension_enable'; 'event.timestamp': string; extension_name: string; + extension_id: string; setting_scope: string; - constructor(extension_name: string, settingScope: string) { + constructor( + extension_name: string, + extension_id: string, + settingScope: string, + ) { this['event.name'] = 'extension_enable'; this['event.timestamp'] = new Date().toISOString(); this.extension_name = extension_name; + this.extension_id = extension_id; this.setting_scope = settingScope; } @@ -1291,12 +1323,18 @@ export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable'; 'event.timestamp': string; extension_name: string; + extension_id: string; setting_scope: string; - constructor(extension_name: string, settingScope: string) { + constructor( + extension_name: string, + extension_id: string, + settingScope: string, + ) { this['event.name'] = 'extension_disable'; this['event.timestamp'] = new Date().toISOString(); this.extension_name = extension_name; + this.extension_id = extension_id; this.setting_scope = settingScope; } diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index 552d190fa0..b0e46900a7 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -610,6 +610,7 @@ export async function discoverTools( mcpServerConfig.trust, undefined, cliConfig, + mcpServerConfig.extension?.id, ), ); } catch (error) { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index afffa103e5..d6d71ad600 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -213,6 +213,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< readonly trust?: boolean, nameOverride?: string, private readonly cliConfig?: Config, + override readonly extensionId?: string, ) { super( nameOverride ?? generateValidName(serverToolName), @@ -222,6 +223,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< parameterSchema, true, // isOutputMarkdown false, // canUpdateOutput + undefined, // messageBus + extensionId, ); } @@ -235,6 +238,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.trust, `${this.serverName}__${this.serverToolName}`, this.cliConfig, + this.extensionId, ); } diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index fbf58b2e4e..1f4f3db3da 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -290,6 +290,7 @@ export abstract class DeclarativeTool< readonly isOutputMarkdown: boolean = true, readonly canUpdateOutput: boolean = false, readonly messageBus?: MessageBus, + readonly extensionId?: string, ) {} get schema(): FunctionDeclaration {