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

View File

@@ -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.

View File

@@ -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: {

View File

@@ -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 │

View File

@@ -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,

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,

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 */
}
}

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;
}

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 {

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -116,5 +116,8 @@ export async function resolveTelemetrySettings(options: {
logPrompts,
outfile,
useCollector,
useCliAuth:
parseBooleanEnvFlag(env['GEMINI_TELEMETRY_USE_CLI_AUTH']) ??
settings.useCliAuth,
};
}

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');
}

View File

@@ -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();
});
});
});

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,
);
});
}

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();
});
});

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;
}
}

View File

@@ -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', () => {

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();

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);
}
}

View File

@@ -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', () => ({

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
);
});
});