summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorKeir Mierle <[email protected]>2025-06-13 18:08:03 -0700
committerGitHub <[email protected]>2025-06-13 18:08:03 -0700
commit99547797390df9062cc68863a11d6a691d6e2bc4 (patch)
treeb12a110cdc8c077cd2e93b7cfe43e5c5767e6c82 /scripts
parent31b28ade010711c578d4be58c0dc439badebe000 (diff)
Add a local telemetry launcher (#1015)
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/local_telemetry.js486
1 files changed, 486 insertions, 0 deletions
diff --git a/scripts/local_telemetry.js b/scripts/local_telemetry.js
new file mode 100755
index 00000000..74e7f750
--- /dev/null
+++ b/scripts/local_telemetry.js
@@ -0,0 +1,486 @@
+#!/usr/bin/env node
+
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import path from 'path';
+import fs from 'fs';
+import net from 'net';
+import os from 'os';
+import { spawn, execSync } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const ROOT_DIR = path.resolve(__dirname, '..');
+const GEMINI_DIR = path.join(ROOT_DIR, '.gemini');
+const OTEL_DIR = path.join(GEMINI_DIR, 'otel');
+const BIN_DIR = path.join(OTEL_DIR, 'bin');
+const OTEL_CONFIG_FILE = path.join(OTEL_DIR, 'collector-local.yaml');
+const OTEL_LOG_FILE = path.join(OTEL_DIR, 'collector.log');
+const JAEGER_LOG_FILE = path.join(OTEL_DIR, 'jaeger.log');
+const JAEGER_PORT = 16686;
+const WORKSPACE_SETTINGS_FILE = path.join(GEMINI_DIR, 'settings.json');
+
+// This configuration is for the primary otelcol-contrib instance.
+// It receives from the CLI on 4317, exports traces to Jaeger on 14317,
+// and sends metrics/logs to the debug log.
+const OTEL_CONFIG_CONTENT = `
+receivers:
+ otlp:
+ protocols:
+ grpc:
+ endpoint: "localhost:4317"
+processors:
+ batch:
+ timeout: 1s
+exporters:
+ otlp:
+ endpoint: "localhost:14317"
+ tls:
+ insecure: true
+ debug:
+ verbosity: detailed
+service:
+ telemetry:
+ logs:
+ level: "debug"
+ metrics:
+ level: "none"
+ pipelines:
+ traces:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [otlp]
+ metrics:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug]
+ logs:
+ receivers: [otlp]
+ processors: [batch]
+ exporters: [debug]
+`;
+
+function getJson(url) {
+ const tmpFile = path.join(
+ os.tmpdir(),
+ `gemini-cli-releases-${Date.now()}.json`,
+ );
+ try {
+ execSync(
+ `curl -sL -H "User-Agent: gemini-cli-dev-script" -o "${tmpFile}" "${url}"`,
+ { stdio: 'pipe' },
+ );
+ const content = fs.readFileSync(tmpFile, 'utf-8');
+ return JSON.parse(content);
+ } catch (e) {
+ console.error(`Failed to fetch or parse JSON from ${url}`);
+ throw e;
+ } finally {
+ if (fs.existsSync(tmpFile)) {
+ fs.unlinkSync(tmpFile);
+ }
+ }
+}
+
+function downloadFile(url, dest) {
+ try {
+ // Use -sS to hide progress but show errors.
+ execSync(`curl -fL -sS -o "${dest}" "${url}"`, {
+ stdio: 'pipe', // Suppress stdout/stderr from the command
+ });
+ return dest;
+ } catch (e) {
+ console.error(`Failed to download file from ${url}`);
+ throw e;
+ }
+}
+
+function findFile(startPath, filter) {
+ if (!fs.existsSync(startPath)) {
+ return null;
+ }
+ const files = fs.readdirSync(startPath);
+ for (const file of files) {
+ const filename = path.join(startPath, file);
+ const stat = fs.lstatSync(filename);
+ if (stat.isDirectory()) {
+ const result = findFile(filename, filter);
+ if (result) return result;
+ } else if (filter(file)) {
+ // Test the simple file name, not the full path.
+ return filename;
+ }
+ }
+ return null;
+}
+
+async function ensureBinary(
+ executableName,
+ repo,
+ assetNameCallback,
+ binaryNameInArchive,
+) {
+ const executablePath = path.join(BIN_DIR, executableName);
+ if (fileExists(executablePath)) {
+ console.log(`โœ… ${executableName} already exists at ${executablePath}`);
+ return executablePath;
+ }
+
+ console.log(`๐Ÿ” ${executableName} not found. Downloading from ${repo}...`);
+
+ const platform = process.platform === 'win32' ? 'windows' : process.platform;
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch;
+ const ext = platform === 'windows' ? 'zip' : 'tar.gz';
+
+ if (platform === 'windows' && arch === 'arm64') {
+ if (repo === 'jaegertracing/jaeger') {
+ console.warn(
+ `โš ๏ธ Jaeger does not have a release for Windows on ARM64. Skipping.`,
+ );
+ return null;
+ }
+ }
+
+ let release;
+ let asset;
+
+ if (repo === 'jaegertracing/jaeger') {
+ console.log(`๐Ÿ” Finding latest Jaeger v2+ asset...`);
+ const releases = getJson(`https://api.github.com/repos/${repo}/releases`);
+ const sortedReleases = releases
+ .filter((r) => !r.prerelease && r.tag_name.startsWith('v'))
+ .sort((a, b) => {
+ const aVersion = a.tag_name.substring(1).split('.').map(Number);
+ const bVersion = b.tag_name.substring(1).split('.').map(Number);
+ for (let i = 0; i < Math.max(aVersion.length, bVersion.length); i++) {
+ if ((aVersion[i] || 0) > (bVersion[i] || 0)) return -1;
+ if ((aVersion[i] || 0) < (bVersion[i] || 0)) return 1;
+ }
+ return 0;
+ });
+
+ for (const r of sortedReleases) {
+ // Jaeger v2 assets are named like 'jaeger-2.7.0-...' but can be in a v1.x release tag.
+ // We must search for the asset using simple string matching.
+ const expectedSuffix = `-${platform}-${arch}.tar.gz`;
+ const foundAsset = r.assets.find(
+ (a) =>
+ a.name.startsWith('jaeger-2.') && a.name.endsWith(expectedSuffix),
+ );
+
+ if (foundAsset) {
+ release = r;
+ asset = foundAsset;
+ console.log(
+ `โฌ‡๏ธ Found ${asset.name} in release ${r.tag_name}, downloading...`,
+ );
+ break;
+ }
+ }
+
+ if (!asset) {
+ throw new Error(
+ `Could not find a suitable Jaeger v2 asset for platform ${platform}/${arch}.`,
+ );
+ }
+ } else {
+ release = getJson(`https://api.github.com/repos/${repo}/releases/latest`);
+ const version = release.tag_name.startsWith('v')
+ ? release.tag_name.substring(1)
+ : release.tag_name;
+ const assetName = assetNameCallback(version, platform, arch, ext);
+ asset = release.assets.find((a) => a.name === assetName);
+ }
+
+ if (!asset) {
+ throw new Error(
+ `Could not find a suitable asset for ${repo} on platform ${platform}/${arch}.`,
+ );
+ }
+
+ const downloadUrl = asset.browser_download_url;
+ const tmpDir = fs.mkdtempSync(
+ path.join(os.tmpdir(), 'gemini-cli-telemetry-'),
+ );
+ const archivePath = path.join(tmpDir, asset.name);
+
+ try {
+ downloadFile(downloadUrl, archivePath);
+
+ if (ext === 'zip') {
+ execSync(`unzip -o "${archivePath}" -d "${tmpDir}"`, { stdio: 'pipe' });
+ } else {
+ execSync(`tar -xzf "${archivePath}" -C "${tmpDir}"`, { stdio: 'pipe' });
+ }
+
+ const nameToFind = binaryNameInArchive || executableName;
+ const foundBinaryPath = findFile(tmpDir, (file) => {
+ if (platform === 'windows') {
+ return file === `${nameToFind}.exe`;
+ }
+ return file === nameToFind;
+ });
+
+ if (!foundBinaryPath) {
+ throw new Error(
+ `Could not find binary "${nameToFind}" in extracted archive.`,
+ );
+ }
+
+ fs.renameSync(foundBinaryPath, executablePath);
+
+ if (platform !== 'windows') {
+ fs.chmodSync(executablePath, '755');
+ }
+
+ console.log(`โœ… ${executableName} installed at ${executablePath}`);
+ return executablePath;
+ } finally {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+ if (fs.existsSync(archivePath)) {
+ fs.unlinkSync(archivePath);
+ }
+ }
+}
+
+function fileExists(filePath) {
+ return fs.existsSync(filePath);
+}
+
+function readJsonFile(filePath) {
+ if (!fileExists(filePath)) {
+ return {};
+ }
+ const content = fs.readFileSync(filePath, 'utf-8');
+ return JSON.parse(content);
+}
+
+function writeJsonFile(filePath, data) {
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
+}
+
+function waitForPort(port, timeout = 5000) {
+ return new Promise((resolve, reject) => {
+ const startTime = Date.now();
+ const tryConnect = () => {
+ const socket = new net.Socket();
+ socket.once('connect', () => {
+ socket.end();
+ resolve();
+ });
+ socket.once('error', (_) => {
+ if (Date.now() - startTime > timeout) {
+ reject(new Error(`Timeout waiting for port ${port} to open.`));
+ } else {
+ setTimeout(tryConnect, 500);
+ }
+ });
+ socket.connect(port, 'localhost');
+ };
+ tryConnect();
+ });
+}
+
+async function main() {
+ // 1. Ensure binaries are available, downloading if necessary.
+ // Binaries are stored in the project's .gemini/otel/bin directory
+ // to avoid modifying the user's system.
+ if (!fileExists(BIN_DIR)) fs.mkdirSync(BIN_DIR, { recursive: true });
+
+ const otelcolPath = await ensureBinary(
+ 'otelcol-contrib',
+ 'open-telemetry/opentelemetry-collector-releases',
+ (version, platform, arch, ext) =>
+ `otelcol-contrib_${version}_${platform}_${arch}.${ext}`,
+ 'otelcol-contrib',
+ ).catch((e) => {
+ console.error(`๐Ÿ›‘ Error getting otelcol-contrib: ${e.message}`);
+ return null;
+ });
+ if (!otelcolPath) process.exit(1);
+
+ const jaegerPath = await ensureBinary(
+ 'jaeger',
+ 'jaegertracing/jaeger',
+ (version, platform, arch, ext) =>
+ `jaeger-${version}-${platform}-${arch}.${ext}`,
+ 'jaeger',
+ ).catch((e) => {
+ console.error(`๐Ÿ›‘ Error getting jaeger: ${e.message}`);
+ return null;
+ });
+ if (!jaegerPath) process.exit(1);
+
+ // 2. Kill any existing processes to ensure a clean start.
+ console.log('๐Ÿงน Cleaning up old processes and logs...');
+ try {
+ execSync('pkill -f "otelcol-contrib"');
+ console.log('โœ… Stopped existing otelcol-contrib process.');
+ } catch (_e) {} // eslint-disable-line no-empty
+ try {
+ execSync('pkill -f "jaeger"');
+ console.log('โœ… Stopped existing jaeger process.');
+ } catch (_e) {} // eslint-disable-line no-empty
+ try {
+ fs.unlinkSync(OTEL_LOG_FILE);
+ console.log('โœ… Deleted old collector log.');
+ } catch (e) {
+ if (e.code !== 'ENOENT') console.error(e);
+ }
+ try {
+ fs.unlinkSync(JAEGER_LOG_FILE);
+ console.log('โœ… Deleted old jaeger log.');
+ } catch (e) {
+ if (e.code !== 'ENOENT') console.error(e);
+ }
+
+ let jaegerProcess, collectorProcess;
+ let jaegerLogFd, collectorLogFd;
+
+ const cleanup = () => {
+ console.log('\n๐Ÿ‘‹ Shutting down...');
+
+ // Restore original settings
+ const finalSettings = readJsonFile(WORKSPACE_SETTINGS_FILE);
+ delete finalSettings.telemetry;
+ delete finalSettings.telemetryOtlpEndpoint;
+ finalSettings.sandbox = originalSandboxSetting;
+ writeJsonFile(WORKSPACE_SETTINGS_FILE, finalSettings);
+ console.log('โœ… Restored original telemetry and sandbox settings.');
+
+ [jaegerProcess, collectorProcess].forEach((proc) => {
+ if (proc && proc.pid) {
+ const name = path.basename(proc.spawnfile);
+ try {
+ console.log(`๐Ÿ›‘ Stopping ${name} (PID: ${proc.pid})...`);
+ // Use SIGTERM for a graceful shutdown
+ process.kill(proc.pid, 'SIGTERM');
+ console.log(`โœ… ${name} stopped.`);
+ } catch (e) {
+ // It's okay if the process is already gone.
+ if (e.code !== 'ESRCH')
+ console.error(`Error stopping ${name}: ${e.message}`);
+ }
+ }
+ });
+ [jaegerLogFd, collectorLogFd].forEach((fd) => {
+ if (fd)
+ try {
+ fs.closeSync(fd);
+ } catch (_) {} // eslint-disable-line no-empty
+ });
+ };
+
+ process.on('exit', cleanup);
+ process.on('SIGINT', () => process.exit(0));
+ process.on('SIGTERM', () => process.exit(0));
+ process.on('uncaughtException', (err) => {
+ console.error('Uncaught Exception:', err);
+ process.exit(1);
+ });
+
+ if (!fileExists(OTEL_DIR)) fs.mkdirSync(OTEL_DIR, { recursive: true });
+ fs.writeFileSync(OTEL_CONFIG_FILE, OTEL_CONFIG_CONTENT);
+ console.log('๐Ÿ“„ Wrote OTEL collector config.');
+
+ const workspaceSettings = readJsonFile(WORKSPACE_SETTINGS_FILE);
+ const originalSandboxSetting = workspaceSettings.sandbox;
+ let settingsModified = false;
+
+ if (workspaceSettings.telemetry !== true) {
+ workspaceSettings.telemetry = true;
+ settingsModified = true;
+ console.log('โš™๏ธ Enabled telemetry in workspace settings.');
+ }
+
+ if (workspaceSettings.sandbox !== false) {
+ workspaceSettings.sandbox = false;
+ settingsModified = true;
+ console.log('โœ… Disabled sandbox mode for local telemetry.');
+ }
+
+ if (workspaceSettings.telemetryOtlpEndpoint !== 'http://localhost:4317') {
+ workspaceSettings.telemetryOtlpEndpoint = 'http://localhost:4317';
+ settingsModified = true;
+ console.log('๐Ÿ”ง Set telemetry endpoint to http://localhost:4317.');
+ }
+
+ if (settingsModified) {
+ writeJsonFile(WORKSPACE_SETTINGS_FILE, workspaceSettings);
+ console.log('โœ… Workspace settings updated.');
+ } else {
+ console.log('โœ… Telemetry is already configured correctly.');
+ }
+
+ // Start Jaeger
+ console.log(`๐Ÿš€ Starting Jaeger service... Logs: ${JAEGER_LOG_FILE}`);
+ jaegerLogFd = fs.openSync(JAEGER_LOG_FILE, 'a');
+ // The collector is on 4317, so we move jaeger to 14317.
+ jaegerProcess = spawn(
+ jaegerPath,
+ ['--set=receivers.otlp.protocols.grpc.endpoint=localhost:14317'],
+ { stdio: ['ignore', jaegerLogFd, jaegerLogFd] },
+ );
+ console.log(`โณ Waiting for Jaeger to start (PID: ${jaegerProcess.pid})...`);
+
+ try {
+ await waitForPort(JAEGER_PORT);
+ console.log(`โœ… Jaeger started successfully.`);
+ } catch (_) {
+ console.error(`๐Ÿ›‘ Error: Jaeger failed to start on port ${JAEGER_PORT}.`);
+ if (jaegerProcess && jaegerProcess.pid) {
+ process.kill(jaegerProcess.pid, 'SIGKILL');
+ }
+ if (fileExists(JAEGER_LOG_FILE)) {
+ console.error('๐Ÿ“„ Jaeger Log Output:');
+ console.error(fs.readFileSync(JAEGER_LOG_FILE, 'utf-8'));
+ }
+ process.exit(1);
+ }
+
+ // Start the primary OTEL collector
+ console.log(`๐Ÿš€ Starting OTEL collector... Logs: ${OTEL_LOG_FILE}`);
+ collectorLogFd = fs.openSync(OTEL_LOG_FILE, 'a');
+ collectorProcess = spawn(otelcolPath, ['--config', OTEL_CONFIG_FILE], {
+ stdio: ['ignore', collectorLogFd, collectorLogFd],
+ });
+ console.log(
+ `โณ Waiting for OTEL collector to start (PID: ${collectorProcess.pid})...`,
+ );
+
+ try {
+ await waitForPort(4317);
+ console.log(`โœ… OTEL collector started successfully.`);
+ } catch (_) {
+ console.error(`๐Ÿ›‘ Error: OTEL collector failed to start on port 4317.`);
+ if (collectorProcess && collectorProcess.pid) {
+ process.kill(collectorProcess.pid, 'SIGKILL');
+ }
+ if (fileExists(OTEL_LOG_FILE)) {
+ console.error('๐Ÿ“„ OTEL Collector Log Output:');
+ console.error(fs.readFileSync(OTEL_LOG_FILE, 'utf-8'));
+ }
+ process.exit(1);
+ }
+
+ [jaegerProcess, collectorProcess].forEach((proc) => {
+ proc.on('error', (err) => {
+ console.error(`${proc.spawnargs[0]} process error:`, err);
+ process.exit(1);
+ });
+ });
+
+ console.log(`\nโœจ Local telemetry environment is running.`);
+ console.log(
+ `\n๐Ÿ”Ž View traces in the Jaeger UI: http://localhost:${JAEGER_PORT}`,
+ );
+ console.log(`\nPress Ctrl+C to exit.`);
+}
+
+main();