summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBilly Biggs <[email protected]>2025-08-15 18:10:21 -0700
committerGitHub <[email protected]>2025-08-16 01:10:21 +0000
commitd57cc0b9306f0359482ef6e243308bcda2989007 (patch)
tree3ec6a716d0a5b8d9c6cacc8a1231d05df6ba43fd
parent4896c7739f57b4e475754854c217cdd1dbf7deaa (diff)
Add support for HTTP OpenTelemetry exporters (#6357)
-rw-r--r--docs/cli/configuration.md2
-rw-r--r--docs/telemetry.md6
-rw-r--r--package-lock.json22
-rw-r--r--packages/cli/src/config/config.test.ts54
-rw-r--r--packages/cli/src/config/config.ts12
-rw-r--r--packages/core/package.json3
-rw-r--r--packages/core/src/config/config.test.ts25
-rw-r--r--packages/core/src/config/config.ts6
-rw-r--r--packages/core/src/telemetry/sdk.test.ts104
-rw-r--r--packages/core/src/telemetry/sdk.ts101
10 files changed, 301 insertions, 34 deletions
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<CliArgs> {
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,