feat(telemetry): add keychain availability and token storage metrics (#18971)

This commit is contained in:
Abhi
2026-02-18 00:11:38 +09:00
committed by GitHub
parent bbf6800778
commit bf9ca33c18
13 changed files with 388 additions and 8 deletions

View File

@@ -79,6 +79,14 @@ vi.mock('./oauth-credential-storage.js', () => ({
},
}));
vi.mock('../mcp/token-storage/hybrid-token-storage.js', () => ({
HybridTokenStorage: vi.fn(() => ({
getCredentials: vi.fn(),
setCredentials: vi.fn(),
deleteCredentials: vi.fn(),
})),
}));
const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',

View File

@@ -22,6 +22,20 @@ vi.mock('./keychain-token-storage.js', () => ({
})),
}));
vi.mock('../../code_assist/oauth-credential-storage.js', () => ({
OAuthCredentialStorage: {
saveCredentials: vi.fn(),
loadCredentials: vi.fn(),
clearCredentials: vi.fn(),
},
}));
vi.mock('../../core/apiKeyCredentialStorage.js', () => ({
loadApiKey: vi.fn(),
saveApiKey: vi.fn(),
clearApiKey: vi.fn(),
}));
vi.mock('./file-token-storage.js', () => ({
FileTokenStorage: vi.fn().mockImplementation(() => ({
getCredentials: vi.fn(),

View File

@@ -8,6 +8,8 @@ import { BaseTokenStorage } from './base-token-storage.js';
import { FileTokenStorage } from './file-token-storage.js';
import type { TokenStorage, OAuthCredentials } from './types.js';
import { TokenStorageType } from './types.js';
import { coreEvents } from '../../utils/events.js';
import { TokenStorageInitializationEvent } from '../../telemetry/types.js';
const FORCE_FILE_STORAGE_ENV_VAR = 'GEMINI_FORCE_FILE_STORAGE';
@@ -34,6 +36,11 @@ export class HybridTokenStorage extends BaseTokenStorage {
if (isAvailable) {
this.storage = keychainStorage;
this.storageType = TokenStorageType.KEYCHAIN;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('keychain', forceFileStorage),
);
return this.storage;
}
} catch (_e) {
@@ -43,6 +50,11 @@ export class HybridTokenStorage extends BaseTokenStorage {
this.storage = new FileTokenStorage(this.serviceName);
this.storageType = TokenStorageType.ENCRYPTED_FILE;
coreEvents.emitTelemetryTokenStorageType(
new TokenStorageInitializationEvent('encrypted_file', forceFileStorage),
);
return this.storage;
}

View File

@@ -25,15 +25,20 @@ vi.mock('keytar', () => ({
default: mockKeytar,
}));
vi.mock('node:crypto', () => ({
randomBytes: vi.fn(() => ({
toString: vi.fn(() => mockCryptoRandomBytesString),
})),
}));
vi.mock('node:crypto', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:crypto')>();
return {
...actual,
randomBytes: vi.fn(() => ({
toString: vi.fn(() => mockCryptoRandomBytesString),
})),
};
});
vi.mock('../../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
emitTelemetryKeychainAvailability: vi.fn(),
},
}));

View File

@@ -8,6 +8,7 @@ import * as crypto from 'node:crypto';
import { BaseTokenStorage } from './base-token-storage.js';
import type { OAuthCredentials, SecretStorage } from './types.js';
import { coreEvents } from '../../utils/events.js';
import { KeychainAvailabilityEvent } from '../../telemetry/types.js';
interface Keytar {
getPassword(service: string, account: string): Promise<string | null>;
@@ -263,9 +264,21 @@ export class KeychainTokenStorage
const success = deleted && retrieved === testPassword;
this.keychainAvailable = success;
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(success),
);
return success;
} catch (_error) {
this.keychainAvailable = false;
// Do not log the raw error message to avoid potential PII leaks
// (e.g. from OS-level error messages containing file paths)
coreEvents.emitTelemetryKeychainAvailability(
new KeychainAvailabilityEvent(false),
);
return false;
}
}

View File

