feat(stats): add avg turn duration and messages per turn (#1328)
* feat(stats): add avg turn duration and messages per turn to averages card Adds two new metrics to the workspace stats Averages card: mean turn duration and mean message count per turn. Both exclude outliers — turns shorter than 1s (no-op/aborted), longer than 1h (zombie turns where completed_at was backfilled across an agent restart), and turns with no messages — so the displayed means reflect typical work rather than tail skew. On a ~4k-turn sample the filter drops avg duration from ~1062s to ~289s. * fix(stats): exclude exact 1h turns and use Tooltip for averages help text Address PR feedback: the BETWEEN bound was inclusive so turns exactly at the 1h ceiling were retained, contradicting the documented half-open range. Also swap the title attribute on the new Averages rows for the shared Tooltip component so the help text is reachable via keyboard and screen readers. * fix(stats): align turn-row zero state with em-dash convention Address PR feedback: avg_messages_per_turn rendered "0.0" while avg_turn_duration_ms rendered "—" under the same empty-data condition. Also corrects the GetGlobalStats docstring to acknowledge that clean_turn_agg adds one correlated per-turn lookup on top of the four single-scan CTEs. * test(stats): cover exact 1h turn boundary in clean-turn exclusion test Add a turn of exactly 3,600,000ms (cleanTurnMaxDurationMs) to TestGetGlobalStats_CleanTurnExcludesOutliers so the exclusive upper bound is exercised directly, not just via the 2h zombie turn that sits well above the boundary. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground cursor-help">Turn duration</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Mean duration of completed turns, excluding turns <1s, >=1h, or with no
|
||||
messages
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="font-medium tabular-nums">
|
||||
{formatDuration(global.avg_turn_duration_ms)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-sm text-muted-foreground cursor-help">Messages per turn</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Mean message count per turn, same exclusions as turn duration
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="font-medium tabular-nums">
|
||||
{global.avg_messages_per_turn === 0 ? "—" : global.avg_messages_per_turn.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">Sessions</span>
|
||||
<span className="font-medium tabular-nums">{global.total_sessions}</span>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user