From b9b3b8050d4884e46f0627b9ecb56c993049aa27 Mon Sep 17 00:00:00 2001 From: Marat Boshernitsan Date: Tue, 2 Dec 2025 21:27:37 -0800 Subject: [PATCH] Allow telemetry exporters to GCP to utilize user's login credentials, if requested (#13778) --- docs/cli/telemetry.md | 47 +- .../components/EditorSettingsDialog.test.tsx | 9 + .../EditorSettingsDialog.test.tsx.snap | 2 +- .../cli/src/ui/utils/terminalSetup.test.ts | 5 + packages/core/src/code_assist/oauth2.test.ts | 52 +- packages/core/src/code_assist/oauth2.ts | 47 +- packages/core/src/config/config.ts | 6 + packages/core/src/core/client.test.ts | 4 + packages/core/src/core/geminiChat.test.ts | 4 + packages/core/src/telemetry/config.test.ts | 28 + packages/core/src/telemetry/config.ts | 3 + packages/core/src/telemetry/gcp-exporters.ts | 11 +- packages/core/src/telemetry/loggers.test.ts | 17 + packages/core/src/telemetry/loggers.ts | 686 +++++++++--------- packages/core/src/telemetry/sdk.test.ts | 190 ++++- packages/core/src/telemetry/sdk.ts | 139 +++- .../src/telemetry/startupProfiler.test.ts | 4 + packages/core/src/telemetry/telemetry.test.ts | 16 +- packages/core/src/utils/debugLogger.ts | 31 + packages/core/src/utils/editCorrector.test.ts | 4 + .../core/src/utils/nextSpeakerChecker.test.ts | 4 + scripts/telemetry.js | 30 +- scripts/telemetry_gcp.js | 6 +- scripts/tests/generate-settings-doc.test.ts | 10 +- .../tests/generate-settings-schema.test.ts | 11 +- scripts/tests/telemetry_gcp.test.ts | 56 ++ 26 files changed, 994 insertions(+), 428 deletions(-) create mode 100644 scripts/tests/telemetry_gcp.test.ts diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index cbf2aef3b0..8fb2fd179e 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -74,15 +74,16 @@ observability framework — Gemini CLI's observability system provides: All telemetry behavior is controlled through your `.gemini/settings.json` file. Environment variables can be used to override the settings in the file. -| Setting | Environment Variable | Description | Values | Default | -| -------------- | -------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- | -| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` | -| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | -| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` | -| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | -| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | -| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | -| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | +| Setting | Environment Variable | Description | Values | Default | +| -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- | +| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` | +| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` | +| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` | +| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` | +| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - | +| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` | +| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` | +| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` | **Note on boolean environment variables:** For the boolean settings (`enabled`, `logPrompts`, `useCollector`), setting the corresponding environment variable to @@ -130,6 +131,34 @@ Before using either method below, complete these steps: --project="$OTLP_GOOGLE_CLOUD_PROJECT" ``` +### Authenticating with CLI Credentials + +By default, the telemetry collector for Google Cloud uses Application Default +Credentials (ADC). However, you can configure it to use the same OAuth +credentials that you use to log in to the Gemini CLI. This is useful in +environments where you don't have ADC set up. + +To enable this, set the `useCliAuth` property in your `telemetry` settings to +`true`: + +```json +{ + "telemetry": { + "enabled": true, + "target": "gcp", + "useCliAuth": true + } +} +``` + +**Important:** + +- This setting requires the use of **Direct Export** (in-process exporters). +- It **cannot** be used with `useCollector: true`. If you enable both, telemetry + will be disabled and an error will be logged. +- The CLI will automatically use your credentials to authenticate with Google + Cloud Trace, Metrics, and Logging APIs. + ### Direct export (recommended) Sends telemetry directly to Google Cloud services. No collector needed. diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 56638a17ba..9aeff70d93 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -13,6 +13,15 @@ import { KeypressProvider } from '../contexts/KeypressContext.js'; import { act } from 'react'; import { waitFor } from '../../test-utils/async.js'; +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isEditorAvailable: () => true, // Mock to behave predictably in CI + }; +}); + // Mock editorSettingsManager vi.mock('../editors/editorSettingsManager.js', () => ({ editorSettingsManager: { diff --git a/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap index dd0719a90e..b88935b408 100644 --- a/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap @@ -8,7 +8,7 @@ exports[`EditorSettingsDialog > renders correctly 1`] = ` │ 2. Vim These editors are currently supported. Please note │ │ that some editors cannot be used in sandbox mode. │ │ Apply To │ -│ ● 1. User Settings Your preferred editor is: None. │ +│ ● 1. User Settings Your preferred editor is: VS Code. │ │ 2. Workspace Settings │ │ │ │ (Use Enter to select, Tab to change │ diff --git a/packages/cli/src/ui/utils/terminalSetup.test.ts b/packages/cli/src/ui/utils/terminalSetup.test.ts index f3ed50726b..5e15b9f6ea 100644 --- a/packages/cli/src/ui/utils/terminalSetup.test.ts +++ b/packages/cli/src/ui/utils/terminalSetup.test.ts @@ -16,6 +16,10 @@ const mocks = vi.hoisted(() => ({ copyFile: vi.fn(), homedir: vi.fn(), platform: vi.fn(), + writeStream: { + write: vi.fn(), + on: vi.fn(), + }, })); vi.mock('node:child_process', () => ({ @@ -24,6 +28,7 @@ vi.mock('node:child_process', () => ({ })); vi.mock('node:fs', () => ({ + createWriteStream: () => mocks.writeStream, promises: { mkdir: mocks.mkdir, readFile: mocks.readFile, diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index da920588b6..1b9b8fa0c9 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -12,6 +12,7 @@ import { resetOauthClientForTesting, clearCachedCredentialFile, clearOauthClientCache, + authEvents, } from './oauth2.js'; import { UserAccountManager } from '../utils/userAccountManager.js'; import { OAuth2Client, Compute, GoogleAuth } from 'google-auth-library'; @@ -109,13 +110,18 @@ describe('oauth2', () => { const mockGetAccessToken = vi .fn() .mockResolvedValue({ token: 'mock-access-token' }); + let tokensListener: ((tokens: Credentials) => void) | undefined; const mockOAuth2Client = { generateAuthUrl: mockGenerateAuthUrl, getToken: mockGetToken, setCredentials: mockSetCredentials, getAccessToken: mockGetAccessToken, credentials: mockTokens, - on: vi.fn(), + on: vi.fn((event, listener) => { + if (event === 'tokens') { + tokensListener = listener; + } + }), } as unknown as OAuth2Client; vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); @@ -195,6 +201,11 @@ describe('oauth2', () => { }); expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens); + // Manually trigger the 'tokens' event listener + if (tokensListener) { + await tokensListener(mockTokens); + } + // Verify Google Account was cached const googleAccountPath = path.join( tempHomeDir, @@ -215,6 +226,45 @@ describe('oauth2', () => { ); }); + it('should clear credentials file', async () => { + // Setup initial state with files + const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); + + await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); + await fs.promises.writeFile(credsPath, '{}'); + + await clearCachedCredentialFile(); + + expect(fs.existsSync(credsPath)).toBe(false); + }); + + it('should emit post_auth event when loading cached credentials', async () => { + const cachedCreds = { refresh_token: 'cached-token' }; + const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json'); + await fs.promises.mkdir(path.dirname(credsPath), { recursive: true }); + await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds)); + + const mockClient = { + setCredentials: vi.fn(), + getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), + getTokenInfo: vi.fn().mockResolvedValue({}), + on: vi.fn(), + }; + vi.mocked(OAuth2Client).mockImplementation( + () => mockClient as unknown as OAuth2Client, + ); + + const eventPromise = new Promise((resolve) => { + authEvents.once('post_auth', (creds) => { + expect(creds.refresh_token).toBe('cached-token'); + resolve(); + }); + }); + + await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig); + await eventPromise; + }); + it('should perform login with user code', async () => { const mockConfigWithNoBrowser = { getNoBrowser: () => true, diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index 0dbf5d1205..a99dde03f5 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -15,6 +15,7 @@ import * as http from 'node:http'; import url from 'node:url'; import crypto from 'node:crypto'; import * as net from 'node:net'; +import { EventEmitter } from 'node:events'; import open from 'open'; import path from 'node:path'; import { promises as fs } from 'node:fs'; @@ -45,6 +46,22 @@ import { } from '../utils/terminal.js'; import { coreEvents, CoreEvent } from '../utils/events.js'; +export const authEvents = new EventEmitter(); + +async function triggerPostAuthCallbacks(tokens: Credentials) { + // Construct a JWTInput object to pass to callbacks, as this is the + // type expected by the downstream Google Cloud client libraries. + const jwtInput: JWTInput = { + client_id: OAUTH_CLIENT_ID, + client_secret: OAUTH_CLIENT_SECRET, + refresh_token: tokens.refresh_token ?? undefined, // Ensure null is not passed + type: 'authorized_user', + }; + + // Execute all registered post-authentication callbacks. + authEvents.emit('post_auth', jwtInput); +} + const userAccountManager = new UserAccountManager(); // OAuth Client ID used to initiate OAuth2Client class. @@ -139,6 +156,8 @@ async function initOauthClient( } else { await cacheCredentials(tokens); } + + await triggerPostAuthCallbacks(tokens); }); if (credentials) { @@ -162,6 +181,8 @@ async function initOauthClient( } } debugLogger.log('Loaded cached credentials.'); + await triggerPostAuthCallbacks(credentials as Credentials); + return client; } } catch (error) { @@ -570,19 +591,6 @@ async function fetchCachedCredentials(): Promise< return null; } -async function cacheCredentials(credentials: Credentials) { - const filePath = Storage.getOAuthCredsPath(); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - - const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString, { mode: 0o600 }); - try { - await fs.chmod(filePath, 0o600); - } catch { - /* empty */ - } -} - export function clearOauthClientCache() { oauthClientPromises.clear(); } @@ -640,3 +648,16 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise { export function resetOauthClientForTesting() { oauthClientPromises.clear(); } + +async function cacheCredentials(credentials: Credentials) { + const filePath = Storage.getOAuthCredsPath(); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + + const credString = JSON.stringify(credentials, null, 2); + await fs.writeFile(filePath, credString, { mode: 0o600 }); + try { + await fs.chmod(filePath, 0o600); + } catch { + /* empty */ + } +} diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index cadb5898bb..34a4304a75 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -113,6 +113,7 @@ export interface TelemetrySettings { logPrompts?: boolean; outfile?: string; useCollector?: boolean; + useCliAuth?: boolean; } export interface OutputSettings { @@ -475,6 +476,7 @@ export class Config { logPrompts: params.telemetry?.logPrompts ?? true, outfile: params.telemetry?.outfile, useCollector: params.telemetry?.useCollector, + useCliAuth: params.telemetry?.useCliAuth, }; this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; @@ -1067,6 +1069,10 @@ export class Config { return this.telemetrySettings.useCollector ?? false; } + getTelemetryUseCliAuth(): boolean { + return this.telemetrySettings.useCliAuth ?? false; + } + getGeminiClient(): GeminiClient { return this.geminiClient; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 6487e04e80..e362243054 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -65,6 +65,10 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), }; return { diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 18b291bfbf..faa2b29ad4 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -48,6 +48,10 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), }; return { diff --git a/packages/core/src/telemetry/config.test.ts b/packages/core/src/telemetry/config.test.ts index 1ded8d4900..232aa27466 100644 --- a/packages/core/src/telemetry/config.test.ts +++ b/packages/core/src/telemetry/config.test.ts @@ -120,9 +120,37 @@ describe('telemetry/config helpers', () => { logPrompts: false, outfile: 'argv.log', useCollector: true, // from env as no argv option + useCliAuth: undefined, }); }); + it('resolves useCliAuth from settings', async () => { + const settings = { + useCliAuth: true, + }; + const resolved = await resolveTelemetrySettings({ settings }); + expect(resolved.useCliAuth).toBe(true); + }); + + it('resolves useCliAuth from env', async () => { + const env = { + GEMINI_TELEMETRY_USE_CLI_AUTH: 'true', + }; + const resolved = await resolveTelemetrySettings({ env }); + expect(resolved.useCliAuth).toBe(true); + }); + + it('env overrides settings for useCliAuth', async () => { + const settings = { + useCliAuth: false, + }; + const env = { + GEMINI_TELEMETRY_USE_CLI_AUTH: 'true', + }; + const resolved = await resolveTelemetrySettings({ env, settings }); + expect(resolved.useCliAuth).toBe(true); + }); + it('falls back to OTEL_EXPORTER_OTLP_ENDPOINT when GEMINI var is missing', async () => { const settings = {}; const env = { diff --git a/packages/core/src/telemetry/config.ts b/packages/core/src/telemetry/config.ts index bfca365c81..5569d4137a 100644 --- a/packages/core/src/telemetry/config.ts +++ b/packages/core/src/telemetry/config.ts @@ -116,5 +116,8 @@ export async function resolveTelemetrySettings(options: { logPrompts, outfile, useCollector, + useCliAuth: + parseBooleanEnvFlag(env['GEMINI_TELEMETRY_USE_CLI_AUTH']) ?? + settings.useCliAuth, }; } diff --git a/packages/core/src/telemetry/gcp-exporters.ts b/packages/core/src/telemetry/gcp-exporters.ts index 8bc776f636..584bf6ac61 100644 --- a/packages/core/src/telemetry/gcp-exporters.ts +++ b/packages/core/src/telemetry/gcp-exporters.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { type JWTInput } from 'google-auth-library'; import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter'; import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter'; import { Logging } from '@google-cloud/logging'; @@ -20,9 +21,10 @@ import type { * Google Cloud Trace exporter that extends the official trace exporter */ export class GcpTraceExporter extends TraceExporter { - constructor(projectId?: string) { + constructor(projectId?: string, credentials?: JWTInput) { super({ projectId, + credentials, resourceFilter: /^gcp\./, }); } @@ -32,9 +34,10 @@ export class GcpTraceExporter extends TraceExporter { * Google Cloud Monitoring exporter that extends the official metrics exporter */ export class GcpMetricExporter extends MetricExporter { - constructor(projectId?: string) { + constructor(projectId?: string, credentials?: JWTInput) { super({ projectId, + credentials, prefix: 'custom.googleapis.com/gemini_cli', }); } @@ -48,8 +51,8 @@ export class GcpLogExporter implements LogRecordExporter { private log: Log; private pendingWrites: Array> = []; - constructor(projectId?: string) { - this.logging = new Logging({ projectId }); + constructor(projectId?: string, credentials?: JWTInput) { + this.logging = new Logging({ projectId, credentials }); this.log = this.logging.log('gemini_cli'); } diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index bdf5de6865..27897b2f2e 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -117,6 +117,7 @@ describe('loggers', () => { beforeEach(() => { vi.clearAllMocks(); vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(true); + vi.spyOn(sdk, 'bufferTelemetryEvent').mockImplementation((cb) => cb()); vi.spyOn(logs, 'getLogger').mockReturnValue(mockLogger); vi.spyOn(uiTelemetry.uiTelemetryService, 'addEvent').mockImplementation( mockUiEvent.addEvent, @@ -1719,6 +1720,7 @@ describe('loggers', () => { it('should only log to Clearcut if OTEL SDK is not initialized', () => { vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false); + vi.spyOn(sdk, 'bufferTelemetryEvent').mockImplementation(() => {}); const event = new ModelRoutingEvent( 'gemini-pro', 'default', @@ -2086,4 +2088,19 @@ describe('loggers', () => { }); }); }); + describe('Telemetry Buffering', () => { + it('should buffer events when SDK is not initialized', () => { + vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false); + const bufferSpy = vi + .spyOn(sdk, 'bufferTelemetryEvent') + .mockImplementation(() => {}); + + const mockConfig = makeFakeConfig(); + const event = new StartSessionEvent(mockConfig); + logCliConfiguration(mockConfig, event); + + expect(bufferSpy).toHaveBeenCalled(); + expect(mockLogger.emit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 20525e6529..4706a28218 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -69,7 +69,7 @@ import { recordLinesChanged, recordHookCallMetrics, } from './metrics.js'; -import { isTelemetrySdkInitialized } from './sdk.js'; +import { bufferTelemetryEvent } from './sdk.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js'; @@ -79,27 +79,27 @@ export function logCliConfiguration( event: StartSessionEvent, ): void { ClearcutLogger.getInstance(config)?.logStartSessionEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logUserPrompt(config: Config, event: UserPromptEvent): void { ClearcutLogger.getInstance(config)?.logNewPromptEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); - const logger = logs.getLogger(SERVICE_NAME); - - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logToolCall(config: Config, event: ToolCallEvent): void { @@ -110,35 +110,35 @@ export function logToolCall(config: Config, event: ToolCallEvent): void { } as UiEvent; uiTelemetryService.addEvent(uiEvent); ClearcutLogger.getInstance(config)?.logToolCallEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordToolCallMetrics(config, event.duration_ms, { + function_name: event.function_name, + success: event.success, + decision: event.decision, + tool_type: event.tool_type, + }); - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordToolCallMetrics(config, event.duration_ms, { - function_name: event.function_name, - success: event.success, - decision: event.decision, - tool_type: event.tool_type, + if (event.metadata) { + const added = event.metadata['model_added_lines']; + if (typeof added === 'number' && added > 0) { + recordLinesChanged(config, added, 'added', { + function_name: event.function_name, + }); + } + const removed = event.metadata['model_removed_lines']; + if (typeof removed === 'number' && removed > 0) { + recordLinesChanged(config, removed, 'removed', { + function_name: event.function_name, + }); + } + } }); - - if (event.metadata) { - const added = event.metadata['model_added_lines']; - if (typeof added === 'number' && added > 0) { - recordLinesChanged(config, added, 'added', { - function_name: event.function_name, - }); - } - const removed = event.metadata['model_removed_lines']; - if (typeof removed === 'number' && removed > 0) { - recordLinesChanged(config, removed, 'removed', { - function_name: event.function_name, - }); - } - } } export function logToolOutputTruncated( @@ -146,14 +146,14 @@ export function logToolOutputTruncated( event: ToolOutputTruncatedEvent, ): void { ClearcutLogger.getInstance(config)?.logToolOutputTruncatedEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logFileOperation( @@ -161,31 +161,31 @@ export function logFileOperation( event: FileOperationEvent, ): void { ClearcutLogger.getInstance(config)?.logFileOperationEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - - recordFileOperationMetric(config, { - operation: event.operation, - lines: event.lines, - mimetype: event.mimetype, - extension: event.extension, - programming_language: event.programming_language, + recordFileOperationMetric(config, { + operation: event.operation, + lines: event.lines, + mimetype: event.mimetype, + extension: event.extension, + programming_language: event.programming_language, + }); }); } export function logApiRequest(config: Config, event: ApiRequestEvent): void { ClearcutLogger.getInstance(config)?.logApiRequestEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - logger.emit(event.toLogRecord(config)); - logger.emit(event.toSemanticLogRecord(config)); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + logger.emit(event.toLogRecord(config)); + logger.emit(event.toSemanticLogRecord(config)); + }); } export function logFlashFallback( @@ -193,14 +193,14 @@ export function logFlashFallback( event: FlashFallbackEvent, ): void { ClearcutLogger.getInstance(config)?.logFlashFallbackEvent(); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logRipgrepFallback( @@ -208,14 +208,14 @@ export function logRipgrepFallback( event: RipgrepFallbackEvent, ): void { ClearcutLogger.getInstance(config)?.logRipgrepFallbackEvent(); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logApiError(config: Config, event: ApiErrorEvent): void { @@ -226,26 +226,26 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { } as UiEvent; uiTelemetryService.addEvent(uiEvent); ClearcutLogger.getInstance(config)?.logApiErrorEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + logger.emit(event.toLogRecord(config)); + logger.emit(event.toSemanticLogRecord(config)); - const logger = logs.getLogger(SERVICE_NAME); - logger.emit(event.toLogRecord(config)); - logger.emit(event.toSemanticLogRecord(config)); + recordApiErrorMetrics(config, event.duration_ms, { + model: event.model, + status_code: event.status_code, + error_type: event.error_type, + }); - recordApiErrorMetrics(config, event.duration_ms, { - model: event.model, - status_code: event.status_code, - error_type: event.error_type, - }); - - // Record GenAI operation duration for errors - recordApiResponseMetrics(config, event.duration_ms, { - model: event.model, - status_code: event.status_code, - genAiAttributes: { - ...getConventionAttributes(event), - 'error.type': event.error_type || 'unknown', - }, + // Record GenAI operation duration for errors + recordApiResponseMetrics(config, event.duration_ms, { + model: event.model, + status_code: event.status_code, + genAiAttributes: { + ...getConventionAttributes(event), + 'error.type': event.error_type || 'unknown', + }, + }); }); } @@ -257,35 +257,35 @@ export function logApiResponse(config: Config, event: ApiResponseEvent): void { } as UiEvent; uiTelemetryService.addEvent(uiEvent); ClearcutLogger.getInstance(config)?.logApiResponseEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + logger.emit(event.toLogRecord(config)); + logger.emit(event.toSemanticLogRecord(config)); - const logger = logs.getLogger(SERVICE_NAME); - logger.emit(event.toLogRecord(config)); - logger.emit(event.toSemanticLogRecord(config)); + const conventionAttributes = getConventionAttributes(event); - const conventionAttributes = getConventionAttributes(event); - - recordApiResponseMetrics(config, event.duration_ms, { - model: event.model, - status_code: event.status_code, - genAiAttributes: conventionAttributes, - }); - - const tokenUsageData = [ - { count: event.usage.input_token_count, type: 'input' as const }, - { count: event.usage.output_token_count, type: 'output' as const }, - { count: event.usage.cached_content_token_count, type: 'cache' as const }, - { count: event.usage.thoughts_token_count, type: 'thought' as const }, - { count: event.usage.tool_token_count, type: 'tool' as const }, - ]; - - for (const { count, type } of tokenUsageData) { - recordTokenUsageMetrics(config, count, { + recordApiResponseMetrics(config, event.duration_ms, { model: event.model, - type, + status_code: event.status_code, genAiAttributes: conventionAttributes, }); - } + + const tokenUsageData = [ + { count: event.usage.input_token_count, type: 'input' as const }, + { count: event.usage.output_token_count, type: 'output' as const }, + { count: event.usage.cached_content_token_count, type: 'cache' as const }, + { count: event.usage.thoughts_token_count, type: 'thought' as const }, + { count: event.usage.tool_token_count, type: 'tool' as const }, + ]; + + for (const { count, type } of tokenUsageData) { + recordTokenUsageMetrics(config, count, { + model: event.model, + type, + genAiAttributes: conventionAttributes, + }); + } + }); } export function logLoopDetected( @@ -293,14 +293,14 @@ export function logLoopDetected( event: LoopDetectedEvent, ): void { ClearcutLogger.getInstance(config)?.logLoopDetectedEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logLoopDetectionDisabled( @@ -308,14 +308,14 @@ export function logLoopDetectionDisabled( event: LoopDetectionDisabledEvent, ): void { ClearcutLogger.getInstance(config)?.logLoopDetectionDisabledEvent(); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logNextSpeakerCheck( @@ -323,14 +323,14 @@ export function logNextSpeakerCheck( event: NextSpeakerCheckEvent, ): void { ClearcutLogger.getInstance(config)?.logNextSpeakerCheck(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logSlashCommand( @@ -338,14 +338,14 @@ export function logSlashCommand( event: SlashCommandEvent, ): void { ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logIdeConnection( @@ -353,14 +353,14 @@ export function logIdeConnection( event: IdeConnectionEvent, ): void { ClearcutLogger.getInstance(config)?.logIdeConnectionEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logConversationFinishedEvent( @@ -368,14 +368,14 @@ export function logConversationFinishedEvent( event: ConversationFinishedEvent, ): void { ClearcutLogger.getInstance(config)?.logConversationFinishedEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logChatCompression( @@ -402,14 +402,14 @@ export function logMalformedJsonResponse( event: MalformedJsonResponseEvent, ): void { ClearcutLogger.getInstance(config)?.logMalformedJsonResponseEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logInvalidChunk( @@ -417,15 +417,15 @@ export function logInvalidChunk( event: InvalidChunkEvent, ): void { ClearcutLogger.getInstance(config)?.logInvalidChunkEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordInvalidChunk(config); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordInvalidChunk(config); + }); } export function logContentRetry( @@ -433,15 +433,15 @@ export function logContentRetry( event: ContentRetryEvent, ): void { ClearcutLogger.getInstance(config)?.logContentRetryEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordContentRetry(config); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordContentRetry(config); + }); } export function logContentRetryFailure( @@ -449,15 +449,15 @@ export function logContentRetryFailure( event: ContentRetryFailureEvent, ): void { ClearcutLogger.getInstance(config)?.logContentRetryFailureEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordContentRetryFailure(config); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordContentRetryFailure(config); + }); } export function logModelRouting( @@ -465,15 +465,15 @@ export function logModelRouting( event: ModelRoutingEvent, ): void { ClearcutLogger.getInstance(config)?.logModelRoutingEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordModelRoutingMetrics(config, event); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordModelRoutingMetrics(config, event); + }); } export function logModelSlashCommand( @@ -481,15 +481,15 @@ export function logModelSlashCommand( event: ModelSlashCommandEvent, ): void { ClearcutLogger.getInstance(config)?.logModelSlashCommandEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - recordModelSlashCommand(config, event); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + recordModelSlashCommand(config, event); + }); } export async function logExtensionInstallEvent( @@ -497,14 +497,14 @@ export async function logExtensionInstallEvent( event: ExtensionInstallEvent, ): Promise { await ClearcutLogger.getInstance(config)?.logExtensionInstallEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export async function logExtensionUninstall( @@ -512,14 +512,14 @@ export async function logExtensionUninstall( event: ExtensionUninstallEvent, ): Promise { await ClearcutLogger.getInstance(config)?.logExtensionUninstallEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export async function logExtensionUpdateEvent( @@ -527,14 +527,14 @@ export async function logExtensionUpdateEvent( event: ExtensionUpdateEvent, ): Promise { await ClearcutLogger.getInstance(config)?.logExtensionUpdateEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export async function logExtensionEnable( @@ -542,14 +542,14 @@ export async function logExtensionEnable( event: ExtensionEnableEvent, ): Promise { await ClearcutLogger.getInstance(config)?.logExtensionEnableEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export async function logExtensionDisable( @@ -557,14 +557,14 @@ export async function logExtensionDisable( event: ExtensionDisableEvent, ): Promise { await ClearcutLogger.getInstance(config)?.logExtensionDisableEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logSmartEditStrategy( @@ -572,14 +572,14 @@ export function logSmartEditStrategy( event: SmartEditStrategyEvent, ): void { ClearcutLogger.getInstance(config)?.logSmartEditStrategyEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logSmartEditCorrectionEvent( @@ -587,40 +587,40 @@ export function logSmartEditCorrectionEvent( event: SmartEditCorrectionEvent, ): void { ClearcutLogger.getInstance(config)?.logSmartEditCorrectionEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logAgentStart(config: Config, event: AgentStartEvent): void { ClearcutLogger.getInstance(config)?.logAgentStartEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logAgentFinish(config: Config, event: AgentFinishEvent): void { ClearcutLogger.getInstance(config)?.logAgentFinishEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - - recordAgentRunMetrics(config, event); + recordAgentRunMetrics(config, event); + }); } export function logRecoveryAttempt( @@ -628,16 +628,16 @@ export function logRecoveryAttempt( event: RecoveryAttemptEvent, ): void { ClearcutLogger.getInstance(config)?.logRecoveryAttemptEvent(event); - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - - recordRecoveryAttemptMetrics(config, event); + recordRecoveryAttemptMetrics(config, event); + }); } export function logWebFetchFallbackAttempt( @@ -645,14 +645,14 @@ export function logWebFetchFallbackAttempt( event: WebFetchFallbackAttemptEvent, ): void { ClearcutLogger.getInstance(config)?.logWebFetchFallbackAttemptEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logLlmLoopCheck( @@ -660,31 +660,31 @@ export function logLlmLoopCheck( event: LlmLoopCheckEvent, ): void { ClearcutLogger.getInstance(config)?.logLlmLoopCheckEvent(event); - if (!isTelemetrySdkInitialized()) return; - - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); + }); } export function logHookCall(config: Config, event: HookCallEvent): void { - if (!isTelemetrySdkInitialized()) return; + bufferTelemetryEvent(() => { + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: event.toLogBody(), + attributes: event.toOpenTelemetryAttributes(config), + }; + logger.emit(logRecord); - const logger = logs.getLogger(SERVICE_NAME); - const logRecord: LogRecord = { - body: event.toLogBody(), - attributes: event.toOpenTelemetryAttributes(config), - }; - logger.emit(logRecord); - - recordHookCallMetrics( - config, - event.hook_event_name, - event.hook_name, - event.duration_ms, - event.success, - ); + recordHookCallMetrics( + config, + event.hook_event_name, + event.hook_name, + event.duration_ms, + event.success, + ); + }); } diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 8808ee1704..caaf1e69a3 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -6,14 +6,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Config } from '../config/config.js'; -import { initializeTelemetry, shutdownTelemetry } from './sdk.js'; +import { + initializeTelemetry, + shutdownTelemetry, + bufferTelemetryEvent, +} from './sdk.js'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; import { OTLPTraceExporter as OTLPTraceExporterHttp } from '@opentelemetry/exporter-trace-otlp-http'; import { OTLPLogExporter as OTLPLogExporterHttp } from '@opentelemetry/exporter-logs-otlp-http'; import { OTLPMetricExporter as OTLPMetricExporterHttp } from '@opentelemetry/exporter-metrics-otlp-http'; +import { ConsoleSpanExporter } from '@opentelemetry/sdk-trace-node'; import { NodeSDK } from '@opentelemetry/sdk-node'; +import { GoogleAuth } from 'google-auth-library'; import { GcpTraceExporter, GcpLogExporter, @@ -24,20 +30,40 @@ import { TelemetryTarget } from './index.js'; import * as os from 'node:os'; import * as path from 'node:path'; +import { authEvents } from '../code_assist/oauth2.js'; +import { debugLogger } from '../utils/debugLogger.js'; + vi.mock('@opentelemetry/exporter-trace-otlp-grpc'); vi.mock('@opentelemetry/exporter-logs-otlp-grpc'); vi.mock('@opentelemetry/exporter-metrics-otlp-grpc'); vi.mock('@opentelemetry/exporter-trace-otlp-http'); vi.mock('@opentelemetry/exporter-logs-otlp-http'); vi.mock('@opentelemetry/exporter-metrics-otlp-http'); +vi.mock('@opentelemetry/sdk-trace-node'); vi.mock('@opentelemetry/sdk-node'); vi.mock('./gcp-exporters.js'); +vi.mock('google-auth-library'); +vi.mock('../utils/debugLogger.js', () => ({ + debugLogger: { + log: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})); describe('Telemetry SDK', () => { let mockConfig: Config; + const mockGetApplicationDefault = vi.fn(); beforeEach(() => { vi.clearAllMocks(); + vi.mocked(GoogleAuth).mockImplementation( + () => + ({ + getApplicationDefault: mockGetApplicationDefault, + }) as unknown as GoogleAuth, + ); mockConfig = { getTelemetryEnabled: () => true, getTelemetryOtlpEndpoint: () => 'http://localhost:4317', @@ -47,6 +73,8 @@ describe('Telemetry SDK', () => { getTelemetryOutfile: () => undefined, getDebugMode: () => false, getSessionId: () => 'test-session', + getTelemetryUseCliAuth: () => false, + isInteractive: () => false, } as unknown as Config; }); @@ -54,8 +82,8 @@ describe('Telemetry SDK', () => { await shutdownTelemetry(mockConfig); }); - it('should use gRPC exporters when protocol is grpc', () => { - initializeTelemetry(mockConfig); + it('should use gRPC exporters when protocol is grpc', async () => { + await initializeTelemetry(mockConfig); expect(OTLPTraceExporter).toHaveBeenCalledWith({ url: 'http://localhost:4317', @@ -72,14 +100,14 @@ describe('Telemetry SDK', () => { expect(NodeSDK.prototype.start).toHaveBeenCalled(); }); - it('should use HTTP exporters when protocol is http', () => { + it('should use HTTP exporters when protocol is http', async () => { vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true); vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http'); vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( 'http://localhost:4318', ); - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); expect(OTLPTraceExporterHttp).toHaveBeenCalledWith({ url: 'http://localhost:4318/', @@ -93,28 +121,29 @@ describe('Telemetry SDK', () => { expect(NodeSDK.prototype.start).toHaveBeenCalled(); }); - it('should parse gRPC endpoint correctly', () => { + it('should parse gRPC endpoint correctly', async () => { vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( 'https://my-collector.com', ); - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); expect(OTLPTraceExporter).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://my-collector.com' }), ); }); - it('should parse HTTP endpoint correctly', () => { + it('should parse HTTP endpoint correctly', async () => { vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http'); vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( 'https://my-collector.com', ); - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); expect(OTLPTraceExporterHttp).toHaveBeenCalledWith( expect.objectContaining({ url: 'https://my-collector.com/' }), ); }); - it('should use direct GCP exporters when target is gcp, project ID is set, and useCollector is false', () => { + it('should use direct GCP exporters when target is gcp, project ID is set, and useCollector is false', async () => { + mockGetApplicationDefault.mockResolvedValue(undefined); // Simulate ADC available vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( TelemetryTarget.GCP, ); @@ -125,11 +154,11 @@ describe('Telemetry SDK', () => { process.env['OTLP_GOOGLE_CLOUD_PROJECT'] = 'test-project'; try { - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); - expect(GcpTraceExporter).toHaveBeenCalledWith('test-project'); - expect(GcpLogExporter).toHaveBeenCalledWith('test-project'); - expect(GcpMetricExporter).toHaveBeenCalledWith('test-project'); + expect(GcpTraceExporter).toHaveBeenCalledWith('test-project', undefined); + expect(GcpLogExporter).toHaveBeenCalledWith('test-project', undefined); + expect(GcpMetricExporter).toHaveBeenCalledWith('test-project', undefined); expect(NodeSDK.prototype.start).toHaveBeenCalled(); } finally { if (originalEnv) { @@ -140,13 +169,13 @@ describe('Telemetry SDK', () => { } }); - it('should use OTLP exporters when target is gcp but useCollector is true', () => { + it('should use OTLP exporters when target is gcp but useCollector is true', async () => { vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( TelemetryTarget.GCP, ); vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(true); - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); expect(OTLPTraceExporter).toHaveBeenCalledWith({ url: 'http://localhost:4317', @@ -162,7 +191,8 @@ describe('Telemetry SDK', () => { }); }); - it('should not use GCP exporters when project ID environment variable is not set', () => { + it('should use GCP exporters even when project ID environment variable is not set', async () => { + mockGetApplicationDefault.mockResolvedValue(undefined); // Simulate ADC available vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( TelemetryTarget.GCP, ); @@ -175,11 +205,11 @@ describe('Telemetry SDK', () => { delete process.env['GOOGLE_CLOUD_PROJECT']; try { - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); - expect(GcpTraceExporter).not.toHaveBeenCalled(); - expect(GcpLogExporter).not.toHaveBeenCalled(); - expect(GcpMetricExporter).not.toHaveBeenCalled(); + expect(GcpTraceExporter).toHaveBeenCalledWith(undefined, undefined); + expect(GcpLogExporter).toHaveBeenCalledWith(undefined, undefined); + expect(GcpMetricExporter).toHaveBeenCalledWith(undefined, undefined); expect(NodeSDK.prototype.start).toHaveBeenCalled(); } finally { if (originalOtlpEnv) { @@ -191,7 +221,8 @@ describe('Telemetry SDK', () => { } }); - it('should use GOOGLE_CLOUD_PROJECT as fallback when OTLP_GOOGLE_CLOUD_PROJECT is not set', () => { + it('should use GOOGLE_CLOUD_PROJECT as fallback when OTLP_GOOGLE_CLOUD_PROJECT is not set', async () => { + mockGetApplicationDefault.mockResolvedValue(undefined); // Simulate ADC available vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( TelemetryTarget.GCP, ); @@ -204,11 +235,20 @@ describe('Telemetry SDK', () => { process.env['GOOGLE_CLOUD_PROJECT'] = 'fallback-project'; try { - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); - expect(GcpTraceExporter).toHaveBeenCalledWith('fallback-project'); - expect(GcpLogExporter).toHaveBeenCalledWith('fallback-project'); - expect(GcpMetricExporter).toHaveBeenCalledWith('fallback-project'); + expect(GcpTraceExporter).toHaveBeenCalledWith( + 'fallback-project', + undefined, + ); + expect(GcpLogExporter).toHaveBeenCalledWith( + 'fallback-project', + undefined, + ); + expect(GcpMetricExporter).toHaveBeenCalledWith( + 'fallback-project', + undefined, + ); expect(NodeSDK.prototype.start).toHaveBeenCalled(); } finally { if (originalOtlpEnv) { @@ -222,11 +262,11 @@ describe('Telemetry SDK', () => { } }); - it('should not use OTLP exporters when telemetryOutfile is set', () => { + it('should not use OTLP exporters when telemetryOutfile is set', async () => { vi.spyOn(mockConfig, 'getTelemetryOutfile').mockReturnValue( path.join(os.tmpdir(), 'test.log'), ); - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); expect(OTLPTraceExporter).not.toHaveBeenCalled(); expect(OTLPLogExporter).not.toHaveBeenCalled(); @@ -236,4 +276,98 @@ describe('Telemetry SDK', () => { expect(OTLPMetricExporterHttp).not.toHaveBeenCalled(); expect(NodeSDK.prototype.start).toHaveBeenCalled(); }); + + it('should defer initialization when useCliAuth is true and no credentials are provided', async () => { + vi.spyOn(mockConfig, 'getTelemetryUseCliAuth').mockReturnValue(true); + vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( + TelemetryTarget.GCP, + ); + vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(''); + + // 1. Initial state: No credentials. + // Should NOT initialize any exporters. + await initializeTelemetry(mockConfig); + + // Verify nothing was initialized + expect(ConsoleSpanExporter).not.toHaveBeenCalled(); + expect(GcpTraceExporter).not.toHaveBeenCalled(); + + // Verify deferral log + expect(debugLogger.log).toHaveBeenCalledWith( + expect.stringContaining('deferring telemetry initialization'), + ); + }); + + it('should initialize with GCP exporters when credentials are provided via post_auth', async () => { + vi.spyOn(mockConfig, 'getTelemetryUseCliAuth').mockReturnValue(true); + vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue( + TelemetryTarget.GCP, + ); + vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(''); + + // 1. Initial state: No credentials. + await initializeTelemetry(mockConfig); + + // Verify nothing happened yet + expect(GcpTraceExporter).not.toHaveBeenCalled(); + + // 2. Set project ID and emit post_auth event + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; + + const mockCredentials = { + client_email: 'test@example.com', + private_key: '-----BEGIN PRIVATE KEY-----\n...', + type: 'authorized_user', + }; + + // Emit the event directly + authEvents.emit('post_auth', mockCredentials); + + // Wait for the event handler to process. + await vi.waitFor(() => { + // Check if debugLogger was called, which indicates the listener ran + expect(debugLogger.log).toHaveBeenCalledWith( + 'Telemetry reinit with credentials: ', + mockCredentials, + ); + + // Should use GCP exporters now with the project ID + expect(GcpTraceExporter).toHaveBeenCalledWith( + 'test-project', + mockCredentials, + ); + }); + }); + + describe('bufferTelemetryEvent', () => { + it('should execute immediately if SDK is initialized', async () => { + await initializeTelemetry(mockConfig); + const callback = vi.fn(); + bufferTelemetryEvent(callback); + expect(callback).toHaveBeenCalled(); + }); + + it('should buffer if SDK is not initialized, and flush on initialization', async () => { + const callback = vi.fn(); + bufferTelemetryEvent(callback); + expect(callback).not.toHaveBeenCalled(); + + await initializeTelemetry(mockConfig); + expect(callback).toHaveBeenCalled(); + }); + }); + + it('should disable telemetry and log error if useCollector and useCliAuth are both true', async () => { + vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(true); + vi.spyOn(mockConfig, 'getTelemetryUseCliAuth').mockReturnValue(true); + + await initializeTelemetry(mockConfig); + + expect(debugLogger.error).toHaveBeenCalledWith( + expect.stringContaining( + 'Telemetry configuration error: "useCollector" and "useCliAuth" cannot both be true', + ), + ); + expect(NodeSDK.prototype.start).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index d4d66b112f..bd15851ddc 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -4,7 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api'; +import { + DiagLogLevel, + diag, + trace, + context, + metrics, + propagation, +} from '@opentelemetry/api'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-grpc'; import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-grpc'; import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-grpc'; @@ -28,6 +35,7 @@ import { PeriodicExportingMetricReader, } from '@opentelemetry/sdk-metrics'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import type { JWTInput } from 'google-auth-library'; import type { Config } from '../config/config.js'; import { SERVICE_NAME } from './constants.js'; import { initializeMetrics } from './metrics.js'; @@ -44,17 +52,66 @@ import { } from './gcp-exporters.js'; import { TelemetryTarget } from './index.js'; import { debugLogger } from '../utils/debugLogger.js'; +import { authEvents } from '../code_assist/oauth2.js'; // For troubleshooting, set the log level to DiagLogLevel.DEBUG -diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); +class DiagLoggerAdapter { + error(message: string, ...args: unknown[]): void { + debugLogger.error(message, ...args); + } + + warn(message: string, ...args: unknown[]): void { + debugLogger.warn(message, ...args); + } + + info(message: string, ...args: unknown[]): void { + debugLogger.log(message, ...args); + } + + debug(message: string, ...args: unknown[]): void { + debugLogger.debug(message, ...args); + } + + verbose(message: string, ...args: unknown[]): void { + debugLogger.debug(message, ...args); + } +} + +diag.setLogger(new DiagLoggerAdapter(), DiagLogLevel.INFO); let sdk: NodeSDK | undefined; let telemetryInitialized = false; +let callbackRegistered = false; +let authListener: ((newCredentials: JWTInput) => Promise) | undefined = + undefined; +const telemetryBuffer: Array<() => void | Promise> = []; export function isTelemetrySdkInitialized(): boolean { return telemetryInitialized; } +export function bufferTelemetryEvent(fn: () => void | Promise): void { + if (telemetryInitialized) { + fn(); + } else { + telemetryBuffer.push(fn); + } +} + +async function flushTelemetryBuffer(): Promise { + if (!telemetryInitialized) return; + while (telemetryBuffer.length > 0) { + const fn = telemetryBuffer.shift(); + if (fn) { + try { + await fn(); + } catch (e) { + debugLogger.error('Error executing buffered telemetry event', e); + } + } + } +} + function parseOtlpEndpoint( otlpEndpointSetting: string | undefined, protocol: 'grpc' | 'http', @@ -80,11 +137,46 @@ function parseOtlpEndpoint( } } -export function initializeTelemetry(config: Config): void { +export async function initializeTelemetry( + config: Config, + credentials?: JWTInput, +): Promise { if (telemetryInitialized || !config.getTelemetryEnabled()) { return; } + if (config.getTelemetryUseCollector() && config.getTelemetryUseCliAuth()) { + debugLogger.error( + 'Telemetry configuration error: "useCollector" and "useCliAuth" cannot both be true. ' + + 'CLI authentication is only supported with in-process exporters. ' + + 'Disabling telemetry.', + ); + return; + } + + // If using CLI auth and no credentials provided, defer initialization + if (config.getTelemetryUseCliAuth() && !credentials) { + // Register a callback to initialize telemetry when the user logs in. + // This is done only once. + if (!callbackRegistered) { + callbackRegistered = true; + authListener = async (newCredentials: JWTInput) => { + if (config.getTelemetryEnabled() && config.getTelemetryUseCliAuth()) { + debugLogger.log( + 'Telemetry reinit with credentials: ', + newCredentials, + ); + await initializeTelemetry(config, newCredentials); + } + }; + authEvents.on('post_auth', authListener); + } + debugLogger.log( + 'CLI auth is requested but no credentials, deferring telemetry initialization.', + ); + return; + } + const resource = resourceFromAttributes({ [SemanticResourceAttributes.SERVICE_NAME]: SERVICE_NAME, [SemanticResourceAttributes.SERVICE_VERSION]: process.version, @@ -95,6 +187,7 @@ export function initializeTelemetry(config: Config): void { const otlpProtocol = config.getTelemetryOtlpProtocol(); const telemetryTarget = config.getTelemetryTarget(); const useCollector = config.getTelemetryUseCollector(); + const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); const telemetryOutfile = config.getTelemetryOutfile(); const useOtlp = !!parsedEndpoint && !telemetryOutfile; @@ -103,7 +196,7 @@ export function initializeTelemetry(config: Config): void { process.env['OTLP_GOOGLE_CLOUD_PROJECT'] || process.env['GOOGLE_CLOUD_PROJECT']; const useDirectGcpExport = - telemetryTarget === TelemetryTarget.GCP && !!gcpProjectId && !useCollector; + telemetryTarget === TelemetryTarget.GCP && !useCollector; let spanExporter: | OTLPTraceExporter @@ -120,10 +213,16 @@ export function initializeTelemetry(config: Config): void { let metricReader: PeriodicExportingMetricReader; if (useDirectGcpExport) { - spanExporter = new GcpTraceExporter(gcpProjectId); - logExporter = new GcpLogExporter(gcpProjectId); + debugLogger.log( + 'Creating GCP exporters with projectId:', + gcpProjectId, + 'using', + credentials ? 'provided credentials' : 'ADC', + ); + spanExporter = new GcpTraceExporter(gcpProjectId, credentials); + logExporter = new GcpLogExporter(gcpProjectId, credentials); metricReader = new PeriodicExportingMetricReader({ - exporter: new GcpMetricExporter(gcpProjectId), + exporter: new GcpMetricExporter(gcpProjectId, credentials), exportIntervalMillis: 30000, }); } else if (useOtlp) { @@ -183,12 +282,13 @@ export function initializeTelemetry(config: Config): void { }); try { - sdk.start(); + await sdk.start(); if (config.getDebugMode()) { debugLogger.log('OpenTelemetry SDK started successfully.'); } telemetryInitialized = true; initializeMetrics(config); + void flushTelemetryBuffer(); } catch (error) { console.error('Error starting OpenTelemetry SDK:', error); } @@ -204,19 +304,36 @@ export function initializeTelemetry(config: Config): void { }); } -export async function shutdownTelemetry(config: Config): Promise { +export async function shutdownTelemetry( + config: Config, + fromProcessExit = true, +): Promise { if (!telemetryInitialized || !sdk) { return; } try { - ClearcutLogger.getInstance()?.shutdown(); + await ClearcutLogger.getInstance()?.shutdown(); await sdk.shutdown(); - if (config.getDebugMode()) { + if (config.getDebugMode() && fromProcessExit) { debugLogger.log('OpenTelemetry SDK shut down successfully.'); } } catch (error) { console.error('Error shutting down SDK:', error); } finally { telemetryInitialized = false; + sdk = undefined; + // Fully reset the global APIs to allow for re-initialization. + // This is primarily for testing environments where the SDK is started + // and stopped multiple times in the same process. + trace.disable(); + context.disable(); + metrics.disable(); + propagation.disable(); + diag.disable(); + if (authListener) { + authEvents.off('post_auth', authListener); + authListener = undefined; + } + callbackRegistered = false; } } diff --git a/packages/core/src/telemetry/startupProfiler.test.ts b/packages/core/src/telemetry/startupProfiler.test.ts index b0de573d35..51c86ca89e 100644 --- a/packages/core/src/telemetry/startupProfiler.test.ts +++ b/packages/core/src/telemetry/startupProfiler.test.ts @@ -23,6 +23,10 @@ vi.mock('node:os', () => ({ // Mock fs module vi.mock('node:fs', () => ({ existsSync: vi.fn(() => false), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), })); describe('StartupProfiler', () => { diff --git a/packages/core/src/telemetry/telemetry.test.ts b/packages/core/src/telemetry/telemetry.test.ts index 15bd2e95e9..735dd29b56 100644 --- a/packages/core/src/telemetry/telemetry.test.ts +++ b/packages/core/src/telemetry/telemetry.test.ts @@ -12,16 +12,26 @@ import { } from './sdk.js'; import { Config } from '../config/config.js'; import { NodeSDK } from '@opentelemetry/sdk-node'; +import { GoogleAuth } from 'google-auth-library'; vi.mock('@opentelemetry/sdk-node'); vi.mock('../config/config.js'); +vi.mock('google-auth-library'); describe('telemetry', () => { let mockConfig: Config; let mockNodeSdk: NodeSDK; + const mockGetApplicationDefault = vi.fn(); beforeEach(() => { vi.resetAllMocks(); + vi.mocked(GoogleAuth).mockImplementation( + () => + ({ + getApplicationDefault: mockGetApplicationDefault, + }) as unknown as GoogleAuth, + ); + mockGetApplicationDefault.mockResolvedValue(undefined); // Simulate ADC available mockConfig = new Config({ sessionId: 'test-session-id', @@ -49,14 +59,14 @@ describe('telemetry', () => { } }); - it('should initialize the telemetry service', () => { - initializeTelemetry(mockConfig); + it('should initialize the telemetry service', async () => { + await initializeTelemetry(mockConfig); expect(NodeSDK).toHaveBeenCalled(); expect(mockNodeSdk.start).toHaveBeenCalled(); }); it('should shutdown the telemetry service', async () => { - initializeTelemetry(mockConfig); + await initializeTelemetry(mockConfig); await shutdownTelemetry(mockConfig); expect(mockNodeSdk.shutdown).toHaveBeenCalled(); diff --git a/packages/core/src/utils/debugLogger.ts b/packages/core/src/utils/debugLogger.ts index e16d045adb..15ed154f95 100644 --- a/packages/core/src/utils/debugLogger.ts +++ b/packages/core/src/utils/debugLogger.ts @@ -4,6 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as fs from 'node:fs'; +import * as util from 'node:util'; + /** * A simple, centralized logger for developer-facing debug messages. * @@ -17,19 +20,47 @@ * will intercept these calls and route them to the debug drawer UI. */ class DebugLogger { + private logStream: fs.WriteStream | undefined; + + constructor() { + this.logStream = process.env['GEMINI_DEBUG_LOG_FILE'] + ? fs.createWriteStream(process.env['GEMINI_DEBUG_LOG_FILE'], { + flags: 'a', + }) + : undefined; + // Handle potential errors with the stream + this.logStream?.on('error', (err) => { + // Log to console as a fallback, but don't crash the app + console.error('Error writing to debug log stream:', err); + }); + } + + private writeToFile(level: string, args: unknown[]) { + if (this.logStream) { + const message = util.format(...args); + const timestamp = new Date().toISOString(); + const logEntry = `[${timestamp}] [${level}] ${message}\n`; + this.logStream.write(logEntry); + } + } + log(...args: unknown[]): void { + this.writeToFile('LOG', args); console.log(...args); } warn(...args: unknown[]): void { + this.writeToFile('WARN', args); console.warn(...args); } error(...args: unknown[]): void { + this.writeToFile('ERROR', args); console.error(...args); } debug(...args: unknown[]): void { + this.writeToFile('DEBUG', args); console.debug(...args); } } diff --git a/packages/core/src/utils/editCorrector.test.ts b/packages/core/src/utils/editCorrector.test.ts index dbe1fea7f8..5d955e4e9a 100644 --- a/packages/core/src/utils/editCorrector.test.ts +++ b/packages/core/src/utils/editCorrector.test.ts @@ -22,6 +22,10 @@ let mockSendMessageStream: any; vi.mock('fs', () => ({ statSync: vi.fn(), mkdirSync: vi.fn(), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), })); vi.mock('../core/client.js', () => ({ diff --git a/packages/core/src/utils/nextSpeakerChecker.test.ts b/packages/core/src/utils/nextSpeakerChecker.test.ts index 865f94df0a..fbf3bb8b90 100644 --- a/packages/core/src/utils/nextSpeakerChecker.test.ts +++ b/packages/core/src/utils/nextSpeakerChecker.test.ts @@ -32,6 +32,10 @@ vi.mock('node:fs', () => { }); }), existsSync: vi.fn((path: string) => mockFileSystem.has(path)), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), }; return { diff --git a/scripts/telemetry.js b/scripts/telemetry.js index 7cce93f222..0fd686beb2 100755 --- a/scripts/telemetry.js +++ b/scripts/telemetry.js @@ -20,15 +20,15 @@ const USER_SETTINGS_DIR = join( const USER_SETTINGS_PATH = join(USER_SETTINGS_DIR, 'settings.json'); const WORKSPACE_SETTINGS_PATH = join(projectRoot, GEMINI_DIR, 'settings.json'); -let settingsTarget = undefined; +let telemetrySettings = undefined; -function loadSettingsValue(filePath) { +function loadSettings(filePath) { try { if (existsSync(filePath)) { const content = readFileSync(filePath, 'utf-8'); const jsonContent = content.replace(/\/\/[^\n]*/g, ''); const settings = JSON.parse(jsonContent); - return settings.telemetry?.target; + return settings.telemetry; } } catch (e) { console.warn( @@ -38,13 +38,13 @@ function loadSettingsValue(filePath) { return undefined; } -settingsTarget = loadSettingsValue(WORKSPACE_SETTINGS_PATH); +telemetrySettings = loadSettings(WORKSPACE_SETTINGS_PATH); -if (!settingsTarget) { - settingsTarget = loadSettingsValue(USER_SETTINGS_PATH); +if (!telemetrySettings) { + telemetrySettings = loadSettings(USER_SETTINGS_PATH); } -let target = settingsTarget || 'local'; +let target = telemetrySettings?.target || 'local'; const allowedTargets = ['local', 'gcp', 'genkit']; const targetArg = process.argv.find((arg) => arg.startsWith('--target=')); @@ -55,13 +55,15 @@ if (targetArg) { console.log(`⚙️ Using command-line target: ${target}`); } else { console.error( - `🛑 Error: Invalid target '${potentialTarget}'. Allowed targets are: ${allowedTargets.join(', ')}.`, + `🛑 Error: Invalid target '${potentialTarget}'. Allowed targets are: ${allowedTargets.join( + ', ', + )}.`, ); process.exit(1); } -} else if (settingsTarget) { +} else if (telemetrySettings?.target) { console.log( - `⚙️ Using telemetry target from settings.json: ${settingsTarget}`, + `⚙️ Using telemetry target from settings.json: ${telemetrySettings.target}`, ); } @@ -75,7 +77,13 @@ const scriptPath = join(projectRoot, 'scripts', targetScripts[target]); try { console.log(`🚀 Running telemetry script for target: ${target}.`); - execSync(`node ${scriptPath}`, { stdio: 'inherit', cwd: projectRoot }); + const env = { ...process.env }; + + execSync(`node ${scriptPath}`, { + stdio: 'inherit', + cwd: projectRoot, + env, + }); } catch (error) { console.error(`🛑 Failed to run telemetry script for target: ${target}`); console.error(error); diff --git a/scripts/telemetry_gcp.js b/scripts/telemetry_gcp.js index 33ac2d42b3..fc477e2ccd 100755 --- a/scripts/telemetry_gcp.js +++ b/scripts/telemetry_gcp.js @@ -7,7 +7,7 @@ */ import path from 'node:path'; -import fs from 'node:fs'; +import * as fs from 'node:fs'; import { spawn, execSync } from 'node:child_process'; import { OTEL_DIR, @@ -132,11 +132,13 @@ async function main() { fs.writeFileSync(OTEL_CONFIG_FILE, getOtelConfigContent(projectId)); console.log(`📄 Wrote OTEL collector config to ${OTEL_CONFIG_FILE}`); + const spawnEnv = { ...process.env }; + console.log(`🚀 Starting OTEL collector for GCP... Logs: ${OTEL_LOG_FILE}`); collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a'); collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], { stdio: ['ignore', collectorLogFd, collectorLogFd], - env: { ...process.env }, + env: spawnEnv, }); console.log( diff --git a/scripts/tests/generate-settings-doc.test.ts b/scripts/tests/generate-settings-doc.test.ts index 01f0e2d089..6e051cd15c 100644 --- a/scripts/tests/generate-settings-doc.test.ts +++ b/scripts/tests/generate-settings-doc.test.ts @@ -4,9 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { main as generateDocs } from '../generate-settings-doc.ts'; +vi.mock('fs', () => ({ + readFileSync: vi.fn().mockReturnValue(''), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), +})); + describe('generate-settings-doc', () => { it('keeps documentation in sync in check mode', async () => { const previousExitCode = process.exitCode; diff --git a/scripts/tests/generate-settings-schema.test.ts b/scripts/tests/generate-settings-schema.test.ts index c899db9af2..a0bea9c085 100644 --- a/scripts/tests/generate-settings-schema.test.ts +++ b/scripts/tests/generate-settings-schema.test.ts @@ -4,12 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { main as generateSchema } from '../generate-settings-schema.ts'; +vi.mock('fs', () => ({ + readFileSync: vi.fn().mockReturnValue(''), + writeFileSync: vi.fn(), + createWriteStream: vi.fn(() => ({ + write: vi.fn(), + on: vi.fn(), + })), +})); + describe('generate-settings-schema', () => { it('keeps schema in sync in check mode', async () => { const previousExitCode = process.exitCode; diff --git a/scripts/tests/telemetry_gcp.test.ts b/scripts/tests/telemetry_gcp.test.ts new file mode 100644 index 0000000000..0dda2d6f80 --- /dev/null +++ b/scripts/tests/telemetry_gcp.test.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +const mockSpawn = vi.fn(() => ({ on: vi.fn(), pid: 123 })); + +vi.mock('node:child_process', () => ({ + spawn: mockSpawn, + execSync: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + readFileSync: vi.fn(), + writeFileSync: vi.fn(), + openSync: vi.fn(() => 1), + unlinkSync: vi.fn(), + mkdirSync: vi.fn(), +})); + +vi.mock('../telemetry_utils.js', () => ({ + ensureBinary: vi.fn(() => Promise.resolve('/fake/path/to/otelcol-contrib')), + waitForPort: vi.fn(() => Promise.resolve()), + manageTelemetrySettings: vi.fn(), + registerCleanup: vi.fn(), + fileExists: vi.fn(() => true), // Assume all files exist for simplicity + OTEL_DIR: '/tmp/otel', + BIN_DIR: '/tmp/bin', +})); + +describe('telemetry_gcp.js', () => { + beforeEach(() => { + vi.resetModules(); // This is key to re-run the script + vi.clearAllMocks(); + process.env.OTLP_GOOGLE_CLOUD_PROJECT = 'test-project'; + // Clear the env var before each test + delete process.env.GEMINI_CLI_CREDENTIALS_PATH; + }); + + afterEach(() => { + delete process.env.OTLP_GOOGLE_CLOUD_PROJECT; + }); + + it('should not set GOOGLE_APPLICATION_CREDENTIALS when env var is not set', async () => { + await import('../telemetry_gcp.js'); + + expect(mockSpawn).toHaveBeenCalled(); + const spawnOptions = mockSpawn.mock.calls[0][2]; + expect(spawnOptions?.env).not.toHaveProperty( + 'GOOGLE_APPLICATION_CREDENTIALS', + ); + }); +});