mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 05:12:55 -07:00
feat(telemetry): add clearcut logging for hooks (#15405)
This commit is contained in:
@@ -40,6 +40,14 @@ vi.mock('../utils/events.js', () => ({
|
|||||||
coreEvents: mockCoreEvents,
|
coreEvents: mockCoreEvents,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../telemetry/clearcut-logger/clearcut-logger.js', () => ({
|
||||||
|
ClearcutLogger: {
|
||||||
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
logHookCallEvent: vi.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
describe('HookEventHandler', () => {
|
describe('HookEventHandler', () => {
|
||||||
let hookEventHandler: HookEventHandler;
|
let hookEventHandler: HookEventHandler;
|
||||||
let mockConfig: Config;
|
let mockConfig: Config;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
AgentFinishEvent,
|
AgentFinishEvent,
|
||||||
WebFetchFallbackAttemptEvent,
|
WebFetchFallbackAttemptEvent,
|
||||||
|
HookCallEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { AgentTerminateMode } from '../../agents/types.js';
|
import { AgentTerminateMode } from '../../agents/types.js';
|
||||||
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
|
||||||
@@ -1102,4 +1103,44 @@ describe('ClearcutLogger', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('logHookCallEvent', () => {
|
||||||
|
it('logs an event with proper fields', () => {
|
||||||
|
const { logger } = setup();
|
||||||
|
const hookName = '/path/to/my/script.sh';
|
||||||
|
|
||||||
|
const event = new HookCallEvent(
|
||||||
|
'before-tool',
|
||||||
|
'command',
|
||||||
|
hookName,
|
||||||
|
{}, // input
|
||||||
|
150, // duration
|
||||||
|
true, // success
|
||||||
|
{}, // output
|
||||||
|
0, // exit code
|
||||||
|
);
|
||||||
|
|
||||||
|
logger?.logHookCallEvent(event);
|
||||||
|
|
||||||
|
const events = getEvents(logger!);
|
||||||
|
expect(events.length).toBe(1);
|
||||||
|
expect(events[0]).toHaveEventName(EventNames.HOOK_CALL);
|
||||||
|
expect(events[0]).toHaveMetadataValue([
|
||||||
|
EventMetadataKey.GEMINI_CLI_HOOK_EVENT_NAME,
|
||||||
|
'before-tool',
|
||||||
|
]);
|
||||||
|
expect(events[0]).toHaveMetadataValue([
|
||||||
|
EventMetadataKey.GEMINI_CLI_HOOK_DURATION_MS,
|
||||||
|
'150',
|
||||||
|
]);
|
||||||
|
expect(events[0]).toHaveMetadataValue([
|
||||||
|
EventMetadataKey.GEMINI_CLI_HOOK_SUCCESS,
|
||||||
|
'true',
|
||||||
|
]);
|
||||||
|
expect(events[0]).toHaveMetadataValue([
|
||||||
|
EventMetadataKey.GEMINI_CLI_HOOK_EXIT_CODE,
|
||||||
|
'0',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
WebFetchFallbackAttemptEvent,
|
WebFetchFallbackAttemptEvent,
|
||||||
ExtensionUpdateEvent,
|
ExtensionUpdateEvent,
|
||||||
LlmLoopCheckEvent,
|
LlmLoopCheckEvent,
|
||||||
|
HookCallEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EventMetadataKey } from './event-metadata-key.js';
|
import { EventMetadataKey } from './event-metadata-key.js';
|
||||||
import type { Config } from '../../config/config.js';
|
import type { Config } from '../../config/config.js';
|
||||||
@@ -95,6 +96,7 @@ export enum EventNames {
|
|||||||
RECOVERY_ATTEMPT = 'recovery_attempt',
|
RECOVERY_ATTEMPT = 'recovery_attempt',
|
||||||
WEB_FETCH_FALLBACK_ATTEMPT = 'web_fetch_fallback_attempt',
|
WEB_FETCH_FALLBACK_ATTEMPT = 'web_fetch_fallback_attempt',
|
||||||
LLM_LOOP_CHECK = 'llm_loop_check',
|
LLM_LOOP_CHECK = 'llm_loop_check',
|
||||||
|
HOOK_CALL = 'hook_call',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogResponse {
|
export interface LogResponse {
|
||||||
@@ -1381,6 +1383,33 @@ export class ClearcutLogger {
|
|||||||
this.flushIfNeeded();
|
this.flushIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logHookCallEvent(event: HookCallEvent): void {
|
||||||
|
const data: EventValue[] = [
|
||||||
|
{
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_HOOK_EVENT_NAME,
|
||||||
|
value: event.hook_event_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_HOOK_DURATION_MS,
|
||||||
|
value: event.duration_ms.toString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_HOOK_SUCCESS,
|
||||||
|
value: event.success.toString(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.exit_code !== undefined) {
|
||||||
|
data.push({
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_HOOK_EXIT_CODE,
|
||||||
|
value: event.exit_code.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enqueueLogEvent(this.createLogEvent(EventNames.HOOK_CALL, data));
|
||||||
|
this.flushIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds default fields to data, and returns a new data array. This fields
|
* Adds default fields to data, and returns a new data array. This fields
|
||||||
* should exist on all log events.
|
* should exist on all log events.
|
||||||
|
|||||||
@@ -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: 133
|
// Next ID: 137
|
||||||
|
|
||||||
GEMINI_CLI_KEY_UNKNOWN = 0,
|
GEMINI_CLI_KEY_UNKNOWN = 0,
|
||||||
|
|
||||||
@@ -501,4 +501,20 @@ export enum EventMetadataKey {
|
|||||||
|
|
||||||
// Logs the model that confirmed the loop.
|
// Logs the model that confirmed the loop.
|
||||||
GEMINI_CLI_LOOP_DETECTED_CONFIRMED_BY_MODEL = 129,
|
GEMINI_CLI_LOOP_DETECTED_CONFIRMED_BY_MODEL = 129,
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Hook Call Event Keys
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// Logs the name of the hook event (e.g., 'BeforeTool', 'AfterModel').
|
||||||
|
GEMINI_CLI_HOOK_EVENT_NAME = 133,
|
||||||
|
|
||||||
|
// Logs the duration of the hook execution in milliseconds.
|
||||||
|
GEMINI_CLI_HOOK_DURATION_MS = 134,
|
||||||
|
|
||||||
|
// Logs whether the hook execution was successful.
|
||||||
|
GEMINI_CLI_HOOK_SUCCESS = 135,
|
||||||
|
|
||||||
|
// Logs the exit code of the hook script (if applicable).
|
||||||
|
GEMINI_CLI_HOOK_EXIT_CODE = 136,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
logAgentFinish,
|
logAgentFinish,
|
||||||
logWebFetchFallbackAttempt,
|
logWebFetchFallbackAttempt,
|
||||||
logExtensionUpdateEvent,
|
logExtensionUpdateEvent,
|
||||||
|
logHookCall,
|
||||||
} from './loggers.js';
|
} from './loggers.js';
|
||||||
import { ToolCallDecision } from './tool-call-decision.js';
|
import { ToolCallDecision } from './tool-call-decision.js';
|
||||||
import {
|
import {
|
||||||
@@ -87,6 +88,8 @@ import {
|
|||||||
WebFetchFallbackAttemptEvent,
|
WebFetchFallbackAttemptEvent,
|
||||||
ExtensionUpdateEvent,
|
ExtensionUpdateEvent,
|
||||||
EVENT_EXTENSION_UPDATE,
|
EVENT_EXTENSION_UPDATE,
|
||||||
|
HookCallEvent,
|
||||||
|
EVENT_HOOK_CALL,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import * as metrics from './metrics.js';
|
import * as metrics from './metrics.js';
|
||||||
import { FileOperation } from './metrics.js';
|
import { FileOperation } from './metrics.js';
|
||||||
@@ -2101,6 +2104,66 @@ describe('loggers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('logHookCall', () => {
|
||||||
|
const mockConfig = {
|
||||||
|
getSessionId: () => 'test-session-id',
|
||||||
|
getUsageStatisticsEnabled: () => true,
|
||||||
|
isInteractive: () => false,
|
||||||
|
getTelemetryLogPromptsEnabled: () => false,
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(ClearcutLogger.prototype, 'logHookCallEvent');
|
||||||
|
vi.spyOn(metrics, 'recordHookCallMetrics');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log hook call event to Clearcut and OTEL', () => {
|
||||||
|
const event = new HookCallEvent(
|
||||||
|
'before-tool',
|
||||||
|
'command',
|
||||||
|
'/path/to/script.sh',
|
||||||
|
{ arg: 'val' },
|
||||||
|
150,
|
||||||
|
true,
|
||||||
|
{ out: 'res' },
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
logHookCall(mockConfig, event);
|
||||||
|
|
||||||
|
expect(ClearcutLogger.prototype.logHookCallEvent).toHaveBeenCalledWith(
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockLogger.emit).toHaveBeenCalledWith({
|
||||||
|
body: 'Hook call before-tool./path/to/script.sh succeeded in 150ms',
|
||||||
|
attributes: {
|
||||||
|
'session.id': 'test-session-id',
|
||||||
|
'user.email': 'test-user@example.com',
|
||||||
|
'installation.id': 'test-installation-id',
|
||||||
|
'event.name': EVENT_HOOK_CALL,
|
||||||
|
'event.timestamp': '2025-01-01T00:00:00.000Z',
|
||||||
|
interactive: false,
|
||||||
|
hook_event_name: 'before-tool',
|
||||||
|
hook_type: 'command',
|
||||||
|
hook_name: 'script.sh', // Sanitized because logPrompts is false
|
||||||
|
duration_ms: 150,
|
||||||
|
success: true,
|
||||||
|
exit_code: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(metrics.recordHookCallMetrics).toHaveBeenCalledWith(
|
||||||
|
mockConfig,
|
||||||
|
'before-tool',
|
||||||
|
'/path/to/script.sh',
|
||||||
|
150,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Telemetry Buffering', () => {
|
describe('Telemetry Buffering', () => {
|
||||||
it('should buffer events when SDK is not initialized', () => {
|
it('should buffer events when SDK is not initialized', () => {
|
||||||
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
|
vi.spyOn(sdk, 'isTelemetrySdkInitialized').mockReturnValue(false);
|
||||||
|
|||||||
@@ -672,6 +672,7 @@ export function logLlmLoopCheck(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function logHookCall(config: Config, event: HookCallEvent): void {
|
export function logHookCall(config: Config, event: HookCallEvent): void {
|
||||||
|
ClearcutLogger.getInstance(config)?.logHookCallEvent(event);
|
||||||
bufferTelemetryEvent(() => {
|
bufferTelemetryEvent(() => {
|
||||||
const logger = logs.getLogger(SERVICE_NAME);
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
const logRecord: LogRecord = {
|
const logRecord: LogRecord = {
|
||||||
|
|||||||
Reference in New Issue
Block a user