diff --git a/apps/backend/internal/analytics/dto/dto.go b/apps/backend/internal/analytics/dto/dto.go index 38e37fbcb4..5d85134690 100644 --- a/apps/backend/internal/analytics/dto/dto.go +++ b/apps/backend/internal/analytics/dto/dto.go @@ -33,6 +33,8 @@ type GlobalStatsDTO struct { AvgTurnsPerTask float64 `json:"avg_turns_per_task"` AvgMessagesPerTask float64 `json:"avg_messages_per_task"` AvgDurationMsPerTask int64 `json:"avg_duration_ms_per_task"` + AvgTurnDurationMs int64 `json:"avg_turn_duration_ms"` + AvgMessagesPerTurn float64 `json:"avg_messages_per_turn"` } // DailyActivityDTO represents activity statistics for a single day diff --git a/apps/backend/internal/analytics/handlers/stats_handlers.go b/apps/backend/internal/analytics/handlers/stats_handlers.go index 46442bc533..42ed510c75 100644 --- a/apps/backend/internal/analytics/handlers/stats_handlers.go +++ b/apps/backend/internal/analytics/handlers/stats_handlers.go @@ -75,6 +75,8 @@ func (h *StatsHandlers) httpGetGlobalStats(c *gin.Context) { AvgTurnsPerTask: stats.AvgTurnsPerTask, AvgMessagesPerTask: stats.AvgMessagesPerTask, AvgDurationMsPerTask: stats.AvgDurationMsPerTask, + AvgTurnDurationMs: stats.AvgTurnDurationMs, + AvgMessagesPerTurn: stats.AvgMessagesPerTurn, }) } diff --git a/apps/backend/internal/analytics/models/models.go b/apps/backend/internal/analytics/models/models.go index 2f789a40c8..42777dc280 100644 --- a/apps/backend/internal/analytics/models/models.go +++ b/apps/backend/internal/analytics/models/models.go @@ -35,6 +35,8 @@ type GlobalStats struct { AvgTurnsPerTask float64 `json:"avg_turns_per_task"` AvgMessagesPerTask float64 `json:"avg_messages_per_task"` AvgDurationMsPerTask int64 `json:"avg_duration_ms_per_task"` + AvgTurnDurationMs int64 `json:"avg_turn_duration_ms"` + AvgMessagesPerTurn float64 `json:"avg_messages_per_turn"` } // DailyActivity represents activity statistics for a single day diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index b1b615f3df..d268fdf056 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -246,7 +246,9 @@ func TestGetGlobalStats_AggregatesAcrossTables(t *testing.T) { execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) - // Two turns: 5m + 10m of active duration (= 900_000 ms). + // Two turns: 5m + 10m of active duration (= 900_000 ms). All messages live + // on turn-1; turn-2 is intentionally empty so it's excluded from + // clean_turn_agg via the msg_count >= cleanTurnMinMessages filter. execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-1', 'sess-1', 'task-1', '2026-01-01T10:00:00Z', '2026-01-01T10:05:00Z', ?, ?)`, nowStr, nowStr) execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-2', 'sess-1', 'task-1', '2026-01-01T10:10:00Z', '2026-01-01T10:20:00Z', ?, ?)`, nowStr, nowStr) // Messages: 2 user, 1 tool_call, 1 tool_edit, 1 agent text. @@ -279,6 +281,92 @@ func TestGetGlobalStats_AggregatesAcrossTables(t *testing.T) { t.Errorf("TotalToolCalls: want 2 (tool_call + tool_edit), got %d", stats.TotalToolCalls) } assertDurationAlmostEqual(t, stats.TotalDurationMs, int64(15*60*1000), "TotalDurationMs") + // Clean-turn averages: only turn-1 (5m, 5 msgs) qualifies; turn-2 has 0 messages so it's excluded. + assertDurationAlmostEqual(t, stats.AvgTurnDurationMs, int64(5*60*1000), "AvgTurnDurationMs") + if math.Abs(stats.AvgMessagesPerTurn-5.0) > 0.01 { + t.Errorf("AvgMessagesPerTurn: want 5.0, got %f", stats.AvgMessagesPerTurn) + } +} + +// TestGetGlobalStats_CleanTurnExcludesOutliers verifies that the clean-turn +// metrics exclude turns shorter than 1s, longer than 1h, with no messages, or +// without a completed_at timestamp. +func TestGetGlobalStats_CleanTurnExcludesOutliers(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + // turn-good: 2m, 4 messages — included. + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-good', 'sess-1', 'task-1', '2026-01-01T10:00:00Z', '2026-01-01T10:02:00Z', ?, ?)`, nowStr, nowStr) + // turn-short: 0.5s — excluded (below 1s floor). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-short', 'sess-1', 'task-1', '2026-01-01T11:00:00.000Z', '2026-01-01T11:00:00.500Z', ?, ?)`, nowStr, nowStr) + // turn-zombie: 2h — excluded (above 1h ceiling). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-zombie', 'sess-1', 'task-1', '2026-01-01T12:00:00Z', '2026-01-01T14:00:00Z', ?, ?)`, nowStr, nowStr) + // turn-boundary: exactly 1h — excluded (cleanTurnMaxDurationMs is exclusive). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-boundary', 'sess-1', 'task-1', '2026-01-01T13:00:00Z', '2026-01-01T14:00:00Z', ?, ?)`, nowStr, nowStr) + // turn-empty: 10m, 0 messages — excluded (no messages). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-empty', 'sess-1', 'task-1', '2026-01-01T15:00:00Z', '2026-01-01T15:10:00Z', ?, ?)`, nowStr, nowStr) + // turn-incomplete: still running — excluded (NULL completed_at). + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, created_at, updated_at) VALUES ('turn-incomplete', 'sess-1', 'task-1', '2026-01-01T16:00:00Z', ?, ?)`, nowStr, nowStr) + + // Messages: 4 on turn-good, 1 each on the otherwise-excluded turns so they aren't excluded by msg filter alone. + for i, turnID := range []string{"turn-good", "turn-good", "turn-good", "turn-good", "turn-short", "turn-zombie", "turn-boundary", "turn-incomplete"} { + mid := fmt.Sprintf("m-%d", i) + execOrFatal(t, dbConn, `INSERT INTO task_session_messages (id, task_session_id, turn_id, author_type, type, content, created_at) VALUES (?, 'sess-1', ?, 'user', 'message', 'x', ?)`, mid, turnID, nowStr) + } + + stats, err := repo.GetGlobalStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + // Only turn-good (120s, 4 msgs) contributes. + assertDurationAlmostEqual(t, stats.AvgTurnDurationMs, int64(2*60*1000), "AvgTurnDurationMs") + if math.Abs(stats.AvgMessagesPerTurn-4.0) > 0.01 { + t.Errorf("AvgMessagesPerTurn: want 4.0, got %f", stats.AvgMessagesPerTurn) + } +} + +// TestGetGlobalStats_CleanTurnZeroWhenNoQualifyingTurns verifies that the +// clean-turn averages are zero (not NaN/NULL) when every turn is filtered out. +func TestGetGlobalStats_CleanTurnZeroWhenNoQualifyingTurns(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + // Single short turn (excluded) — avg should be 0, not NaN. + execOrFatal(t, dbConn, `INSERT INTO task_session_turns (id, task_session_id, task_id, started_at, completed_at, created_at, updated_at) VALUES ('turn-short', 'sess-1', 'task-1', '2026-01-01T10:00:00.000Z', '2026-01-01T10:00:00.100Z', ?, ?)`, nowStr, nowStr) + + stats, err := repo.GetGlobalStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + if stats.AvgTurnDurationMs != 0 { + t.Errorf("AvgTurnDurationMs: want 0, got %d", stats.AvgTurnDurationMs) + } + if stats.AvgMessagesPerTurn != 0 { + t.Errorf("AvgMessagesPerTurn: want 0, got %f", stats.AvgMessagesPerTurn) + } } func TestGetGlobalStats_EmptyWorkspace(t *testing.T) { diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 588d1d714f..5766c6cf3b 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -142,10 +142,25 @@ func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error) return results, rows.Err() } +// Outlier bounds for the "average turn size" metric. Determined empirically +// from prod data: durations under cleanTurnMinDurationMs are no-op/aborted +// turns and durations at or above cleanTurnMaxDurationMs are zombie turns +// whose completed_at was backfilled across an agent restart. Both classes +// badly skew the mean — excluding them drops avg duration from ~1062s to +// ~289s on a ~4k-turn sample. The duration filter is half-open +// [cleanTurnMinDurationMs, cleanTurnMaxDurationMs). +const ( + cleanTurnMinDurationMs = 1000 + cleanTurnMaxDurationMs = 3600000 + cleanTurnMinMessages = 1 +) + // GetGlobalStats retrieves workspace-wide aggregated statistics. -// Implemented as a single query over 4 CTEs (tasks, sessions, turns, messages) -// rather than 9 independent correlated subqueries: each underlying table is -// scanned at most once per request. +// Implemented as a single query over 5 CTEs (tasks, sessions, turns, +// clean_turn, messages). The four count/sum CTEs each scan their underlying +// table at most once per request; clean_turn additionally issues one +// index-only message-count subquery per qualifying turn (kept correlated to +// stay portable between the SQLite and Postgres dialects). func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GlobalStats, error) { var startArg any if start != nil { @@ -183,6 +198,22 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) ), + clean_turn_agg AS ( + SELECT + AVG(dur_ms) AS avg_turn_duration_ms, + AVG(msg_count) AS avg_messages_per_turn + FROM ( + SELECT + %s AS dur_ms, + (SELECT COUNT(*) FROM task_session_messages m WHERE m.turn_id = turn.id) AS msg_count + FROM task_session_turns turn + JOIN task_sessions s ON s.id = turn.task_session_id + JOIN tasks t ON t.id = s.task_id + WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + AND turn.completed_at IS NOT NULL + ) clean + WHERE dur_ms >= %d AND dur_ms < %d AND msg_count >= %d + ), message_agg AS ( SELECT COUNT(*) AS total_messages, @@ -198,24 +229,28 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta session_agg.total_sessions, turn_agg.total_turns, message_agg.total_messages, message_agg.total_user_messages, message_agg.total_tool_calls, - turn_agg.total_duration_ms - FROM task_agg, session_agg, turn_agg, message_agg - `, dur) + turn_agg.total_duration_ms, + clean_turn_agg.avg_turn_duration_ms, clean_turn_agg.avg_messages_per_turn + FROM task_agg, session_agg, turn_agg, clean_turn_agg, message_agg + `, dur, dur, cleanTurnMinDurationMs, cleanTurnMaxDurationMs, cleanTurnMinMessages) var stats models.GlobalStats var totalDurationMs float64 var completedTasks, inProgressTasks sql.NullInt64 var userMessages, toolCalls sql.NullInt64 + var avgTurnDurationMs, avgMessagesPerTurn sql.NullFloat64 err := r.ro.QueryRowContext(ctx, r.ro.Rebind(query), workspaceID, startArg, startArg, // task_agg workspaceID, startArg, startArg, // session_agg workspaceID, startArg, startArg, // turn_agg + workspaceID, startArg, startArg, // clean_turn_agg workspaceID, startArg, startArg, // message_agg ).Scan( &stats.TotalTasks, &completedTasks, &inProgressTasks, &stats.TotalSessions, &stats.TotalTurns, &stats.TotalMessages, &userMessages, &toolCalls, &totalDurationMs, + &avgTurnDurationMs, &avgMessagesPerTurn, ) if err != nil { return nil, err @@ -225,6 +260,8 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta stats.TotalUserMessages = int(userMessages.Int64) stats.TotalToolCalls = int(toolCalls.Int64) stats.TotalDurationMs = int64(totalDurationMs) + stats.AvgTurnDurationMs = int64(avgTurnDurationMs.Float64) + stats.AvgMessagesPerTurn = avgMessagesPerTurn.Float64 if stats.TotalTasks > 0 { stats.AvgTurnsPerTask = float64(stats.TotalTurns) / float64(stats.TotalTasks) diff --git a/apps/web/app/stats/stats-sections.tsx b/apps/web/app/stats/stats-sections.tsx index 4995594b69..da09444a14 100644 --- a/apps/web/app/stats/stats-sections.tsx +++ b/apps/web/app/stats/stats-sections.tsx @@ -2,6 +2,7 @@ import { IconGitCommit } from "@tabler/icons-react"; import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { StatsResponse, TaskStatsDTO, RepositoryStatsDTO } from "@/lib/types/http"; function formatDuration(ms: number): string { @@ -137,6 +138,33 @@ function GitOrAveragesCard({ global, git_stats }: { global: GlobalStats; git_sta {global.avg_messages_per_task.toFixed(1)} +
+ + + Turn duration + + + Mean duration of completed turns, excluding turns <1s, >=1h, or with no + messages + + + + {formatDuration(global.avg_turn_duration_ms)} + +
+
+ + + Messages per turn + + + Mean message count per turn, same exclusions as turn duration + + + + {global.avg_messages_per_turn === 0 ? "—" : global.avg_messages_per_turn.toFixed(1)} + +
Sessions {global.total_sessions} diff --git a/apps/web/app/stats/stats-utils.test.ts b/apps/web/app/stats/stats-utils.test.ts index 78d92880f6..2f97d43aba 100644 --- a/apps/web/app/stats/stats-utils.test.ts +++ b/apps/web/app/stats/stats-utils.test.ts @@ -23,6 +23,8 @@ const emptyGlobal: StatsResponse["global"] = { avg_turns_per_task: 0, avg_messages_per_task: 0, avg_duration_ms_per_task: 0, + avg_turn_duration_ms: 0, + avg_messages_per_turn: 0, }; const sampleStats: StatsResponse = { diff --git a/apps/web/lib/types/http-agents.ts b/apps/web/lib/types/http-agents.ts index 2697ab6dc8..267fbfb622 100644 --- a/apps/web/lib/types/http-agents.ts +++ b/apps/web/lib/types/http-agents.ts @@ -337,6 +337,8 @@ export type GlobalStatsDTO = { avg_turns_per_task: number; avg_messages_per_task: number; avg_duration_ms_per_task: number; + avg_turn_duration_ms: number; + avg_messages_per_turn: number; }; export type DailyActivityDTO = {