Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/core/src/code_assist/oauth2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 12 additions & 0 deletions packages/core/src/mcp/token-storage/hybrid-token-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) {
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}));

Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/mcp/token-storage/keychain-token-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand Down Expand Up @@ -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;
}
}
Expand Down
38 changes: 38 additions & 0 deletions packages/core/src/telemetry/clearcut-logger/clearcut-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -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
// ==========================================================================

Expand All @@ -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,
}
38 changes: 38 additions & 0 deletions packages/core/src/telemetry/loggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import type {
LlmLoopCheckEvent,
PlanExecutionEvent,
ToolOutputMaskingEvent,
KeychainAvailabilityEvent,
TokenStorageInitializationEvent,
} from './types.js';
import {
recordApiErrorMetrics,
Expand All @@ -76,6 +78,8 @@ import {
recordLinesChanged,
recordHookCallMetrics,
recordPlanExecution,
recordKeychainAvailability,
recordTokenStorageInitialization,
} from './metrics.js';
import { bufferTelemetryEvent } from './sdk.js';
import type { UiEvent } from './uiTelemetry.js';
Expand Down Expand Up @@ -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);
});
}
66 changes: 65 additions & 1 deletion packages/core/src/telemetry/metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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,
});
});
});
});
});
Loading
Loading