Handle telemetry in non-interactive mode (#1002)

Changes:
- Ensure proper shutdown in non-interactive mode
- Ensures the initial user prompt is logged in non-interactive mode
- Improve telemetry for streaming - handle chunks and input token count is now alongside other token counts in response

To test:
- Follow instructions in https://github.com/google-gemini/gemini-cli/blob/main/docs/core/telemetry.md#google-cloud
- Run CLI in non-interactive mode and observe logs/metrics in GCP Logs Explorer and Metrics Explorer

#750
This commit is contained in:
Jerop Kipruto 2025-06-13 03:44:17 -04:00 committed by GitHub
parent 8bb6eca915
commit b20c8389f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 126 additions and 89 deletions

View File

@ -296,7 +296,6 @@ These are timestamped records of specific events.
- **Attributes**: - **Attributes**:
- `model` - `model`
- `input_token_count`
- `request_text` (optional) - `request_text` (optional)
- `gemini_cli.api_error`: Fired if the API request fails. - `gemini_cli.api_error`: Fired if the API request fails.

View File

@ -33,6 +33,7 @@ import {
WebSearchTool, WebSearchTool,
WriteFileTool, WriteFileTool,
sessionId, sessionId,
logUserPrompt,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
export async function main() { export async function main() {
@ -120,6 +121,11 @@ export async function main() {
process.exit(1); process.exit(1);
} }
logUserPrompt(config, {
prompt: input,
prompt_length: input.length,
});
// Non-interactive mode handled by runNonInteractive // Non-interactive mode handled by runNonInteractive
const nonInteractiveConfig = await loadNonInteractiveConfig( const nonInteractiveConfig = await loadNonInteractiveConfig(
config, config,

View File

@ -9,6 +9,8 @@ import {
ToolCallRequestInfo, ToolCallRequestInfo,
executeToolCall, executeToolCall,
ToolRegistry, ToolRegistry,
shutdownTelemetry,
isTelemetrySdkInitialized,
} from '@gemini-cli/core'; } from '@gemini-cli/core';
import { import {
Content, Content,
@ -119,5 +121,9 @@ export async function runNonInteractive(
} catch (error) { } catch (error) {
console.error('Error processing input:', error); console.error('Error processing input:', error);
process.exit(1); process.exit(1);
} finally {
if (isTelemetrySdkInitialized()) {
await shutdownTelemetry();
}
} }
} }

View File

@ -14,6 +14,7 @@ import {
SendMessageParameters, SendMessageParameters,
createUserContent, createUserContent,
Part, Part,
GenerateContentResponseUsageMetadata,
} from '@google/genai'; } from '@google/genai';
import { retryWithBackoff } from '../utils/retry.js'; import { retryWithBackoff } from '../utils/retry.js';
import { isFunctionResponse } from '../utils/messageInspectors.js'; import { isFunctionResponse } from '../utils/messageInspectors.js';
@ -23,13 +24,12 @@ import {
logApiRequest, logApiRequest,
logApiResponse, logApiResponse,
logApiError, logApiError,
combinedUsageMetadata,
} from '../telemetry/loggers.js'; } from '../telemetry/loggers.js';
import { import {
getResponseText, getResponseText,
getResponseTextFromParts, getResponseTextFromParts,
} from '../utils/generateContentResponseUtilities.js'; } from '../utils/generateContentResponseUtilities.js';
import { getErrorMessage } from '../utils/errors.js';
import { getRequestTextFromContents } from '../utils/requestUtils.js';
/** /**
* Returns true if the response is valid, false otherwise. * Returns true if the response is valid, false otherwise.
@ -140,51 +140,42 @@ export class GeminiChat {
validateHistory(history); validateHistory(history);
} }
private _getRequestTextFromContents(contents: Content[]): string {
return contents
.flatMap((content) => content.parts ?? [])
.map((part) => part.text)
.filter(Boolean)
.join('');
}
private async _logApiRequest( private async _logApiRequest(
contents: Content[], contents: Content[],
model: string, model: string,
): Promise<void> { ): Promise<void> {
let inputTokenCount = 0;
try {
const { totalTokens } = await this.contentGenerator.countTokens({
model,
contents,
});
inputTokenCount = totalTokens || 0;
} catch (_e) {
console.warn(
`Failed to count tokens for model ${model}. Proceeding with inputTokenCount = 0. Error: ${getErrorMessage(_e)}`,
);
inputTokenCount = 0;
}
const shouldLogUserPrompts = (config: Config): boolean => const shouldLogUserPrompts = (config: Config): boolean =>
config.getTelemetryLogUserPromptsEnabled() ?? false; config.getTelemetryLogUserPromptsEnabled() ?? false;
const requestText = getRequestTextFromContents(contents); const requestText = this._getRequestTextFromContents(contents);
logApiRequest(this.config, { logApiRequest(this.config, {
model, model,
input_token_count: inputTokenCount,
request_text: shouldLogUserPrompts(this.config) ? requestText : undefined, request_text: shouldLogUserPrompts(this.config) ? requestText : undefined,
}); });
} }
private async _logApiResponse( private async _logApiResponse(
durationMs: number, durationMs: number,
response: GenerateContentResponse, usageMetadata?: GenerateContentResponseUsageMetadata,
fullStreamedText?: string, responseText?: string,
): Promise<void> { ): Promise<void> {
const responseText = fullStreamedText ?? getResponseText(response);
logApiResponse(this.config, { logApiResponse(this.config, {
model: this.model, model: this.model,
duration_ms: durationMs, duration_ms: durationMs,
status_code: 200, // Assuming 200 for success status_code: 200, // Assuming 200 for success
input_token_count: response.usageMetadata?.promptTokenCount ?? 0, input_token_count: usageMetadata?.promptTokenCount ?? 0,
output_token_count: response.usageMetadata?.candidatesTokenCount ?? 0, output_token_count: usageMetadata?.candidatesTokenCount ?? 0,
cached_content_token_count: cached_content_token_count: usageMetadata?.cachedContentTokenCount ?? 0,
response.usageMetadata?.cachedContentTokenCount ?? 0, thoughts_token_count: usageMetadata?.thoughtsTokenCount ?? 0,
thoughts_token_count: response.usageMetadata?.thoughtsTokenCount ?? 0, tool_token_count: usageMetadata?.toolUsePromptTokenCount ?? 0,
tool_token_count: response.usageMetadata?.toolUsePromptTokenCount ?? 0,
response_text: responseText, response_text: responseText,
}); });
} }
@ -245,7 +236,11 @@ export class GeminiChat {
response = await retryWithBackoff(apiCall); response = await retryWithBackoff(apiCall);
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
await this._logApiResponse(durationMs, response); await this._logApiResponse(
durationMs,
response.usageMetadata,
getResponseText(response),
);
this.sendPromise = (async () => { this.sendPromise = (async () => {
const outputContent = response.candidates?.[0]?.content; const outputContent = response.candidates?.[0]?.content;
@ -413,18 +408,18 @@ export class GeminiChat {
startTime: number, startTime: number,
) { ) {
const outputContent: Content[] = []; const outputContent: Content[] = [];
let lastChunk: GenerateContentResponse | undefined; const chunks: GenerateContentResponse[] = [];
let errorOccurred = false; let errorOccurred = false;
try { try {
for await (const chunk of streamResponse) { for await (const chunk of streamResponse) {
if (isValidResponse(chunk)) { if (isValidResponse(chunk)) {
chunks.push(chunk);
const content = chunk.candidates?.[0]?.content; const content = chunk.candidates?.[0]?.content;
if (content !== undefined) { if (content !== undefined) {
outputContent.push(content); outputContent.push(content);
} }
} }
lastChunk = chunk;
yield chunk; yield chunk;
} }
} catch (error) { } catch (error) {
@ -434,7 +429,7 @@ export class GeminiChat {
throw error; throw error;
} }
if (!errorOccurred && lastChunk) { if (!errorOccurred) {
const durationMs = Date.now() - startTime; const durationMs = Date.now() - startTime;
const allParts: Part[] = []; const allParts: Part[] = [];
for (const content of outputContent) { for (const content of outputContent) {
@ -443,7 +438,11 @@ export class GeminiChat {
} }
} }
const fullText = getResponseTextFromParts(allParts); const fullText = getResponseTextFromParts(allParts);
await this._logApiResponse(durationMs, lastChunk, fullText); await this._logApiResponse(
durationMs,
combinedUsageMetadata(chunks),
fullText,
);
} }
this.recordHistory(inputContent, outputContent); this.recordHistory(inputContent, outputContent);
} }

View File

@ -5,6 +5,7 @@
*/ */
import { import {
logToolCall,
ToolCallRequestInfo, ToolCallRequestInfo,
ToolCallResponseInfo, ToolCallResponseInfo,
ToolRegistry, ToolRegistry,
@ -25,10 +26,19 @@ export async function executeToolCall(
): Promise<ToolCallResponseInfo> { ): Promise<ToolCallResponseInfo> {
const tool = toolRegistry.getTool(toolCallRequest.name); const tool = toolRegistry.getTool(toolCallRequest.name);
const startTime = Date.now();
if (!tool) { if (!tool) {
const error = new Error( const error = new Error(
`Tool "${toolCallRequest.name}" not found in registry.`, `Tool "${toolCallRequest.name}" not found in registry.`,
); );
const durationMs = Date.now() - startTime;
logToolCall(config, {
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
});
// Ensure the response structure matches what the API expects for an error // Ensure the response structure matches what the API expects for an error
return { return {
callId: toolCallRequest.callId, callId: toolCallRequest.callId,
@ -55,6 +65,14 @@ export async function executeToolCall(
// No live output callback for non-interactive mode // No live output callback for non-interactive mode
); );
const durationMs = Date.now() - startTime;
logToolCall(config, {
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: true,
});
const response = convertToFunctionResponse( const response = convertToFunctionResponse(
toolCallRequest.name, toolCallRequest.name,
toolCallRequest.callId, toolCallRequest.callId,
@ -69,6 +87,14 @@ export async function executeToolCall(
}; };
} catch (e) { } catch (e) {
const error = e instanceof Error ? e : new Error(String(e)); const error = e instanceof Error ? e : new Error(String(e));
const durationMs = Date.now() - startTime;
logToolCall(config, {
function_name: toolCallRequest.name,
function_args: toolCallRequest.args,
duration_ms: durationMs,
success: false,
error: error.message,
});
return { return {
callId: toolCallRequest.callId, callId: toolCallRequest.callId,
responseParts: [ responseParts: [

View File

@ -16,6 +16,7 @@ export {
logApiRequest, logApiRequest,
logApiError, logApiError,
logApiResponse, logApiResponse,
combinedUsageMetadata,
} from './loggers.js'; } from './loggers.js';
export { export {
UserPromptEvent, UserPromptEvent,

View File

@ -113,7 +113,7 @@ describe('loggers', () => {
logUserPrompt(mockConfig, event); logUserPrompt(mockConfig, event);
expect(mockLogger.emit).toHaveBeenCalledWith({ expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'User prompt. Length: 11', body: 'User prompt. Length: 11.',
attributes: { attributes: {
'session.id': 'test-session-id', 'session.id': 'test-session-id',
'event.name': EVENT_USER_PROMPT, 'event.name': EVENT_USER_PROMPT,
@ -137,7 +137,7 @@ describe('loggers', () => {
logUserPrompt(mockConfig, event); logUserPrompt(mockConfig, event);
expect(mockLogger.emit).toHaveBeenCalledWith({ expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'User prompt. Length: 11', body: 'User prompt. Length: 11.',
attributes: { attributes: {
'session.id': 'test-session-id', 'session.id': 'test-session-id',
'event.name': EVENT_USER_PROMPT, 'event.name': EVENT_USER_PROMPT,
@ -250,70 +250,42 @@ describe('loggers', () => {
getSessionId: () => 'test-session-id', getSessionId: () => 'test-session-id',
} as Config; } as Config;
const mockMetrics = {
recordTokenUsageMetrics: vi.fn(),
};
beforeEach(() => {
vi.spyOn(metrics, 'recordTokenUsageMetrics').mockImplementation(
mockMetrics.recordTokenUsageMetrics,
);
});
it('should log an API request with request_text', () => { it('should log an API request with request_text', () => {
const event = { const event = {
model: 'test-model', model: 'test-model',
input_token_count: 123,
request_text: 'This is a test request', request_text: 'This is a test request',
}; };
logApiRequest(mockConfig, event); logApiRequest(mockConfig, event);
expect(mockLogger.emit).toHaveBeenCalledWith({ expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'API request to test-model. Tokens: 123.', body: 'API request to test-model.',
attributes: { attributes: {
'session.id': 'test-session-id', 'session.id': 'test-session-id',
'event.name': EVENT_API_REQUEST, 'event.name': EVENT_API_REQUEST,
'event.timestamp': '2025-01-01T00:00:00.000Z', 'event.timestamp': '2025-01-01T00:00:00.000Z',
model: 'test-model', model: 'test-model',
input_token_count: 123,
request_text: 'This is a test request', request_text: 'This is a test request',
}, },
}); });
expect(mockMetrics.recordTokenUsageMetrics).toHaveBeenCalledWith(
mockConfig,
'test-model',
123,
'input',
);
}); });
it('should log an API request without request_text', () => { it('should log an API request without request_text', () => {
const event = { const event = {
model: 'test-model', model: 'test-model',
input_token_count: 456,
}; };
logApiRequest(mockConfig, event); logApiRequest(mockConfig, event);
expect(mockLogger.emit).toHaveBeenCalledWith({ expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'API request to test-model. Tokens: 456.', body: 'API request to test-model.',
attributes: { attributes: {
'session.id': 'test-session-id', 'session.id': 'test-session-id',
'event.name': EVENT_API_REQUEST, 'event.name': EVENT_API_REQUEST,
'event.timestamp': '2025-01-01T00:00:00.000Z', 'event.timestamp': '2025-01-01T00:00:00.000Z',
model: 'test-model', model: 'test-model',
input_token_count: 456,
}, },
}); });
expect(mockMetrics.recordTokenUsageMetrics).toHaveBeenCalledWith(
mockConfig,
'test-model',
456,
'input',
);
}); });
}); });

View File

@ -31,6 +31,10 @@ import {
} from './metrics.js'; } from './metrics.js';
import { isTelemetrySdkInitialized } from './sdk.js'; import { isTelemetrySdkInitialized } from './sdk.js';
import { ToolConfirmationOutcome } from '../index.js'; import { ToolConfirmationOutcome } from '../index.js';
import {
GenerateContentResponse,
GenerateContentResponseUsageMetadata,
} from '@google/genai';
const shouldLogUserPrompts = (config: Config): boolean => const shouldLogUserPrompts = (config: Config): boolean =>
config.getTelemetryLogUserPromptsEnabled() ?? false; config.getTelemetryLogUserPromptsEnabled() ?? false;
@ -119,7 +123,7 @@ export function logUserPrompt(
const logger = logs.getLogger(SERVICE_NAME); const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = { const logRecord: LogRecord = {
body: `User prompt. Length: ${event.prompt_length}`, body: `User prompt. Length: ${event.prompt_length}.`,
attributes, attributes,
}; };
logger.emit(logRecord); logger.emit(logRecord);
@ -176,16 +180,10 @@ export function logApiRequest(
}; };
const logger = logs.getLogger(SERVICE_NAME); const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = { const logRecord: LogRecord = {
body: `API request to ${event.model}. Tokens: ${event.input_token_count}.`, body: `API request to ${event.model}.`,
attributes, attributes,
}; };
logger.emit(logRecord); logger.emit(logRecord);
recordTokenUsageMetrics(
config,
event.model,
event.input_token_count,
'input',
);
} }
export function logApiError( export function logApiError(
@ -258,6 +256,12 @@ export function logApiResponse(
event.status_code, event.status_code,
event.error, event.error,
); );
recordTokenUsageMetrics(
config,
event.model,
event.input_token_count,
'input',
);
recordTokenUsageMetrics( recordTokenUsageMetrics(
config, config,
event.model, event.model,
@ -278,3 +282,43 @@ export function logApiResponse(
); );
recordTokenUsageMetrics(config, event.model, event.tool_token_count, 'tool'); recordTokenUsageMetrics(config, event.model, event.tool_token_count, 'tool');
} }
export function combinedUsageMetadata(
chunks: GenerateContentResponse[],
): GenerateContentResponseUsageMetadata {
const metadataKeys: Array<keyof GenerateContentResponseUsageMetadata> = [
'promptTokenCount',
'candidatesTokenCount',
'cachedContentTokenCount',
'thoughtsTokenCount',
'toolUsePromptTokenCount',
'totalTokenCount',
];
const totals: Record<keyof GenerateContentResponseUsageMetadata, number> = {
promptTokenCount: 0,
candidatesTokenCount: 0,
cachedContentTokenCount: 0,
thoughtsTokenCount: 0,
toolUsePromptTokenCount: 0,
totalTokenCount: 0,
cacheTokensDetails: 0,
candidatesTokensDetails: 0,
promptTokensDetails: 0,
toolUsePromptTokensDetails: 0,
trafficType: 0,
};
for (const chunk of chunks) {
if (chunk.usageMetadata) {
for (const key of metadataKeys) {
const chunkValue = chunk.usageMetadata[key];
if (typeof chunkValue === 'number') {
totals[key] += chunkValue;
}
}
}
}
return totals as unknown as GenerateContentResponseUsageMetadata;
}

View File

@ -29,7 +29,6 @@ export interface ApiRequestEvent {
'event.name': 'api_request'; 'event.name': 'api_request';
'event.timestamp': string; // ISO 8601 'event.timestamp': string; // ISO 8601
model: string; model: string;
input_token_count: number;
request_text?: string; request_text?: string;
} }

View File

@ -1,15 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Content } from '@google/genai';
export function getRequestTextFromContents(contents: Content[]): string {
return contents
.flatMap((content) => content.parts ?? [])
.map((part) => part.text)
.filter(Boolean)
.join('');
}