Adds centralized support to log slash commands + sub commands (#5128)
This commit is contained in:
parent
80079cd2a5
commit
293bb82019
|
@ -209,6 +209,11 @@ Logs are timestamped records of specific events. The following events are logged
|
||||||
- **Attributes**:
|
- **Attributes**:
|
||||||
- `auth_type`
|
- `auth_type`
|
||||||
|
|
||||||
|
- `gemini_cli.slash_command`: This event occurs when a user executes a slash command.
|
||||||
|
- **Attributes**:
|
||||||
|
- `command` (string)
|
||||||
|
- `subcommand` (string, if applicable)
|
||||||
|
|
||||||
### Metrics
|
### Metrics
|
||||||
|
|
||||||
Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI:
|
Metrics are numerical measurements of behavior over time. The following metrics are collected for Gemini CLI:
|
||||||
|
|
|
@ -4,6 +4,21 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const { logSlashCommand, SlashCommandEvent } = vi.hoisted(() => ({
|
||||||
|
logSlashCommand: vi.fn(),
|
||||||
|
SlashCommandEvent: vi.fn((command, subCommand) => ({ command, subCommand })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
|
const original =
|
||||||
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
logSlashCommand,
|
||||||
|
SlashCommandEvent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { mockProcessExit } = vi.hoisted(() => ({
|
const { mockProcessExit } = vi.hoisted(() => ({
|
||||||
mockProcessExit: vi.fn((_code?: number): never => undefined as never),
|
mockProcessExit: vi.fn((_code?: number): never => undefined as never),
|
||||||
}));
|
}));
|
||||||
|
@ -814,4 +829,83 @@ describe('useSlashCommandProcessor', () => {
|
||||||
expect(abortSpy).toHaveBeenCalledTimes(1);
|
expect(abortSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Slash Command Logging', () => {
|
||||||
|
const mockCommandAction = vi.fn().mockResolvedValue({ type: 'handled' });
|
||||||
|
const loggingTestCommands: SlashCommand[] = [
|
||||||
|
createTestCommand({
|
||||||
|
name: 'logtest',
|
||||||
|
action: mockCommandAction,
|
||||||
|
}),
|
||||||
|
createTestCommand({
|
||||||
|
name: 'logwithsub',
|
||||||
|
subCommands: [
|
||||||
|
createTestCommand({
|
||||||
|
name: 'sub',
|
||||||
|
action: mockCommandAction,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
createTestCommand({
|
||||||
|
name: 'logalias',
|
||||||
|
altNames: ['la'],
|
||||||
|
action: mockCommandAction,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCommandAction.mockClear();
|
||||||
|
vi.mocked(logSlashCommand).mockClear();
|
||||||
|
vi.mocked(SlashCommandEvent).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a simple slash command', async () => {
|
||||||
|
const result = setupProcessorHook(loggingTestCommands);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/logtest');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SlashCommandEvent).toHaveBeenCalledWith('logtest', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log a slash command with a subcommand', async () => {
|
||||||
|
const result = setupProcessorHook(loggingTestCommands);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/logwithsub sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SlashCommandEvent).toHaveBeenCalledWith('logwithsub', 'sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should log the command path when an alias is used', async () => {
|
||||||
|
const result = setupProcessorHook(loggingTestCommands);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/la');
|
||||||
|
});
|
||||||
|
expect(logSlashCommand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(SlashCommandEvent).toHaveBeenCalledWith('logalias', undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not log for unknown commands', async () => {
|
||||||
|
const result = setupProcessorHook(loggingTestCommands);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current.slashCommands.length).toBeGreaterThan(0),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.handleSlashCommand('/unknown');
|
||||||
|
});
|
||||||
|
expect(logSlashCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -13,6 +13,8 @@ import {
|
||||||
Config,
|
Config,
|
||||||
GitService,
|
GitService,
|
||||||
Logger,
|
Logger,
|
||||||
|
logSlashCommand,
|
||||||
|
SlashCommandEvent,
|
||||||
ToolConfirmationOutcome,
|
ToolConfirmationOutcome,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import { useSessionStats } from '../contexts/SessionContext.js';
|
import { useSessionStats } from '../contexts/SessionContext.js';
|
||||||
|
@ -233,6 +235,7 @@ export const useSlashCommandProcessor = (
|
||||||
let currentCommands = commands;
|
let currentCommands = commands;
|
||||||
let commandToExecute: SlashCommand | undefined;
|
let commandToExecute: SlashCommand | undefined;
|
||||||
let pathIndex = 0;
|
let pathIndex = 0;
|
||||||
|
const canonicalPath: string[] = [];
|
||||||
|
|
||||||
for (const part of commandPath) {
|
for (const part of commandPath) {
|
||||||
// TODO: For better performance and architectural clarity, this two-pass
|
// TODO: For better performance and architectural clarity, this two-pass
|
||||||
|
@ -253,6 +256,7 @@ export const useSlashCommandProcessor = (
|
||||||
|
|
||||||
if (foundCommand) {
|
if (foundCommand) {
|
||||||
commandToExecute = foundCommand;
|
commandToExecute = foundCommand;
|
||||||
|
canonicalPath.push(foundCommand.name);
|
||||||
pathIndex++;
|
pathIndex++;
|
||||||
if (foundCommand.subCommands) {
|
if (foundCommand.subCommands) {
|
||||||
currentCommands = foundCommand.subCommands;
|
currentCommands = foundCommand.subCommands;
|
||||||
|
@ -268,6 +272,17 @@ export const useSlashCommandProcessor = (
|
||||||
const args = parts.slice(pathIndex).join(' ');
|
const args = parts.slice(pathIndex).join(' ');
|
||||||
|
|
||||||
if (commandToExecute.action) {
|
if (commandToExecute.action) {
|
||||||
|
if (config) {
|
||||||
|
const resolvedCommandPath = canonicalPath;
|
||||||
|
const event = new SlashCommandEvent(
|
||||||
|
resolvedCommandPath[0],
|
||||||
|
resolvedCommandPath.length > 1
|
||||||
|
? resolvedCommandPath.slice(1).join(' ')
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
logSlashCommand(config, event);
|
||||||
|
}
|
||||||
|
|
||||||
const fullCommandContext: CommandContext = {
|
const fullCommandContext: CommandContext = {
|
||||||
...commandContext,
|
...commandContext,
|
||||||
invocation: {
|
invocation: {
|
||||||
|
|
|
@ -19,6 +19,7 @@ import {
|
||||||
FlashFallbackEvent,
|
FlashFallbackEvent,
|
||||||
LoopDetectedEvent,
|
LoopDetectedEvent,
|
||||||
FlashDecidedToContinueEvent,
|
FlashDecidedToContinueEvent,
|
||||||
|
SlashCommandEvent,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { EventMetadataKey } from './event-metadata-key.js';
|
import { EventMetadataKey } from './event-metadata-key.js';
|
||||||
import { Config } from '../../config/config.js';
|
import { Config } from '../../config/config.js';
|
||||||
|
@ -40,6 +41,7 @@ const end_session_event_name = 'end_session';
|
||||||
const flash_fallback_event_name = 'flash_fallback';
|
const flash_fallback_event_name = 'flash_fallback';
|
||||||
const loop_detected_event_name = 'loop_detected';
|
const loop_detected_event_name = 'loop_detected';
|
||||||
const flash_decided_to_continue_event_name = 'flash_decided_to_continue';
|
const flash_decided_to_continue_event_name = 'flash_decided_to_continue';
|
||||||
|
const slash_command_event_name = 'slash_command';
|
||||||
|
|
||||||
export interface LogResponse {
|
export interface LogResponse {
|
||||||
nextRequestWaitMs?: number;
|
nextRequestWaitMs?: number;
|
||||||
|
@ -528,6 +530,25 @@ export class ClearcutLogger {
|
||||||
this.flushIfNeeded();
|
this.flushIfNeeded();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logSlashCommandEvent(event: SlashCommandEvent): void {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_NAME,
|
||||||
|
value: JSON.stringify(event.command),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (event.subcommand) {
|
||||||
|
data.push({
|
||||||
|
gemini_cli_key: EventMetadataKey.GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND,
|
||||||
|
value: JSON.stringify(event.subcommand),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enqueueLogEvent(this.createLogEvent(slash_command_event_name, data));
|
||||||
|
this.flushIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
logEndSessionEvent(event: EndSessionEvent): void {
|
logEndSessionEvent(event: EndSessionEvent): void {
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
|
|
|
@ -163,6 +163,16 @@ export enum EventMetadataKey {
|
||||||
|
|
||||||
// Logs the type of loop detected.
|
// Logs the type of loop detected.
|
||||||
GEMINI_CLI_LOOP_DETECTED_TYPE = 38,
|
GEMINI_CLI_LOOP_DETECTED_TYPE = 38,
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Slash Command Event Keys
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
// Logs the name of the slash command.
|
||||||
|
GEMINI_CLI_SLASH_COMMAND_NAME = 41,
|
||||||
|
|
||||||
|
// Logs the subcommand of the slash command.
|
||||||
|
GEMINI_CLI_SLASH_COMMAND_SUBCOMMAND = 42,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getEventMetadataKey(
|
export function getEventMetadataKey(
|
||||||
|
|
|
@ -15,6 +15,7 @@ export const EVENT_CLI_CONFIG = 'gemini_cli.config';
|
||||||
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
export const EVENT_FLASH_FALLBACK = 'gemini_cli.flash_fallback';
|
||||||
export const EVENT_FLASH_DECIDED_TO_CONTINUE =
|
export const EVENT_FLASH_DECIDED_TO_CONTINUE =
|
||||||
'gemini_cli.flash_decided_to_continue';
|
'gemini_cli.flash_decided_to_continue';
|
||||||
|
export const EVENT_SLASH_COMMAND = 'gemini_cli.slash_command';
|
||||||
|
|
||||||
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
export const METRIC_TOOL_CALL_COUNT = 'gemini_cli.tool.call.count';
|
||||||
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
export const METRIC_TOOL_CALL_LATENCY = 'gemini_cli.tool.call.latency';
|
||||||
|
|
|
@ -26,6 +26,7 @@ export {
|
||||||
logApiError,
|
logApiError,
|
||||||
logApiResponse,
|
logApiResponse,
|
||||||
logFlashFallback,
|
logFlashFallback,
|
||||||
|
logSlashCommand,
|
||||||
} from './loggers.js';
|
} from './loggers.js';
|
||||||
export {
|
export {
|
||||||
StartSessionEvent,
|
StartSessionEvent,
|
||||||
|
@ -37,6 +38,7 @@ export {
|
||||||
ApiResponseEvent,
|
ApiResponseEvent,
|
||||||
TelemetryEvent,
|
TelemetryEvent,
|
||||||
FlashFallbackEvent,
|
FlashFallbackEvent,
|
||||||
|
SlashCommandEvent,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
export { SpanStatusCode, ValueType } from '@opentelemetry/api';
|
export { SpanStatusCode, ValueType } from '@opentelemetry/api';
|
||||||
export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
export { SemanticAttributes } from '@opentelemetry/semantic-conventions';
|
||||||
|
|
|
@ -17,6 +17,7 @@ import {
|
||||||
EVENT_FLASH_FALLBACK,
|
EVENT_FLASH_FALLBACK,
|
||||||
EVENT_FLASH_DECIDED_TO_CONTINUE,
|
EVENT_FLASH_DECIDED_TO_CONTINUE,
|
||||||
SERVICE_NAME,
|
SERVICE_NAME,
|
||||||
|
EVENT_SLASH_COMMAND,
|
||||||
} from './constants.js';
|
} from './constants.js';
|
||||||
import {
|
import {
|
||||||
ApiErrorEvent,
|
ApiErrorEvent,
|
||||||
|
@ -28,6 +29,7 @@ import {
|
||||||
FlashFallbackEvent,
|
FlashFallbackEvent,
|
||||||
FlashDecidedToContinueEvent,
|
FlashDecidedToContinueEvent,
|
||||||
LoopDetectedEvent,
|
LoopDetectedEvent,
|
||||||
|
SlashCommandEvent,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
recordApiErrorMetrics,
|
recordApiErrorMetrics,
|
||||||
|
@ -332,3 +334,24 @@ export function logFlashDecidedToContinue(
|
||||||
};
|
};
|
||||||
logger.emit(logRecord);
|
logger.emit(logRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function logSlashCommand(
|
||||||
|
config: Config,
|
||||||
|
event: SlashCommandEvent,
|
||||||
|
): void {
|
||||||
|
ClearcutLogger.getInstance(config)?.logSlashCommandEvent(event);
|
||||||
|
if (!isTelemetrySdkInitialized()) return;
|
||||||
|
|
||||||
|
const attributes: LogAttributes = {
|
||||||
|
...getCommonAttributes(config),
|
||||||
|
...event,
|
||||||
|
'event.name': EVENT_SLASH_COMMAND,
|
||||||
|
};
|
||||||
|
|
||||||
|
const logger = logs.getLogger(SERVICE_NAME);
|
||||||
|
const logRecord: LogRecord = {
|
||||||
|
body: `Slash command: ${event.command}.`,
|
||||||
|
attributes,
|
||||||
|
};
|
||||||
|
logger.emit(logRecord);
|
||||||
|
}
|
||||||
|
|
|
@ -278,6 +278,20 @@ export class FlashDecidedToContinueEvent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SlashCommandEvent {
|
||||||
|
'event.name': 'slash_command';
|
||||||
|
'event.timestamp': string; // ISO 8106
|
||||||
|
command: string;
|
||||||
|
subcommand?: string;
|
||||||
|
|
||||||
|
constructor(command: string, subcommand?: string) {
|
||||||
|
this['event.name'] = 'slash_command';
|
||||||
|
this['event.timestamp'] = new Date().toISOString();
|
||||||
|
this.command = command;
|
||||||
|
this.subcommand = subcommand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type TelemetryEvent =
|
export type TelemetryEvent =
|
||||||
| StartSessionEvent
|
| StartSessionEvent
|
||||||
| EndSessionEvent
|
| EndSessionEvent
|
||||||
|
@ -288,4 +302,5 @@ export type TelemetryEvent =
|
||||||
| ApiResponseEvent
|
| ApiResponseEvent
|
||||||
| FlashFallbackEvent
|
| FlashFallbackEvent
|
||||||
| LoopDetectedEvent
|
| LoopDetectedEvent
|
||||||
| FlashDecidedToContinueEvent;
|
| FlashDecidedToContinueEvent
|
||||||
|
| SlashCommandEvent;
|
||||||
|
|
Loading…
Reference in New Issue