feat(analytics): add top tools and topics to session analytics

This commit is contained in:
William Valentin
2026-02-16 14:11:50 -08:00
parent 3d7144b2c5
commit 93621bbe6e
10 changed files with 173 additions and 8 deletions
+25
View File
@@ -96,6 +96,31 @@ describe('ToolExecutor', () => {
expect(result.output).toBe('hello');
});
it('notifies execution observer on success and failure', async () => {
const registry = new ToolRegistry();
registry.register(echoTool);
registry.register(failTool);
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
const executor = new ToolExecutor(registry, hooks);
const observer = vi.fn();
executor.setExecutionObserver(observer);
await executor.execute('test.echo', { text: 'hello' }, { sessionId: 'ws:1' });
await executor.execute('test.fail', {}, { sessionId: 'ws:1' });
expect(observer).toHaveBeenCalledTimes(2);
expect(observer.mock.calls[0]?.[0]).toMatchObject({
toolName: 'test.echo',
sessionId: 'ws:1',
success: true,
});
expect(observer.mock.calls[1]?.[0]).toMatchObject({
toolName: 'test.fail',
sessionId: 'ws:1',
success: false,
});
});
it('returns error for unknown tool', async () => {
const registry = new ToolRegistry();
const hooks = new HookEngine({ confirm: [], log: [], silent: [] });
+40
View File
@@ -15,12 +15,20 @@ export interface ToolExecutorConfig {
maxOutputBytes?: number;
}
export interface ToolExecutionObserverEvent {
toolName: string;
sessionId?: string;
success: boolean;
timestampSeconds: number;
}
export class ToolExecutor {
private registry: ToolRegistry;
private hooks: HookEngine;
private defaultTimeoutMs: number;
private maxOutputBytes: number;
private sandboxManager?: SandboxManager;
private executionObserver?: (event: ToolExecutionObserverEvent) => void;
constructor(registry: ToolRegistry, hooks: HookEngine, config?: ToolExecutorConfig) {
this.registry = registry;
@@ -33,6 +41,10 @@ export class ToolExecutor {
this.sandboxManager = manager;
}
setExecutionObserver(observer?: (event: ToolExecutionObserverEvent) => void): void {
this.executionObserver = observer;
}
private isElevationActive(context?: ToolPolicyContext): boolean {
const untilMs = context?.elevatedHostUntilMs;
return typeof untilMs === 'number' && Number.isFinite(untilMs) && untilMs > Date.now();
@@ -271,6 +283,13 @@ export class ToolExecutor {
session_id: context?.sessionId,
});
this.notifyExecutionObserver({
toolName,
sessionId: context?.sessionId,
success: result.success,
timestampSeconds: Math.floor(Date.now() / 1000),
});
return result;
} catch (error) {
const duration = Date.now() - startTime;
@@ -289,6 +308,13 @@ export class ToolExecutor {
redactions_applied: argsRedaction.redactions + errorRedaction.redactions,
});
this.notifyExecutionObserver({
toolName,
sessionId: context?.sessionId,
success: false,
timestampSeconds: Math.floor(Date.now() / 1000),
});
return {
success: false,
output: '',
@@ -301,6 +327,20 @@ export class ToolExecutor {
}
}
private notifyExecutionObserver(event: ToolExecutionObserverEvent): void {
if (!this.executionObserver) {
return;
}
try {
this.executionObserver(event);
} catch (error) {
console.warn(
'ToolExecutor: execution observer failed:',
error instanceof Error ? error.message : String(error),
);
}
}
private resolveAllowedSecretScopes(context?: ToolPolicyContext): string[] {
if (context?.allowedSecretScopes) {
return context.allowedSecretScopes;