From bcfd50b45b5bd857c963d92d563ce8c48edcde83 Mon Sep 17 00:00:00 2001 From: shishu314 Date: Thu, 18 Sep 2025 14:01:36 -0400 Subject: [PATCH] metrics(extensions) - Add logging methods for extensions operations (#8702) Co-authored-by: Shi Shu --- .../clearcut-logger/clearcut-logger.ts | 21 +++ .../clearcut-logger/event-metadata-key.ts | 3 + packages/core/src/telemetry/constants.ts | 3 + packages/core/src/telemetry/loggers.test.ts | 129 +++++++++++++++++- packages/core/src/telemetry/loggers.ts | 76 +++++++++++ packages/core/src/telemetry/types.ts | 15 ++ 6 files changed, 246 insertions(+), 1 deletion(-) diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 7ab156430d..57ce1323e3 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -24,6 +24,7 @@ import type { InvalidChunkEvent, ContentRetryEvent, ContentRetryFailureEvent, + ExtensionEnableEvent, ExtensionInstallEvent, ToolOutputTruncatedEvent, ExtensionUninstallEvent, @@ -61,6 +62,7 @@ export enum EventNames { INVALID_CHUNK = 'invalid_chunk', CONTENT_RETRY = 'content_retry', CONTENT_RETRY_FAILURE = 'content_retry_failure', + EXTENSION_ENABLE = 'extension_enable', EXTENSION_INSTALL = 'extension_install', EXTENSION_UNINSTALL = 'extension_uninstall', TOOL_OUTPUT_TRUNCATED = 'tool_output_truncated', @@ -959,6 +961,25 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logExtensionEnableEvent(event: ExtensionEnableEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_EXTENSION_NAME, + value: event.extension_name, + }, + { + gemini_cli_key: + EventMetadataKey.GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE, + value: event.setting_scope, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.EXTENSION_ENABLE, data), + ); + this.flushIfNeeded(); + } + /** * Adds default fields to data, and returns a new data array. This fields * should exist on all log events. diff --git a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts index 6a783098b3..f700c259d5 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -353,6 +353,9 @@ export enum EventMetadataKey { // Logs the status of the extension uninstall GEMINI_CLI_EXTENSION_UNINSTALL_STATUS = 96, + // Logs the setting scope for an extension enablement. + GEMINI_CLI_EXTENSION_ENABLE_SETTING_SCOPE = 102, + // ========================================================================== // Tool Output Truncated Event Keys // =========================================================================== diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index c10dfdc9a5..fcff8d0334 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -12,6 +12,9 @@ export const EVENT_API_REQUEST = 'gemini_cli.api_request'; export const EVENT_API_ERROR = 'gemini_cli.api_error'; export const EVENT_API_RESPONSE = 'gemini_cli.api_response'; export const EVENT_CLI_CONFIG = 'gemini_cli.config'; +export const EVENT_EXTENSION_ENABLE = 'gemini_cli.extension_enable'; +export const EVENT_EXTENSION_INSTALL = 'gemini_cli.extension_install'; +export const EVENT_EXTENSION_UNINSTALL = 'gemini_cli.extension_uninstall'; export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; export const EVENT_RIPGREP_FALLBACK = 'gemini_cli.ripgrep_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 3432391447..4086a448da 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -33,6 +33,9 @@ import { EVENT_FILE_OPERATION, EVENT_RIPGREP_FALLBACK, EVENT_MODEL_ROUTING, + EVENT_EXTENSION_ENABLE, + EVENT_EXTENSION_INSTALL, + EVENT_EXTENSION_UNINSTALL, } from './constants.js'; import { logApiRequest, @@ -47,6 +50,9 @@ import { logRipgrepFallback, logToolOutputTruncated, logModelRouting, + logExtensionEnable, + logExtensionInstallEvent, + logExtensionUninstall, } from './loggers.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { @@ -62,11 +68,14 @@ import { FileOperationEvent, ToolOutputTruncatedEvent, ModelRoutingEvent, + ExtensionEnableEvent, + ExtensionInstallEvent, + ExtensionUninstallEvent, } from './types.js'; import * as metrics from './metrics.js'; import { FileOperation } from './metrics.js'; import * as sdk from './sdk.js'; -import { vi, describe, beforeEach, it, expect } from 'vitest'; +import { vi, describe, beforeEach, it, expect, afterEach } from 'vitest'; import type { GenerateContentResponseUsageMetadata } from '@google/genai'; import * as uiTelemetry from './uiTelemetry.js'; import { makeFakeConfig } from '../test-utils/config.js'; @@ -1108,4 +1117,122 @@ describe('loggers', () => { expect(metrics.recordModelRoutingMetrics).not.toHaveBeenCalled(); }); }); + + describe('logExtensionInstall', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionInstallEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension install event', () => { + const event = new ExtensionInstallEvent( + 'vscode', + '0.1.0', + 'git', + 'success', + ); + + logExtensionInstallEvent(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionInstallEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Installed extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_INSTALL, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + extension_version: '0.1.0', + extension_source: 'git', + status: 'success', + }, + }); + }); + }); + + describe('logExtensionUninstall', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionUninstallEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension uninstall event', () => { + const event = new ExtensionUninstallEvent('vscode', 'success'); + + logExtensionUninstall(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionUninstallEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Uninstalled extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_UNINSTALL, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + status: 'success', + }, + }); + }); + }); + + describe('logExtensionEnable', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logExtensionEnableEvent'); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('should log extension enable event', () => { + const event = new ExtensionEnableEvent('vscode', 'user'); + + logExtensionEnable(mockConfig, event); + + expect( + ClearcutLogger.prototype.logExtensionEnableEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Enabled extension vscode', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_EXTENSION_ENABLE, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + extension_name: 'vscode', + setting_scope: 'user', + }, + }); + }); + }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index cbe706df08..153b4477b0 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -13,6 +13,8 @@ import { EVENT_API_REQUEST, EVENT_API_RESPONSE, EVENT_CLI_CONFIG, + EVENT_EXTENSION_UNINSTALL, + EVENT_EXTENSION_ENABLE, EVENT_IDE_CONNECTION, EVENT_TOOL_CALL, EVENT_USER_PROMPT, @@ -29,6 +31,7 @@ import { EVENT_FILE_OPERATION, EVENT_RIPGREP_FALLBACK, EVENT_MODEL_ROUTING, + EVENT_EXTENSION_INSTALL, } from './constants.js'; import type { ApiErrorEvent, @@ -54,6 +57,9 @@ import type { RipgrepFallbackEvent, ToolOutputTruncatedEvent, ModelRoutingEvent, + ExtensionEnableEvent, + ExtensionUninstallEvent, + ExtensionInstallEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -691,3 +697,73 @@ export function logModelRouting( logger.emit(logRecord); recordModelRoutingMetrics(config, event); } + +export function logExtensionInstallEvent( + config: Config, + event: ExtensionInstallEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionInstallEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_INSTALL, + 'event.timestamp': new Date().toISOString(), + extension_name: event.extension_name, + extension_version: event.extension_version, + extension_source: event.extension_source, + status: event.status, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Installed extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} + +export function logExtensionUninstall( + config: Config, + event: ExtensionUninstallEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionUninstallEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_UNINSTALL, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Uninstalled extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} + +export function logExtensionEnable( + config: Config, + event: ExtensionEnableEvent, +): void { + ClearcutLogger.getInstance(config)?.logExtensionEnableEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_EXTENSION_ENABLE, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Enabled extension ${event.extension_name}`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index b6a7b932f2..dda0d8f7f8 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -576,6 +576,7 @@ export type TelemetryEvent = | InvalidChunkEvent | ContentRetryEvent | ContentRetryFailureEvent + | ExtensionEnableEvent | ExtensionInstallEvent | ExtensionUninstallEvent | ModelRoutingEvent @@ -648,3 +649,17 @@ export class ExtensionUninstallEvent implements BaseTelemetryEvent { this.status = status; } } + +export class ExtensionEnableEvent implements BaseTelemetryEvent { + 'event.name': 'extension_enable'; + 'event.timestamp': string; + extension_name: string; + setting_scope: string; + + constructor(extension_name: string, settingScope: string) { + this['event.name'] = 'extension_enable'; + this['event.timestamp'] = new Date().toISOString(); + this.extension_name = extension_name; + this.setting_scope = settingScope; + } +}