mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-18 02:14:43 +00:00
feat(usage): add event analytics dashboard
This commit is contained in:
1 parent
b4c63e4afb
commit
4002bb4c13
31 files changed
+2316
-24
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user