diff --git a/docs/telemetry.md b/docs/telemetry.md index 562e065769..3e05307c92 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -1,168 +1,212 @@ -# Gemini CLI Observability Guide +# Observability with OpenTelemetry -Telemetry provides data about Gemini CLI's performance, health, and usage. By enabling it, you can monitor operations, debug issues, and optimize tool usage through traces, metrics, and structured logs. +Learn how to enable and setup OpenTelemetry for Gemini CLI. -Gemini CLI's telemetry system is built on the **[OpenTelemetry] (OTEL)** standard, allowing you to send data to any compatible backend. +## Key Benefits + +- **🔍 Usage Analytics**: Understand interaction patterns and feature adoption + across your team +- **⚡ Performance Monitoring**: Track response times, token consumption, and + resource utilization +- **🐛 Real-time Debugging**: Identify bottlenecks, failures, and error patterns + as they occur +- **📊 Workflow Optimization**: Make informed decisions to improve + configurations and processes +- **🏢 Enterprise Governance**: Monitor usage across teams, track costs, ensure + compliance, and integrate with existing monitoring infrastructure + +## OpenTelemetry Integration + +Built on **[OpenTelemetry]** — the vendor-neutral, industry-standard +observability framework — Gemini CLI's observability system provides: + +- **Universal Compatibility**: Export to any OpenTelemetry backend (Google + Cloud, Jaeger, Prometheus, Datadog, etc.) +- **Standardized Data**: Use consistent formats and collection methods across + your toolchain +- **Future-Proof Integration**: Connect with existing and future observability + infrastructure +- **No Vendor Lock-in**: Switch between backends without changing your + instrumentation [OpenTelemetry]: https://opentelemetry.io/ -## Enabling telemetry +- [Observability with OpenTelemetry](#observability-with-opentelemetry) + - [Key Benefits](#key-benefits) + - [OpenTelemetry Integration](#opentelemetry-integration) + - [Configuration](#configuration) + - [Google Cloud Telemetry](#google-cloud-telemetry) + - [Prerequisites](#prerequisites) + - [Direct Export (Recommended)](#direct-export-recommended) + - [Collector-Based Export (Advanced)](#collector-based-export-advanced) + - [Local Telemetry](#local-telemetry) + - [File-based Output (Recommended)](#file-based-output-recommended) + - [Collector-Based Export (Advanced)](#collector-based-export-advanced-1) + - [Logs and Metrics](#logs-and-metrics) + - [Logs](#logs) + - [Metrics](#metrics) -You can enable telemetry in multiple ways. Configuration is primarily managed via the [`.gemini/settings.json` file](./cli/configuration.md) and environment variables, but CLI flags can override these settings for a specific session. +## Configuration -### Order of precedence +All telemetry behavior is controlled through your `.gemini/settings.json` file +and can be overridden with CLI flags: -The following lists the precedence for applying telemetry settings, with items listed higher having greater precedence: +| Setting | Values | Default | CLI Override | Description | +| -------------- | ----------------- | ----------------------- | -------------------------------------------------------- | ---------------------------------------------------- | +| `enabled` | `true`/`false` | `false` | `--telemetry` / `--no-telemetry` | Enable or disable telemetry | +| `target` | `"gcp"`/`"local"` | `"local"` | `--telemetry-target ` | Where to send telemetry data | +| `otlpEndpoint` | URL string | `http://localhost:4317` | `--telemetry-otlp-endpoint ` | OTLP collector endpoint | +| `otlpProtocol` | `"grpc"`/`"http"` | `"grpc"` | `--telemetry-otlp-protocol ` | OTLP transport protocol | +| `outfile` | file path | - | `--telemetry-outfile ` | Save telemetry to file (requires `otlpEndpoint: ""`) | +| `logPrompts` | `true`/`false` | `true` | `--telemetry-log-prompts` / `--no-telemetry-log-prompts` | Include prompts in telemetry logs | +| `useCollector` | `true`/`false` | `false` | - | Use external OTLP collector (advanced) | -1. **CLI flags (for `gemini` command):** - - `--telemetry` / `--no-telemetry`: Overrides `telemetry.enabled`. - - `--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). +For detailed information about all configuration options, see the +[Configuration Guide](./cli/configuration.md). -1. **Environment variables:** - - `OTEL_EXPORTER_OTLP_ENDPOINT`: Overrides `telemetry.otlpEndpoint`. +## Google Cloud Telemetry -1. **Workspace settings file (`.gemini/settings.json`):** Values from the `telemetry` object in this project-specific file. +### Prerequisites -1. **User settings file (`~/.gemini/settings.json`):** Values from the `telemetry` object in this global user file. +Before using either method below, complete these steps: -1. **Defaults:** applied if not set by any of the above. - - `telemetry.enabled`: `false` - - `telemetry.target`: `local` - - `telemetry.otlpEndpoint`: `http://localhost:4317` - - `telemetry.logPrompts`: `true` +1. Set your Google Cloud project ID: + - For telemetry in a separate project from inference: + ```bash + export OTLP_GOOGLE_CLOUD_PROJECT="your-telemetry-project-id" + ``` + - For telemetry in the same project as inference: + ```bash + export GOOGLE_CLOUD_PROJECT="your-project-id" + ``` -**For the `npm run telemetry -- --target=` script:** -The `--target` argument to this script _only_ overrides the `telemetry.target` for the duration and purpose of that script (i.e., choosing which collector to start). It does not permanently change your `settings.json`. The script will first look at `settings.json` for a `telemetry.target` to use as its default. +2. Authenticate with Google Cloud: + - If using a user account: + ```bash + gcloud auth application-default login + ``` + - If using a service account: + ```bash + export GOOGLE_APPLICATION_CREDENTIALS="/path/to/your/service-account.json" + ``` +3. Make sure your account or service account has these IAM roles: + - Cloud Trace Agent + - Monitoring Metric Writer + - Logs Writer -### Example settings +4. Enable the required Google Cloud APIs (if not already enabled): + ```bash + gcloud services enable \ + cloudtrace.googleapis.com \ + monitoring.googleapis.com \ + logging.googleapis.com \ + --project="$OTLP_GOOGLE_CLOUD_PROJECT" + ``` -The following code can be added to your workspace (`.gemini/settings.json`) or user (`~/.gemini/settings.json`) settings to enable telemetry and send the output to Google Cloud: +### Direct Export (Recommended) -```json -{ - "telemetry": { - "enabled": true, - "target": "gcp" - }, - "tools": { - "sandbox": false - } -} -``` +Sends telemetry directly to Google Cloud services. No collector needed. -### Exporting to a file +1. Enable telemetry in your `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs and metrics: + - Open the Google Cloud Console in your browser after sending prompts: + - Logs: https://console.cloud.google.com/logs/ + - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer + - Traces: https://console.cloud.google.com/traces/list -You can export all telemetry data to a file for local inspection. +### Collector-Based Export (Advanced) -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`. +For custom processing, filtering, or routing, use an OpenTelemetry collector to +forward data to Google Cloud. -```bash -# Set your desired output file path -TELEMETRY_FILE=".gemini/telemetry.log" +1. Configure your `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "gcp", + "useCollector": true + } + } + ``` +2. Run the automation script: + ```bash + npm run telemetry -- --target=gcp + ``` + This will: + - Start a local OTEL collector that forwards to Google Cloud + - Configure your workspace + - Provide links to view traces, metrics, and logs in Google Cloud Console + - Save collector logs to `~/.gemini/tmp//otel/collector-gcp.log` + - Stop collector on exit (e.g. `Ctrl+C`) +3. Run Gemini CLI and send prompts. +4. View logs and metrics: + - Open the Google Cloud Console in your browser after sending prompts: + - Logs: https://console.cloud.google.com/logs/ + - Metrics: https://console.cloud.google.com/monitoring/metrics-explorer + - Traces: https://console.cloud.google.com/traces/list + - Open `~/.gemini/tmp//otel/collector-gcp.log` to view local + collector logs. -# Run Gemini CLI with local telemetry -# NOTE: --telemetry-otlp-endpoint="" is required to override the default -# OTLP exporter and ensure telemetry is written to the local file. -gemini --telemetry \ - --telemetry-target=local \ - --telemetry-otlp-endpoint="" \ - --telemetry-outfile="$TELEMETRY_FILE" \ - --prompt "What is OpenTelemetry?" -``` +## Local Telemetry -## Running an OTEL Collector +For local development and debugging, you can capture telemetry data locally: -An OTEL Collector is a service that receives, processes, and exports telemetry data. -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. +### File-based Output (Recommended) -Learn more about OTEL exporter standard configuration in [documentation][otel-config-docs]. +1. Enable telemetry in your `.gemini/settings.json`: + ```json + { + "telemetry": { + "enabled": true, + "target": "local", + "otlpEndpoint": "", + "outfile": ".gemini/telemetry.log" + } + } + ``` +2. Run Gemini CLI and send prompts. +3. View logs and metrics in the specified file (e.g., `.gemini/telemetry.log`). -[otel-config-docs]: https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/ +### Collector-Based Export (Advanced) -### Local +1. Run the automation script: + ```bash + npm run telemetry -- --target=local + ``` + This will: + - Download and start Jaeger and OTEL collector + - Configure your workspace for local telemetry + - Provide a Jaeger UI at http://localhost:16686 + - Save logs/metrics to `~/.gemini/tmp//otel/collector.log` + - Stop collector on exit (e.g. `Ctrl+C`) +2. Run Gemini CLI and send prompts. +3. View traces at http://localhost:16686 and logs/metrics in the collector log + file. -Use the `npm run telemetry -- --target=local` command to automate the process of setting up a local telemetry pipeline, including configuring the necessary settings in your `.gemini/settings.json` file. The underlying script installs `otelcol-contrib` (the OpenTelemetry Collector) and `jaeger` (The Jaeger UI for viewing traces). To use it: +## Logs and Metrics -1. **Run the command**: - Execute the command from the root of the repository: - - ```bash - npm run telemetry -- --target=local - ``` - - The script will: - - Download Jaeger and OTEL if needed. - - Start a local Jaeger instance. - - Start an OTEL collector configured to receive data from Gemini CLI. - - Automatically enable telemetry in your workspace settings. - - On exit, disable telemetry. - -1. **View traces**: - Open your web browser and navigate to **http://localhost:16686** to access the Jaeger UI. Here you can inspect detailed traces of Gemini CLI operations. - -1. **Inspect logs and metrics**: - The script redirects the OTEL collector output (which includes logs and metrics) to `~/.gemini/tmp//otel/collector.log`. The script will provide links to view and a command to tail your telemetry data (traces, metrics, logs) locally. - -1. **Stop the services**: - Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector and Jaeger services. - -### Google Cloud - -Use the `npm run telemetry -- --target=gcp` command to automate setting up a local OpenTelemetry collector that forwards data to your Google Cloud project, including configuring the necessary settings in your `.gemini/settings.json` file. The underlying script installs `otelcol-contrib`. To use it: - -1. **Prerequisites**: - - Have a Google Cloud project ID. - - Export the `GOOGLE_CLOUD_PROJECT` environment variable to make it available to the OTEL collector. - ```bash - export OTLP_GOOGLE_CLOUD_PROJECT="your-project-id" - ``` - - Authenticate with Google Cloud (e.g., run `gcloud auth application-default login` or ensure `GOOGLE_APPLICATION_CREDENTIALS` is set). - - Ensure your Google Cloud account/service account has the necessary IAM roles: "Cloud Trace Agent", "Monitoring Metric Writer", and "Logs Writer". - -1. **Run the command**: - Execute the command from the root of the repository: - - ```bash - npm run telemetry -- --target=gcp - ``` - - The script will: - - Download the `otelcol-contrib` binary if needed. - - Start an OTEL collector configured to receive data from Gemini CLI and export it to your specified Google Cloud project. - - Automatically enable telemetry and disable sandbox mode in your workspace settings (`.gemini/settings.json`). - - Provide direct links to view traces, metrics, and logs in your Google Cloud Console. - - On exit (Ctrl+C), it will attempt to restore your original telemetry and sandbox settings. - -1. **Run Gemini CLI:** - In a separate terminal, run your Gemini CLI commands. This generates telemetry data that the collector captures. - -1. **View telemetry in Google Cloud**: - Use the links provided by the script to navigate to the Google Cloud Console and view your traces, metrics, and logs. - -1. **Inspect local collector logs**: - The script redirects the local OTEL collector output to `~/.gemini/tmp//otel/collector-gcp.log`. The script provides links to view and command to tail your collector logs locally. - -1. **Stop the service**: - Press `Ctrl+C` in the terminal where the script is running to stop the OTEL Collector. - -## Logs and metric reference - -The following section describes the structure of logs and metrics generated for Gemini CLI. +The following section describes the structure of logs and metrics generated for +Gemini CLI. - A `sessionId` is included as a common attribute on all logs and metrics. ### Logs -Logs are timestamped records of specific events. The following events are logged for Gemini CLI: +Logs are timestamped records of specific events. The following events are logged +for Gemini CLI: -- `gemini_cli.config`: This event occurs once at startup with the CLI's configuration. +- `gemini_cli.config`: This event occurs once at startup with the CLI's + configuration. - **Attributes**: - `model` (string) - `embedding_model` (string) @@ -182,7 +226,8 @@ Logs are timestamped records of specific events. The following events are logged - **Attributes**: - `prompt_length` (int) - `prompt_id` (string) - - `prompt` (string, this attribute is excluded if `log_prompts_enabled` is configured to be `false`) + - `prompt` (string, this attribute is excluded if `log_prompts_enabled` is + configured to be `false`) - `auth_type` (string) - `gemini_cli.tool_call`: This event occurs for each function call. @@ -191,7 +236,8 @@ Logs are timestamped records of specific events. The following events are logged - `function_args` - `duration_ms` - `success` (boolean) - - `decision` (string: "accept", "reject", "auto_accept", or "modify", if applicable) + - `decision` (string: "accept", "reject", "auto_accept", or "modify", if + applicable) - `error` (if applicable) - `error_type` (if applicable) - `content_length` (int, if applicable) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index be7ed4277a..d3af6c6c0d 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1235,6 +1235,145 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@google-cloud/common": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-5.0.2.tgz", + "integrity": "sha512-V7bmBKYQyu0eVG2BFejuUjlBt+zrya6vtsKdY+JxMM/dNntPF41vZ9+LhOshEUH01zOHEqBSvI7Dad7ZS6aUeA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.1", + "duplexify": "^4.1.1", + "extend": "^3.0.2", + "google-auth-library": "^9.0.0", + "html-entities": "^2.5.2", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/logging": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@google-cloud/logging/-/logging-11.2.1.tgz", + "integrity": "sha512-2h9HBJG3OAsvzXmb81qXmaTPfXYU7KJTQUxunoOKFGnY293YQ/eCkW1Y5mHLocwpEqeqQYT/Qvl6Tk+Q7PfStw==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/common": "^5.0.0", + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "4.0.0", + "@opentelemetry/api": "^1.7.0", + "arrify": "^2.0.1", + "dot-prop": "^6.0.0", + "eventid": "^2.0.0", + "extend": "^3.0.2", + "gcp-metadata": "^6.0.0", + "google-auth-library": "^9.0.0", + "google-gax": "^4.0.3", + "on-finished": "^2.3.0", + "pumpify": "^2.0.1", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/logging/node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-monitoring-exporter": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-monitoring-exporter/-/opentelemetry-cloud-monitoring-exporter-0.21.0.tgz", + "integrity": "sha512-+lAew44pWt6rA4l8dQ1gGhH7Uo95wZKfq/GBf9aEyuNDDLQ2XppGEEReu6ujesSqTtZ8ueQFt73+7SReSHbwqg==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/opentelemetry-resource-util": "^3.0.0", + "@google-cloud/precise-date": "^4.0.0", + "google-auth-library": "^9.0.0", + "googleapis": "^137.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-metrics": "^2.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-trace-exporter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-cloud-trace-exporter/-/opentelemetry-cloud-trace-exporter-3.0.0.tgz", + "integrity": "sha512-mUfLJBFo+ESbO0dAGboErx2VyZ7rbrHcQvTP99yH/J72dGaPbH2IzS+04TFbTbEd1VW5R9uK3xq2CqawQaG+1Q==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/opentelemetry-resource-util": "^3.0.0", + "@grpc/grpc-js": "^1.1.8", + "@grpc/proto-loader": "^0.8.0", + "google-auth-library": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/sdk-trace-base": "^2.0.0" + } + }, + "node_modules/@google-cloud/opentelemetry-cloud-trace-exporter/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@google-cloud/opentelemetry-resource-util": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/opentelemetry-resource-util/-/opentelemetry-resource-util-3.0.0.tgz", + "integrity": "sha512-CGR/lNzIfTKlZoZFfS6CkVzx+nsC9gzy6S8VcyaLegfEJbiPjxbMLP7csyhJTvZe/iRRcQJxSk0q8gfrGqD3/Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.22.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0" + } + }, "node_modules/@google-cloud/paginator": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", @@ -1248,6 +1387,15 @@ "node": ">=14.0.0" } }, + "node_modules/@google-cloud/precise-date": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/precise-date/-/precise-date-4.0.0.tgz", + "integrity": "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@google-cloud/projectify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", @@ -2657,6 +2805,24 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/resource-detector-gcp": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resource-detector-gcp/-/resource-detector-gcp-0.40.0.tgz", + "integrity": "sha512-uAsUV8K4R9OJ3cgPUGYDqQByxOMTz4StmzJyofIv7+W+c1dTSEc1WVjWpTS2PAmywik++JlSmd8O4rMRJZpO8Q==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/resources": "^2.0.0", + "@opentelemetry/semantic-conventions": "^1.27.0", + "gcp-metadata": "^6.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0" + } + }, "node_modules/@opentelemetry/resources": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", @@ -3823,6 +3989,12 @@ "@types/node": "*" } }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, "node_modules/@types/marked": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz", @@ -7763,6 +7935,27 @@ "node": ">=6" } }, + "node_modules/eventid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/eventid/-/eventid-2.0.1.tgz", + "integrity": "sha512-sPNTqiMokAvV048P2c9+foqVJzk49o6d4e0D/sq5jog3pw+4kBgyR0gaM1FM7Mx6Kzd9dztesh9oYz1LWWOpzw==", + "license": "Apache-2.0", + "dependencies": { + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/eventid/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8804,6 +8997,29 @@ "node": ">=14" } }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/google-logging-utils": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", @@ -8813,6 +9029,36 @@ "node": ">=14" } }, + "node_modules/googleapis": { + "version": "137.1.0", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-137.1.0.tgz", + "integrity": "sha512-2L7SzN0FLHyQtFmyIxrcXhgust77067pkkduqkbIpDuj9JzVnByxsRrcRfUMFQam3rQkWW2B0f1i40IwKDWIVQ==", + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^9.0.0", + "googleapis-common": "^7.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/googleapis-common": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "gaxios": "^6.0.3", + "google-auth-library": "^9.7.0", + "qs": "^6.7.0", + "url-template": "^2.0.8", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9896,6 +10142,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-path-inside": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", @@ -11493,6 +11748,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -12230,6 +12494,18 @@ "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", "license": "ISC" }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/protobufjs": { "version": "7.5.3", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", @@ -12290,6 +12566,17 @@ "once": "^1.3.1" } }, + "node_modules/pumpify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz", + "integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==", + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -15128,6 +15415,12 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-template": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", + "integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==", + "license": "BSD" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -16451,6 +16744,9 @@ "name": "@google/gemini-cli-core", "version": "0.6.0-nightly", "dependencies": { + "@google-cloud/logging": "^11.2.1", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", + "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", "@google/genai": "1.16.0", "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", @@ -16462,6 +16758,7 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resource-detector-gcp": "^0.40.0", "@opentelemetry/sdk-node": "^0.203.0", "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f2d058b2bf..9e04c84dec 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -602,6 +602,7 @@ export async function loadCliConfig( ), logPrompts: argv.telemetryLogPrompts ?? settings.telemetry?.logPrompts, outfile: argv.telemetryOutfile ?? settings.telemetry?.outfile, + useCollector: settings.telemetry?.useCollector, }, usageStatisticsEnabled: settings.privacy?.usageStatisticsEnabled ?? true, fileFiltering: settings.context?.fileFiltering, diff --git a/packages/core/package.json b/packages/core/package.json index a27ef8f006..7c4fc1e9c7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -21,6 +21,9 @@ ], "dependencies": { "@google/genai": "1.16.0", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "^0.21.0", + "@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0", + "@google-cloud/logging": "^11.2.1", "@joshua.litt/get-ripgrep": "^0.0.2", "@modelcontextprotocol/sdk": "^1.11.0", "@opentelemetry/api": "^1.9.0", @@ -31,6 +34,7 @@ "@opentelemetry/exporter-trace-otlp-grpc": "^0.203.0", "@opentelemetry/exporter-trace-otlp-http": "^0.203.0", "@opentelemetry/instrumentation-http": "^0.203.0", + "@opentelemetry/resource-detector-gcp": "^0.40.0", "@opentelemetry/sdk-node": "^0.203.0", "@types/glob": "^8.1.0", "@types/html-to-text": "^9.0.4", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e63853286f..c83102f0b8 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -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(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 1d88f51baf..dc03743988 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -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; } diff --git a/packages/core/src/telemetry/gcp-exporters.test.ts b/packages/core/src/telemetry/gcp-exporters.test.ts new file mode 100644 index 0000000000..c863a74459 --- /dev/null +++ b/packages/core/src/telemetry/gcp-exporters.test.ts @@ -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((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((resolve) => { + resolveWrite1 = resolve; + }); + const writePromise2 = new Promise((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((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); + }); + }); + }); +}); diff --git a/packages/core/src/telemetry/gcp-exporters.ts b/packages/core/src/telemetry/gcp-exporters.ts new file mode 100644 index 0000000000..3abf9ab43b --- /dev/null +++ b/packages/core/src/telemetry/gcp-exporters.ts @@ -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> = []; + + 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 { + if (this.pendingWrites.length > 0) { + await Promise.all(this.pendingWrites); + } + } + + async shutdown(): Promise { + 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'; + } +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 01317afcce..e713593cf8 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -18,6 +18,11 @@ export { shutdownTelemetry, isTelemetrySdkInitialized, } from './sdk.js'; +export { + GcpTraceExporter, + GcpMetricExporter, + GcpLogExporter, +} from './gcp-exporters.js'; export { logCliConfiguration, logUserPrompt, diff --git a/packages/core/src/telemetry/sdk.test.ts b/packages/core/src/telemetry/sdk.test.ts index 4eefb62090..8dae131507 100644 --- a/packages/core/src/telemetry/sdk.test.ts +++ b/packages/core/src/telemetry/sdk.test.ts @@ -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']; + } + } + }); }); diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 3db41c6720..78a9c453bf 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -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,