mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 17:11:04 -07:00
410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
import type { Config } from '../config/config.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, type JWTInput } from 'google-auth-library';
|
|
import {
|
|
GcpTraceExporter,
|
|
GcpLogExporter,
|
|
GcpMetricExporter,
|
|
} from './gcp-exporters.js';
|
|
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',
|
|
getTelemetryOtlpProtocol: () => 'grpc',
|
|
getTelemetryTarget: () => 'local',
|
|
getTelemetryUseCollector: () => false,
|
|
getTelemetryOutfile: () => undefined,
|
|
getDebugMode: () => false,
|
|
getSessionId: () => 'test-session',
|
|
getTelemetryUseCliAuth: () => false,
|
|
isInteractive: () => false,
|
|
getExperiments: () => undefined,
|
|
getExperimentsAsync: async () => undefined,
|
|
getContentGeneratorConfig: () => undefined,
|
|
} as unknown as Config;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await shutdownTelemetry(mockConfig);
|
|
});
|
|
|
|
it('should use gRPC exporters when protocol is grpc', async () => {
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
expect(OTLPTraceExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
expect(OTLPLogExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
expect(OTLPMetricExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
expect(NodeSDK.prototype.start).toHaveBeenCalled();
|
|
});
|
|
|
|
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',
|
|
);
|
|
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
expect(OTLPTraceExporterHttp).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4318/',
|
|
});
|
|
expect(OTLPLogExporterHttp).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4318/',
|
|
});
|
|
expect(OTLPMetricExporterHttp).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4318/',
|
|
});
|
|
expect(NodeSDK.prototype.start).toHaveBeenCalled();
|
|
});
|
|
|
|
it('should parse gRPC endpoint correctly', async () => {
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
|
'https://my-collector.com',
|
|
);
|
|
await initializeTelemetry(mockConfig);
|
|
expect(OTLPTraceExporter).toHaveBeenCalledWith(
|
|
expect.objectContaining({ url: 'https://my-collector.com' }),
|
|
);
|
|
});
|
|
|
|
it('should parse HTTP endpoint correctly', async () => {
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http');
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue(
|
|
'https://my-collector.com',
|
|
);
|
|
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', async () => {
|
|
mockGetApplicationDefault.mockResolvedValue(undefined); // Simulate ADC available
|
|
vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue(
|
|
TelemetryTarget.GCP,
|
|
);
|
|
vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(false);
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue('');
|
|
|
|
const originalEnv = process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
process.env['OTLP_GOOGLE_CLOUD_PROJECT'] = 'test-project';
|
|
|
|
try {
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
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) {
|
|
process.env['OTLP_GOOGLE_CLOUD_PROJECT'] = originalEnv;
|
|
} else {
|
|
delete process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
}
|
|
}
|
|
});
|
|
|
|
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);
|
|
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
expect(OTLPTraceExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
expect(OTLPLogExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
expect(OTLPMetricExporter).toHaveBeenCalledWith({
|
|
url: 'http://localhost:4317',
|
|
compression: 'gzip',
|
|
});
|
|
});
|
|
|
|
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,
|
|
);
|
|
vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(false);
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue('');
|
|
|
|
const originalOtlpEnv = process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
const originalGoogleEnv = process.env['GOOGLE_CLOUD_PROJECT'];
|
|
delete process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
|
|
|
try {
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
expect(GcpTraceExporter).toHaveBeenCalledWith(undefined, undefined);
|
|
expect(GcpLogExporter).toHaveBeenCalledWith(undefined, undefined);
|
|
expect(GcpMetricExporter).toHaveBeenCalledWith(undefined, undefined);
|
|
expect(NodeSDK.prototype.start).toHaveBeenCalled();
|
|
} finally {
|
|
if (originalOtlpEnv) {
|
|
process.env['OTLP_GOOGLE_CLOUD_PROJECT'] = originalOtlpEnv;
|
|
}
|
|
if (originalGoogleEnv) {
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = originalGoogleEnv;
|
|
}
|
|
}
|
|
});
|
|
|
|
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,
|
|
);
|
|
vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(false);
|
|
vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue('');
|
|
|
|
const originalOtlpEnv = process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
const originalGoogleEnv = process.env['GOOGLE_CLOUD_PROJECT'];
|
|
delete process.env['OTLP_GOOGLE_CLOUD_PROJECT'];
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = 'fallback-project';
|
|
|
|
try {
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
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) {
|
|
process.env['OTLP_GOOGLE_CLOUD_PROJECT'] = originalOtlpEnv;
|
|
}
|
|
if (originalGoogleEnv) {
|
|
process.env['GOOGLE_CLOUD_PROJECT'] = originalGoogleEnv;
|
|
} else {
|
|
delete process.env['GOOGLE_CLOUD_PROJECT'];
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should not use OTLP exporters when telemetryOutfile is set', async () => {
|
|
vi.spyOn(mockConfig, 'getTelemetryOutfile').mockReturnValue(
|
|
path.join(os.tmpdir(), 'test.log'),
|
|
);
|
|
await initializeTelemetry(mockConfig);
|
|
|
|
expect(OTLPTraceExporter).not.toHaveBeenCalled();
|
|
expect(OTLPLogExporter).not.toHaveBeenCalled();
|
|
expect(OTLPMetricExporter).not.toHaveBeenCalled();
|
|
expect(OTLPTraceExporterHttp).not.toHaveBeenCalled();
|
|
expect(OTLPLogExporterHttp).not.toHaveBeenCalled();
|
|
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
|
|
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'test-project');
|
|
vi.stubEnv('OTLP_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.',
|
|
);
|
|
|
|
// 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);
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
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();
|
|
});
|
|
it('should log error when re-initializing with different credentials', async () => {
|
|
const creds1 = { client_email: 'user1@example.com' };
|
|
const creds2 = { client_email: 'user2@example.com' };
|
|
|
|
// 1. Initialize with first account
|
|
await initializeTelemetry(mockConfig, creds1 as JWTInput);
|
|
|
|
// 2. Attempt to initialize with second account
|
|
await initializeTelemetry(mockConfig, creds2 as JWTInput);
|
|
|
|
// 3. Verify error log
|
|
expect(debugLogger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(
|
|
'Telemetry credentials have changed (from user1@example.com to user2@example.com)',
|
|
),
|
|
);
|
|
});
|
|
|
|
it('should NOT log error when re-initializing with SAME credentials', async () => {
|
|
const creds1 = { client_email: 'user1@example.com' };
|
|
|
|
// 1. Initialize with first account
|
|
await initializeTelemetry(mockConfig, creds1 as JWTInput);
|
|
|
|
// 2. Attempt to initialize with same account
|
|
await initializeTelemetry(mockConfig, creds1 as JWTInput);
|
|
|
|
// 3. Verify NO error log
|
|
expect(debugLogger.error).not.toHaveBeenCalledWith(
|
|
expect.stringContaining('Telemetry credentials have changed'),
|
|
);
|
|
});
|
|
});
|