diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts index a4873c1f26..5656d40ec1 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts @@ -31,6 +31,7 @@ import { ToolCallEvent, AgentStartEvent, AgentFinishEvent, + WebFetchFallbackAttemptEvent, } from '../types.js'; import { AgentTerminateMode } from '../../agents/types.js'; import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js'; @@ -945,4 +946,21 @@ describe('ClearcutLogger', () => { ); }); }); + + describe('logWebFetchFallbackAttemptEvent', () => { + it('logs an event with the proper name and reason', () => { + const { logger } = setup(); + const event = new WebFetchFallbackAttemptEvent('private_ip'); + + logger?.logWebFetchFallbackAttemptEvent(event); + + const events = getEvents(logger!); + expect(events.length).toBe(1); + expect(events[0]).toHaveEventName(EventNames.WEB_FETCH_FALLBACK_ATTEMPT); + expect(events[0]).toHaveMetadataValue([ + EventMetadataKey.GEMINI_CLI_WEB_FETCH_FALLBACK_REASON, + 'private_ip', + ]); + }); + }); }); diff --git a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts index 5fe283648d..6d69b06c55 100644 --- a/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts +++ b/packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts @@ -35,6 +35,7 @@ import type { SmartEditCorrectionEvent, AgentStartEvent, AgentFinishEvent, + WebFetchFallbackAttemptEvent, } from '../types.js'; import { EventMetadataKey } from './event-metadata-key.js'; import type { Config } from '../../config/config.js'; @@ -83,6 +84,7 @@ export enum EventNames { SMART_EDIT_CORRECTION = 'smart_edit_correction', AGENT_START = 'agent_start', AGENT_FINISH = 'agent_finish', + WEB_FETCH_FALLBACK_ATTEMPT = 'web_fetch_fallback_attempt', } export interface LogResponse { @@ -1111,6 +1113,20 @@ export class ClearcutLogger { this.flushIfNeeded(); } + logWebFetchFallbackAttemptEvent(event: WebFetchFallbackAttemptEvent): void { + const data: EventValue[] = [ + { + gemini_cli_key: EventMetadataKey.GEMINI_CLI_WEB_FETCH_FALLBACK_REASON, + value: event.reason, + }, + ]; + + this.enqueueLogEvent( + this.createLogEvent(EventNames.WEB_FETCH_FALLBACK_ATTEMPT, 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 2e0da3a0c2..2e242cb424 100644 --- a/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts +++ b/packages/core/src/telemetry/clearcut-logger/event-metadata-key.ts @@ -7,6 +7,7 @@ // Defines valid event metadata keys for Clearcut logging. export enum EventMetadataKey { // Deleted enums: 24 + // Next ID: 117 GEMINI_CLI_KEY_UNKNOWN = 0, @@ -98,6 +99,9 @@ export enum EventMetadataKey { // Logs a smart edit correction event. GEMINI_CLI_SMART_EDIT_CORRECTION = 110, + // Logs the reason for web fetch fallback. + GEMINI_CLI_WEB_FETCH_FALLBACK_REASON = 116, + // ========================================================================== // GenAI API Request Event Keys // =========================================================================== diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index 083ecdb677..f93871e5d7 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -35,6 +35,8 @@ export const EVENT_MODEL_SLASH_COMMAND = 'gemini_cli.slash_command.model'; export const EVENT_SMART_EDIT_STRATEGY = 'gemini_cli.smart_edit.strategy'; export const EVENT_MODEL_ROUTING = 'gemini_cli.model_routing'; export const EVENT_SMART_EDIT_CORRECTION = 'gemini_cli.smart_edit.correction'; +export const EVENT_WEB_FETCH_FALLBACK_ATTEMPT = + 'gemini_cli.web_fetch_fallback_attempt'; // Agent Events export const EVENT_AGENT_START = 'gemini_cli.agent.start'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index fa1718f675..04bf3587d8 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -44,6 +44,7 @@ export { logExtensionEnable, logExtensionInstallEvent, logExtensionUninstall, + logWebFetchFallbackAttempt, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -59,6 +60,7 @@ export { ConversationFinishedEvent, KittySequenceOverflowEvent, ToolOutputTruncatedEvent, + WebFetchFallbackAttemptEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; export type { TelemetryEvent } from './types.js'; diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index 127c6d7e3c..9f956ac490 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -40,6 +40,7 @@ import { EVENT_TOOL_OUTPUT_TRUNCATED, EVENT_AGENT_START, EVENT_AGENT_FINISH, + EVENT_WEB_FETCH_FALLBACK_ATTEMPT, } from './constants.js'; import { logApiRequest, @@ -60,6 +61,7 @@ import { logExtensionUninstall, logAgentStart, logAgentFinish, + logWebFetchFallbackAttempt, } from './loggers.js'; import { ToolCallDecision } from './tool-call-decision.js'; import { @@ -81,6 +83,7 @@ import { ExtensionUninstallEvent, AgentStartEvent, AgentFinishEvent, + WebFetchFallbackAttemptEvent, } from './types.js'; import * as metrics from './metrics.js'; import { @@ -1494,4 +1497,36 @@ describe('loggers', () => { ); }); }); + + describe('logWebFetchFallbackAttempt', () => { + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + } as unknown as Config; + + beforeEach(() => { + vi.spyOn(ClearcutLogger.prototype, 'logWebFetchFallbackAttemptEvent'); + }); + + it('should log web fetch fallback attempt event', () => { + const event = new WebFetchFallbackAttemptEvent('private_ip'); + + logWebFetchFallbackAttempt(mockConfig, event); + + expect( + ClearcutLogger.prototype.logWebFetchFallbackAttemptEvent, + ).toHaveBeenCalledWith(event); + + expect(mockLogger.emit).toHaveBeenCalledWith({ + body: 'Web fetch fallback attempt. Reason: private_ip', + attributes: { + 'session.id': 'test-session-id', + 'user.email': 'test-user@example.com', + 'event.name': EVENT_WEB_FETCH_FALLBACK_ATTEMPT, + 'event.timestamp': '2025-01-01T00:00:00.000Z', + reason: 'private_ip', + }, + }); + }); + }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 3dce516619..0ba68cf266 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -39,6 +39,7 @@ import { EVENT_SMART_EDIT_CORRECTION, EVENT_AGENT_START, EVENT_AGENT_FINISH, + EVENT_WEB_FETCH_FALLBACK_ATTEMPT, } from './constants.js'; import type { ApiErrorEvent, @@ -73,6 +74,7 @@ import type { SmartEditCorrectionEvent, AgentStartEvent, AgentFinishEvent, + WebFetchFallbackAttemptEvent, } from './types.js'; import { recordApiErrorMetrics, @@ -863,7 +865,7 @@ export function logSmartEditCorrectionEvent( const logger = logs.getLogger(SERVICE_NAME); const logRecord: LogRecord = { - body: `Smart Edit Tool Correction: ${event.correction}`, + body: `Smart Edit Correction`, attributes, }; logger.emit(logRecord); @@ -906,3 +908,24 @@ export function logAgentFinish(config: Config, event: AgentFinishEvent): void { recordAgentRunMetrics(config, event); } + +export function logWebFetchFallbackAttempt( + config: Config, + event: WebFetchFallbackAttemptEvent, +): void { + ClearcutLogger.getInstance(config)?.logWebFetchFallbackAttemptEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_WEB_FETCH_FALLBACK_ATTEMPT, + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Web fetch fallback attempt. Reason: ${event.reason}`, + attributes, + }; + logger.emit(logRecord); +} diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index bb6c705d0a..ba8a1924ab 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -690,7 +690,8 @@ export type TelemetryEvent = | ToolOutputTruncatedEvent | ModelSlashCommandEvent | AgentStartEvent - | AgentFinishEvent; + | AgentFinishEvent + | WebFetchFallbackAttemptEvent; export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable'; @@ -769,3 +770,15 @@ export class AgentFinishEvent implements BaseTelemetryEvent { this.terminate_reason = terminate_reason; } } + +export class WebFetchFallbackAttemptEvent implements BaseTelemetryEvent { + 'event.name': 'web_fetch_fallback_attempt'; + 'event.timestamp': string; + reason: 'private_ip' | 'primary_failed'; + + constructor(reason: 'private_ip' | 'primary_failed') { + this['event.name'] = 'web_fetch_fallback_attempt'; + this['event.timestamp'] = new Date().toISOString(); + this.reason = reason; + } +} diff --git a/packages/core/src/tools/web-fetch.test.ts b/packages/core/src/tools/web-fetch.test.ts index 585f662c0f..b79ae5b928 100644 --- a/packages/core/src/tools/web-fetch.test.ts +++ b/packages/core/src/tools/web-fetch.test.ts @@ -11,12 +11,21 @@ import { ApprovalMode } from '../config/config.js'; import { ToolConfirmationOutcome } from './tools.js'; import { ToolErrorType } from './tool-error.js'; import * as fetchUtils from '../utils/fetch.js'; +import { + logWebFetchFallbackAttempt, + WebFetchFallbackAttemptEvent, +} from '../telemetry/index.js'; const mockGenerateContent = vi.fn(); const mockGetGeminiClient = vi.fn(() => ({ generateContent: mockGenerateContent, })); +vi.mock('../telemetry/index.js', () => ({ + logWebFetchFallbackAttempt: vi.fn(), + WebFetchFallbackAttemptEvent: vi.fn(), +})); + vi.mock('../utils/fetch.js', async (importOriginal) => { const actual = await importOriginal(); return { @@ -70,6 +79,59 @@ describe('WebFetchTool', () => { const result = await invocation.execute(new AbortController().signal); expect(result.error?.type).toBe(ToolErrorType.WEB_FETCH_PROCESSING_ERROR); }); + + it('should log telemetry when falling back due to private IP', async () => { + vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(true); + // Mock fetchWithTimeout to succeed so fallback proceeds + vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ + ok: true, + text: () => Promise.resolve('some content'), + } as Response); + mockGenerateContent.mockResolvedValue({ + candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], + }); + + const tool = new WebFetchTool(mockConfig); + const params = { prompt: 'fetch https://private.ip' }; + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith( + mockConfig, + expect.any(WebFetchFallbackAttemptEvent), + ); + expect(WebFetchFallbackAttemptEvent).toHaveBeenCalledWith('private_ip'); + }); + + it('should log telemetry when falling back due to primary fetch failure', async () => { + vi.spyOn(fetchUtils, 'isPrivateIp').mockReturnValue(false); + // Mock primary fetch to return empty response, triggering fallback + mockGenerateContent.mockResolvedValueOnce({ + candidates: [], + }); + // Mock fetchWithTimeout to succeed so fallback proceeds + vi.spyOn(fetchUtils, 'fetchWithTimeout').mockResolvedValue({ + ok: true, + text: () => Promise.resolve('some content'), + } as Response); + // Mock fallback LLM call + mockGenerateContent.mockResolvedValueOnce({ + candidates: [{ content: { parts: [{ text: 'fallback response' }] } }], + }); + + const tool = new WebFetchTool(mockConfig); + const params = { prompt: 'fetch https://public.ip' }; + const invocation = tool.build(params); + await invocation.execute(new AbortController().signal); + + expect(logWebFetchFallbackAttempt).toHaveBeenCalledWith( + mockConfig, + expect.any(WebFetchFallbackAttemptEvent), + ); + expect(WebFetchFallbackAttemptEvent).toHaveBeenCalledWith( + 'primary_failed', + ); + }); }); describe('shouldConfirmExecute', () => { diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index 5e1e9bf531..95baab28c3 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -23,6 +23,10 @@ import { getResponseText } from '../utils/partUtils.js'; import { fetchWithTimeout, isPrivateIp } from '../utils/fetch.js'; import { convert } from 'html-to-text'; import { ProxyAgent, setGlobalDispatcher } from 'undici'; +import { + logWebFetchFallbackAttempt, + WebFetchFallbackAttemptEvent, +} from '../telemetry/index.js'; const URL_FETCH_TIMEOUT_MS = 10000; const MAX_CONTENT_LENGTH = 100000; @@ -184,6 +188,10 @@ ${textContent} const isPrivate = isPrivateIp(url); if (isPrivate) { + logWebFetchFallbackAttempt( + this.config, + new WebFetchFallbackAttemptEvent('private_ip'), + ); return this.executeFallback(signal); } @@ -243,6 +251,10 @@ ${textContent} } if (processingError) { + logWebFetchFallbackAttempt( + this.config, + new WebFetchFallbackAttemptEvent('primary_failed'), + ); return this.executeFallback(signal); }