feat(core): set up onboarding telemetry (#23118)

Co-authored-by: Yuna Seol <yunaseol@google.com>
This commit is contained in:
Yuna Seol
2026-03-20 21:15:47 -04:00
committed by GitHub
parent fc03891a11
commit 244a608186
14 changed files with 390 additions and 36 deletions
+28
View File
@@ -904,6 +904,20 @@ Logs keychain availability checks.
- `available` (boolean) - `available` (boolean)
##### `gemini_cli.startup_stats`
Logs detailed startup performance statistics.
<details>
<summary>Attributes</summary>
- `phases` (json array of startup phases)
- `os_platform` (string)
- `os_release` (string)
- `is_docker` (boolean)
</details>
</details> </details>
### Metrics ### Metrics
@@ -920,6 +934,20 @@ Gemini CLI exports several custom metrics.
Incremented once per CLI startup. Incremented once per CLI startup.
##### Onboarding
Tracks onboarding flow from authentication to the user
- `gemini_cli.onboarding.start` (Counter, Int): Incremented when the
authentication flow begins.
- `gemini_cli.onboarding.success` (Counter, Int): Incremented when the user
onboarding flow completes successfully.
<details>
<summary>Attributes (Success)</summary>
- `user_tier` (string)
##### Tools ##### Tools
##### `gemini_cli.tool.call.count` ##### `gemini_cli.tool.call.count`
@@ -44,6 +44,7 @@ describe('codeAssist', () => {
projectId: 'test-project', projectId: 'test-project',
userTier: UserTierId.FREE, userTier: UserTierId.FREE,
userTierName: 'free-tier-name', userTierName: 'free-tier-name',
hasOnboardedPreviously: false,
}; };
it('should create a server for LOGIN_WITH_GOOGLE', async () => { it('should create a server for LOGIN_WITH_GOOGLE', async () => {
@@ -63,7 +64,7 @@ describe('codeAssist', () => {
); );
expect(setupUser).toHaveBeenCalledWith( expect(setupUser).toHaveBeenCalledWith(
mockAuthClient, mockAuthClient,
mockValidationHandler, mockConfig,
httpOptions, httpOptions,
); );
expect(MockedCodeAssistServer).toHaveBeenCalledWith( expect(MockedCodeAssistServer).toHaveBeenCalledWith(
@@ -95,7 +96,7 @@ describe('codeAssist', () => {
); );
expect(setupUser).toHaveBeenCalledWith( expect(setupUser).toHaveBeenCalledWith(
mockAuthClient, mockAuthClient,
mockValidationHandler, mockConfig,
httpOptions, httpOptions,
); );
expect(MockedCodeAssistServer).toHaveBeenCalledWith( expect(MockedCodeAssistServer).toHaveBeenCalledWith(
+1 -5
View File
@@ -22,11 +22,7 @@ export async function createCodeAssistContentGenerator(
authType === AuthType.COMPUTE_ADC authType === AuthType.COMPUTE_ADC
) { ) {
const authClient = await getOauthClient(authType, config); const authClient = await getOauthClient(authType, config);
const userData = await setupUser( const userData = await setupUser(authClient, config, httpOptions);
authClient,
config.getValidationHandler(),
httpOptions,
);
return new CodeAssistServer( return new CodeAssistServer(
authClient, authClient,
userData.projectId, userData.projectId,
+42 -22
View File
@@ -14,6 +14,7 @@ import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { CodeAssistServer } from '../code_assist/server.js'; import { CodeAssistServer } from '../code_assist/server.js';
import type { OAuth2Client } from 'google-auth-library'; import type { OAuth2Client } from 'google-auth-library';
import { UserTierId, type GeminiUserTier } from './types.js'; import { UserTierId, type GeminiUserTier } from './types.js';
import type { Config } from '../config/config.js';
vi.mock('../code_assist/server.js'); vi.mock('../code_assist/server.js');
@@ -35,6 +36,8 @@ describe('setupUser', () => {
let mockLoad: ReturnType<typeof vi.fn>; let mockLoad: ReturnType<typeof vi.fn>;
let mockOnboardUser: ReturnType<typeof vi.fn>; let mockOnboardUser: ReturnType<typeof vi.fn>;
let mockGetOperation: ReturnType<typeof vi.fn>; let mockGetOperation: ReturnType<typeof vi.fn>;
let mockConfig: Config;
let mockValidationHandler: ReturnType<typeof vi.fn>;
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
@@ -60,6 +63,18 @@ describe('setupUser', () => {
getOperation: mockGetOperation, getOperation: mockGetOperation,
}) as unknown as CodeAssistServer, }) as unknown as CodeAssistServer,
); );
mockValidationHandler = vi.fn();
mockConfig = {
getValidationHandler: () => mockValidationHandler,
getUsageStatisticsEnabled: () => true,
getSessionId: () => 'test-session-id',
getContentGeneratorConfig: () => ({
authType: 'google-login',
}),
isInteractive: () => false,
getExperiments: () => undefined,
} as unknown as Config;
}); });
afterEach(() => { afterEach(() => {
@@ -76,9 +91,9 @@ describe('setupUser', () => {
const client = {} as OAuth2Client; const client = {} as OAuth2Client;
// First call // First call
await setupUser(client); await setupUser(client, mockConfig);
// Second call // Second call
await setupUser(client); await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(1); expect(mockLoad).toHaveBeenCalledTimes(1);
}); });
@@ -91,10 +106,10 @@ describe('setupUser', () => {
const client = {} as OAuth2Client; const client = {} as OAuth2Client;
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p1'); vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p1');
await setupUser(client); await setupUser(client, mockConfig);
vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p2'); vi.stubEnv('GOOGLE_CLOUD_PROJECT', 'p2');
await setupUser(client); await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2); expect(mockLoad).toHaveBeenCalledTimes(2);
}); });
@@ -106,11 +121,11 @@ describe('setupUser', () => {
}); });
const client = {} as OAuth2Client; const client = {} as OAuth2Client;
await setupUser(client); await setupUser(client, mockConfig);
vi.advanceTimersByTime(31000); // 31s > 30s expiration vi.advanceTimersByTime(31000); // 31s > 30s expiration
await setupUser(client); await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2); expect(mockLoad).toHaveBeenCalledTimes(2);
}); });
@@ -123,8 +138,10 @@ describe('setupUser', () => {
}); });
const client = {} as OAuth2Client; const client = {} as OAuth2Client;
await expect(setupUser(client)).rejects.toThrow('Network error'); await expect(setupUser(client, mockConfig)).rejects.toThrow(
await setupUser(client); 'Network error',
);
await setupUser(client, mockConfig);
expect(mockLoad).toHaveBeenCalledTimes(2); expect(mockLoad).toHaveBeenCalledTimes(2);
}); });
@@ -136,7 +153,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({ mockLoad.mockResolvedValue({
currentTier: mockPaidTier, currentTier: mockPaidTier,
}); });
await setupUser({} as OAuth2Client); await setupUser({} as OAuth2Client, mockConfig);
expect(CodeAssistServer).toHaveBeenCalledWith( expect(CodeAssistServer).toHaveBeenCalledWith(
{}, {},
'test-project', 'test-project',
@@ -157,7 +174,7 @@ describe('setupUser', () => {
'User-Agent': 'GeminiCLI/1.0.0/gemini-2.0-flash (darwin; arm64)', 'User-Agent': 'GeminiCLI/1.0.0/gemini-2.0-flash (darwin; arm64)',
}, },
}; };
await setupUser({} as OAuth2Client, undefined, httpOptions); await setupUser({} as OAuth2Client, mockConfig, httpOptions);
expect(CodeAssistServer).toHaveBeenCalledWith( expect(CodeAssistServer).toHaveBeenCalledWith(
{}, {},
'test-project', 'test-project',
@@ -174,7 +191,7 @@ describe('setupUser', () => {
cloudaicompanionProject: 'server-project', cloudaicompanionProject: 'server-project',
currentTier: mockPaidTier, currentTier: mockPaidTier,
}); });
const result = await setupUser({} as OAuth2Client); const result = await setupUser({} as OAuth2Client, mockConfig);
expect(result.projectId).toBe('server-project'); expect(result.projectId).toBe('server-project');
}); });
@@ -185,7 +202,7 @@ describe('setupUser', () => {
throw new ProjectIdRequiredError(); throw new ProjectIdRequiredError();
}); });
await expect(setupUser({} as OAuth2Client)).rejects.toThrow( await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
ProjectIdRequiredError, ProjectIdRequiredError,
); );
}); });
@@ -197,7 +214,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({ mockLoad.mockResolvedValue({
allowedTiers: [mockPaidTier], allowedTiers: [mockPaidTier],
}); });
const userData = await setupUser({} as OAuth2Client); const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(mockOnboardUser).toHaveBeenCalledWith( expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tierId: UserTierId.STANDARD, tierId: UserTierId.STANDARD,
@@ -208,6 +225,7 @@ describe('setupUser', () => {
projectId: 'server-project', projectId: 'server-project',
userTier: UserTierId.STANDARD, userTier: UserTierId.STANDARD,
userTierName: 'paid', userTierName: 'paid',
hasOnboardedPreviously: false,
}); });
}); });
@@ -216,7 +234,7 @@ describe('setupUser', () => {
mockLoad.mockResolvedValue({ mockLoad.mockResolvedValue({
allowedTiers: [mockFreeTier], allowedTiers: [mockFreeTier],
}); });
const userData = await setupUser({} as OAuth2Client); const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(mockOnboardUser).toHaveBeenCalledWith( expect(mockOnboardUser).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
tierId: UserTierId.FREE, tierId: UserTierId.FREE,
@@ -227,6 +245,7 @@ describe('setupUser', () => {
projectId: 'server-project', projectId: 'server-project',
userTier: UserTierId.FREE, userTier: UserTierId.FREE,
userTierName: 'free', userTierName: 'free',
hasOnboardedPreviously: false,
}); });
}); });
@@ -241,11 +260,12 @@ describe('setupUser', () => {
cloudaicompanionProject: undefined, cloudaicompanionProject: undefined,
}, },
}); });
const userData = await setupUser({} as OAuth2Client); const userData = await setupUser({} as OAuth2Client, mockConfig);
expect(userData).toEqual({ expect(userData).toEqual({
projectId: 'test-project', projectId: 'test-project',
userTier: UserTierId.STANDARD, userTier: UserTierId.STANDARD,
userTierName: 'paid', userTierName: 'paid',
hasOnboardedPreviously: false,
}); });
}); });
@@ -276,7 +296,7 @@ describe('setupUser', () => {
}, },
}); });
const promise = setupUser({} as OAuth2Client); const promise = setupUser({} as OAuth2Client, mockConfig);
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
await vi.advanceTimersByTimeAsync(5000); await vi.advanceTimersByTimeAsync(5000);
@@ -308,10 +328,10 @@ describe('setupUser', () => {
cloudaicompanionProject: 'p1', cloudaicompanionProject: 'p1',
}); });
const mockHandler = vi.fn().mockResolvedValue('verify'); mockValidationHandler.mockResolvedValue('verify');
const result = await setupUser({} as OAuth2Client, mockHandler); const result = await setupUser({} as OAuth2Client, mockConfig);
expect(mockHandler).toHaveBeenCalledWith( expect(mockValidationHandler).toHaveBeenCalledWith(
'https://verify', 'https://verify',
'Verify please', 'Verify please',
); );
@@ -333,9 +353,9 @@ describe('setupUser', () => {
], ],
}); });
const mockHandler = vi.fn().mockResolvedValue('cancel'); mockValidationHandler.mockResolvedValue('cancel');
await expect(setupUser({} as OAuth2Client, mockHandler)).rejects.toThrow( await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
ValidationCancelledError, ValidationCancelledError,
); );
}); });
@@ -343,7 +363,7 @@ describe('setupUser', () => {
it('should throw error if LoadCodeAssist returns empty response', async () => { it('should throw error if LoadCodeAssist returns empty response', async () => {
mockLoad.mockResolvedValue(null); mockLoad.mockResolvedValue(null);
await expect(setupUser({} as OAuth2Client)).rejects.toThrow( await expect(setupUser({} as OAuth2Client, mockConfig)).rejects.toThrow(
'LoadCodeAssist returned empty response', 'LoadCodeAssist returned empty response',
); );
}); });
+26 -5
View File
@@ -15,11 +15,17 @@ import {
} from './types.js'; } from './types.js';
import { CodeAssistServer, type HttpOptions } from './server.js'; import { CodeAssistServer, type HttpOptions } from './server.js';
import type { AuthClient } from 'google-auth-library'; import type { AuthClient } from 'google-auth-library';
import type { ValidationHandler } from '../fallback/types.js';
import { ChangeAuthRequestedError } from '../utils/errors.js'; import { ChangeAuthRequestedError } from '../utils/errors.js';
import { ValidationRequiredError } from '../utils/googleQuotaErrors.js'; import { ValidationRequiredError } from '../utils/googleQuotaErrors.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { createCache, type CacheService } from '../utils/cache.js'; import { createCache, type CacheService } from '../utils/cache.js';
import type { Config } from '../config/config.js';
import {
logOnboardingStart,
logOnboardingSuccess,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../telemetry/index.js';
export class ProjectIdRequiredError extends Error { export class ProjectIdRequiredError extends Error {
constructor() { constructor() {
@@ -54,6 +60,7 @@ export interface UserData {
userTier: UserTierId; userTier: UserTierId;
userTierName?: string; userTierName?: string;
paidTier?: GeminiUserTier; paidTier?: GeminiUserTier;
hasOnboardedPreviously?: boolean;
} }
// Cache to store the results of setupUser to avoid redundant network calls. // Cache to store the results of setupUser to avoid redundant network calls.
@@ -94,7 +101,8 @@ export function resetUserDataCacheForTesting() {
* retry, auth change, or cancellation. * retry, auth change, or cancellation.
* *
* @param client - The authenticated client to use for API calls * @param client - The authenticated client to use for API calls
* @param validationHandler - Optional handler for account validation flow * @param config - The CLI configuration
* @param httpOptions - Optional HTTP options
* @returns The user's project ID, tier ID, and tier name * @returns The user's project ID, tier ID, and tier name
* @throws {ValidationRequiredError} If account validation is required * @throws {ValidationRequiredError} If account validation is required
* @throws {ProjectIdRequiredError} If no project ID is available and required * @throws {ProjectIdRequiredError} If no project ID is available and required
@@ -103,7 +111,7 @@ export function resetUserDataCacheForTesting() {
*/ */
export async function setupUser( export async function setupUser(
client: AuthClient, client: AuthClient,
validationHandler?: ValidationHandler, config: Config,
httpOptions: HttpOptions = {}, httpOptions: HttpOptions = {},
): Promise<UserData> { ): Promise<UserData> {
const projectId = const projectId =
@@ -119,7 +127,7 @@ export async function setupUser(
); );
return projectCache.getOrCreate(projectId, () => return projectCache.getOrCreate(projectId, () =>
_doSetupUser(client, projectId, validationHandler, httpOptions), _doSetupUser(client, projectId, config, httpOptions),
); );
} }
@@ -129,7 +137,7 @@ export async function setupUser(
async function _doSetupUser( async function _doSetupUser(
client: AuthClient, client: AuthClient,
projectId: string | undefined, projectId: string | undefined,
validationHandler?: ValidationHandler, config: Config,
httpOptions: HttpOptions = {}, httpOptions: HttpOptions = {},
): Promise<UserData> { ): Promise<UserData> {
const caServer = new CodeAssistServer( const caServer = new CodeAssistServer(
@@ -146,6 +154,8 @@ async function _doSetupUser(
pluginType: 'GEMINI', pluginType: 'GEMINI',
}; };
const validationHandler = config.getValidationHandler();
let loadRes: LoadCodeAssistResponse; let loadRes: LoadCodeAssistResponse;
while (true) { while (true) {
loadRes = await caServer.loadCodeAssist({ loadRes = await caServer.loadCodeAssist({
@@ -194,6 +204,8 @@ async function _doSetupUser(
UserTierId.STANDARD, UserTierId.STANDARD,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name, userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
paidTier: loadRes.paidTier ?? undefined, paidTier: loadRes.paidTier ?? undefined,
hasOnboardedPreviously:
loadRes.currentTier.hasOnboardedPreviously ?? true,
}; };
} }
@@ -206,6 +218,8 @@ async function _doSetupUser(
loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD, loadRes.paidTier?.id ?? loadRes.currentTier.id ?? UserTierId.STANDARD,
userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name, userTierName: loadRes.paidTier?.name ?? loadRes.currentTier.name,
paidTier: loadRes.paidTier ?? undefined, paidTier: loadRes.paidTier ?? undefined,
hasOnboardedPreviously:
loadRes.currentTier.hasOnboardedPreviously ?? true,
}; };
} }
@@ -236,6 +250,8 @@ async function _doSetupUser(
}; };
} }
logOnboardingStart(config, new OnboardingStartEvent());
let lroRes = await caServer.onboardUser(onboardReq); let lroRes = await caServer.onboardUser(onboardReq);
if (!lroRes.done && lroRes.name) { if (!lroRes.done && lroRes.name) {
const operationName = lroRes.name; const operationName = lroRes.name;
@@ -245,12 +261,16 @@ async function _doSetupUser(
} }
} }
const userTier = tier.id ?? UserTierId.STANDARD;
logOnboardingSuccess(config, new OnboardingSuccessEvent(userTier));
if (!lroRes.response?.cloudaicompanionProject?.id) { if (!lroRes.response?.cloudaicompanionProject?.id) {
if (projectId) { if (projectId) {
return { return {
projectId, projectId,
userTier: tier.id ?? UserTierId.STANDARD, userTier: tier.id ?? UserTierId.STANDARD,
userTierName: tier.name, userTierName: tier.name,
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
}; };
} }
@@ -261,6 +281,7 @@ async function _doSetupUser(
projectId: lroRes.response.cloudaicompanionProject.id, projectId: lroRes.response.cloudaicompanionProject.id,
userTier: tier.id ?? UserTierId.STANDARD, userTier: tier.id ?? UserTierId.STANDARD,
userTierName: tier.name, userTierName: tier.name,
hasOnboardedPreviously: tier.hasOnboardedPreviously ?? false,
}; };
} }
@@ -41,6 +41,8 @@ import {
AgentFinishEvent, AgentFinishEvent,
WebFetchFallbackAttemptEvent, WebFetchFallbackAttemptEvent,
HookCallEvent, HookCallEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../types.js'; } from '../types.js';
import { HookType } from '../../hooks/types.js'; import { HookType } from '../../hooks/types.js';
import { AgentTerminateMode } from '../../agents/types.js'; import { AgentTerminateMode } from '../../agents/types.js';
@@ -1652,4 +1654,38 @@ describe('ClearcutLogger', () => {
]); ]);
}); });
}); });
describe('logOnboardingStartEvent', () => {
it('logs an event with proper name and start key', () => {
const { logger } = setup();
const event = new OnboardingStartEvent();
logger?.logOnboardingStartEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_START);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
'true',
]);
});
});
describe('logOnboardingSuccessEvent', () => {
it('logs an event with proper name and user tier', () => {
const { logger } = setup();
const event = new OnboardingSuccessEvent('standard-tier');
logger?.logOnboardingSuccessEvent(event);
const events = getEvents(logger!);
expect(events.length).toBe(1);
expect(events[0]).toHaveEventName(EventNames.ONBOARDING_SUCCESS);
expect(events[0]).toHaveMetadataValue([
EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
'standard-tier',
]);
});
});
}); });
@@ -51,6 +51,8 @@ import type {
KeychainAvailabilityEvent, KeychainAvailabilityEvent,
TokenStorageInitializationEvent, TokenStorageInitializationEvent,
StartupStatsEvent, StartupStatsEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
} from '../types.js'; } from '../types.js';
import type { import type {
CreditsUsedEvent, CreditsUsedEvent,
@@ -124,6 +126,8 @@ export enum EventNames {
TOOL_OUTPUT_MASKING = 'tool_output_masking', TOOL_OUTPUT_MASKING = 'tool_output_masking',
KEYCHAIN_AVAILABILITY = 'keychain_availability', KEYCHAIN_AVAILABILITY = 'keychain_availability',
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization', TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
ONBOARDING_START = 'onboarding_start',
ONBOARDING_SUCCESS = 'onboarding_success',
CONSECA_POLICY_GENERATION = 'conseca_policy_generation', CONSECA_POLICY_GENERATION = 'conseca_policy_generation',
CONSECA_VERDICT = 'conseca_verdict', CONSECA_VERDICT = 'conseca_verdict',
STARTUP_STATS = 'startup_stats', STARTUP_STATS = 'startup_stats',
@@ -1796,6 +1800,33 @@ export class ClearcutLogger {
this.flushIfNeeded(); this.flushIfNeeded();
} }
logOnboardingStartEvent(_event: OnboardingStartEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_START,
value: 'true',
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_START, data),
);
this.flushIfNeeded();
}
logOnboardingSuccessEvent(event: OnboardingSuccessEvent): void {
const data: EventValue[] = [];
if (event.userTier) {
data.push({
gemini_cli_key: EventMetadataKey.GEMINI_CLI_ONBOARDING_USER_TIER,
value: event.userTier,
});
}
this.enqueueLogEvent(
this.createLogEvent(EventNames.ONBOARDING_SUCCESS, data),
);
this.flushIfNeeded();
}
logStartupStatsEvent(event: StartupStatsEvent): void { logStartupStatsEvent(event: StartupStatsEvent): void {
const data: EventValue[] = [ const data: EventValue[] = [
{ {
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging. // Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey { export enum EventMetadataKey {
// Deleted enums: 24 // Deleted enums: 24
// Next ID: 191 // Next ID: 194
GEMINI_CLI_KEY_UNKNOWN = 0, GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -712,4 +712,14 @@ export enum EventMetadataKey {
// Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage). // Logs the source of a credit purchase click (e.g. overage_menu, empty_wallet_menu, manage).
GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190, GEMINI_CLI_BILLING_PURCHASE_SOURCE = 190,
// ==========================================================================
// Gemini Enterprise (GE) Event Keys
// ==========================================================================
// Logs the start of the onboarding process.
GEMINI_CLI_ONBOARDING_START = 192,
// Logs the user tier for onboarding success events.
GEMINI_CLI_ONBOARDING_USER_TIER = 193,
} }
+4
View File
@@ -48,6 +48,8 @@ export {
logWebFetchFallbackAttempt, logWebFetchFallbackAttempt,
logNetworkRetryAttempt, logNetworkRetryAttempt,
logRewind, logRewind,
logOnboardingStart,
logOnboardingSuccess,
} from './loggers.js'; } from './loggers.js';
export { export {
logConsecaPolicyGeneration, logConsecaPolicyGeneration,
@@ -70,6 +72,8 @@ export {
NetworkRetryAttemptEvent, NetworkRetryAttemptEvent,
ToolCallDecision, ToolCallDecision,
RewindEvent, RewindEvent,
OnboardingStartEvent,
OnboardingSuccessEvent,
ConsecaPolicyGenerationEvent, ConsecaPolicyGenerationEvent,
ConsecaVerdictEvent, ConsecaVerdictEvent,
} from './types.js'; } from './types.js';
@@ -48,6 +48,8 @@ import {
logNetworkRetryAttempt, logNetworkRetryAttempt,
logExtensionUpdateEvent, logExtensionUpdateEvent,
logHookCall, logHookCall,
logOnboardingStart,
logOnboardingSuccess,
} from './loggers.js'; } from './loggers.js';
import { ToolCallDecision } from './tool-call-decision.js'; import { ToolCallDecision } from './tool-call-decision.js';
import { import {
@@ -72,6 +74,8 @@ import {
EVENT_WEB_FETCH_FALLBACK_ATTEMPT, EVENT_WEB_FETCH_FALLBACK_ATTEMPT,
EVENT_INVALID_CHUNK, EVENT_INVALID_CHUNK,
EVENT_NETWORK_RETRY_ATTEMPT, EVENT_NETWORK_RETRY_ATTEMPT,
EVENT_ONBOARDING_START,
EVENT_ONBOARDING_SUCCESS,
ApiErrorEvent, ApiErrorEvent,
ApiRequestEvent, ApiRequestEvent,
ApiResponseEvent, ApiResponseEvent,
@@ -98,6 +102,8 @@ import {
EVENT_EXTENSION_UPDATE, EVENT_EXTENSION_UPDATE,
HookCallEvent, HookCallEvent,
EVENT_HOOK_CALL, EVENT_HOOK_CALL,
OnboardingStartEvent,
OnboardingSuccessEvent,
LlmRole, LlmRole,
} from './types.js'; } from './types.js';
import { HookType } from '../hooks/types.js'; import { HookType } from '../hooks/types.js';
@@ -2508,6 +2514,76 @@ describe('loggers', () => {
}); });
}); });
describe('logOnboardingStart', () => {
const mockConfig = makeFakeConfig();
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingStartEvent');
vi.spyOn(metrics, 'recordOnboardingStart');
});
it('should log onboarding start event to Clearcut and OTEL, and record metrics', () => {
const event = new OnboardingStartEvent();
logOnboardingStart(mockConfig, event);
expect(
ClearcutLogger.prototype.logOnboardingStartEvent,
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Onboarding started.',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'installation.id': 'test-installation-id',
'event.name': EVENT_ONBOARDING_START,
'event.timestamp': '2025-01-01T00:00:00.000Z',
interactive: false,
},
});
expect(metrics.recordOnboardingStart).toHaveBeenCalledWith(mockConfig);
});
});
describe('logOnboardingSuccess', () => {
const mockConfig = makeFakeConfig();
beforeEach(() => {
vi.spyOn(ClearcutLogger.prototype, 'logOnboardingSuccessEvent');
vi.spyOn(metrics, 'recordOnboardingSuccess');
});
it('should log onboarding success event to Clearcut and OTEL, and record metrics', () => {
const event = new OnboardingSuccessEvent('standard-tier');
logOnboardingSuccess(mockConfig, event);
expect(
ClearcutLogger.prototype.logOnboardingSuccessEvent,
).toHaveBeenCalledWith(event);
expect(mockLogger.emit).toHaveBeenCalledWith({
body: 'Onboarding succeeded. Tier: standard-tier',
attributes: {
'session.id': 'test-session-id',
'user.email': 'test-user@example.com',
'installation.id': 'test-installation-id',
'event.name': EVENT_ONBOARDING_SUCCESS,
'event.timestamp': '2025-01-01T00:00:00.000Z',
interactive: false,
user_tier: 'standard-tier',
},
});
expect(metrics.recordOnboardingSuccess).toHaveBeenCalledWith(
mockConfig,
'standard-tier',
);
});
});
describe('Telemetry Buffering', () => { describe('Telemetry Buffering', () => {
it('should buffer events when SDK is not initialized', async () => { it('should buffer events when SDK is not initialized', async () => {
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false); vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
+38
View File
@@ -57,6 +57,8 @@ import {
type ToolOutputMaskingEvent, type ToolOutputMaskingEvent,
type KeychainAvailabilityEvent, type KeychainAvailabilityEvent,
type TokenStorageInitializationEvent, type TokenStorageInitializationEvent,
type OnboardingStartEvent,
type OnboardingSuccessEvent,
} from './types.js'; } from './types.js';
import { import {
recordApiErrorMetrics, recordApiErrorMetrics,
@@ -79,6 +81,8 @@ import {
recordKeychainAvailability, recordKeychainAvailability,
recordTokenStorageInitialization, recordTokenStorageInitialization,
recordInvalidChunk, recordInvalidChunk,
recordOnboardingStart,
recordOnboardingSuccess,
} from './metrics.js'; } from './metrics.js';
import { bufferTelemetryEvent } from './sdk.js'; import { bufferTelemetryEvent } from './sdk.js';
import { uiTelemetryService, type UiEvent } from './uiTelemetry.js'; import { uiTelemetryService, type UiEvent } from './uiTelemetry.js';
@@ -871,6 +875,40 @@ export function logTokenStorageInitialization(
}); });
} }
export function logOnboardingStart(
config: Config,
event: OnboardingStartEvent,
): void {
ClearcutLogger.getInstance(config)?.logOnboardingStartEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordOnboardingStart(config);
});
}
export function logOnboardingSuccess(
config: Config,
event: OnboardingSuccessEvent,
): void {
ClearcutLogger.getInstance(config)?.logOnboardingSuccessEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordOnboardingSuccess(config, event.userTier);
});
}
export function logBillingEvent( export function logBillingEvent(
config: Config, config: Config,
event: BillingTelemetryEvent, event: BillingTelemetryEvent,
+43
View File
@@ -51,6 +51,8 @@ const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count'; const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count'; const OVERAGE_OPTION_COUNT = 'gemini_cli.overage_option.count';
const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count'; const CREDIT_PURCHASE_COUNT = 'gemini_cli.credit_purchase.count';
const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
// Agent Metrics // Agent Metrics
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count'; const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
@@ -299,6 +301,20 @@ const COUNTER_DEFINITIONS = {
model: string; model: string;
}, },
}, },
[EVENT_ONBOARDING_START]: {
description: 'Counts onboarding started',
valueType: ValueType.INT,
assign: (c: Counter) => (onboardingStartCounter = c),
attributes: {} as Record<string, never>,
},
[EVENT_ONBOARDING_SUCCESS]: {
description: 'Counts onboarding succeeded',
valueType: ValueType.INT,
assign: (c: Counter) => (onboardingSuccessCounter = c),
attributes: {} as {
user_tier?: string;
},
},
} as const; } as const;
const HISTOGRAM_DEFINITIONS = { const HISTOGRAM_DEFINITIONS = {
@@ -640,6 +656,8 @@ let keychainAvailabilityCounter: Counter | undefined;
let tokenStorageTypeCounter: Counter | undefined; let tokenStorageTypeCounter: Counter | undefined;
let overageOptionCounter: Counter | undefined; let overageOptionCounter: Counter | undefined;
let creditPurchaseCounter: Counter | undefined; let creditPurchaseCounter: Counter | undefined;
let onboardingStartCounter: Counter | undefined;
let onboardingSuccessCounter: Counter | undefined;
// OpenTelemetry GenAI Semantic Convention Metrics // OpenTelemetry GenAI Semantic Convention Metrics
let genAiClientTokenUsageHistogram: Histogram | undefined; let genAiClientTokenUsageHistogram: Histogram | undefined;
@@ -812,6 +830,31 @@ export function recordLinesChanged(
// --- New Metric Recording Functions --- // --- New Metric Recording Functions ---
/**
* Records a metric for when the Google auth process starts.
*/
export function recordOnboardingStart(config: Config): void {
if (!onboardingStartCounter || !isMetricsInitialized) return;
onboardingStartCounter.add(
1,
baseMetricDefinition.getCommonAttributes(config),
);
}
/**
* Records a metric for when the Google auth process ends successfully.
*/
export function recordOnboardingSuccess(
config: Config,
userTier?: string,
): void {
if (!onboardingSuccessCounter || !isMetricsInitialized) return;
onboardingSuccessCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
...(userTier && { user_tier: userTier }),
});
}
/** /**
* Records a metric for when a UI frame flickers. * Records a metric for when a UI frame flickers.
*/ */
+1 -1
View File
@@ -344,9 +344,9 @@ export async function initializeTelemetry(
if (config.getDebugMode()) { if (config.getDebugMode()) {
debugLogger.log('OpenTelemetry SDK started successfully.'); debugLogger.log('OpenTelemetry SDK started successfully.');
} }
telemetryInitialized = true;
activeTelemetryEmail = credentials?.client_email; activeTelemetryEmail = credentials?.client_email;
initializeMetrics(config); initializeMetrics(config);
telemetryInitialized = true;
void flushTelemetryBuffer(); void flushTelemetryBuffer();
} catch (error) { } catch (error) {
debugLogger.error('Error starting OpenTelemetry SDK:', error); debugLogger.error('Error starting OpenTelemetry SDK:', error);
+50
View File
@@ -44,6 +44,7 @@ import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
import { LlmRole } from './llmRole.js'; import { LlmRole } from './llmRole.js';
export { LlmRole }; export { LlmRole };
import type { HookType } from '../hooks/types.js'; import type { HookType } from '../hooks/types.js';
import type { UserTierId } from '../code_assist/types.js';
export interface BaseTelemetryEvent { export interface BaseTelemetryEvent {
'event.name': string; 'event.name': string;
@@ -2360,6 +2361,55 @@ export class KeychainAvailabilityEvent implements BaseTelemetryEvent {
} }
} }
export const EVENT_ONBOARDING_START = 'gemini_cli.onboarding.start';
export class OnboardingStartEvent implements BaseTelemetryEvent {
'event.name': 'onboarding_start';
'event.timestamp': string;
constructor() {
this['event.name'] = 'onboarding_start';
this['event.timestamp'] = new Date().toISOString();
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_ONBOARDING_START,
'event.timestamp': this['event.timestamp'],
};
}
toLogBody(): string {
return 'Onboarding started.';
}
}
export const EVENT_ONBOARDING_SUCCESS = 'gemini_cli.onboarding.success';
export class OnboardingSuccessEvent implements BaseTelemetryEvent {
'event.name': 'onboarding_success';
'event.timestamp': string;
userTier?: UserTierId;
constructor(userTier?: UserTierId) {
this['event.name'] = 'onboarding_success';
this['event.timestamp'] = new Date().toISOString();
this.userTier = userTier;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_ONBOARDING_SUCCESS,
'event.timestamp': this['event.timestamp'],
user_tier: this.userTier ?? '',
};
}
toLogBody(): string {
return `Onboarding succeeded.${this.userTier ? ` Tier: ${this.userTier}` : ''}`;
}
}
export const EVENT_TOKEN_STORAGE_INITIALIZATION = export const EVENT_TOKEN_STORAGE_INITIALIZATION =
'gemini_cli.token_storage.initialization'; 'gemini_cli.token_storage.initialization';
export class TokenStorageInitializationEvent implements BaseTelemetryEvent { export class TokenStorageInitializationEvent implements BaseTelemetryEvent {