mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-20 18:14:29 -07:00
feat: add direct Google Cloud telemetry exporters (#8541)
This commit is contained in:
@@ -363,6 +363,33 @@ describe('Server Config (config.ts)', () => {
|
||||
expect(config.getTelemetryEnabled()).toBe(TELEMETRY_SETTINGS.enabled);
|
||||
});
|
||||
|
||||
it('Config constructor should set telemetry useCollector to true when provided', () => {
|
||||
const paramsWithTelemetry: ConfigParameters = {
|
||||
...baseParams,
|
||||
telemetry: { enabled: true, useCollector: true },
|
||||
};
|
||||
const config = new Config(paramsWithTelemetry);
|
||||
expect(config.getTelemetryUseCollector()).toBe(true);
|
||||
});
|
||||
|
||||
it('Config constructor should set telemetry useCollector to false when provided', () => {
|
||||
const paramsWithTelemetry: ConfigParameters = {
|
||||
...baseParams,
|
||||
telemetry: { enabled: true, useCollector: false },
|
||||
};
|
||||
const config = new Config(paramsWithTelemetry);
|
||||
expect(config.getTelemetryUseCollector()).toBe(false);
|
||||
});
|
||||
|
||||
it('Config constructor should default telemetry useCollector to false if not provided', () => {
|
||||
const paramsWithTelemetry: ConfigParameters = {
|
||||
...baseParams,
|
||||
telemetry: { enabled: true },
|
||||
};
|
||||
const config = new Config(paramsWithTelemetry);
|
||||
expect(config.getTelemetryUseCollector()).toBe(false);
|
||||
});
|
||||
|
||||
it('should have a getFileService method that returns FileDiscoveryService', () => {
|
||||
const config = new Config(baseParams);
|
||||
const fileService = config.getFileService();
|
||||
|
||||
@@ -105,6 +105,7 @@ export interface TelemetrySettings {
|
||||
otlpProtocol?: 'grpc' | 'http';
|
||||
logPrompts?: boolean;
|
||||
outfile?: string;
|
||||
useCollector?: boolean;
|
||||
}
|
||||
|
||||
export interface OutputSettings {
|
||||
@@ -364,6 +365,7 @@ export class Config {
|
||||
otlpProtocol: params.telemetry?.otlpProtocol,
|
||||
logPrompts: params.telemetry?.logPrompts ?? true,
|
||||
outfile: params.telemetry?.outfile,
|
||||
useCollector: params.telemetry?.useCollector,
|
||||
};
|
||||
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
|
||||
|
||||
@@ -708,6 +710,10 @@ export class Config {
|
||||
return this.telemetrySettings.outfile;
|
||||
}
|
||||
|
||||
getTelemetryUseCollector(): boolean {
|
||||
return this.telemetrySettings.useCollector ?? false;
|
||||
}
|
||||
|
||||
getGeminiClient(): GeminiClient {
|
||||
return this.geminiClient;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ExportResultCode } from '@opentelemetry/core';
|
||||
import type { ReadableLogRecord } from '@opentelemetry/sdk-logs';
|
||||
import {
|
||||
GcpTraceExporter,
|
||||
GcpMetricExporter,
|
||||
GcpLogExporter,
|
||||
} from './gcp-exporters.js';
|
||||
|
||||
const mockLogEntry = { test: 'entry' };
|
||||
const mockLogWrite = vi.fn().mockResolvedValue(undefined);
|
||||
const mockLog = {
|
||||
entry: vi.fn().mockReturnValue(mockLogEntry),
|
||||
write: mockLogWrite,
|
||||
};
|
||||
const mockLogging = {
|
||||
projectId: 'test-project',
|
||||
log: vi.fn().mockReturnValue(mockLog),
|
||||
};
|
||||
|
||||
vi.mock('@google-cloud/opentelemetry-cloud-trace-exporter', () => ({
|
||||
TraceExporter: vi.fn().mockImplementation(() => ({
|
||||
export: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
forceFlush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@google-cloud/opentelemetry-cloud-monitoring-exporter', () => ({
|
||||
MetricExporter: vi.fn().mockImplementation(() => ({
|
||||
export: vi.fn(),
|
||||
shutdown: vi.fn(),
|
||||
forceFlush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@google-cloud/logging', () => ({
|
||||
Logging: vi.fn().mockImplementation(() => mockLogging),
|
||||
}));
|
||||
|
||||
describe('GCP Exporters', () => {
|
||||
describe('GcpTraceExporter', () => {
|
||||
it('should create a trace exporter with correct configuration', () => {
|
||||
const exporter = new GcpTraceExporter('test-project');
|
||||
expect(exporter).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a trace exporter without project ID', () => {
|
||||
const exporter = new GcpTraceExporter();
|
||||
expect(exporter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GcpMetricExporter', () => {
|
||||
it('should create a metric exporter with correct configuration', () => {
|
||||
const exporter = new GcpMetricExporter('test-project');
|
||||
expect(exporter).toBeDefined();
|
||||
});
|
||||
|
||||
it('should create a metric exporter without project ID', () => {
|
||||
const exporter = new GcpMetricExporter();
|
||||
expect(exporter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GcpLogExporter', () => {
|
||||
let exporter: GcpLogExporter;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockLogWrite.mockResolvedValue(undefined);
|
||||
mockLog.entry.mockReturnValue(mockLogEntry);
|
||||
exporter = new GcpLogExporter('test-project');
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should create a log exporter with project ID', () => {
|
||||
expect(exporter).toBeDefined();
|
||||
expect(mockLogging.log).toHaveBeenCalledWith('gemini_cli');
|
||||
});
|
||||
|
||||
it('should create a log exporter without project ID', () => {
|
||||
const exporterNoProject = new GcpLogExporter();
|
||||
expect(exporterNoProject).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('export', () => {
|
||||
it('should export logs successfully', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
severityNumber: 9,
|
||||
severityText: 'INFO',
|
||||
body: 'Test log message',
|
||||
attributes: {
|
||||
'session.id': 'test-session',
|
||||
'custom.attribute': 'value',
|
||||
},
|
||||
resource: {
|
||||
attributes: {
|
||||
'service.name': 'test-service',
|
||||
},
|
||||
},
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(mockLog.entry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: 'INFO',
|
||||
timestamp: expect.any(Date),
|
||||
resource: {
|
||||
type: 'global',
|
||||
labels: {
|
||||
project_id: 'test-project',
|
||||
},
|
||||
},
|
||||
}),
|
||||
expect.objectContaining({
|
||||
message: 'Test log message',
|
||||
session_id: 'test-session',
|
||||
'custom.attribute': 'value',
|
||||
'service.name': 'test-service',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(mockLog.write).toHaveBeenCalledWith([mockLogEntry]);
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
code: ExportResultCode.SUCCESS,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle export failures', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const error = new Error('Write failed');
|
||||
mockLogWrite.mockRejectedValueOnce(error);
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
code: ExportResultCode.FAILED,
|
||||
error,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle synchronous errors', () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
mockLog.entry.mockImplementation(() => {
|
||||
throw new Error('Entry creation failed');
|
||||
});
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: expect.any(Error),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('severity mapping', () => {
|
||||
it('should map OpenTelemetry severity numbers to Cloud Logging levels', () => {
|
||||
const testCases = [
|
||||
{ severityNumber: undefined, expected: 'DEFAULT' },
|
||||
{ severityNumber: 1, expected: 'DEFAULT' },
|
||||
{ severityNumber: 5, expected: 'DEBUG' },
|
||||
{ severityNumber: 9, expected: 'INFO' },
|
||||
{ severityNumber: 13, expected: 'WARNING' },
|
||||
{ severityNumber: 17, expected: 'ERROR' },
|
||||
{ severityNumber: 21, expected: 'CRITICAL' },
|
||||
{ severityNumber: 25, expected: 'CRITICAL' },
|
||||
];
|
||||
|
||||
testCases.forEach(({ severityNumber, expected }) => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
severityNumber,
|
||||
body: 'Test message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const callback = vi.fn();
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
expect(mockLog.entry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
severity: expected,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
|
||||
mockLog.entry.mockClear();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('forceFlush', () => {
|
||||
it('should resolve immediately when no pending writes exist', async () => {
|
||||
await expect(exporter.forceFlush()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should wait for pending writes to complete', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
let resolveWrite: () => void;
|
||||
const writePromise = new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
});
|
||||
mockLogWrite.mockReturnValueOnce(writePromise);
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
const flushPromise = exporter.forceFlush();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
resolveWrite!();
|
||||
await writePromise;
|
||||
|
||||
await expect(flushPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle multiple pending writes', async () => {
|
||||
const mockLogRecords1: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message 1',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const mockLogRecords2: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message 2',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
let resolveWrite1: () => void;
|
||||
let resolveWrite2: () => void;
|
||||
const writePromise1 = new Promise<void>((resolve) => {
|
||||
resolveWrite1 = resolve;
|
||||
});
|
||||
const writePromise2 = new Promise<void>((resolve) => {
|
||||
resolveWrite2 = resolve;
|
||||
});
|
||||
|
||||
mockLogWrite
|
||||
.mockReturnValueOnce(writePromise1)
|
||||
.mockReturnValueOnce(writePromise2);
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords1, callback);
|
||||
exporter.export(mockLogRecords2, callback);
|
||||
|
||||
const flushPromise = exporter.forceFlush();
|
||||
|
||||
resolveWrite1!();
|
||||
await writePromise1;
|
||||
|
||||
resolveWrite2!();
|
||||
await writePromise2;
|
||||
|
||||
await expect(flushPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle write failures gracefully', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const error = new Error('Write failed');
|
||||
mockLogWrite.mockRejectedValueOnce(error);
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
await expect(exporter.forceFlush()).resolves.toBeUndefined();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
code: ExportResultCode.FAILED,
|
||||
error,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('should call forceFlush', async () => {
|
||||
const forceFlushSpy = vi.spyOn(exporter, 'forceFlush');
|
||||
|
||||
await exporter.shutdown();
|
||||
|
||||
expect(forceFlushSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle shutdown gracefully', async () => {
|
||||
const forceFlushSpy = vi.spyOn(exporter, 'forceFlush');
|
||||
|
||||
await expect(exporter.shutdown()).resolves.toBeUndefined();
|
||||
expect(forceFlushSpy).toHaveBeenCalled();
|
||||
});
|
||||
it('should wait for pending writes before shutting down', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
let resolveWrite: () => void;
|
||||
const writePromise = new Promise<void>((resolve) => {
|
||||
resolveWrite = resolve;
|
||||
});
|
||||
mockLogWrite.mockReturnValueOnce(writePromise);
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
const shutdownPromise = exporter.shutdown();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
|
||||
resolveWrite!();
|
||||
await writePromise;
|
||||
|
||||
await expect(shutdownPromise).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should clear pending writes array after shutdown', async () => {
|
||||
const mockLogRecords: ReadableLogRecord[] = [
|
||||
{
|
||||
hrTime: [1234567890, 123456789],
|
||||
hrTimeObserved: [1234567890, 123456789],
|
||||
body: 'Test log message',
|
||||
} as unknown as ReadableLogRecord,
|
||||
];
|
||||
|
||||
const callback = vi.fn();
|
||||
|
||||
exporter.export(mockLogRecords, callback);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await exporter.shutdown();
|
||||
|
||||
const start = Date.now();
|
||||
await exporter.forceFlush();
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
expect(elapsed).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
||||
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
||||
import { Logging } from '@google-cloud/logging';
|
||||
import type { Log } from '@google-cloud/logging';
|
||||
import { hrTimeToMilliseconds } from '@opentelemetry/core';
|
||||
import type { ExportResult } from '@opentelemetry/core';
|
||||
import { ExportResultCode } from '@opentelemetry/core';
|
||||
import type {
|
||||
ReadableLogRecord,
|
||||
LogRecordExporter,
|
||||
} from '@opentelemetry/sdk-logs';
|
||||
|
||||
/**
|
||||
* Google Cloud Trace exporter that extends the official trace exporter
|
||||
*/
|
||||
export class GcpTraceExporter extends TraceExporter {
|
||||
constructor(projectId?: string) {
|
||||
super({
|
||||
projectId,
|
||||
resourceFilter: /^gcp\./,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Cloud Monitoring exporter that extends the official metrics exporter
|
||||
*/
|
||||
export class GcpMetricExporter extends MetricExporter {
|
||||
constructor(projectId?: string) {
|
||||
super({
|
||||
projectId,
|
||||
prefix: 'custom.googleapis.com/gemini_cli',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Cloud Logging exporter that uses the Cloud Logging client
|
||||
*/
|
||||
export class GcpLogExporter implements LogRecordExporter {
|
||||
private logging: Logging;
|
||||
private log: Log;
|
||||
private pendingWrites: Array<Promise<void>> = [];
|
||||
|
||||
constructor(projectId?: string) {
|
||||
this.logging = new Logging({ projectId });
|
||||
this.log = this.logging.log('gemini_cli');
|
||||
}
|
||||
|
||||
export(
|
||||
logs: ReadableLogRecord[],
|
||||
resultCallback: (result: ExportResult) => void,
|
||||
): void {
|
||||
try {
|
||||
const entries = logs.map((log) => {
|
||||
const entry = this.log.entry(
|
||||
{
|
||||
severity: this.mapSeverityToCloudLogging(log.severityNumber),
|
||||
timestamp: new Date(hrTimeToMilliseconds(log.hrTime)),
|
||||
resource: {
|
||||
type: 'global',
|
||||
labels: {
|
||||
project_id: this.logging.projectId,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
session_id: log.attributes?.['session.id'],
|
||||
...log.attributes,
|
||||
...log.resource?.attributes,
|
||||
message: log.body,
|
||||
},
|
||||
);
|
||||
return entry;
|
||||
});
|
||||
|
||||
const writePromise = this.log
|
||||
.write(entries)
|
||||
.then(() => {
|
||||
resultCallback({ code: ExportResultCode.SUCCESS });
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
const index = this.pendingWrites.indexOf(writePromise);
|
||||
if (index > -1) {
|
||||
this.pendingWrites.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this.pendingWrites.push(writePromise);
|
||||
} catch (error) {
|
||||
resultCallback({
|
||||
code: ExportResultCode.FAILED,
|
||||
error: error as Error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async forceFlush(): Promise<void> {
|
||||
if (this.pendingWrites.length > 0) {
|
||||
await Promise.all(this.pendingWrites);
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await this.forceFlush();
|
||||
this.pendingWrites = [];
|
||||
}
|
||||
|
||||
private mapSeverityToCloudLogging(severityNumber?: number): string {
|
||||
if (!severityNumber) return 'DEFAULT';
|
||||
|
||||
// Map OpenTelemetry severity numbers to Cloud Logging severity levels
|
||||
// https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
|
||||
if (severityNumber >= 21) return 'CRITICAL';
|
||||
if (severityNumber >= 17) return 'ERROR';
|
||||
if (severityNumber >= 13) return 'WARNING';
|
||||
if (severityNumber >= 9) return 'INFO';
|
||||
if (severityNumber >= 5) return 'DEBUG';
|
||||
return 'DEFAULT';
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export {
|
||||
shutdownTelemetry,
|
||||
isTelemetrySdkInitialized,
|
||||
} from './sdk.js';
|
||||
export {
|
||||
GcpTraceExporter,
|
||||
GcpMetricExporter,
|
||||
GcpLogExporter,
|
||||
} from './gcp-exporters.js';
|
||||
export {
|
||||
logCliConfiguration,
|
||||
logUserPrompt,
|
||||
|
||||
@@ -14,6 +14,12 @@ import { OTLPTraceExporter as OTLPTraceExporterHttp } from '@opentelemetry/expor
|
||||
import { OTLPLogExporter as OTLPLogExporterHttp } from '@opentelemetry/exporter-logs-otlp-http';
|
||||
import { OTLPMetricExporter as OTLPMetricExporterHttp } from '@opentelemetry/exporter-metrics-otlp-http';
|
||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||
import {
|
||||
GcpTraceExporter,
|
||||
GcpLogExporter,
|
||||
GcpMetricExporter,
|
||||
} from './gcp-exporters.js';
|
||||
import { TelemetryTarget } from './index.js';
|
||||
|
||||
vi.mock('@opentelemetry/exporter-trace-otlp-grpc');
|
||||
vi.mock('@opentelemetry/exporter-logs-otlp-grpc');
|
||||
@@ -22,6 +28,7 @@ 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-node');
|
||||
vi.mock('./gcp-exporters.js');
|
||||
|
||||
describe('Telemetry SDK', () => {
|
||||
let mockConfig: Config;
|
||||
@@ -32,6 +39,8 @@ describe('Telemetry SDK', () => {
|
||||
getTelemetryEnabled: () => true,
|
||||
getTelemetryOtlpEndpoint: () => 'http://localhost:4317',
|
||||
getTelemetryOtlpProtocol: () => 'grpc',
|
||||
getTelemetryTarget: () => 'local',
|
||||
getTelemetryUseCollector: () => false,
|
||||
getTelemetryOutfile: () => undefined,
|
||||
getDebugMode: () => false,
|
||||
getSessionId: () => 'test-session',
|
||||
@@ -101,4 +110,112 @@ describe('Telemetry SDK', () => {
|
||||
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', () => {
|
||||
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 {
|
||||
initializeTelemetry(mockConfig);
|
||||
|
||||
expect(GcpTraceExporter).toHaveBeenCalledWith('test-project');
|
||||
expect(GcpLogExporter).toHaveBeenCalledWith('test-project');
|
||||
expect(GcpMetricExporter).toHaveBeenCalledWith('test-project');
|
||||
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', () => {
|
||||
vi.spyOn(mockConfig, 'getTelemetryTarget').mockReturnValue(
|
||||
TelemetryTarget.GCP,
|
||||
);
|
||||
vi.spyOn(mockConfig, 'getTelemetryUseCollector').mockReturnValue(true);
|
||||
|
||||
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 not use GCP exporters when project ID environment variable is not set', () => {
|
||||
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 {
|
||||
initializeTelemetry(mockConfig);
|
||||
|
||||
expect(GcpTraceExporter).not.toHaveBeenCalled();
|
||||
expect(GcpLogExporter).not.toHaveBeenCalled();
|
||||
expect(GcpMetricExporter).not.toHaveBeenCalled();
|
||||
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', () => {
|
||||
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 {
|
||||
initializeTelemetry(mockConfig);
|
||||
|
||||
expect(GcpTraceExporter).toHaveBeenCalledWith('fallback-project');
|
||||
expect(GcpLogExporter).toHaveBeenCalledWith('fallback-project');
|
||||
expect(GcpMetricExporter).toHaveBeenCalledWith('fallback-project');
|
||||
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'];
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,12 @@ import {
|
||||
FileMetricExporter,
|
||||
FileSpanExporter,
|
||||
} from './file-exporters.js';
|
||||
import {
|
||||
GcpTraceExporter,
|
||||
GcpMetricExporter,
|
||||
GcpLogExporter,
|
||||
} from './gcp-exporters.js';
|
||||
import { TelemetryTarget } from './index.js';
|
||||
|
||||
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO);
|
||||
@@ -86,23 +92,40 @@ export function initializeTelemetry(config: Config): void {
|
||||
|
||||
const otlpEndpoint = config.getTelemetryOtlpEndpoint();
|
||||
const otlpProtocol = config.getTelemetryOtlpProtocol();
|
||||
const telemetryTarget = config.getTelemetryTarget();
|
||||
const useCollector = config.getTelemetryUseCollector();
|
||||
const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol);
|
||||
const useOtlp = !!parsedEndpoint;
|
||||
const telemetryOutfile = config.getTelemetryOutfile();
|
||||
|
||||
const gcpProjectId =
|
||||
process.env['OTLP_GOOGLE_CLOUD_PROJECT'] ||
|
||||
process.env['GOOGLE_CLOUD_PROJECT'];
|
||||
const useDirectGcpExport =
|
||||
telemetryTarget === TelemetryTarget.GCP && !!gcpProjectId && !useCollector;
|
||||
|
||||
let spanExporter:
|
||||
| OTLPTraceExporter
|
||||
| OTLPTraceExporterHttp
|
||||
| GcpTraceExporter
|
||||
| FileSpanExporter
|
||||
| ConsoleSpanExporter;
|
||||
let logExporter:
|
||||
| OTLPLogExporter
|
||||
| OTLPLogExporterHttp
|
||||
| GcpLogExporter
|
||||
| FileLogExporter
|
||||
| ConsoleLogRecordExporter;
|
||||
let metricReader: PeriodicExportingMetricReader;
|
||||
|
||||
if (useOtlp) {
|
||||
if (useDirectGcpExport) {
|
||||
spanExporter = new GcpTraceExporter(gcpProjectId);
|
||||
logExporter = new GcpLogExporter(gcpProjectId);
|
||||
metricReader = new PeriodicExportingMetricReader({
|
||||
exporter: new GcpMetricExporter(gcpProjectId),
|
||||
exportIntervalMillis: 30000,
|
||||
});
|
||||
} else if (useOtlp) {
|
||||
if (otlpProtocol === 'http') {
|
||||
spanExporter = new OTLPTraceExporterHttp({
|
||||
url: parsedEndpoint,
|
||||
|
||||
Reference in New Issue
Block a user