@@ -47,6 +47,8 @@ import type {
ApprovalModeDurationEvent,
PlanExecutionEvent,
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from '../types.js';
import { EventMetadataKey } from './event-metadata-key.js';
import type { Config } from '../../config/config.js';
@@ -111,6 +113,8 @@ export enum EventNames {
APPROVAL_MODE_DURATION = 'approval_mode_duration',
PLAN_EXECUTION = 'plan_execution',
TOOL_OUTPUT_MASKING = 'tool_output_masking',
KEYCHAIN_AVAILABILITY = 'keychain_availability',
TOKEN_STORAGE_INITIALIZATION = 'token_storage_initialization',
}
export interface LogResponse {
@@ -1613,6 +1617,40 @@ export class ClearcutLogger {
this.flushIfNeeded();
}
logKeychainAvailabilityEvent(event: KeychainAvailabilityEvent): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_KEYCHAIN_AVAILABLE,
value: JSON.stringify(event.available),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.KEYCHAIN_AVAILABILITY, data),
);
this.flushIfNeeded();
}
logTokenStorageInitializationEvent(
event: TokenStorageInitializationEvent,
): void {
const data: EventValue[] = [
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOKEN_STORAGE_TYPE,
value: event.type,
},
{
gemini_cli_key: EventMetadataKey.GEMINI_CLI_TOKEN_STORAGE_FORCED,
value: JSON.stringify(event.forced),
},
];
this.enqueueLogEvent(
this.createLogEvent(EventNames.TOKEN_STORAGE_INITIALIZATION, data),
);
this.flushIfNeeded();
}
/**
* Adds default fields to data, and returns a new data array. This fields
* should exist on all log events.

View File

@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 156
// Next ID: 159
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -578,7 +578,6 @@ export enum EventMetadataKey {
// Logs the total prunable tokens identified at the trigger point.
GEMINI_CLI_TOOL_OUTPUT_MASKING_TOTAL_PRUNABLE_TOKENS = 151,
// ==========================================================================
// Ask User Stats Event Keys
// ==========================================================================
@@ -593,4 +592,17 @@ export enum EventMetadataKey {
// Logs the number of questions answered in the ask_user tool.
GEMINI_CLI_ASK_USER_ANSWER_COUNT = 155,
// ==========================================================================
// Keychain & Token Storage Event Keys
// ==========================================================================
// Logs whether the keychain is available.
GEMINI_CLI_KEYCHAIN_AVAILABLE = 156,
// Logs the type of token storage initialized.
GEMINI_CLI_TOKEN_STORAGE_TYPE = 157,
// Logs whether the token storage type was forced by an environment variable.
GEMINI_CLI_TOKEN_STORAGE_FORCED = 158,
}

View File

@@ -57,6 +57,8 @@ import type {
LlmLoopCheckEvent,
PlanExecutionEvent,
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import {
recordApiErrorMetrics,
@@ -76,6 +78,8 @@ import {
recordLinesChanged,
recordHookCallMetrics,
recordPlanExecution,
recordKeychainAvailability,
recordTokenStorageInitialization,
} from './metrics.js';
import { bufferTelemetryEvent } from './sdk.js';
import type { UiEvent } from './uiTelemetry.js';
@@ -805,3 +809,37 @@ export function logStartupStats(
});
});
}
export function logKeychainAvailability(
config: Config,
event: KeychainAvailabilityEvent,
): void {
ClearcutLogger.getInstance(config)?.logKeychainAvailabilityEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordKeychainAvailability(config, event);
});
}
export function logTokenStorageInitialization(
config: Config,
event: TokenStorageInitializationEvent,
): void {
ClearcutLogger.getInstance(config)?.logTokenStorageInitializationEvent(event);
bufferTelemetryEvent(() => {
const logger = logs.getLogger(SERVICE_NAME);
const logRecord: LogRecord = {
body: event.toLogBody(),
attributes: event.toOpenTelemetryAttributes(config),
};
logger.emit(logRecord);
recordTokenStorageInitialization(config, event);
});
}

View File

@@ -20,7 +20,12 @@ import {
ApiRequestPhase,
} from './metrics.js';
import { makeFakeConfig } from '../test-utils/config.js';
import { ModelRoutingEvent, AgentFinishEvent } from './types.js';
import {
ModelRoutingEvent,
AgentFinishEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import { AgentTerminateMode } from '../agents/types.js';
const mockCounterAddFn: Mock<
@@ -97,6 +102,8 @@ describe('Telemetry Metrics', () => {
let recordLinesChangedModule: typeof import('./metrics.js').recordLinesChanged;
let recordSlowRenderModule: typeof import('./metrics.js').recordSlowRender;
let recordPlanExecutionModule: typeof import('./metrics.js').recordPlanExecution;
let recordKeychainAvailabilityModule: typeof import('./metrics.js').recordKeychainAvailability;
let recordTokenStorageInitializationModule: typeof import('./metrics.js').recordTokenStorageInitialization;
beforeEach(async () => {
vi.resetModules();
@@ -142,6 +149,10 @@ describe('Telemetry Metrics', () => {
recordLinesChangedModule = metricsJsModule.recordLinesChanged;
recordSlowRenderModule = metricsJsModule.recordSlowRender;
recordPlanExecutionModule = metricsJsModule.recordPlanExecution;
recordKeychainAvailabilityModule =
metricsJsModule.recordKeychainAvailability;
recordTokenStorageInitializationModule =
metricsJsModule.recordTokenStorageInitialization;
const otelApiModule = await import('@opentelemetry/api');
@@ -1485,4 +1496,57 @@ describe('Telemetry Metrics', () => {
});
});
});
describe('Keychain and Token Storage Metrics', () => {
describe('recordKeychainAvailability', () => {
it('should not record metrics if not initialized', () => {
const config = makeFakeConfig({});
const event = new KeychainAvailabilityEvent(true);
recordKeychainAvailabilityModule(config, event);
expect(mockCounterAddFn).not.toHaveBeenCalled();
});
it('should record keychain availability when initialized', () => {
const config = makeFakeConfig({});
initializeMetricsModule(config);
mockCounterAddFn.mockClear();
const event = new KeychainAvailabilityEvent(true);
recordKeychainAvailabilityModule(config, event);
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
available: true,
});
});
});
describe('recordTokenStorageInitialization', () => {
it('should not record metrics if not initialized', () => {
const config = makeFakeConfig({});
const event = new TokenStorageInitializationEvent('hybrid', false);
recordTokenStorageInitializationModule(config, event);
expect(mockCounterAddFn).not.toHaveBeenCalled();
});
it('should record token storage initialization when initialized', () => {
const config = makeFakeConfig({});
initializeMetricsModule(config);
mockCounterAddFn.mockClear();
const event = new TokenStorageInitializationEvent('keychain', true);
recordTokenStorageInitializationModule(config, event);
expect(mockCounterAddFn).toHaveBeenCalledWith(1, {
'session.id': 'test-session-id',
'installation.id': 'test-installation-id',
'user.email': 'test@example.com',
type: 'keychain',
forced: true,
});
});
});
});
});

View File

@@ -13,6 +13,8 @@ import type {
ModelSlashCommandEvent,
AgentFinishEvent,
RecoveryAttemptEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import { AuthType } from '../core/contentGenerator.js';
import { getCommonAttributes } from './telemetryAttributes.js';
@@ -37,6 +39,8 @@ const MODEL_SLASH_COMMAND_CALL_COUNT =
'gemini_cli.slash_command.model.call_count';
const EVENT_HOOK_CALL_COUNT = 'gemini_cli.hook_call.count';
const EVENT_HOOK_CALL_LATENCY = 'gemini_cli.hook_call.latency';
const KEYCHAIN_AVAILABILITY_COUNT = 'gemini_cli.keychain.availability.count';
const TOKEN_STORAGE_TYPE_COUNT = 'gemini_cli.token_storage.type.count';
// Agent Metrics
const AGENT_RUN_COUNT = 'gemini_cli.agent.run.count';
@@ -236,6 +240,25 @@ const COUNTER_DEFINITIONS = {
success: boolean;
},
},
[KEYCHAIN_AVAILABILITY_COUNT]: {
description: 'Counts keychain availability checks.',
valueType: ValueType.INT,
assign: (c: Counter) => (keychainAvailabilityCounter = c),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
attributes: {} as {
available: boolean;
},
},
[TOKEN_STORAGE_TYPE_COUNT]: {
description: 'Counts token storage type initializations.',
valueType: ValueType.INT,
assign: (c: Counter) => (tokenStorageTypeCounter = c),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
attributes: {} as {
type: string;
forced: boolean;
},
},
} as const;
const HISTOGRAM_DEFINITIONS = {
@@ -572,6 +595,8 @@ let planExecutionCounter: Counter | undefined;
let slowRenderHistogram: Histogram | undefined;
let hookCallCounter: Counter | undefined;
let hookCallLatencyHistogram: Histogram | undefined;
let keychainAvailabilityCounter: Counter | undefined;
let tokenStorageTypeCounter: Counter | undefined;
// OpenTelemetry GenAI Semantic Convention Metrics
let genAiClientTokenUsageHistogram: Histogram | undefined;
@@ -1279,3 +1304,32 @@ export function recordHookCallMetrics(
hookCallCounter.add(1, metricAttributes);
hookCallLatencyHistogram.record(durationMs, metricAttributes);
}
/**
* Records a metric for keychain availability.
*/
export function recordKeychainAvailability(
config: Config,
event: KeychainAvailabilityEvent,
): void {
if (!keychainAvailabilityCounter || !isMetricsInitialized) return;
keychainAvailabilityCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
available: event.available,
});
}
/**
* Records a metric for token storage type initialization.
*/
export function recordTokenStorageInitialization(
config: Config,
event: TokenStorageInitializationEvent,
): void {
if (!tokenStorageTypeCounter || !isMetricsInitialized) return;
tokenStorageTypeCounter.add(1, {
...baseMetricDefinition.getCommonAttributes(config),
type: event.type,
forced: event.forced,
});
}

