Allow telemetry exporters to GCP to utilize user's login credentials, if requested (#13778)

This commit is contained in:
Marat Boshernitsan
2025-12-02 21:27:37 -08:00
committed by GitHub
parent 92e95ed806
commit b9b3b8050d
26 changed files with 994 additions and 428 deletions
@@ -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<typeof import('@google/gemini-cli-core')>();
return {
...actual,
isEditorAvailable: () => true, // Mock to behave predictably in CI
};
});
// Mock editorSettingsManager
vi.mock('../editors/editorSettingsManager.js', () => ({
editorSettingsManager: {
@@ -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 │
@@ -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,
+51 -1
View File
@@ -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<void>((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,
+34 -13
View File
@@ -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<void> {
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 */
}
}
+6
View File
@@ -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;
}
+4
View File
@@ -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 {
@@ -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 {
@@ -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 = {
+3
View File
@@ -116,5 +116,8 @@ export async function resolveTelemetrySettings(options: {
logPrompts,
outfile,
useCollector,
useCliAuth:
parseBooleanEnvFlag(env['GEMINI_TELEMETRY_USE_CLI_AUTH']) ??
settings.useCliAuth,
};
}
+7 -4
View File
@@ -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<Promise<void>> = [];
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');
}
@@ -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();
});
});
});
+343 -343
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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,
);
});
}
+162 -28
View File
@@ -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();
});
});
+128 -11
View File
@@ -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<void>) | undefined =
undefined;
const telemetryBuffer: Array<() => void | Promise<void>> = [];
export function isTelemetrySdkInitialized(): boolean {
return telemetryInitialized;
}
export function bufferTelemetryEvent(fn: () => void | Promise<void>): void {
if (telemetryInitialized) {
fn();
} else {
telemetryBuffer.push(fn);
}
}
async function flushTelemetryBuffer(): Promise<void> {
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<void> {
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<void> {
export async function shutdownTelemetry(
config: Config,
fromProcessExit = true,
): Promise<void> {
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;
}
}
@@ -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', () => {
+13 -3
View File
@@ -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();
+31
View File
@@ -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);
}
}
@@ -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', () => ({
@@ -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 {