Skip to content

Commit c210602

Browse files
committed
feat(core): include surface identifier in User-Agent header
Add the detected surface/IDE (e.g., vscode, cursor, terminal) to the User-Agent string sent with API requests. This allows enterprise customers to distinguish traffic from different clients (e.g., GCA Agent Mode vs standalone CLI) in their GCP logs. The surface is determined by a new shared utility that checks: 1. GEMINI_CLI_SURFACE env var (first-class enterprise override) 2. SURFACE env var (legacy, backward-compatible) 3. Auto-detection via existing detectIdeFromEnv() The clearcut-logger telemetry is also updated to use this shared utility, gaining GEMINI_CLI_SURFACE support. Closes #18007
1 parent a9500d6 commit c210602

File tree

6 files changed

+228
-29
lines changed

6 files changed

+228
-29
lines changed

packages/core/src/core/contentGenerator.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,80 @@ describe('createContentGenerator', () => {
152152
);
153153
});
154154

155+
it('should include surface in User-Agent from GEMINI_CLI_SURFACE env var', async () => {
156+
const mockConfig = {
157+
getModel: vi.fn().mockReturnValue('gemini-pro'),
158+
getProxy: vi.fn().mockReturnValue(undefined),
159+
getUsageStatisticsEnabled: () => true,
160+
} as unknown as Config;
161+
162+
vi.stubEnv('CLI_VERSION', '1.2.3');
163+
vi.stubEnv('GEMINI_CLI_SURFACE', 'my-custom-app');
164+
165+
const mockGenerator = {
166+
models: {},
167+
} as unknown as GoogleGenAI;
168+
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
169+
await createContentGenerator(
170+
{
171+
apiKey: 'test-api-key',
172+
authType: AuthType.USE_GEMINI,
173+
},
174+
mockConfig,
175+
);
176+
expect(GoogleGenAI).toHaveBeenCalledWith(
177+
expect.objectContaining({
178+
httpOptions: expect.objectContaining({
179+
headers: expect.objectContaining({
180+
'User-Agent': expect.stringMatching(
181+
/GeminiCLI\/1\.2\.3\/gemini-pro \(.+; .+; my-custom-app\)/,
182+
),
183+
}),
184+
}),
185+
}),
186+
);
187+
});
188+
189+
it('should include default surface "SURFACE_NOT_SET" in User-Agent when no surface is detected', async () => {
190+
const mockConfig = {
191+
getModel: vi.fn().mockReturnValue('gemini-pro'),
192+
getProxy: vi.fn().mockReturnValue(undefined),
193+
getUsageStatisticsEnabled: () => true,
194+
} as unknown as Config;
195+
196+
vi.stubEnv('CLI_VERSION', '1.2.3');
197+
// Ensure no surface env vars are set
198+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
199+
vi.stubEnv('SURFACE', '');
200+
vi.stubEnv('TERM_PROGRAM', '');
201+
vi.stubEnv('GITHUB_SHA', '');
202+
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', '');
203+
vi.stubEnv('CLOUD_SHELL', '');
204+
205+
const mockGenerator = {
206+
models: {},
207+
} as unknown as GoogleGenAI;
208+
vi.mocked(GoogleGenAI).mockImplementation(() => mockGenerator as never);
209+
await createContentGenerator(
210+
{
211+
apiKey: 'test-api-key',
212+
authType: AuthType.USE_GEMINI,
213+
},
214+
mockConfig,
215+
);
216+
expect(GoogleGenAI).toHaveBeenCalledWith(
217+
expect.objectContaining({
218+
httpOptions: expect.objectContaining({
219+
headers: expect.objectContaining({
220+
'User-Agent': expect.stringMatching(
221+
/GeminiCLI\/1\.2\.3\/gemini-pro \(.+; .+; SURFACE_NOT_SET\)/,
222+
),
223+
}),
224+
}),
225+
}),
226+
);
227+
});
228+
155229
it('should include custom headers from GEMINI_CLI_CUSTOM_HEADERS for Code Assist requests', async () => {
156230
const mockGenerator = {} as unknown as ContentGenerator;
157231
vi.mocked(createCodeAssistContentGenerator).mockResolvedValue(

packages/core/src/core/contentGenerator.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { LoggingContentGenerator } from './loggingContentGenerator.js';
2222
import { InstallationManager } from '../utils/installationManager.js';
2323
import { FakeContentGenerator } from './fakeContentGenerator.js';
2424
import { parseCustomHeaders } from '../utils/customHeaderUtils.js';
25+
import { determineSurface } from '../utils/surface.js';
2526
import { RecordingContentGenerator } from './recordingContentGenerator.js';
2627
import { getVersion, resolveModel } from '../../index.js';
2728
import type { LlmRole } from '../telemetry/llmRole.js';
@@ -173,7 +174,8 @@ export async function createContentGenerator(
173174
);
174175
const customHeadersEnv =
175176
process.env['GEMINI_CLI_CUSTOM_HEADERS'] || undefined;
176-
const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch})`;
177+
const surface = determineSurface();
178+
const userAgent = `GeminiCLI/${version}/${model} (${process.platform}; ${process.arch}; ${surface})`;
177179
const customHeadersMap = parseCustomHeaders(customHeadersEnv);
178180
const apiKeyAuthMechanism =
179181
process.env['GEMINI_API_KEY_AUTH_MECHANISM'] || 'x-goog-api-key';

packages/core/src/telemetry/clearcut-logger/clearcut-logger.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,13 +512,27 @@ describe('ClearcutLogger', () => {
512512
},
513513
expected: 'positron',
514514
},
515+
{
516+
name: 'GEMINI_CLI_SURFACE env var',
517+
env: { GEMINI_CLI_SURFACE: 'gca-agent' },
518+
expected: 'gca-agent',
519+
},
520+
{
521+
name: 'GEMINI_CLI_SURFACE takes precedence over SURFACE and TERM_PROGRAM',
522+
env: {
523+
GEMINI_CLI_SURFACE: 'gca-agent',
524+
SURFACE: 'ide-1234',
525+
TERM_PROGRAM: 'vscode',
526+
},
527+
expected: 'gca-agent',
528+
},
515529
{
516530
name: 'SURFACE env var',
517531
env: { SURFACE: 'ide-1234' },
518532
expected: 'ide-1234',
519533
},
520534
{
521-
name: 'SURFACE env var takes precedence',
535+
name: 'SURFACE env var takes precedence over auto-detection',
522536
env: { TERM_PROGRAM: 'vscode', SURFACE: 'ide-1234' },
523537
expected: 'ide-1234',
524538
},

packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,7 @@ import {
6262
import { ASK_USER_TOOL_NAME } from '../../tools/tool-names.js';
6363
import { FixedDeque } from 'mnemonist';
6464
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
65-
import {
66-
IDE_DEFINITIONS,
67-
detectIdeFromEnv,
68-
isCloudShell,
69-
} from '../../ide/detect-ide.js';
65+
import { determineSurface } from '../../utils/surface.js';
7066
import { debugLogger } from '../../utils/debugLogger.js';
7167
import { getErrorMessage } from '../../utils/errors.js';
7268

@@ -153,28 +149,8 @@ export interface LogRequest {
153149
log_event: LogEventEntry[][];
154150
}
155151

156-
/**
157-
* Determine the surface that the user is currently using. Surface is effectively the
158-
* distribution channel in which the user is using Gemini CLI. Gemini CLI comes bundled
159-
* w/ Firebase Studio and Cloud Shell. Users that manually download themselves will
160-
* likely be "SURFACE_NOT_SET".
161-
*
162-
* This is computed based upon a series of environment variables these distribution
163-
* methods might have in their runtimes.
164-
*/
165-
function determineSurface(): string {
166-
if (process.env['SURFACE']) {
167-
return process.env['SURFACE'];
168-
} else if (isCloudShell()) {
169-
return IDE_DEFINITIONS.cloudshell.name;
170-
} else if (process.env['GITHUB_SHA']) {
171-
return 'GitHub';
172-
} else if (process.env['TERM_PROGRAM'] === 'vscode') {
173-
return detectIdeFromEnv().name || IDE_DEFINITIONS.vscode.name;
174-
} else {
175-
return 'SURFACE_NOT_SET';
176-
}
177-
}
152+
// Surface detection is provided by the shared determineSurface() utility
153+
// imported from '../../utils/surface.js'.
178154

179155
/**
180156
* Determines the GitHub Actions workflow name if the CLI is running in a GitHub Actions environment.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { describe, it, expect, vi, afterEach } from 'vitest';
8+
import { determineSurface, SURFACE_NOT_SET } from './surface.js';
9+
10+
describe('determineSurface', () => {
11+
afterEach(() => {
12+
vi.unstubAllEnvs();
13+
});
14+
15+
it('should return GEMINI_CLI_SURFACE when set', () => {
16+
vi.stubEnv('GEMINI_CLI_SURFACE', 'my-custom-app');
17+
expect(determineSurface()).toBe('my-custom-app');
18+
});
19+
20+
it('should prioritize GEMINI_CLI_SURFACE over SURFACE', () => {
21+
vi.stubEnv('GEMINI_CLI_SURFACE', 'gca-agent');
22+
vi.stubEnv('SURFACE', 'ide-1234');
23+
expect(determineSurface()).toBe('gca-agent');
24+
});
25+
26+
it('should prioritize GEMINI_CLI_SURFACE over auto-detection', () => {
27+
vi.stubEnv('GEMINI_CLI_SURFACE', 'gca-agent');
28+
vi.stubEnv('TERM_PROGRAM', 'vscode');
29+
expect(determineSurface()).toBe('gca-agent');
30+
});
31+
32+
it('should fall back to SURFACE env var when GEMINI_CLI_SURFACE is not set', () => {
33+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
34+
vi.stubEnv('SURFACE', 'ide-1234');
35+
expect(determineSurface()).toBe('ide-1234');
36+
});
37+
38+
it('should detect Cloud Shell via CLOUD_SHELL env var', () => {
39+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
40+
vi.stubEnv('SURFACE', '');
41+
vi.stubEnv('CLOUD_SHELL', 'true');
42+
expect(determineSurface()).toBe('cloudshell');
43+
});
44+
45+
it('should detect Cloud Shell via EDITOR_IN_CLOUD_SHELL env var', () => {
46+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
47+
vi.stubEnv('SURFACE', '');
48+
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', 'true');
49+
expect(determineSurface()).toBe('cloudshell');
50+
});
51+
52+
it('should detect GitHub Actions via GITHUB_SHA env var', () => {
53+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
54+
vi.stubEnv('SURFACE', '');
55+
vi.stubEnv('GITHUB_SHA', 'abc123');
56+
expect(determineSurface()).toBe('GitHub');
57+
});
58+
59+
it('should detect VSCode via TERM_PROGRAM env var', () => {
60+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
61+
vi.stubEnv('SURFACE', '');
62+
vi.stubEnv('GITHUB_SHA', '');
63+
vi.stubEnv('TERM_PROGRAM', 'vscode');
64+
vi.stubEnv('CURSOR_TRACE_ID', '');
65+
vi.stubEnv('MONOSPACE_ENV', '');
66+
vi.stubEnv('POSITRON', '');
67+
vi.stubEnv('ANTIGRAVITY_CLI_ALIAS', '');
68+
vi.stubEnv('__COG_BASHRC_SOURCED', '');
69+
vi.stubEnv('REPLIT_USER', '');
70+
vi.stubEnv('CODESPACES', '');
71+
vi.stubEnv('CLOUD_SHELL', '');
72+
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', '');
73+
expect(determineSurface()).toBe('vscode');
74+
});
75+
76+
it('should detect Cursor when CURSOR_TRACE_ID is set', () => {
77+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
78+
vi.stubEnv('SURFACE', '');
79+
vi.stubEnv('GITHUB_SHA', '');
80+
vi.stubEnv('TERM_PROGRAM', 'vscode');
81+
vi.stubEnv('CURSOR_TRACE_ID', 'abc123');
82+
expect(determineSurface()).toBe('cursor');
83+
});
84+
85+
it('should return SURFACE_NOT_SET when no surface is detected', () => {
86+
vi.stubEnv('GEMINI_CLI_SURFACE', '');
87+
vi.stubEnv('SURFACE', '');
88+
vi.stubEnv('GITHUB_SHA', '');
89+
vi.stubEnv('TERM_PROGRAM', '');
90+
vi.stubEnv('CLOUD_SHELL', '');
91+
vi.stubEnv('EDITOR_IN_CLOUD_SHELL', '');
92+
expect(determineSurface()).toBe(SURFACE_NOT_SET);
93+
});
94+
});

packages/core/src/utils/surface.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import { detectIdeFromEnv, isCloudShell } from '../ide/detect-ide.js';
8+
9+
/** Default surface value when no IDE/environment is detected. */
10+
export const SURFACE_NOT_SET = 'SURFACE_NOT_SET';
11+
12+
/**
13+
* Determines the surface/distribution channel the CLI is running in.
14+
*
15+
* Priority:
16+
* 1. `GEMINI_CLI_SURFACE` env var (first-class override for enterprise customers)
17+
* 2. `SURFACE` env var (legacy override, kept for backward compatibility)
18+
* 3. Auto-detection via environment variables (Cloud Shell, GitHub Actions, IDE, etc.)
19+
*
20+
* @returns A human-readable surface identifier (e.g., "vscode", "cursor", "terminal").
21+
*/
22+
export function determineSurface(): string {
23+
if (process.env['GEMINI_CLI_SURFACE']) {
24+
return process.env['GEMINI_CLI_SURFACE'];
25+
}
26+
if (process.env['SURFACE']) {
27+
return process.env['SURFACE'];
28+
}
29+
if (isCloudShell()) {
30+
return 'cloudshell';
31+
}
32+
if (process.env['GITHUB_SHA']) {
33+
return 'GitHub';
34+
}
35+
if (process.env['TERM_PROGRAM'] === 'vscode') {
36+
return detectIdeFromEnv().name;
37+
}
38+
return SURFACE_NOT_SET;
39+
}

0 commit comments

Comments
 (0)