View File

@@ -53,6 +53,15 @@ import {
import { TelemetryTarget } from './index.js';
import { debugLogger } from '../utils/debugLogger.js';
import { authEvents } from '../code_assist/oauth2.js';
import { coreEvents, CoreEvent } from '../utils/events.js';
import {
logKeychainAvailability,
logTokenStorageInitialization,
} from './loggers.js';
import type {
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
// For troubleshooting, set the log level to DiagLogLevel.DEBUG
class DiagLoggerAdapter {
@@ -86,6 +95,12 @@ let telemetryInitialized = false;
let callbackRegistered = false;
let authListener: ((newCredentials: JWTInput) => Promise<void>) | undefined =
undefined;
let keychainAvailabilityListener:
| ((event: KeychainAvailabilityEvent) => void)
| undefined = undefined;
let tokenStorageTypeListener:
| ((event: TokenStorageInitializationEvent) => void)
| undefined = undefined;
const telemetryBuffer: Array<() => void | Promise<void>> = [];
let activeTelemetryEmail: string | undefined;
@@ -196,6 +211,26 @@ export async function initializeTelemetry(
'session.id': config.getSessionId(),
});
if (!keychainAvailabilityListener) {
keychainAvailabilityListener = (event: KeychainAvailabilityEvent) => {
logKeychainAvailability(config, event);
};
coreEvents.on(
CoreEvent.TelemetryKeychainAvailability,
keychainAvailabilityListener,
);
}
if (!tokenStorageTypeListener) {
tokenStorageTypeListener = (event: TokenStorageInitializationEvent) => {
logTokenStorageInitialization(config, event);
};
coreEvents.on(
CoreEvent.TelemetryTokenStorageType,
tokenStorageTypeListener,
);
}
const otlpEndpoint = config.getTelemetryOtlpEndpoint();
const otlpProtocol = config.getTelemetryOtlpProtocol();
const telemetryTarget = config.getTelemetryTarget();
@@ -376,6 +411,20 @@ export async function shutdownTelemetry(
authEvents.off('post_auth', authListener);
authListener = undefined;
}
if (keychainAvailabilityListener) {
coreEvents.off(
CoreEvent.TelemetryKeychainAvailability,
keychainAvailabilityListener,
);
keychainAvailabilityListener = undefined;
}
if (tokenStorageTypeListener) {
coreEvents.off(
CoreEvent.TelemetryTokenStorageType,
tokenStorageTypeListener,
);
tokenStorageTypeListener = undefined;
}
callbackRegistered = false;
activeTelemetryEmail = undefined;
}

View File

@@ -2111,3 +2111,60 @@ export class HookCallEvent implements BaseTelemetryEvent {
return `Hook call ${hookId} ${status} in ${this.duration_ms}ms`;
}
}
export const EVENT_KEYCHAIN_AVAILABILITY = 'gemini_cli.keychain.availability';
export class KeychainAvailabilityEvent implements BaseTelemetryEvent {
'event.name': 'keychain_availability';
'event.timestamp': string;
available: boolean;
constructor(available: boolean) {
this['event.name'] = 'keychain_availability';
this['event.timestamp'] = new Date().toISOString();
this.available = available;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
const attributes: LogAttributes = {
...getCommonAttributes(config),
'event.name': EVENT_KEYCHAIN_AVAILABILITY,
'event.timestamp': this['event.timestamp'],
available: this.available,
};
return attributes;
}
toLogBody(): string {
return `Keychain availability: ${this.available}`;
}
}
export const EVENT_TOKEN_STORAGE_INITIALIZATION =
'gemini_cli.token_storage.initialization';
export class TokenStorageInitializationEvent implements BaseTelemetryEvent {
'event.name': 'token_storage_initialization';
'event.timestamp': string;
type: string;
forced: boolean;
constructor(type: string, forced: boolean) {
this['event.name'] = 'token_storage_initialization';
this['event.timestamp'] = new Date().toISOString();
this.type = type;
this.forced = forced;
}
toOpenTelemetryAttributes(config: Config): LogAttributes {
return {
...getCommonAttributes(config),
'event.name': EVENT_TOKEN_STORAGE_INITIALIZATION,
'event.timestamp': this['event.timestamp'],
type: this.type,
forced: this.forced,
};
}
toLogBody(): string {
return `Token storage initialized. Type: ${this.type}. Forced: ${this.forced}`;
}
}

