Skip to content

Commit b9b3b80

Browse files
Allow telemetry exporters to GCP to utilize user's login credentials, if requested (google-gemini#13778)
1 parent 92e95ed commit b9b3b80

26 files changed

+999
-433
lines changed

docs/cli/telemetry.md

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,15 +74,16 @@ observability framework — Gemini CLI's observability system provides:
7474
All telemetry behavior is controlled through your `.gemini/settings.json` file.
7575
Environment variables can be used to override the settings in the file.
7676

77-
| Setting | Environment Variable | Description | Values | Default |
78-
| -------------- | -------------------------------- | ------------------------------------------------- | ----------------- | ----------------------- |
79-
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
80-
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
81-
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
82-
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
83-
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
84-
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
85-
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
77+
| Setting | Environment Variable | Description | Values | Default |
78+
| -------------- | -------------------------------- | --------------------------------------------------- | ----------------- | ----------------------- |
79+
| `enabled` | `GEMINI_TELEMETRY_ENABLED` | Enable or disable telemetry | `true`/`false` | `false` |
80+
| `target` | `GEMINI_TELEMETRY_TARGET` | Where to send telemetry data | `"gcp"`/`"local"` | `"local"` |
81+
| `otlpEndpoint` | `GEMINI_TELEMETRY_OTLP_ENDPOINT` | OTLP collector endpoint | URL string | `http://localhost:4317` |
82+
| `otlpProtocol` | `GEMINI_TELEMETRY_OTLP_PROTOCOL` | OTLP transport protocol | `"grpc"`/`"http"` | `"grpc"` |
83+
| `outfile` | `GEMINI_TELEMETRY_OUTFILE` | Save telemetry to file (overrides `otlpEndpoint`) | file path | - |
84+
| `logPrompts` | `GEMINI_TELEMETRY_LOG_PROMPTS` | Include prompts in telemetry logs | `true`/`false` | `true` |
85+
| `useCollector` | `GEMINI_TELEMETRY_USE_COLLECTOR` | Use external OTLP collector (advanced) | `true`/`false` | `false` |
86+
| `useCliAuth` | `GEMINI_TELEMETRY_USE_CLI_AUTH` | Use CLI credentials for telemetry (GCP target only) | `true`/`false` | `false` |
8687

