Skip to content

Commit 077d15d

Browse files
Alexey Panfilovclaude
andcommitted
feat(store): track per-tool latency and end-to-end turn time
Add tool_call_log table (tool_name, latency_ms, success, error_text per invocation) and turn_latency_ms column on usage_log (backfilled on the final LLM call of each turn). Both SQLite and Postgres with idempotent migrations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 352bc6f commit 077d15d

4 files changed

Lines changed: 140 additions & 11 deletions

File tree

internal/agent/agent.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ func (a *Agent) TTSEnabled() bool {
118118
// Process runs the agentic loop. onToolCall is called before each tool execution (may be nil).
119119
func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(toolName string)) (string, error) {
120120
tr := newRequestTrace()
121+
turnStart := time.Now()
121122
queryText := messageText(userMsg)
122123

123124
// Store user message; embed it if semantic store + MCP embeddings are both available.
@@ -204,6 +205,7 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
204205
// Link the last usage_log row to this assistant message so analytics
205206
// can pair cost/tokens with the actual reply the user saw.
206207
a.backfillAssistantMsgID(ctx, lastUsageID, asstID)
208+
a.backfillTurnLatency(lastUsageID, time.Since(turnStart))
207209
// Cache only pure direct responses (first iteration, no tool calls).
208210
if i == 0 {
209211
a.cache.Set(chatID, queryEmb, resp.Content)
@@ -231,6 +233,7 @@ func (a *Agent) Process(ctx context.Context, chatID int64, userMsg llm.Message,
231233
// Tool-calling iterations remain synchronous. onChunk receives the accumulated text so far.
232234
func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Message, onToolCall func(string), onChunk func(accumulated string)) (string, error) {
233235
tr := newRequestTrace()
236+
turnStart := time.Now()
234237
queryText := messageText(userMsg)
235238

236239
endEmbed := tr.begin("embed")
@@ -343,6 +346,7 @@ func (a *Agent) ProcessStream(ctx context.Context, chatID int64, userMsg llm.Mes
343346
if len(toolCalls) == 0 {
344347
asstID := a.store.AddMessage(chatID, llm.Message{Role: "assistant", Content: accumulated})
345348
a.backfillAssistantMsgID(ctx, lastUsageID, asstID)
349+
a.backfillTurnLatency(lastUsageID, time.Since(turnStart))
346350
if i == 0 {
347351
a.cache.Set(chatID, queryEmb, accumulated)
348352
}
@@ -381,6 +385,13 @@ func (a *Agent) executeToolCalls(ctx context.Context, chatID int64, toolCalls []
381385
results := make([]toolResult, len(toolCalls))
382386
var wg sync.WaitGroup
383387

388+
meta, _ := llm.TurnMetaFrom(ctx)
389+
var userMsgID *int64
390+
if meta.UserMessageID != 0 {
391+
id := meta.UserMessageID
392+
userMsgID = &id
393+
}
394+
384395
for i, tc := range toolCalls {
385396
if onToolCall != nil {
386397
onToolCall(tc.Name)
@@ -390,8 +401,12 @@ func (a *Agent) executeToolCalls(ctx context.Context, chatID int64, toolCalls []
390401
wg.Add(1)
391402
go func(idx int, tc llm.ToolCall) {
392403
defer wg.Done()
404+
toolStart := time.Now()
393405
result, err := a.callTool(ctx, tc.Name, tc.Arguments)
406+
elapsed := time.Since(toolStart)
407+
errText := ""
394408
if err != nil {
409+
errText = err.Error()
395410
a.logger.Warn("tool call failed", "tool", tc.Name, "err", err)
396411
result = fmt.Sprintf("Error: %s", err.Error())
397412
}
@@ -401,7 +416,19 @@ func (a *Agent) executeToolCalls(ctx context.Context, chatID int64, toolCalls []
401416
result = summarized
402417
}
403418
}
404-
a.logger.Info("tool result", "tool", tc.Name, "result_len", len(result))
419+
a.logger.Info("tool result", "tool", tc.Name, "latency_ms", elapsed.Milliseconds(), "result_len", len(result))
420+
if tcs, ok := a.store.(llm.ToolCallStore); ok {
421+
writeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
422+
_ = tcs.PutToolCall(writeCtx, llm.ToolCallLog{
423+
ChatID: meta.ChatID,
424+
UserMsgID: userMsgID,
425+
ToolName: tc.Name,
426+
LatencyMs: int(elapsed.Milliseconds()),
427+
Success: err == nil,
428+
ErrorText: errText,
429+
})
430+
cancel()
431+
}
405432
results[idx] = toolResult{tc: tc, result: result}
406433
}(i, tc)
407434
}
@@ -580,6 +607,23 @@ func (a *Agent) backfillAssistantMsgID(ctx context.Context, usageID, asstID int6
580607
_ = ctx
581608
}
582609

610+
// backfillTurnLatency records the end-to-end turn duration on the final usage_log row.
611+
// Best-effort: no-ops silently when the store doesn't support it or usageID is 0.
612+
func (a *Agent) backfillTurnLatency(usageID int64, elapsed time.Duration) {
613+
if usageID == 0 {
614+
return
615+
}
616+
us, ok := a.store.(llm.UsageStore)
617+
if !ok {
618+
return
619+
}
620+
writeCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
621+
defer cancel()
622+
if err := us.UpdateTurnLatencyMs(writeCtx, usageID, int(elapsed.Milliseconds())); err != nil {
623+
a.logger.Warn("backfill turn_latency_ms failed", "err", err)
624+
}
625+
}
626+
583627
// storeUserMessage saves the user message. If the store and MCP client both support
584628
// embeddings, it also computes and stores the embedding for semantic history retrieval.
585629
// Returns the embedding (may be nil) and the inserted row's id (0 if the backend

internal/llm/usage.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type UsageLog struct {
2323
ReasoningTokens int // for thinking-enabled models: tokens spent on internal reasoning (billed as completion but semantically distinct)
2424
Cost float64 // USD, authoritative per-request cost reported by the provider (OpenRouter's usage.cost). 0 when the provider doesn't surface this.
2525
LatencyMs int
26+
TurnLatencyMs int // end-to-end turn time (user msg → final reply); only set on the last call of the turn
2627
Success bool
2728
ErrorClass string // "" / "rate_limit" / "5xx" / "network" / "timeout" / "other"
2829
RequestID string // provider's request id (e.g. OpenRouter gen-xxxxx) — useful for cross-referencing with provider dashboards
@@ -31,11 +32,29 @@ type UsageLog struct {
3132
AssistantMessageID *int64 // FK to messages.id; NULL except on the final successful call of a turn
3233
}
3334

35+
// ToolCallLog is a single MCP/built-in tool invocation record.
36+
type ToolCallLog struct {
37+
ID int64
38+
Ts time.Time
39+
ChatID int64
40+
UserMsgID *int64 // FK to messages.id; matches the turn's user message
41+
ToolName string
42+
LatencyMs int
43+
Success bool
44+
ErrorText string
45+
}
46+
47+
// ToolCallStore persists per-tool-call timing records.
48+
type ToolCallStore interface {
49+
PutToolCall(ctx context.Context, t ToolCallLog) error
50+
}
51+
3452
// UsageStore persists UsageLog records and exposes aggregation queries used
3553
// by the admin UI.
3654
type UsageStore interface {
3755
PutUsage(ctx context.Context, u UsageLog) (int64, error)
3856
UpdateAssistantMessageID(ctx context.Context, usageID, msgID int64) error
57+
UpdateTurnLatencyMs(ctx context.Context, usageID int64, ms int) error
3958

4059
// Aggregations
4160
UsageTotals(ctx context.Context, since time.Time) (UsageTotals, error)

internal/store/postgres.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,21 @@ func (p *Postgres) migrate(ctx context.Context) error {
9696
assistant_message_id BIGINT
9797
)`,
9898
`ALTER TABLE usage_log ADD COLUMN IF NOT EXISTS cost_usd DOUBLE PRECISION NOT NULL DEFAULT 0`,
99+
`ALTER TABLE usage_log ADD COLUMN IF NOT EXISTS turn_latency_ms INTEGER NOT NULL DEFAULT 0`,
99100
`CREATE INDEX IF NOT EXISTS idx_usage_ts_model ON usage_log(ts, model_id)`,
100101
`CREATE INDEX IF NOT EXISTS idx_usage_chat_ts ON usage_log(chat_id, ts)`,
101102
`CREATE INDEX IF NOT EXISTS idx_usage_user_msg ON usage_log(user_message_id)`,
103+
`CREATE TABLE IF NOT EXISTS tool_call_log (
104+
id BIGSERIAL PRIMARY KEY,
105+
ts TIMESTAMPTZ NOT NULL DEFAULT NOW(),
106+
chat_id BIGINT NOT NULL DEFAULT 0,
107+
user_msg_id BIGINT,
108+
tool_name TEXT NOT NULL,
109+
latency_ms INTEGER NOT NULL DEFAULT 0,
110+
success BOOLEAN NOT NULL DEFAULT TRUE,
111+
error_text TEXT NOT NULL DEFAULT ''
112+
)`,
113+
`CREATE INDEX IF NOT EXISTS idx_tool_call_chat_ts ON tool_call_log(chat_id, ts)`,
102114
}
103115
for _, s := range stmts {
104116
if _, err := p.pool.Exec(ctx, s); err != nil {
@@ -652,13 +664,13 @@ func (p *Postgres) PutUsage(ctx context.Context, u llm.UsageLog) (int64, error)
652664
INSERT INTO usage_log (
653665
provider, model_id, role, chat_id,
654666
prompt_tokens, completion_tokens, cached_prompt_tokens, reasoning_tokens,
655-
cost_usd, latency_ms, success, error_class, request_id, tool_call_count,
667+
cost_usd, latency_ms, turn_latency_ms, success, error_class, request_id, tool_call_count,
656668
user_message_id, assistant_message_id
657-
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
669+
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
658670
RETURNING id`,
659671
u.Provider, u.ModelID, u.Role, u.ChatID,
660672
u.PromptTokens, u.CompletionTokens, u.CachedPromptTokens, u.ReasoningTokens,
661-
u.Cost, u.LatencyMs, u.Success, u.ErrorClass, u.RequestID, u.ToolCallCount,
673+
u.Cost, u.LatencyMs, u.TurnLatencyMs, u.Success, u.ErrorClass, u.RequestID, u.ToolCallCount,
662674
u.UserMessageID, u.AssistantMessageID,
663675
).Scan(&id)
664676
if err != nil {
@@ -676,6 +688,26 @@ func (p *Postgres) UpdateAssistantMessageID(ctx context.Context, usageID, msgID
676688
return nil
677689
}
678690

691+
func (p *Postgres) UpdateTurnLatencyMs(ctx context.Context, usageID int64, ms int) error {
692+
_, err := p.pool.Exec(ctx,
693+
`UPDATE usage_log SET turn_latency_ms = $1 WHERE id = $2`, ms, usageID)
694+
if err != nil {
695+
return fmt.Errorf("update usage turn_latency_ms: %w", err)
696+
}
697+
return nil
698+
}
699+
700+
func (p *Postgres) PutToolCall(ctx context.Context, t llm.ToolCallLog) error {
701+
_, err := p.pool.Exec(ctx, `
702+
INSERT INTO tool_call_log (chat_id, user_msg_id, tool_name, latency_ms, success, error_text)
703+
VALUES ($1, $2, $3, $4, $5, $6)`,
704+
t.ChatID, t.UserMsgID, t.ToolName, t.LatencyMs, t.Success, t.ErrorText)
705+
if err != nil {
706+
return fmt.Errorf("put tool call: %w", err)
707+
}
708+
return nil
709+
}
710+
679711
func (p *Postgres) UsageTotals(ctx context.Context, since time.Time) (llm.UsageTotals, error) {
680712
var t llm.UsageTotals
681713
err := p.pool.QueryRow(ctx, `

internal/store/sqlite.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ CREATE TABLE IF NOT EXISTS usage_log (
6666
reasoning_tokens INTEGER NOT NULL DEFAULT 0,
6767
cost_usd REAL NOT NULL DEFAULT 0,
6868
latency_ms INTEGER NOT NULL DEFAULT 0,
69+
turn_latency_ms INTEGER NOT NULL DEFAULT 0,
6970
success INTEGER NOT NULL DEFAULT 1,
7071
error_class TEXT NOT NULL DEFAULT '',
7172
request_id TEXT NOT NULL DEFAULT '',
@@ -76,6 +77,18 @@ CREATE TABLE IF NOT EXISTS usage_log (
7677
CREATE INDEX IF NOT EXISTS idx_usage_ts_model ON usage_log(ts, model_id);
7778
CREATE INDEX IF NOT EXISTS idx_usage_chat_ts ON usage_log(chat_id, ts);
7879
CREATE INDEX IF NOT EXISTS idx_usage_user_msg ON usage_log(user_message_id);
80+
81+
CREATE TABLE IF NOT EXISTS tool_call_log (
82+
id INTEGER PRIMARY KEY AUTOINCREMENT,
83+
ts DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
84+
chat_id INTEGER NOT NULL DEFAULT 0,
85+
user_msg_id INTEGER,
86+
tool_name TEXT NOT NULL,
87+
latency_ms INTEGER NOT NULL DEFAULT 0,
88+
success INTEGER NOT NULL DEFAULT 1,
89+
error_text TEXT NOT NULL DEFAULT ''
90+
);
91+
CREATE INDEX IF NOT EXISTS idx_tool_call_chat_ts ON tool_call_log(chat_id, ts);
7992
`
8093

8194
const (
@@ -97,10 +110,11 @@ func NewSQLite(path string) (*SQLite, error) {
97110
return nil, fmt.Errorf("init schema: %w", err)
98111
}
99112
// Migrations for existing databases
100-
db.Exec(`ALTER TABLE messages ADD COLUMN parts TEXT`) //nolint:errcheck
101-
db.Exec(`ALTER TABLE messages ADD COLUMN embedding BLOB`) //nolint:errcheck
102-
db.Exec(`ALTER TABLE model_capabilities ADD COLUMN score REAL NOT NULL DEFAULT 0`) //nolint:errcheck
103-
db.Exec(`ALTER TABLE usage_log ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0`) //nolint:errcheck
113+
db.Exec(`ALTER TABLE messages ADD COLUMN parts TEXT`) //nolint:errcheck
114+
db.Exec(`ALTER TABLE messages ADD COLUMN embedding BLOB`) //nolint:errcheck
115+
db.Exec(`ALTER TABLE model_capabilities ADD COLUMN score REAL NOT NULL DEFAULT 0`) //nolint:errcheck
116+
db.Exec(`ALTER TABLE usage_log ADD COLUMN cost_usd REAL NOT NULL DEFAULT 0`) //nolint:errcheck
117+
db.Exec(`ALTER TABLE usage_log ADD COLUMN turn_latency_ms INTEGER NOT NULL DEFAULT 0`) //nolint:errcheck
104118
return &SQLite{db: db}, nil
105119
}
106120

@@ -720,12 +734,12 @@ func (s *SQLite) PutUsage(ctx context.Context, u llm.UsageLog) (int64, error) {
720734
INSERT INTO usage_log (
721735
provider, model_id, role, chat_id,
722736
prompt_tokens, completion_tokens, cached_prompt_tokens, reasoning_tokens,
723-
cost_usd, latency_ms, success, error_class, request_id, tool_call_count,
737+
cost_usd, latency_ms, turn_latency_ms, success, error_class, request_id, tool_call_count,
724738
user_message_id, assistant_message_id
725-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
739+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
726740
u.Provider, u.ModelID, u.Role, u.ChatID,
727741
u.PromptTokens, u.CompletionTokens, u.CachedPromptTokens, u.ReasoningTokens,
728-
u.Cost, u.LatencyMs, boolToInt(u.Success), u.ErrorClass, u.RequestID, u.ToolCallCount,
742+
u.Cost, u.LatencyMs, u.TurnLatencyMs, boolToInt(u.Success), u.ErrorClass, u.RequestID, u.ToolCallCount,
729743
nullableInt64(u.UserMessageID), nullableInt64(u.AssistantMessageID))
730744
if err != nil {
731745
return 0, fmt.Errorf("put usage: %w", err)
@@ -742,6 +756,26 @@ func (s *SQLite) UpdateAssistantMessageID(ctx context.Context, usageID, msgID in
742756
return nil
743757
}
744758

759+
func (s *SQLite) UpdateTurnLatencyMs(ctx context.Context, usageID int64, ms int) error {
760+
_, err := s.db.ExecContext(ctx,
761+
`UPDATE usage_log SET turn_latency_ms = ? WHERE id = ?`, ms, usageID)
762+
if err != nil {
763+
return fmt.Errorf("update usage turn_latency_ms: %w", err)
764+
}
765+
return nil
766+
}
767+
768+
func (s *SQLite) PutToolCall(ctx context.Context, t llm.ToolCallLog) error {
769+
_, err := s.db.ExecContext(ctx, `
770+
INSERT INTO tool_call_log (chat_id, user_msg_id, tool_name, latency_ms, success, error_text)
771+
VALUES (?, ?, ?, ?, ?, ?)`,
772+
t.ChatID, nullableInt64(t.UserMsgID), t.ToolName, t.LatencyMs, boolToInt(t.Success), t.ErrorText)
773+
if err != nil {
774+
return fmt.Errorf("put tool call: %w", err)
775+
}
776+
return nil
777+
}
778+
745779
func (s *SQLite) UsageTotals(ctx context.Context, since time.Time) (llm.UsageTotals, error) {
746780
var t llm.UsageTotals
747781
err := s.db.QueryRowContext(ctx, `

0 commit comments

Comments
 (0)