feat(usage): add event analytics dashboard

This commit is contained in:
Goon committed 2026-06-12 17:51:12 +07:00
1 parent b4c63e4afb
commit 4002bb4c13
31 files changed
+2316 -24

No files matched your search

+3
View File
@@ -378,6 +378,8 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
AgentKey: l.id,
TenantID: l.tenantID,
UserID: req.UserID,
RunID: req.RunID,
SessionKey: req.SessionKey,
CredentialUserID: credUserID,
AgentType: l.agentType,
SenderID: req.SenderID,
@@ -388,6 +390,7 @@ func (l *Loop) injectContext(ctx context.Context, req *RunRequest) (contextSetup
SharedContext: store.IsSharedContext(ctx),
RestrictToWorkspace: l.restrictToWs != nil && *l.restrictToWs,
BuiltinToolSettings: l.builtinToolSettings,
Channel: req.Channel,
ChannelType: req.ChannelType,
SubagentsCfg: l.subagentsCfg,
ParentModel: l.model,
+24 -6
View File
@@ -21,7 +21,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
emitRun := makeToolEmitRun(l, req)
return func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall) ([]providers.Message, error) {
tc = l.normalizeToolCall(tc)
registryName := l.resolveToolCallName(tc.Name)
registryName := l.canonicalToolName(l.resolveToolCallName(tc.Name))
argsJSON, _ := json.Marshal(tc.Arguments)
slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON))
@@ -34,7 +34,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
// Emit tool span start for tracing.
toolStart := time.Now().UTC()
toolSpanID := l.emitToolSpanStart(ctx, toolStart, tc.Name, tc.ID, string(argsJSON))
toolSpanID := l.emitToolSpanStart(ctx, toolStart, registryName, tc.ID, string(argsJSON))
// Inject agent audio snapshot so TTS tool (and any future audio consumers)
// can read agent-level voice/model config without an extra DB lookup.
@@ -55,6 +55,7 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
toolDuration := time.Since(toolStart)
l.emitToolSpanEnd(ctx, toolSpanID, toolStart, result)
l.recordToolUsageEvent(ctx, req, registryName, tc.Name, tc.ID, tc.Arguments, toolStart, result, toolSpanID)
// v3 evolution metrics: record tool execution non-blocking (best-effort).
l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, toolDuration)
@@ -73,6 +74,10 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
type toolRawResult struct {
result *tools.Result
duration time.Duration
start time.Time
spanID uuid.UUID
toolName string
rawName string
}
// makeExecuteToolRaw wraps tool I/O only (parallel-safe, no state mutation).
@@ -81,7 +86,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc
emitRun := makeToolEmitRun(l, req)
return func(ctx context.Context, tc providers.ToolCall) (providers.Message, any, error) {
tc = l.normalizeToolCall(tc)
registryName := l.resolveToolCallName(tc.Name)
registryName := l.canonicalToolName(l.resolveToolCallName(tc.Name))
argsJSON, _ := json.Marshal(tc.Arguments)
slog.Info("tool call", "agent", l.id, "tool", tc.Name, "args_len", len(argsJSON))
@@ -98,7 +103,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc
// Emit tool span start (goroutine-safe: channel send only).
start := time.Now().UTC()
spanID := l.emitToolSpanStart(ctx, start, tc.Name, tc.ID, string(argsJSON))
spanID := l.emitToolSpanStart(ctx, start, registryName, tc.ID, string(argsJSON))
// Inject agent audio snapshot (parallel path — same as sequential makeExecuteToolCall).
if l.agentUUID != uuid.Nil {
@@ -125,7 +130,7 @@ func (l *Loop) makeExecuteToolRaw(req *RunRequest) func(ctx context.Context, tc
ToolCallID: tc.ID,
IsError: result.IsError,
}
return msg, &toolRawResult{result: result, duration: dur}, nil
return msg, &toolRawResult{result: result, duration: dur, start: start, spanID: spanID, toolName: registryName, rawName: tc.Name}, nil
}
}
@@ -151,20 +156,33 @@ func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(c
emitRun := makeToolEmitRun(l, req)
return func(ctx context.Context, state *pipeline.RunState, tc providers.ToolCall, rawMsg providers.Message, rawData any) []providers.Message {
tc = l.normalizeToolCall(tc)
registryName := l.resolveToolCallName(tc.Name)
registryName := l.canonicalToolName(l.resolveToolCallName(tc.Name))
// Extract result and timing from toolRawResult wrapper.
var result *tools.Result
var dur time.Duration
var start time.Time
var spanID uuid.UUID
var rawName string
if raw, ok := rawData.(*toolRawResult); ok && raw != nil {
result = raw.result
dur = raw.duration
start = raw.start
spanID = raw.spanID
registryName = raw.toolName
rawName = raw.rawName
} else if r, ok := rawData.(*tools.Result); ok {
result = r // backward compat
}
if result == nil {
return []providers.Message{rawMsg}
}
if rawName == "" {
rawName = tc.Name
}
if !start.IsZero() {
l.recordToolUsageEvent(ctx, req, registryName, rawName, tc.ID, tc.Arguments, start, result, spanID)
}
// Record tool metrics (non-blocking, best-effort).
l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, dur)
+3
View File
@@ -243,6 +243,7 @@ type Loop struct {
budgetMonthlyCents int
tracingStore store.TracingStore
usageCaps *usagecaps.Service
usageEvents store.UsageEventStore
// Memory store for extractive memory fallback (writes directly when LLM flush fails)
memStore store.MemoryStore
@@ -437,6 +438,7 @@ type LoopConfig struct {
BudgetMonthlyCents int
TracingStore store.TracingStore
UsageCaps *usagecaps.Service
UsageEvents store.UsageEventStore
// Memory store for extractive memory fallback (writes directly when LLM flush fails)
MemoryStore store.MemoryStore
@@ -578,6 +580,7 @@ func NewLoop(cfg LoopConfig) *Loop {
budgetMonthlyCents: cfg.BudgetMonthlyCents,
tracingStore: cfg.TracingStore,
usageCaps: cfg.UsageCaps,
usageEvents: cfg.UsageEvents,
memStore: cfg.MemoryStore,
mcpStore: cfg.MCPStore,
mcpPool: cfg.MCPPool,
+2
View File
@@ -100,6 +100,7 @@ type ResolverDeps struct {
// Tracing store for budget enforcement queries
TracingStore store.TracingStore
UsageCaps *usagecaps.Service
UsageEvents store.UsageEventStore
// Memory store for extractive memory fallback
MemoryStore store.MemoryStore
@@ -534,6 +535,7 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
TracingStore: deps.TracingStore,
UsageCaps: deps.UsageCaps,
UsageEvents: deps.UsageEvents,
MemoryStore: deps.MemoryStore,
MCPStore: deps.MCPStore,
MCPPool: deps.MCPPool,
+1
View File
@@ -41,6 +41,7 @@ func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt
message = result.RemainingPrompt
}
skillFilter = []string{result.Skill.Slug}
l.recordSkillSlashUsageEvent(ctx, result.Skill.Slug)
case skillSlashCommandList:
message = "List the available skills shown in the system instructions."
case skillSlashCommandHelp:
+200
View File
@@ -0,0 +1,200 @@
package agent
import (
"context"
"encoding/json"
"log/slog"
"slices"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
type mcpUsageTool interface {
ServerName() string
OriginalName() string
}
func (l *Loop) canonicalToolName(name string) string {
if l.registry == nil {
return name
}
if tool, ok := l.registry.Get(name); ok && tool != nil {
return tool.Name()
}
return name
}
func (l *Loop) recordToolUsageEvent(ctx context.Context, req *RunRequest, canonicalName, rawName, toolCallID string, args map[string]any, start time.Time, result *tools.Result, spanID uuid.UUID) {
if l.usageEvents == nil || result == nil {
return
}
if tracing.TraceIDFromContext(ctx) == uuid.Nil {
return
}
resourceName := canonicalName
resourceID := canonicalName
eventType := store.UsageEventTypeToolCall
resourceType := store.UsageResourceTypeTool
source := store.UsageSourceToolCall
metadata := map[string]any{}
if canonicalName == "use_skill" {
eventType = store.UsageEventTypeSkillActivation
resourceType = store.UsageResourceTypeSkill
source = store.UsageSourceUseSkill
if skill, _ := args["name"].(string); skill != "" {
resourceName = skill
resourceID = skill
}
} else if l.registry != nil {
tool, ok := l.registry.Get(canonicalName)
if !ok {
tool = nil
}
if mcpTool, ok := tool.(mcpUsageTool); ok {
eventType = store.UsageEventTypeMCPToolCall
resourceType = store.UsageResourceTypeMCPTool
resourceName = mcpTool.ServerName() + "/" + mcpTool.OriginalName()
resourceID = mcpTool.OriginalName()
metadata["server"] = mcpTool.ServerName()
metadata["tool"] = mcpTool.OriginalName()
} else if l.isRuntimeTool(canonicalName) {
eventType = store.UsageEventTypeRuntimeToolCall
resourceType = store.UsageResourceTypeRuntimeTool
}
}
if rawName != "" && rawName != canonicalName {
metadata["raw_tool_name"] = rawName
}
event := l.baseUsageEvent(ctx, req, start, eventType, resourceType, resourceName, resourceID, source)
event.SpanID = uuidPtr(spanID)
event.Status = "completed"
if result.IsError {
event.Status = "error"
event.ErrorCount = 1
}
event.DurationMS = int(time.Since(start).Milliseconds())
if result.Usage != nil {
event.InputTokens = int64(result.Usage.PromptTokens)
event.OutputTokens = int64(result.Usage.CompletionTokens)
event.TotalTokens = int64(result.Usage.TotalTokens)
if event.TotalTokens == 0 {
event.TotalTokens = event.InputTokens + event.OutputTokens
}
}
event.Provider = result.Provider
event.Model = result.Model
event.Metadata = usageMetadata(metadata)
l.insertUsageEventBestEffort(ctx, event)
}
func (l *Loop) recordSkillSlashUsageEvent(ctx context.Context, skillSlug string) {
if l.usageEvents == nil || skillSlug == "" {
return
}
traceID := tracing.TraceIDFromContext(ctx)
if traceID == uuid.Nil {
return
}
rc := store.RunContextFromCtx(ctx)
event := l.baseUsageEvent(ctx, nil, time.Now().UTC(),
store.UsageEventTypeSkillActivation,
store.UsageResourceTypeSkill,
skillSlug,
skillSlug,
store.UsageSourceSlashCommand,
)
event.TraceID = uuidPtr(traceID)
if rc != nil {
event.RunID = rc.RunID
event.SessionKey = rc.SessionKey
event.Channel = rc.Channel
if rc.TeamID != "" {
if teamID, err := uuid.Parse(rc.TeamID); err == nil {
event.TeamID = &teamID
}
}
}
event.Metadata = usageMetadata(map[string]any{"activation_source": store.UsageSourceSlashCommand})
l.insertUsageEventBestEffort(ctx, event)
}
func (l *Loop) baseUsageEvent(ctx context.Context, req *RunRequest, eventTime time.Time, eventType, resourceType, resourceName, resourceID, source string) store.UsageEvent {
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = l.tenantID
}
traceID := tracing.TraceIDFromContext(ctx)
event := store.UsageEvent{
ID: uuid.New(),
TenantID: tenantID,
EventTime: eventTime.UTC(),
BucketHour: eventTime.UTC().Truncate(time.Hour),
EventType: eventType,
ResourceType: resourceType,
ResourceName: resourceName,
ResourceID: resourceID,
Source: source,
AgentID: uuidPtr(l.agentUUID),
TeamID: tracing.TraceTeamIDPtrFromContext(ctx),
TraceID: uuidPtr(traceID),
Status: "completed",
CallCount: 1,
}
if req != nil {
event.RunID = req.RunID
event.SessionKey = req.SessionKey
event.Channel = req.Channel
if req.TeamID != "" && event.TeamID == nil {
if teamID, err := uuid.Parse(req.TeamID); err == nil {
event.TeamID = &teamID
}
}
}
return event
}
func (l *Loop) isRuntimeTool(toolName string) bool {
if l.registry == nil {
return false
}
members, ok := l.registry.GetToolGroup("runtime")
return ok && slices.Contains(members, toolName)
}
func (l *Loop) insertUsageEventBestEffort(ctx context.Context, event store.UsageEvent) {
tenantID := event.TenantID
go func() {
bgCtx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 5*time.Second)
defer cancel()
if err := l.usageEvents.InsertEvent(bgCtx, &event); err != nil {
slog.Debug("usage.event.record_failed", "resource", event.ResourceName, "error", err)
}
}()
}
func uuidPtr(id uuid.UUID) *uuid.UUID {
if id == uuid.Nil {
return nil
}
return &id
}
func usageMetadata(values map[string]any) json.RawMessage {
if len(values) == 0 {
return nil
}
data, err := json.Marshal(values)
if err != nil {
return nil
}
return data
}
+164
View File
@@ -0,0 +1,164 @@
package agent
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
)
type fakeUsageEventStore struct {
events chan store.UsageEvent
}
func newFakeUsageEventStore() *fakeUsageEventStore {
return &fakeUsageEventStore{events: make(chan store.UsageEvent, 8)}
}
func (s *fakeUsageEventStore) InsertEvent(_ context.Context, event *store.UsageEvent) error {
if event != nil {
s.events <- *event
}
return nil
}
func (s *fakeUsageEventStore) InsertEvents(ctx context.Context, events []store.UsageEvent) error {
for i := range events {
if err := s.InsertEvent(ctx, &events[i]); err != nil {
return err
}
}
return nil
}
func (s *fakeUsageEventStore) GetEventTimeSeries(context.Context, store.UsageEventQuery) ([]store.UsageEventTimeSeries, error) {
return nil, nil
}
func (s *fakeUsageEventStore) RefreshEventRollupHour(context.Context, time.Time) error {
return nil
}
func (s *fakeUsageEventStore) GetLatestEventRollupBucket(context.Context) (*time.Time, error) {
return nil, nil
}
func (s *fakeUsageEventStore) GetEventBreakdown(context.Context, store.UsageEventQuery) ([]store.UsageEventBreakdown, error) {
return nil, nil
}
func (s *fakeUsageEventStore) GetEventSummary(context.Context, store.UsageEventQuery) (*store.UsageEventSummary, error) {
return nil, nil
}
type usageFakeTool struct {
name string
}
func (t usageFakeTool) Name() string { return t.name }
func (t usageFakeTool) Description() string { return "" }
func (t usageFakeTool) Parameters() map[string]any { return nil }
func (t usageFakeTool) Execute(context.Context, map[string]any) *tools.Result {
return tools.NewResult("ok")
}
func TestRecordToolUsageEvent_RuntimeAliasUsesCanonicalName(t *testing.T) {
storeSpy := newFakeUsageEventStore()
registry := tools.NewRegistry()
registry.Register(usageFakeTool{name: "exec"})
registry.RegisterAlias("Bash", "exec")
loop := &Loop{registry: registry, usageEvents: storeSpy, agentUUID: uuid.New(), tenantID: uuid.New()}
ctx := tracing.WithTraceID(store.WithTenantID(t.Context(), loop.tenantID), uuid.New())
canonical := loop.canonicalToolName("Bash")
loop.recordToolUsageEvent(ctx, &RunRequest{RunID: "run-1", SessionKey: "session-1", Channel: "telegram"}, canonical, "Bash", "call-1",
map[string]any{"command": "echo secret"}, time.Now().Add(-time.Millisecond), tools.NewResult("ok"), uuid.New())
event := waitUsageEvent(t, storeSpy)
if event.EventType != store.UsageEventTypeRuntimeToolCall {
t.Fatalf("event type = %q, want %q", event.EventType, store.UsageEventTypeRuntimeToolCall)
}
if event.ResourceName != "exec" {
t.Fatalf("resource = %q, want canonical exec", event.ResourceName)
}
if string(event.Metadata) == "" || json.Valid(event.Metadata) == false {
t.Fatalf("metadata should contain valid alias metadata, got %q", string(event.Metadata))
}
if containsJSONKey(event.Metadata, "command") {
t.Fatalf("metadata leaked raw command args: %s", string(event.Metadata))
}
}
func TestRecordToolUsageEvent_UseSkillCountsSkillName(t *testing.T) {
storeSpy := newFakeUsageEventStore()
registry := tools.NewRegistry()
registry.Register(tools.NewUseSkillTool())
loop := &Loop{registry: registry, usageEvents: storeSpy, agentUUID: uuid.New(), tenantID: uuid.New()}
ctx := tracing.WithTraceID(store.WithTenantID(t.Context(), loop.tenantID), uuid.New())
loop.recordToolUsageEvent(ctx, &RunRequest{RunID: "run-1", SessionKey: "session-1"}, "use_skill", "use_skill", "call-1",
map[string]any{"name": "ck:plan"}, time.Now().Add(-time.Millisecond), tools.NewResult("ok"), uuid.New())
event := waitUsageEvent(t, storeSpy)
if event.EventType != store.UsageEventTypeSkillActivation {
t.Fatalf("event type = %q, want skill activation", event.EventType)
}
if event.ResourceType != store.UsageResourceTypeSkill || event.ResourceName != "ck:plan" {
t.Fatalf("resource = %s/%s, want skill ck:plan", event.ResourceType, event.ResourceName)
}
if event.Source != store.UsageSourceUseSkill {
t.Fatalf("source = %q, want use_skill", event.Source)
}
}
func TestRecordSkillSlashUsageEvent_RequiresTraceContext(t *testing.T) {
storeSpy := newFakeUsageEventStore()
loop := &Loop{usageEvents: storeSpy, agentUUID: uuid.New(), tenantID: uuid.New()}
ctx := store.WithRunContext(store.WithTenantID(t.Context(), loop.tenantID), &store.RunContext{
RunID: "run-1",
SessionKey: "session-1",
Channel: "web",
})
loop.recordSkillSlashUsageEvent(ctx, "ck:plan")
select {
case event := <-storeSpy.events:
t.Fatalf("unexpected event without trace: %+v", event)
case <-time.After(50 * time.Millisecond):
}
loop.recordSkillSlashUsageEvent(tracing.WithTraceID(ctx, uuid.New()), "ck:plan")
event := waitUsageEvent(t, storeSpy)
if event.Source != store.UsageSourceSlashCommand || event.ResourceName != "ck:plan" {
t.Fatalf("slash event = %s/%s, want slash-command ck:plan", event.Source, event.ResourceName)
}
if event.RunID != "run-1" || event.SessionKey != "session-1" || event.Channel != "web" {
t.Fatalf("run context not copied: run=%q session=%q channel=%q", event.RunID, event.SessionKey, event.Channel)
}
}
func waitUsageEvent(t *testing.T, storeSpy *fakeUsageEventStore) store.UsageEvent {
t.Helper()
select {
case event := <-storeSpy.events:
return event
case <-time.After(time.Second):
t.Fatal("timed out waiting for usage event")
return store.UsageEvent{}
}
}
func containsJSONKey(raw json.RawMessage, key string) bool {
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
return false
}
_, ok := values[key]
return ok
}