8788
**Note on boolean environment variables:** For the boolean settings (`enabled`,
8889
`logPrompts`, `useCollector`), setting the corresponding environment variable to
@@ -130,6 +131,34 @@ Before using either method below, complete these steps:
130131
--project="$OTLP_GOOGLE_CLOUD_PROJECT"
131132
```
132133

134+
### Authenticating with CLI Credentials
135+
136+
By default, the telemetry collector for Google Cloud uses Application Default
137+
Credentials (ADC). However, you can configure it to use the same OAuth
138+
credentials that you use to log in to the Gemini CLI. This is useful in
139+
environments where you don't have ADC set up.
140+
141+
To enable this, set the `useCliAuth` property in your `telemetry` settings to
142+
`true`:
143+
144+
```json
145+
{
146+
"telemetry": {
147+
"enabled": true,
148+
"target": "gcp",
149+
"useCliAuth": true
150+
}
151+
}
152+
```
153+
154+
**Important:**
155+
156+
- This setting requires the use of **Direct Export** (in-process exporters).
157+
- It **cannot** be used with `useCollector: true`. If you enable both, telemetry
158+
will be disabled and an error will be logged.
159+
- The CLI will automatically use your credentials to authenticate with Google
160+
Cloud Trace, Metrics, and Logging APIs.
161+
133162
### Direct export (recommended)
134163
135164
Sends telemetry directly to Google Cloud services. No collector needed.

packages/cli/src/ui/components/EditorSettingsDialog.test.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ import { KeypressProvider } from '../contexts/KeypressContext.js';
1313
import { act } from 'react';
1414
import { waitFor } from '../../test-utils/async.js';
1515

16+
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
17+
const actual =
18+
await importOriginal<typeof import('@google/gemini-cli-core')>();
19+
return {
20+
...actual,
21+
isEditorAvailable: () => true, // Mock to behave predictably in CI
22+
};
23+
});
24+
1625
// Mock editorSettingsManager
1726
vi.mock('../editors/editorSettingsManager.js', () => ({
1827
editorSettingsManager: {

packages/cli/src/ui/components/__snapshots__/EditorSettingsDialog.test.tsx.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ exports[`EditorSettingsDialog > renders correctly 1`] = `
88
│ 2. Vim These editors are currently supported. Please note │
99
│ that some editors cannot be used in sandbox mode. │
1010
│ Apply To │
11-
│ ● 1. User Settings Your preferred editor is: None.
11+
│ ● 1. User Settings Your preferred editor is: VS Code.
1212
│ 2. Workspace Settings │
1313
│ │
1414
│ (Use Enter to select, Tab to change │

packages/cli/src/ui/utils/terminalSetup.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ const mocks = vi.hoisted(() => ({
1616
copyFile: vi.fn(),
1717
homedir: vi.fn(),
1818
platform: vi.fn(),
19+
writeStream: {
20+
write: vi.fn(),
21+
on: vi.fn(),
22+
},
1923
}));
2024

2125
vi.mock('node:child_process', () => ({
@@ -24,6 +28,7 @@ vi.mock('node:child_process', () => ({
2428
}));
2529

2630
vi.mock('node:fs', () => ({
31+
createWriteStream: () => mocks.writeStream,
2732
promises: {
2833
mkdir: mocks.mkdir,
2934
readFile: mocks.readFile,

packages/core/src/code_assist/oauth2.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
resetOauthClientForTesting,
1313
clearCachedCredentialFile,
1414
clearOauthClientCache,
15+
authEvents,
1516
} from './oauth2.js';
1617
import { UserAccountManager } from '../utils/userAccountManager.js';
1718
import { OAuth2Client, Compute, GoogleAuth } from 'google-auth-library';
@@ -109,13 +110,18 @@ describe('oauth2', () => {
109110
const mockGetAccessToken = vi
110111
.fn()
111112
.mockResolvedValue({ token: 'mock-access-token' });
113+
let tokensListener: ((tokens: Credentials) => void) | undefined;
112114
const mockOAuth2Client = {
113115
generateAuthUrl: mockGenerateAuthUrl,
114116
getToken: mockGetToken,
115117
setCredentials: mockSetCredentials,
116118
getAccessToken: mockGetAccessToken,
117119
credentials: mockTokens,
118-
on: vi.fn(),
120+
on: vi.fn((event, listener) => {
121+
if (event === 'tokens') {
122+
tokensListener = listener;
123+
}
124+
}),
119125
} as unknown as OAuth2Client;
120126
vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client);
121127

@@ -195,6 +201,11 @@ describe('oauth2', () => {
195201
});
196202
expect(mockSetCredentials).toHaveBeenCalledWith(mockTokens);
197203

204+
// Manually trigger the 'tokens' event listener
205+
if (tokensListener) {
206+
await tokensListener(mockTokens);
207+
}
208+
198209
// Verify Google Account was cached
199210
const googleAccountPath = path.join(
200211
tempHomeDir,
@@ -215,6 +226,45 @@ describe('oauth2', () => {
215226
);
216227
});
217228

229+
it('should clear credentials file', async () => {
230+
// Setup initial state with files
231+
const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json');
232+
233+
await fs.promises.mkdir(path.dirname(credsPath), { recursive: true });
234+
await fs.promises.writeFile(credsPath, '{}');
235+
236+
await clearCachedCredentialFile();
237+
238+
expect(fs.existsSync(credsPath)).toBe(false);
239+
});
240+
241+
it('should emit post_auth event when loading cached credentials', async () => {
242+
const cachedCreds = { refresh_token: 'cached-token' };
243+
const credsPath = path.join(tempHomeDir, GEMINI_DIR, 'oauth_creds.json');
244+
await fs.promises.mkdir(path.dirname(credsPath), { recursive: true });
245+
await fs.promises.writeFile(credsPath, JSON.stringify(cachedCreds));
246+
247+
const mockClient = {
248+
setCredentials: vi.fn(),
249+
getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }),
250+
getTokenInfo: vi.fn().mockResolvedValue({}),
251+
on: vi.fn(),
252+
};
253+
vi.mocked(OAuth2Client).mockImplementation(
254+
() => mockClient as unknown as OAuth2Client,
255+
);
256+
257+
const eventPromise = new Promise<void>((resolve) => {
258+
authEvents.once('post_auth', (creds) => {
259+
expect(creds.refresh_token).toBe('cached-token');
260+
resolve();
261+
});
262+
});
263+
264+
await getOauthClient(AuthType.LOGIN_WITH_GOOGLE, mockConfig);
265+
await eventPromise;
266+
});
267+
218268
it('should perform login with user code', async () => {
219269
const mockConfigWithNoBrowser = {
220270
getNoBrowser: () => true,

packages/core/src/code_assist/oauth2.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import * as http from 'node:http';
1515
import url from 'node:url';
1616
import crypto from 'node:crypto';
1717
import * as net from 'node:net';
18+
import { EventEmitter } from 'node:events';
1819
import open from 'open';
1920
import path from 'node:path';
2021
import { promises as fs } from 'node:fs';
@@ -45,6 +46,22 @@ import {
4546
} from '../utils/terminal.js';
4647
import { coreEvents, CoreEvent } from '../utils/events.js';
4748

49+
export const authEvents = new EventEmitter();
50+
51+
async function triggerPostAuthCallbacks(tokens: Credentials) {
52+
// Construct a JWTInput object to pass to callbacks, as this is the
53+
// type expected by the downstream Google Cloud client libraries.
54+
const jwtInput: JWTInput = {
55+
client_id: OAUTH_CLIENT_ID,
56+
client_secret: OAUTH_CLIENT_SECRET,
57+
refresh_token: tokens.refresh_token ?? undefined, // Ensure null is not passed
58+
type: 'authorized_user',
59+
};
60+
61+
// Execute all registered post-authentication callbacks.
62+
authEvents.emit('post_auth', jwtInput);
63+
}
64+
4865
const userAccountManager = new UserAccountManager();
4966

5067
// OAuth Client ID used to initiate OAuth2Client class.
@@ -139,6 +156,8 @@ async function initOauthClient(
139156
} else {
140157
await cacheCredentials(tokens);
141158
}
159+
160+
await triggerPostAuthCallbacks(tokens);
142161
});
143162

144163
if (credentials) {
@@ -162,6 +181,8 @@ async function initOauthClient(
162181
}
163182
}
164183
debugLogger.log('Loaded cached credentials.');
184+
await triggerPostAuthCallbacks(credentials as Credentials);
185+
165186
return client;
166187
}
167188
} catch (error) {
@@ -570,19 +591,6 @@ async function fetchCachedCredentials(): Promise<
570591
return null;
571592
}
572593

573-
async function cacheCredentials(credentials: Credentials) {
574-
const filePath = Storage.getOAuthCredsPath();
575-
await fs.mkdir(path.dirname(filePath), { recursive: true });
576-
577-
const credString = JSON.stringify(credentials, null, 2);
578-
await fs.writeFile(filePath, credString, { mode: 0o600 });
579-
try {
580-
await fs.chmod(filePath, 0o600);
581-
} catch {
582-
/* empty */
583-
}
584-
}
585-
586594
export function clearOauthClientCache() {
587595
oauthClientPromises.clear();
588596
}
@@ -640,3 +648,16 @@ async function fetchAndCacheUserInfo(client: OAuth2Client): Promise<void> {
640648
export function resetOauthClientForTesting() {
641649
oauthClientPromises.clear();
642650
}
651+
652+
async function cacheCredentials(credentials: Credentials) {
653+
const filePath = Storage.getOAuthCredsPath();
654+
await fs.mkdir(path.dirname(filePath), { recursive: true });
655+
656+
const credString = JSON.stringify(credentials, null, 2);
657+
await fs.writeFile(filePath, credString, { mode: 0o600 });
658+
try {
659+
await fs.chmod(filePath, 0o600);
660+
} catch {
661+
/* empty */
662+
}
663+
}

packages/core/src/config/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export interface TelemetrySettings {
113113
logPrompts?: boolean;
114114
outfile?: string;
115115
useCollector?: boolean;
116+
useCliAuth?: boolean;
116117
}
117118

118119
export interface OutputSettings {
@@ -475,6 +476,7 @@ export class Config {
475476
logPrompts: params.telemetry?.logPrompts ?? true,
476477
outfile: params.telemetry?.outfile,
477478
useCollector: params.telemetry?.useCollector,
479+
useCliAuth: params.telemetry?.useCliAuth,
478480
};
479481
this.usageStatisticsEnabled = params.usageStatisticsEnabled ?? true;
480482

@@ -1067,6 +1069,10 @@ export class Config {
10671069
return this.telemetrySettings.useCollector ?? false;
10681070
}
10691071

1072+
getTelemetryUseCliAuth(): boolean {
1073+
return this.telemetrySettings.useCliAuth ?? false;
1074+
}
1075+
10701076
getGeminiClient(): GeminiClient {
10711077
return this.geminiClient;
10721078
}

packages/core/src/core/client.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ vi.mock('node:fs', () => {
6565
});
6666
}),
6767
existsSync: vi.fn((path: string) => mockFileSystem.has(path)),
68+
createWriteStream: vi.fn(() => ({
69+
write: vi.fn(),
70+
on: vi.fn(),
71+
})),
6872
};
6973

7074
return {

packages/core/src/core/geminiChat.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ vi.mock('node:fs', () => {
4848
});
4949
}),
5050
existsSync: vi.fn((path: string) => mockFileSystem.has(path)),
51+
createWriteStream: vi.fn(() => ({
52+
write: vi.fn(),
53+
on: vi.fn(),
54+
})),
5155
};
5256

5357
return {

packages/core/src/telemetry/config.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,37 @@ describe('telemetry/config helpers', () => {
120120
logPrompts: false,
121121
outfile: 'argv.log',
122122
useCollector: true, // from env as no argv option
123+
useCliAuth: undefined,
123124
});
124125
});
125126

127+
it('resolves useCliAuth from settings', async () => {
128+
const settings = {
129+
useCliAuth: true,
130+
};
131+
const resolved = await resolveTelemetrySettings({ settings });
132+
expect(resolved.useCliAuth).toBe(true);
133+
});
134+
135+
it('resolves useCliAuth from env', async () => {
136+
const env = {
137+
GEMINI_TELEMETRY_USE_CLI_AUTH: 'true',
138+
};
139+
const resolved = await resolveTelemetrySettings({ env });
140+
expect(resolved.useCliAuth).toBe(true);
141+
});
142+
143+
it('env overrides settings for useCliAuth', async () => {
144+
const settings = {
145+
useCliAuth: false,
146+
};
147+
const env = {
148+
GEMINI_TELEMETRY_USE_CLI_AUTH: 'true',
149+
};
150+
const resolved = await resolveTelemetrySettings({ env, settings });
151+
expect(resolved.useCliAuth).toBe(true);
152+
});
153+
126154
it('falls back to OTEL_EXPORTER_OTLP_ENDPOINT when GEMINI var is missing', async () => {
127155
const settings = {};
128156
const env = {

0 commit comments

Comments
 (0)