diff --git a/docs/telemetry.md b/docs/telemetry.md index 76958794..2209ee0b 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -19,6 +19,7 @@ The following lists the precedence for applying telemetry settings, with items l - `--telemetry-target `: Overrides `telemetry.target`. - `--telemetry-otlp-endpoint `: Overrides `telemetry.otlpEndpoint`. - `--telemetry-log-prompts` / `--no-telemetry-log-prompts`: Overrides `telemetry.logPrompts`. + - `--telemetry-outfile `: Redirects telemetry output to a file. See [Exporting to a file](#exporting-to-a-file). 1. **Environment variables:** - `OTEL_EXPORTER_OTLP_ENDPOINT`: Overrides `telemetry.otlpEndpoint`. @@ -50,6 +51,16 @@ The following code can be added to your workspace (`.gemini/settings.json`) or u } ``` +### Exporting to a file + +You can export all telemetry data to a file for local inspection. + +To enable file export, use the `--telemetry-outfile` flag with a path to your desired output file. This must be run using `--telemetry-target=local`. + +```bash +gemini --telemetry --telemetry-target=local --telemetry-outfile=/path/to/telemetry.log "your prompt" +``` + ## Running an OTEL Collector An OTEL Collector is a service that receives, processes, and exports telemetry data. diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fd4907d0..ec84db52 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -55,6 +55,7 @@ export interface CliArgs { telemetryTarget: string | undefined; telemetryOtlpEndpoint: string | undefined; telemetryLogPrompts: boolean | undefined; + telemetryOutfile: string | undefined; allowedMcpServerNames: string[] | undefined; experimentalAcp: boolean | undefined; extensions: string[] | undefined; @@ -159,6 +160,10 @@ export async function parseArguments(): Promise { description: 'Enable or disable logging of user prompts for telemetry. Overrides settings files.', }) + .option('telemetry-outfile', { + type: 'string', + description: 'Redirect all telemetry output to the specified file.', + }) .option('checkpointing', { alias: 'c', type: 'boolean', @@ -412,6 +417,7 @@ export async function loadCliConfig( process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? settings.telemetry?.otlpEndpoint, logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts, + outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile, }, usageStatisticsEnabled: settings.usageStatisticsEnabled ?? true, // Git-aware file filtering settings diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0e3171bf..d4427093 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -73,6 +73,7 @@ export interface TelemetrySettings { target?: TelemetryTarget; otlpEndpoint?: string; logPrompts?: boolean; + outfile?: string; } export interface GeminiCLIExtension { @@ -255,6 +256,7 @@ export class Config { target: params.telemetry?.target ?? DEFAULT_TELEMETRY_TARGET, otlpEndpoint: params.telemetry?.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, logPrompts: params.telemetry?.logPrompts ?? true, + outfile: params.telemetry?.outfile, }; this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true; @@ -468,6 +470,10 @@ export class Config { return this.telemetrySettings.target ?? DEFAULT_TELEMETRY_TARGET; } + getTelemetryOutfile(): string | undefined { + return this.telemetrySettings.outfile; + } + getGeminiClient(): GeminiClient { return this.geminiClient; } diff --git a/packages/core/src/telemetry/file-exporters.ts b/packages/core/src/telemetry/file-exporters.ts new file mode 100644 index 00000000..aee3dfd6 --- /dev/null +++ b/packages/core/src/telemetry/file-exporters.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { ExportResult, ExportResultCode } from '@opentelemetry/core'; +import { ReadableSpan, SpanExporter } from '@opentelemetry/sdk-trace-base'; +import { ReadableLogRecord, LogRecordExporter } from '@opentelemetry/sdk-logs'; +import { + ResourceMetrics, + PushMetricExporter, + AggregationTemporality, +} from '@opentelemetry/sdk-metrics'; + +class FileExporter { + protected writeStream: fs.WriteStream; + + constructor(filePath: string) { + this.writeStream = fs.createWriteStream(filePath, { flags: 'a' }); + } + + protected serialize(data: unknown): string { + return JSON.stringify(data, null, 2) + '\n'; + } + + shutdown(): Promise { + return new Promise((resolve) => { + this.writeStream.end(resolve); + }); + } +} + +export class FileSpanExporter extends FileExporter implements SpanExporter { + export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void, + ): void { + const data = spans.map((span) => this.serialize(span)).join(''); + this.writeStream.write(data, (err) => { + resultCallback({ + code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS, + error: err || undefined, + }); + }); + } +} + +export class FileLogExporter extends FileExporter implements LogRecordExporter { + export( + logs: ReadableLogRecord[], + resultCallback: (result: ExportResult) => void, + ): void { + const data = logs.map((log) => this.serialize(log)).join(''); + this.writeStream.write(data, (err) => { + resultCallback({ + code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS, + error: err || undefined, + }); + }); + } +} + +export class FileMetricExporter + extends FileExporter + implements PushMetricExporter +{ + export( + metrics: ResourceMetrics, + resultCallback: (result: ExportResult) => void, + ): void { + const data = this.serialize(metrics); + this.writeStream.write(data, (err) => { + resultCallback({ + code: err ? ExportResultCode.FAILED : ExportResultCode.SUCCESS, + error: err || undefined, + }); + }); + } + + getPreferredAggregationTemporality(): AggregationTemporality { + return AggregationTemporality.CUMULATIVE; + } + + async forceFlush(): Promise { + return Promise.resolve(); + } +} diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 83294651..1167750a 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -29,6 +29,11 @@ import { Config } from '../config/config.js'; import { SERVICE_NAME } from './constants.js'; import { initializeMetrics } from './metrics.js'; import { ClearcutLogger } from './clearcut-logger/clearcut-logger.js'; +import { + FileLogExporter, + FileMetricExporter, + FileSpanExporter, +} from './file-exporters.js'; // For troubleshooting, set the log level to DiagLogLevel.DEBUG diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.INFO); @@ -74,19 +79,24 @@ export function initializeTelemetry(config: Config): void { const otlpEndpoint = config.getTelemetryOtlpEndpoint(); const grpcParsedEndpoint = parseGrpcEndpoint(otlpEndpoint); const useOtlp = !!grpcParsedEndpoint; + const telemetryOutfile = config.getTelemetryOutfile(); const spanExporter = useOtlp ? new OTLPTraceExporter({ url: grpcParsedEndpoint, compression: CompressionAlgorithm.GZIP, }) - : new ConsoleSpanExporter(); + : telemetryOutfile + ? new FileSpanExporter(telemetryOutfile) + : new ConsoleSpanExporter(); const logExporter = useOtlp ? new OTLPLogExporter({ url: grpcParsedEndpoint, compression: CompressionAlgorithm.GZIP, }) - : new ConsoleLogRecordExporter(); + : telemetryOutfile + ? new FileLogExporter(telemetryOutfile) + : new ConsoleLogRecordExporter(); const metricReader = useOtlp ? new PeriodicExportingMetricReader({ exporter: new OTLPMetricExporter({ @@ -95,10 +105,15 @@ export function initializeTelemetry(config: Config): void { }), exportIntervalMillis: 10000, }) - : new PeriodicExportingMetricReader({ - exporter: new ConsoleMetricExporter(), - exportIntervalMillis: 10000, - }); + : telemetryOutfile + ? new PeriodicExportingMetricReader({ + exporter: new FileMetricExporter(telemetryOutfile), + exportIntervalMillis: 10000, + }) + : new PeriodicExportingMetricReader({ + exporter: new ConsoleMetricExporter(), + exportIntervalMillis: 10000, + }); sdk = new NodeSDK({ resource,