View File

@@ -9,6 +9,10 @@ import type { AgentDefinition } from '../agents/types.js';
import type { McpClient } from '../tools/mcp-client.js';
import type { ExtensionEvents } from './extensionLoader.js';
import type { EditorType } from './editor.js';
import type {
TokenStorageInitializationEvent,
KeychainAvailabilityEvent,
} from '../telemetry/types.js';
/**
* Defines the severity level for user-facing feedback.
@@ -168,6 +172,8 @@ export enum CoreEvent {
EditorSelected = 'editor-selected',
SlashCommandConflicts = 'slash-command-conflicts',
QuotaChanged = 'quota-changed',
TelemetryKeychainAvailability = 'telemetry-keychain-availability',
TelemetryTokenStorageType = 'telemetry-token-storage-type',
}
/**
@@ -198,6 +204,8 @@ export interface CoreEvents extends ExtensionEvents {
[CoreEvent.RequestEditorSelection]: never[];
[CoreEvent.EditorSelected]: [EditorSelectedPayload];
[CoreEvent.SlashCommandConflicts]: [SlashCommandConflictsPayload];
[CoreEvent.TelemetryKeychainAvailability]: [KeychainAvailabilityEvent];
[CoreEvent.TelemetryTokenStorageType]: [TokenStorageInitializationEvent];
}
type EventBacklogItem = {
@@ -367,6 +375,14 @@ export class CoreEventEmitter extends EventEmitter<CoreEvents> {
);
}
}
emitTelemetryKeychainAvailability(event: KeychainAvailabilityEvent): void {
this._emitOrQueue(CoreEvent.TelemetryKeychainAvailability, event);
}
emitTelemetryTokenStorageType(event: TokenStorageInitializationEvent): void {
this._emitOrQueue(CoreEvent.TelemetryTokenStorageType, event);
}
}
export const coreEvents = new CoreEventEmitter();