diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index d95793f1..963c7f32 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -435,6 +435,8 @@ Arguments passed directly when running the CLI can override other configurations - Sets the telemetry target. See [telemetry](../telemetry.md) for more information. - **`--telemetry-otlp-endpoint`**: - Sets the OTLP endpoint for telemetry. See [telemetry](../telemetry.md) for more information. +- **`--telemetry-otlp-protocol`**: + - Sets the OTLP protocol for telemetry (`grpc` or `http`). Defaults to `grpc`. See [telemetry](../telemetry.md) for more information. - **`--telemetry-log-prompts`**: - Enables logging of prompts for telemetry. See [telemetry](../telemetry.md) for more information. - **`--checkpointing`**: diff --git a/docs/telemetry.md b/docs/telemetry.md index 68c3aed2..6767e349 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -74,7 +74,11 @@ gemini --telemetry \ ## Running an OTEL Collector An OTEL Collector is a service that receives, processes, and exports telemetry data. -The CLI sends data using the OTLP/gRPC protocol. +The CLI can send data using either the OTLP/gRPC or OTLP/HTTP protocol. +You can specify which protocol to use via the `--telemetry-otlp-protocol` flag +or the `telemetry.otlpProtocol` setting in your `settings.json` file. See the +[configuration docs](./cli/configuration.md#--telemetry-otlp-protocol) for more +details. Learn more about OTEL exporter standard configuration in [documentation][otel-config-docs]. diff --git a/package-lock.json b/package-lock.json index a85be05e..7052ae8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1626,6 +1626,25 @@ "@opentelemetry/api": "^1.0.0" } }, + "node_modules/@opentelemetry/exporter-logs-otlp-http": { + "version": "0.52.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.52.1.tgz", + "integrity": "sha512-qKgywId2DbdowPZpOBXQKp0B8DfhfIArmSic15z13Nk/JAOccBUQdPwDjDnjsM5f0ckZFMVR2t/tijTUAqDZoA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.52.1", + "@opentelemetry/core": "1.25.1", + "@opentelemetry/otlp-exporter-base": "0.52.1", + "@opentelemetry/otlp-transformer": "0.52.1", + "@opentelemetry/sdk-logs": "0.52.1" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { "version": "0.52.1", "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.52.1.tgz", @@ -12496,8 +12515,11 @@ "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.52.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.52.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.52.0", "@opentelemetry/instrumentation-http": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", "@types/glob": "^8.1.0", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index e4535fca..e70fc3b3 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -536,6 +536,60 @@ describe('loadCliConfig telemetry', () => { const config = await loadCliConfig(settings, [], 'test-session', argv); expect(config.getTelemetryLogPromptsEnabled()).toBe(true); }); + + it('should use telemetry OTLP protocol from settings if CLI flag is not present', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { + telemetry: { otlpProtocol: 'http' }, + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getTelemetryOtlpProtocol()).toBe('http'); + }); + + it('should prioritize --telemetry-otlp-protocol CLI flag over settings', async () => { + process.argv = ['node', 'script.js', '--telemetry-otlp-protocol', 'http']; + const argv = await parseArguments(); + const settings: Settings = { + telemetry: { otlpProtocol: 'grpc' }, + }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getTelemetryOtlpProtocol()).toBe('http'); + }); + + it('should use default protocol if no OTLP protocol is provided via CLI or settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + const settings: Settings = { telemetry: { enabled: true } }; + const config = await loadCliConfig(settings, [], 'test-session', argv); + expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); + }); + + it('should reject invalid --telemetry-otlp-protocol values', async () => { + process.argv = [ + 'node', + 'script.js', + '--telemetry-otlp-protocol', + 'invalid', + ]; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining('Invalid values:'), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); }); describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f50cafd4..a943f641 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -64,6 +64,7 @@ export interface CliArgs { checkpointing: boolean | undefined; telemetryTarget: string | undefined; telemetryOtlpEndpoint: string | undefined; + telemetryOtlpProtocol: string | undefined; telemetryLogPrompts: boolean | undefined; telemetryOutfile: string | undefined; allowedMcpServerNames: string[] | undefined; @@ -172,6 +173,12 @@ export async function parseArguments(): Promise { description: 'Set the OTLP endpoint for telemetry. Overrides environment variables and settings files.', }) + .option('telemetry-otlp-protocol', { + type: 'string', + choices: ['grpc', 'http'], + description: + 'Set the OTLP protocol for telemetry (grpc or http). Overrides settings files.', + }) .option('telemetry-log-prompts', { type: 'boolean', description: @@ -491,6 +498,11 @@ export async function loadCliConfig( argv.telemetryOtlpEndpoint ?? process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? settings.telemetry?.otlpEndpoint, + otlpProtocol: (['grpc', 'http'] as const).find( + (p) => + p === + (argv.telemetryOtlpProtocol ?? settings.telemetry?.otlpProtocol), + ), logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts, outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile, }, diff --git a/packages/core/package.json b/packages/core/package.json index 6f670f2c..0fc02e7b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -24,8 +24,11 @@ "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-logs-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.52.0", "@opentelemetry/exporter-metrics-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.52.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.52.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.52.0", "@opentelemetry/instrumentation-http": "^0.52.0", "@opentelemetry/sdk-node": "^0.52.0", "@types/glob": "^8.1.0", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index f1d8b965..3fb71ae8 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -567,5 +567,30 @@ describe('Server Config (config.ts)', () => { const config = new Config(paramsWithoutTelemetry); expect(config.getTelemetryOtlpEndpoint()).toBe(DEFAULT_OTLP_ENDPOINT); }); + + it('should return provided OTLP protocol', () => { + const params: ConfigParameters = { + ...baseParams, + telemetry: { enabled: true, otlpProtocol: 'http' }, + }; + const config = new Config(params); + expect(config.getTelemetryOtlpProtocol()).toBe('http'); + }); + + it('should return default OTLP protocol if not provided', () => { + const params: ConfigParameters = { + ...baseParams, + telemetry: { enabled: true }, + }; + const config = new Config(params); + expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); + }); + + it('should return default OTLP protocol if telemetry object is not provided', () => { + const paramsWithoutTelemetry: ConfigParameters = { ...baseParams }; + delete paramsWithoutTelemetry.telemetry; + const config = new Config(paramsWithoutTelemetry); + expect(config.getTelemetryOtlpProtocol()).toBe('grpc'); + }); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 5c11667b..49f9ab45 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -81,6 +81,7 @@ export interface TelemetrySettings { enabled?: boolean; target?: TelemetryTarget; otlpEndpoint?: string; + otlpProtocol?: 'grpc' | 'http'; logPrompts?: boolean; outfile?: string; } @@ -292,6 +293,7 @@ export class Config { enabled: params.telemetry?.enabled ?? false, target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET, otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, + otlpProtocol: params.telemetry?.otlpProtocol, logPrompts: params.telemetry?.logPrompts ?? true, outfile: params.telemetry?.outfile, }; @@ -564,6 +566,10 @@ export class Config { return this.telemetrySettings.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT; } + getTelemetryOtlpProtocol(): 'grpc' | 'http' { + return this.telemetrySettings.otlpProtocol ?? 'grpc'; + } + getTelemetryTarget(): TelemetryTarget { return this.telemetrySettings.target ?? DEFAULT_TELEMETRY_TARGET; } diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts new file mode 100644 index 00000000..a583bc38 --- /dev/null +++ b/packages/core/src/telemetry/sdk.test.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Config } from '../config/config.js'; +import { initializeTelemetry, shutdownTelemetry } 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 { NodeSDK } from '@opentelemetry/sdk-node'; + +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-node'); + +describe('Telemetry SDK', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = { + getTelemetryEnabled: () => true, + getTelemetryOtlpEndpoint: () => 'http://localhost:4317', + getTelemetryOtlpProtocol: () => 'grpc', + getTelemetryOutfile: () => undefined, + getDebugMode: () => false, + getSessionId: () => 'test-session', + } as unknown as Config; + }); + + afterEach(async () => { + await shutdownTelemetry(mockConfig); + }); + + it('should use gRPC exporters when protocol is grpc', () => { + 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', () => { + vi.spyOn(mockConfig, 'getTelemetryEnabled').mockReturnValue(true); + vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http'); + vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( + 'http://localhost:4318', + ); + + 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', () => { + vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( + 'https://my-collector.com', + ); + initializeTelemetry(mockConfig); + expect(OTLPTraceExporter).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://my-collector.com' }), + ); + }); + + it('should parse HTTP endpoint correctly', () => { + vi.spyOn(mockConfig, 'getTelemetryOtlpProtocol').mockReturnValue('http'); + vi.spyOn(mockConfig, 'getTelemetryOtlpEndpoint').mockReturnValue( + 'https://my-collector.com', + ); + initializeTelemetry(mockConfig); + expect(OTLPTraceExporterHttp).toHaveBeenCalledWith( + expect.objectContaining({ url: 'https://my-collector.com/' }), + ); + }); +}); diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index c6630236..3fbecaa9 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -8,6 +8,9 @@ import { DiagConsoleLogger, DiagLogLevel, diag } 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'; +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 { CompressionAlgorithm } from '@opentelemetry/otlp-exporter-base'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; @@ -45,8 +48,9 @@ export function isTelemetrySdkInitialized(): boolean { return telemetryInitialized; } -function parseGrpcEndpoint( +function parseOtlpEndpoint( otlpEndpointSetting: string | undefined, + protocol: 'grpc' | 'http', ): string | undefined { if (!otlpEndpointSetting) { return undefined; @@ -56,9 +60,13 @@ function parseGrpcEndpoint( try { const url = new URL(trimmedEndpoint); - // OTLP gRPC exporters expect an endpoint in the format scheme://host:port - // The `origin` property provides this, stripping any path, query, or hash. - return url.origin; + if (protocol === 'grpc') { + // OTLP gRPC exporters expect an endpoint in the format scheme://host:port + // The `origin` property provides this, stripping any path, query, or hash. + return url.origin; + } + // For http, use the full href. + return url.href; } catch (error) { diag.error('Invalid OTLP endpoint URL provided:', trimmedEndpoint, error); return undefined; @@ -77,43 +85,70 @@ export function initializeTelemetry(config: Config): void { }); const otlpEndpoint = config.getTelemetryOtlpEndpoint(); - const grpcParsedEndpoint = parseGrpcEndpoint(otlpEndpoint); - const useOtlp = !!grpcParsedEndpoint; + const otlpProtocol = config.getTelemetryOtlpProtocol(); + const parsedEndpoint = parseOtlpEndpoint(otlpEndpoint, otlpProtocol); + const useOtlp = !!parsedEndpoint; const telemetryOutfile = config.getTelemetryOutfile(); - const spanExporter = useOtlp - ? new OTLPTraceExporter({ - url: grpcParsedEndpoint, + let spanExporter: + | OTLPTraceExporter + | OTLPTraceExporterHttp + | FileSpanExporter + | ConsoleSpanExporter; + let logExporter: + | OTLPLogExporter + | OTLPLogExporterHttp + | FileLogExporter + | ConsoleLogRecordExporter; + let metricReader: PeriodicExportingMetricReader; + + if (useOtlp) { + if (otlpProtocol === 'http') { + spanExporter = new OTLPTraceExporterHttp({ + url: parsedEndpoint, + }); + logExporter = new OTLPLogExporterHttp({ + url: parsedEndpoint, + }); + metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporterHttp({ + url: parsedEndpoint, + }), + exportIntervalMillis: 10000, + }); + } else { + // grpc + spanExporter = new OTLPTraceExporter({ + url: parsedEndpoint, compression: CompressionAlgorithm.GZIP, - }) - : telemetryOutfile - ? new FileSpanExporter(telemetryOutfile) - : new ConsoleSpanExporter(); - const logExporter = useOtlp - ? new OTLPLogExporter({ - url: grpcParsedEndpoint, + }); + logExporter = new OTLPLogExporter({ + url: parsedEndpoint, compression: CompressionAlgorithm.GZIP, - }) - : telemetryOutfile - ? new FileLogExporter(telemetryOutfile) - : new ConsoleLogRecordExporter(); - const metricReader = useOtlp - ? new PeriodicExportingMetricReader({ + }); + metricReader = new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ - url: grpcParsedEndpoint, + url: parsedEndpoint, compression: CompressionAlgorithm.GZIP, }), exportIntervalMillis: 10000, - }) - : telemetryOutfile - ? new PeriodicExportingMetricReader({ - exporter: new FileMetricExporter(telemetryOutfile), - exportIntervalMillis: 10000, - }) - : new PeriodicExportingMetricReader({ - exporter: new ConsoleMetricExporter(), - exportIntervalMillis: 10000, - }); + }); + } + } else if (telemetryOutfile) { + spanExporter = new FileSpanExporter(telemetryOutfile); + logExporter = new FileLogExporter(telemetryOutfile); + metricReader = new PeriodicExportingMetricReader({ + exporter: new FileMetricExporter(telemetryOutfile), + exportIntervalMillis: 10000, + }); + } else { + spanExporter = new ConsoleSpanExporter(); + logExporter = new ConsoleLogRecordExporter(); + metricReader = new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter(), + exportIntervalMillis: 10000, + }); + } sdk = new NodeSDK({ resource,