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:
João Salavisa
2026-06-11 13:45:16 +01:00
committed by GitHub
parent 8e4ee65b31
commit 672f2b1f26
8 changed files with 170 additions and 7 deletions
@@ -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)
+28
View File
@@ -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 &lt;1s, &gt;=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>
+2
View File
@@ -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 = {
+2
View File
@@ -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 = {