feat(stats): show active time and elapsed span per task (#748)
* feat(stats): show active time and elapsed span per task * fix(stats): address review feedback on task duration metrics --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -13,6 +13,8 @@ type TaskStatsDTO struct {
|
||||
UserMessageCount int `json:"user_message_count"`
|
||||
ToolCallCount int `json:"tool_call_count"`
|
||||
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||
ActiveDurationMs int64 `json:"active_duration_ms"`
|
||||
ElapsedSpanMs int64 `json:"elapsed_span_ms"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
CompletedAt *string `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ func taskStatsToDTOs(taskStats []*models.TaskStats) []dto.TaskStatsDTO {
|
||||
UserMessageCount: ts.UserMessageCount,
|
||||
ToolCallCount: ts.ToolCallCount,
|
||||
TotalDurationMs: ts.TotalDurationMs,
|
||||
ActiveDurationMs: ts.ActiveDurationMs,
|
||||
ElapsedSpanMs: ts.ElapsedSpanMs,
|
||||
CreatedAt: ts.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
if ts.CompletedAt != nil {
|
||||
|
||||
@@ -15,6 +15,8 @@ type TaskStats struct {
|
||||
UserMessageCount int `json:"user_message_count"`
|
||||
ToolCallCount int `json:"tool_call_count"`
|
||||
TotalDurationMs int64 `json:"total_duration_ms"`
|
||||
ActiveDurationMs int64 `json:"active_duration_ms"`
|
||||
ElapsedSpanMs int64 `json:"elapsed_span_ms"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -132,6 +133,15 @@ func execOrFatal(t *testing.T, dbConn *sqlx.DB, query string, args ...any) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertDurationAlmostEqual(t *testing.T, got, want int64, label string) {
|
||||
t.Helper()
|
||||
const toleranceMs = int64(2)
|
||||
diff := int64(math.Abs(float64(got - want)))
|
||||
if diff > toleranceMs {
|
||||
t.Errorf("expected %s %dms (+/-%dms), got %dms", label, want, toleranceMs, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStatsIndexes_CreatesIndexes(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
|
||||
@@ -372,6 +382,44 @@ func TestGetTaskStats_ExcludesEphemeralTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTaskStats_IncludesActiveAndElapsedDurations(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', '', 'Task 1', 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 10-minute turns with a 60-minute idle gap in between.
|
||||
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:10: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-01T11:10:00Z', '2026-01-01T11:20:00Z', ?, ?)`, nowStr, nowStr)
|
||||
|
||||
results, err := repo.GetTaskStats(ctx, "ws-1", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskStats failed: %v", err)
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 task stat, got %d", len(results))
|
||||
}
|
||||
|
||||
// Active duration = 20m, elapsed span = 80m.
|
||||
const (
|
||||
wantActiveMs = int64(20 * 60 * 1000)
|
||||
wantElapsedMs = int64(80 * 60 * 1000)
|
||||
)
|
||||
assertDurationAlmostEqual(t, results[0].ActiveDurationMs, wantActiveMs, "active duration")
|
||||
assertDurationAlmostEqual(t, results[0].ElapsedSpanMs, wantElapsedMs, "elapsed span")
|
||||
assertDurationAlmostEqual(t, results[0].TotalDurationMs, wantActiveMs, "total duration")
|
||||
}
|
||||
|
||||
func TestGetDailyActivity_ExcludesEphemeralTasks(t *testing.T) {
|
||||
dbConn := createTestDB(t)
|
||||
repo, err := NewWithDB(dbConn, dbConn)
|
||||
|
||||
@@ -51,7 +51,9 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
|
||||
COALESCE(session_stats.message_count, 0) as message_count,
|
||||
COALESCE(session_stats.user_message_count, 0) as user_message_count,
|
||||
COALESCE(session_stats.tool_call_count, 0) as tool_call_count,
|
||||
COALESCE(turn_stats.total_duration_ms, 0) as total_duration_ms,
|
||||
COALESCE(turn_stats.active_duration_ms, 0) as total_duration_ms,
|
||||
COALESCE(turn_stats.active_duration_ms, 0) as active_duration_ms,
|
||||
COALESCE(turn_stats.elapsed_span_ms, 0) as elapsed_span_ms,
|
||||
t.created_at, session_stats.last_completed_at
|
||||
FROM tasks t
|
||||
LEFT JOIN (
|
||||
@@ -70,7 +72,8 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
|
||||
) session_stats ON session_stats.task_id = t.id
|
||||
LEFT JOIN (
|
||||
SELECT s.task_id,
|
||||
SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as total_duration_ms
|
||||
SUM(CASE WHEN turn.completed_at IS NOT NULL THEN %s ELSE 0 END) as active_duration_ms,
|
||||
%s as elapsed_span_ms
|
||||
FROM task_sessions s
|
||||
LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id
|
||||
WHERE (? IS NULL OR s.started_at >= ?)
|
||||
@@ -78,7 +81,11 @@ func (r *Repository) GetTaskStats(ctx context.Context, workspaceID string, start
|
||||
) turn_stats ON turn_stats.task_id = t.id
|
||||
WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?)
|
||||
ORDER BY t.updated_at DESC
|
||||
`, dur)
|
||||
`, dur, dialect.DurationMs(
|
||||
drv,
|
||||
"MAX(CASE WHEN turn.completed_at IS NOT NULL THEN turn.completed_at END)",
|
||||
"MIN(CASE WHEN turn.completed_at IS NOT NULL THEN turn.started_at END)",
|
||||
))
|
||||
|
||||
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query),
|
||||
startArg, startArg, startArg, startArg,
|
||||
@@ -99,16 +106,21 @@ func (r *Repository) scanTaskStats(rows *sql.Rows) ([]*models.TaskStats, error)
|
||||
var completedAtStr sql.NullString
|
||||
var createdAtStr string
|
||||
var totalDurationMs float64
|
||||
var activeDurationMs float64
|
||||
var elapsedSpanMs float64
|
||||
err := rows.Scan(
|
||||
&stat.TaskID, &stat.TaskTitle, &stat.WorkspaceID, &stat.WorkflowID, &stat.State,
|
||||
&stat.SessionCount, &stat.TurnCount, &stat.MessageCount,
|
||||
&stat.UserMessageCount, &stat.ToolCallCount, &totalDurationMs,
|
||||
&activeDurationMs, &elapsedSpanMs,
|
||||
&createdAtStr, &completedAtStr,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat.TotalDurationMs = int64(totalDurationMs)
|
||||
stat.ActiveDurationMs = int64(activeDurationMs)
|
||||
stat.ElapsedSpanMs = int64(elapsedSpanMs)
|
||||
stat.CreatedAt = parseTimeString(createdAtStr)
|
||||
if completedAtStr.Valid && completedAtStr.String != "" {
|
||||
parsedTime := parseTimeString(completedAtStr.String)
|
||||
|
||||
@@ -223,6 +223,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) {
|
||||
<Card className="rounded-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Longest Tasks</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Ranked by elapsed span</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskDurationList
|
||||
@@ -239,6 +240,7 @@ export function WorkloadSection({ task_stats }: WorkloadSectionProps) {
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
Quickest Tasks
|
||||
</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">Ranked by elapsed span</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TaskDurationList
|
||||
@@ -433,11 +435,11 @@ export function RepoLeaders({ repositoryStats }: { repositoryStats: RepositorySt
|
||||
}
|
||||
|
||||
function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationListProps) {
|
||||
const filtered = [...tasks].filter((t) => t.total_duration_ms > 0);
|
||||
const filtered = [...tasks].filter((t) => t.active_duration_ms > 0 || t.elapsed_span_ms > 0);
|
||||
const sorted =
|
||||
sortDirection === "desc"
|
||||
? filtered.sort((a, b) => b.total_duration_ms - a.total_duration_ms)
|
||||
: filtered.sort((a, b) => a.total_duration_ms - b.total_duration_ms);
|
||||
? filtered.sort((a, b) => b.elapsed_span_ms - a.elapsed_span_ms)
|
||||
: filtered.sort((a, b) => a.elapsed_span_ms - b.elapsed_span_ms);
|
||||
const top3 = sorted.slice(0, 3);
|
||||
|
||||
return (
|
||||
@@ -454,7 +456,10 @@ function TaskDurationList({ tasks, sortDirection, emptyLabel }: TaskDurationList
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-medium tabular-nums text-right">
|
||||
{formatDuration(task.total_duration_ms)}
|
||||
<div>{formatDuration(task.active_duration_ms)}</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
span {formatDuration(task.elapsed_span_ms)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -297,6 +297,8 @@ export type TaskStatsDTO = {
|
||||
user_message_count: number;
|
||||
tool_call_count: number;
|
||||
total_duration_ms: number;
|
||||
active_duration_ms: number;
|
||||
elapsed_span_ms: number;
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user