feat: add direct Google Cloud telemetry exporters (#8541)

This commit is contained in:
Jerop Kipruto
2025-09-17 04:13:57 +09:00
committed by GitHub
parent 4e5c1fce8d
commit 88272cba8b
11 changed files with 1196 additions and 133 deletions
+27
View File
@@ -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();
+6
View File
@@ -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';
}
}
+5
View File
@@ -18,6 +18,11 @@ export {
shutdownTelemetry,
isTelemetrySdkInitialized,
} from './sdk.js';
export {
GcpTraceExporter,
GcpMetricExporter,
GcpLogExporter,
} from './gcp-exporters.js';
export {
logCliConfiguration,
logUserPrompt,
+117
View File
@@ -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'];
}
}
});
});
+24 -1
View File
@@ -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,