diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 97a442efce..5f4438f09e 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -26,6 +26,7 @@ import { TrackedCancelledToolCall, } from './useReactToolScheduler.js'; import { + ApprovalMode, Config, EditorType, AuthType, @@ -194,6 +195,7 @@ describe('useGeminiStream', () => { getProjectRoot: vi.fn(() => '/test/dir'), getCheckpointingEnabled: vi.fn(() => false), getGeminiClient: mockGetGeminiClient, + getApprovalMode: () => ApprovalMode.DEFAULT, getUsageStatisticsEnabled: () => true, getDebugMode: () => false, addHistory: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 0c58fa17b1..aaa54bd121 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -25,6 +25,9 @@ import { UnauthorizedError, UserPromptEvent, DEFAULT_GEMINI_FLASH_MODEL, + logConversationFinishedEvent, + ConversationFinishedEvent, + ApprovalMode, parseAndFormatApiError, } from '@google/gemini-cli-core'; import { type Part, type PartListUnion, FinishReason } from '@google/genai'; @@ -172,6 +175,27 @@ export const useGeminiStream = ( return StreamingState.Idle; }, [isResponding, toolCalls]); + useEffect(() => { + if ( + config.getApprovalMode() === ApprovalMode.YOLO && + streamingState === StreamingState.Idle + ) { + const lastUserMessageIndex = history.findLastIndex( + (item: HistoryItem) => item.type === MessageType.USER, + ); + + const turnCount = + lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex; + + if (turnCount > 0) { + logConversationFinishedEvent( + config, + new ConversationFinishedEvent(config.getApprovalMode(), turnCount), + ); + } + } + }, [streamingState, config, history]); + const cancelOngoingRequest = useCallback(() => { if (streamingState !== StreamingState.Responding) { return; diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index 46fce70784..44424d671d 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "outDir": "dist", "jsx": "react-jsx", - "lib": ["DOM", "DOM.Iterable", "ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2023"], "types": ["node", "vitest/globals"] }, "include": [ diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 2a66fa0ca5..9b51b13dab 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -17,6 +17,7 @@ import { SlashCommandEvent, MalformedJsonResponseEvent, IdeConnectionEvent, + ConversationFinishedEvent, KittySequenceOverflowEvent, ChatCompressionEvent, FileOperationEvent, @@ -47,6 +48,7 @@ export enum EventNames { IDE_CONNECTION = 'ide_connection', KITTY_SEQUENCE_OVERFLOW = 'kitty_sequence_overflow', CHAT_COMPRESSION = 'chat_compression', + CONVERSATION_FINISHED = 'conversation_finished', } export interface LogResponse { @@ -728,6 +730,28 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logConversationFinishedEvent(event: ConversationFinishedEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_SESSION_ID, + value: this.config?.getSessionId() ?? '', + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_CONVERSATION_TURN_COUNT, + value: JSON.stringify(event.turnCount), + }, + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_APPROVAL_MODE, + value: event.approvalMode, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.CONVERSATION_FINISHED, data), + ); + this.flushIfNeeded(); + } + logKittySequenceOverflowEvent(event: KittySequenceOverflowEvent): 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 500b472b39..5b7005c70f 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -232,6 +232,16 @@ export enum EventMetadataKey { // Logs the length of the kitty sequence that overflowed. GEMINI_CLI_KITTY_SEQUENCE_LENGTH = 53, + // ========================================================================== + // Conversation Finished Event Keys + // =========================================================================== + + // Logs the approval mode + GEMINI_CLI_APPROVAL_MODE = 70, + + // Logs the number of turns + GEMINI_CLI_CONVERSATION_TURN_COUNT = 71, + // Logs the number of tokens before context window compression. GEMINI_CLI_COMPRESSION_TOKENS_BEFORE = 60, diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 5decd77101..7b9e6f0fea 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -16,6 +16,7 @@ export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback'; export const EVENT_NEXT_SPEAKER_CHECK = 'gemini_cli.next_speaker_check'; export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command'; export const EVENT_IDE_CONNECTION = 'gemini_cli.ide_connection'; +export const EVENT_CONVERSATION_FINISHED = 'gemini_cli.conversation_finished'; export const EVENT_CHAT_COMPRESSION = 'gemini_cli.chat_compression'; export const EVENT_MALFORMED_JSON_RESPONSE = 'gemini_cli.malformed_json_response'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index b0c26046bb..a0b78add88 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -27,6 +27,7 @@ export { logApiResponse, logFlashFallback, logSlashCommand, + logConversationFinishedEvent, logKittySequenceOverflow, logChatCompression, } from './loggers.js'; @@ -40,6 +41,7 @@ export { ApiResponseEvent, TelemetryEvent, FlashFallbackEvent, + ConversationFinishedEvent, KittySequenceOverflowEvent, SlashCommandEvent, makeSlashCommandEvent, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index dcf29f8fe9..04fd244783 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -19,6 +19,7 @@ import { EVENT_NEXT_SPEAKER_CHECK, SERVICE_NAME, EVENT_SLASH_COMMAND, + EVENT_CONVERSATION_FINISHED, EVENT_CHAT_COMPRESSION, EVENT_MALFORMED_JSON_RESPONSE, } from './constants.js'; @@ -35,6 +36,7 @@ import { NextSpeakerCheckEvent, LoopDetectedEvent, SlashCommandEvent, + ConversationFinishedEvent, KittySequenceOverflowEvent, ChatCompressionEvent, MalformedJsonResponseEvent, @@ -409,6 +411,27 @@ export function logIdeConnection( logger.emit(logRecord); } +export function logConversationFinishedEvent( + config: Config, + event: ConversationFinishedEvent, +): void { + ClearcutLogger.getInstance(config)?.logConversationFinishedEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_CONVERSATION_FINISHED, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Conversation finished.`, + attributes, + }; + logger.emit(logRecord); +} + export function logChatCompression( config: Config, event: ChatCompressionEvent, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 7010e69846..176bbf88da 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -5,7 +5,7 @@ */ import { GenerateContentResponseUsageMetadata } from '@google/genai'; -import { Config } from '../config/config.js'; +import { ApprovalMode, Config } from '../config/config.js'; import { CompletedToolCall } from '../core/coreToolScheduler.js'; import { DiscoveredMCPTool } from '../tools/mcp-tool.js'; import { DiffStat, FileDiff } from '../tools/tools.js'; @@ -387,6 +387,20 @@ export class IdeConnectionEvent { } } +export class ConversationFinishedEvent { + 'event_name': 'conversation_finished'; + 'event.timestamp': string; // ISO 8601; + approvalMode: ApprovalMode; + turnCount: number; + + constructor(approvalMode: ApprovalMode, turnCount: number) { + this['event_name'] = 'conversation_finished'; + this['event.timestamp'] = new Date().toISOString(); + this.approvalMode = approvalMode; + this.turnCount = turnCount; + } +} + export class KittySequenceOverflowEvent { 'event.name': 'kitty_sequence_overflow'; 'event.timestamp': string; // ISO 8601 @@ -447,5 +461,6 @@ export type TelemetryEvent = | KittySequenceOverflowEvent | MalformedJsonResponseEvent | IdeConnectionEvent + | ConversationFinishedEvent | SlashCommandEvent | FileOperationEvent;