Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

4226 lines
120 KiB
Go

package duckdb
import (
"context"
"database/sql"
"fmt"
"math"
"slices"
"sort"
"strings"
"time"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
pricingpkg "go.kenn.io/agentsview/internal/pricing"
"go.kenn.io/agentsview/internal/signals"
)
const (
duckActiveWindow = 10 * time.Minute
duckStaleWindow = 60 * time.Minute
)
type duckAnalyticsSession struct {
id string
project string
machine string
agent string
firstMessage *string
displayName *string
startedAt string
endedAt string
createdAt string
messageCount int
userMessageCount int
totalOutputTokens int
hasTotalOutputTokens bool
isAutomated bool
terminationStatus *string
healthScore *int
healthGrade *string
outcome string
outcomeConfidence string
toolFailures int
toolRetries int
editChurn int
compactions int
midTaskCompactions int
contextPressureMax *float64
qualitySignalVersion int
shortPromptCount int
unstructuredStart bool
missingSuccessCriteriaCount int
missingVerificationCount int
duplicatePromptCount int
noCodeContextCount int
runawayToolLoopCount int
frustrationMarkerCount int
}
func (s *Store) analyticsSessions(
ctx context.Context, f db.AnalyticsFilter,
) ([]duckAnalyticsSession, error) {
return s.analyticsSessionsFiltered(ctx, f, true, true)
}
// analyticsSessionsFiltered loads candidate sessions, optionally applying
// the date and hour/day-of-week predicates at the session level. Skill
// analytics passes false for both so those filters can be applied to each
// call's own message timestamp instead. With a model filter and an active
// hour/dow filter it pairs through the shared scope reducer (see
// analyticsSessionsModelTimeFiltered) so an empty-model user turn at the
// selected hour keeps its session, matching how the model-scoped panels count.
func (s *Store) analyticsSessionsFiltered(
ctx context.Context, f db.AnalyticsFilter,
includeDate, includeTime bool,
) ([]duckAnalyticsSession, error) {
if includeTime && f.HasTimeFilter() && strings.TrimSpace(f.Model) != "" {
return s.analyticsSessionsModelTimeFiltered(ctx, f, includeDate)
}
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.",
includeDate, includeTime)
rows, err := s.queryContext(ctx, `
SELECT id, project, machine, agent, first_message,
COALESCE(display_name, session_name) AS display_name,
started_at, ended_at, created_at, message_count,
user_message_count, total_output_tokens,
has_total_output_tokens, is_automated,
termination_status, health_score, health_grade, outcome,
outcome_confidence, tool_failure_signal_count,
tool_retry_count, edit_churn_count, compaction_count,
mid_task_compaction_count, context_pressure_max,
quality_signal_version, short_prompt_count,
unstructured_start, missing_success_criteria_count,
missing_verification_count, duplicate_prompt_count,
no_code_context_count, runaway_tool_loop_count
FROM sessions s
WHERE `+where, args...)
if err != nil {
return nil, fmt.Errorf("querying duckdb analytics sessions: %w", err)
}
defer rows.Close()
var out []duckAnalyticsSession
for rows.Next() {
var r duckAnalyticsSession
var startedAt, endedAt, createdAt any
if err := rows.Scan(
&r.id, &r.project, &r.machine, &r.agent,
&r.firstMessage, &r.displayName,
&startedAt, &endedAt, &createdAt,
&r.messageCount, &r.userMessageCount,
&r.totalOutputTokens, &r.hasTotalOutputTokens,
&r.isAutomated, &r.terminationStatus,
&r.healthScore, &r.healthGrade, &r.outcome,
&r.outcomeConfidence, &r.toolFailures, &r.toolRetries,
&r.editChurn, &r.compactions, &r.midTaskCompactions,
&r.contextPressureMax, &r.qualitySignalVersion,
&r.shortPromptCount, &r.unstructuredStart,
&r.missingSuccessCriteriaCount, &r.missingVerificationCount,
&r.duplicatePromptCount, &r.noCodeContextCount,
&r.runawayToolLoopCount,
); err != nil {
return nil, fmt.Errorf("scanning duckdb analytics session: %w", err)
}
r.startedAt = formatDBTime(startedAt)
r.endedAt = formatDBTime(endedAt)
r.createdAt = formatDBTime(createdAt)
out = append(out, r)
}
return out, rows.Err()
}
// analyticsSessionsModelTimeFiltered loads the date- and model-scoped sessions
// (without the in-SQL day/hour predicate) and keeps only those with at least
// one scoped message matching the hour/dow filter. Running the shared reducer
// instead of the direct m.model time predicate keeps sessions whose matching
// message is an empty-model user turn paired with the selected-model assistant.
func (s *Store) analyticsSessionsModelTimeFiltered(
ctx context.Context, f db.AnalyticsFilter, includeDate bool,
) ([]duckAnalyticsSession, error) {
sessions, err := s.analyticsSessionsFiltered(ctx, f, includeDate, false)
if err != nil {
return nil, err
}
candidateIDs := make([]string, 0, len(sessions))
for _, session := range sessions {
candidateIDs = append(candidateIDs, session.id)
}
scope, err := s.resolveAnalyticsMessageScope(ctx, candidateIDs, f, false)
if err != nil {
return nil, err
}
matched := make(map[string]struct{})
if scope != nil {
for id := range scope.MessagesBySession() {
matched[id] = struct{}{}
}
}
out := make([]duckAnalyticsSession, 0, len(sessions))
for _, session := range sessions {
if _, ok := matched[session.id]; ok {
out = append(out, session)
}
}
return out, nil
}
func duckBuildAnalyticsWhere(
f db.AnalyticsFilter,
dateCol string,
tablePrefix string,
includeDate bool,
includeTime bool,
) (string, []any) {
q := func(col string) string { return tablePrefix + col }
preds := []string{
q("message_count") + " > 0",
// Mirror the SQLite analytics filter: subagent and fork rows are
// excluded unless the filter opts in (sum/count surfaces for
// subagents, the activity report for both). The shared helper
// qualifies the column with tablePrefix directly.
db.RelationshipExclusionSQL(f.IncludeSubagents, f.IncludeForks, tablePrefix),
q("deleted_at") + " IS NULL",
}
var args []any
if includeDate {
if f.From != "" {
preds = append(preds, dateCol+" >= CAST(? AS TIMESTAMP)")
args = append(args, duckUsagePaddedUTCBound(f.From+"T00:00:00Z", -14))
}
if f.To != "" {
preds = append(preds, dateCol+" <= CAST(? AS TIMESTAMP)")
args = append(args, duckUsagePaddedUTCBound(f.To+"T23:59:59Z", 14))
}
localDate, localDateArgs := duckAnalyticsLocalDateExpr(dateCol, f)
if f.From != "" {
preds = append(preds, localDate+" >= ?")
args = append(args, append(localDateArgs, f.From)...)
}
if f.To != "" {
preds = append(preds, localDate+" <= ?")
args = append(args, append(localDateArgs, f.To)...)
}
}
if f.Machine != "" {
preds, args = appendDuckAnalyticsCSVFilter(preds, args, q("machine"), f.Machine)
}
if f.Project != "" {
preds = append(preds, q("project")+" = ?")
args = append(args, f.Project)
}
if f.GitBranch != "" {
var clause string
clause, args = db.BranchPairClauseArgs(q("project"), q("git_branch"), f.GitBranch, args)
preds = append(preds, clause)
}
if f.Agent != "" {
preds, args = appendDuckAnalyticsCSVFilter(preds, args, q("agent"), f.Agent)
}
if modelPred, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model); modelPred != "" {
preds = append(preds,
"EXISTS (SELECT 1 FROM messages m WHERE m.session_id = "+q("id")+" AND "+modelPred+")")
args = append(args, modelArgs...)
}
if f.MinUserMessages > 0 {
preds = append(preds, q("user_message_count")+" >= ?")
args = append(args, f.MinUserMessages)
}
scope := duckNormalizeAutomatedScope(
f.AutomatedScope, f.ExcludeAutomated)
if f.ExcludeOneShot {
// Exempt subagents from one-shot exclusion when counting them,
// mirroring db.AnalyticsFilter.OneShotExclusionSQL. Workflow
// subagents are inherently one-shot but represent real work.
oneShot := func(base string) string {
if f.IncludeSubagents {
return "(" + base + " OR " +
q("relationship_type") + " = 'subagent')"
}
return base
}
if scope != "human" {
preds = append(preds, oneShot("("+q("user_message_count")+" > 1 OR "+q("is_automated")+" = TRUE)"))
} else {
preds = append(preds, oneShot(q("user_message_count")+" > 1"))
}
}
if pred := duckAutomatedScopePredicate(
scope, q("is_automated")); pred != "" {
preds = append(preds, pred)
}
if f.ExcludeInteractive {
preds = append(preds, q("is_automated")+" = TRUE")
}
if f.ActiveSince != "" {
activeSince := f.ActiveSince
if parsed, ok := parseAnalyticsTime(f.ActiveSince); ok {
activeSince = parsed.Format(time.RFC3339)
}
preds = append(preds,
"COALESCE("+q("ended_at")+", "+q("started_at")+", "+q("created_at")+") >= CAST(? AS TIMESTAMP)")
args = append(args, activeSince)
}
if pred, predArgs := duckAnalyticsTerminationPred(
f.Termination,
"COALESCE("+q("ended_at")+", "+q("started_at")+", "+q("created_at")+")",
q("termination_status"),
); pred != "" {
preds = append(preds, pred)
args = append(args, predArgs...)
}
if includeTime && (f.DayOfWeek != nil || f.Hour != nil) {
pred, predArgs := duckAnalyticsMessageTimeExists(f, q("id"))
preds = append(preds, pred)
args = append(args, predArgs...)
}
return strings.Join(preds, " AND "), args
}
func duckNormalizeAutomatedScope(
scope string,
excludeAutomated bool,
) string {
switch strings.TrimSpace(scope) {
case "human", "all", "automated":
return strings.TrimSpace(scope)
}
if excludeAutomated {
return "human"
}
return "all"
}
func duckAutomatedScopePredicate(scope, col string) string {
switch scope {
case "human":
return col + " = FALSE"
case "automated":
return col + " = TRUE"
default:
return ""
}
}
func appendDuckAnalyticsCSVFilter(
preds []string, args []any, col string, raw string,
) ([]string, []any) {
pred, predArgs := duckAnalyticsCSVPredicate(col, raw)
if pred != "" {
preds = append(preds, pred)
args = append(args, predArgs...)
}
return preds, args
}
func duckAnalyticsCSVValues(raw string) []string {
values := strings.Split(raw, ",")
out := values[:0]
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
out = append(out, trimmed)
}
}
return out
}
func duckAnalyticsCSVPredicate(
col string, raw string,
) (string, []any) {
values := duckAnalyticsCSVValues(raw)
if len(values) == 0 {
return "", nil
}
if len(values) == 1 {
return col + " = ?", []any{values[0]}
}
placeholders := make([]string, len(values))
args := make([]any, 0, len(values))
for i, value := range values {
placeholders[i] = "?"
args = append(args, value)
}
return col + " IN (" + strings.Join(placeholders, ",") + ")", args
}
func duckAnalyticsLocalDateExpr(
tsExpr string, f db.AnalyticsFilter,
) (string, []any) {
if f.Timezone != "" {
return "strftime(timezone(?, timezone('UTC', " + tsExpr + ")), '%Y-%m-%d')",
[]any{f.Timezone}
}
return "strftime(" + tsExpr + ", '%Y-%m-%d')", nil
}
func duckAnalyticsLocalTimeExpr(
tsExpr string, f db.AnalyticsFilter,
) (string, []any) {
if f.Timezone != "" {
return "timezone(?, timezone('UTC', " + tsExpr + "))", []any{f.Timezone}
}
return tsExpr, nil
}
func duckAnalyticsMessageTimeExists(
f db.AnalyticsFilter, sessionIDExpr string,
) (string, []any) {
preds := []string{
"m.session_id = " + sessionIDExpr,
"m.timestamp IS NOT NULL",
}
var args []any
if modelPred, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model); modelPred != "" {
preds = append(preds, modelPred)
args = append(args, modelArgs...)
}
if f.DayOfWeek != nil {
local, localArgs := duckAnalyticsLocalTimeExpr("m.timestamp", f)
preds = append(preds,
"((CAST(strftime("+local+", '%w') AS INTEGER) + 6) % 7) = ?")
args = append(args, append(localArgs, *f.DayOfWeek)...)
}
if f.Hour != nil {
local, localArgs := duckAnalyticsLocalTimeExpr("m.timestamp", f)
preds = append(preds,
"CAST(strftime("+local+", '%H') AS INTEGER) = ?")
args = append(args, append(localArgs, *f.Hour)...)
}
return "EXISTS (SELECT 1 FROM messages m WHERE " +
strings.Join(preds, " AND ") + ")", args
}
func duckAnalyticsTerminationPred(
status string,
activityExpr string,
statusExpr string,
) (string, []any) {
if status == "" || status == "all" {
return "", nil
}
now := time.Now().UTC()
activeCutoff := now.Add(-duckActiveWindow)
staleCutoff := now.Add(-duckStaleWindow)
flagged := statusExpr + " IN ('tool_call_pending', 'truncated')"
var parts []string
var args []any
for part := range strings.SplitSeq(status, ",") {
switch strings.TrimSpace(part) {
case "active":
parts = append(parts, activityExpr+" > CAST(? AS TIMESTAMP)")
args = append(args, activeCutoff.Format(time.RFC3339))
case "stale":
parts = append(parts, "("+flagged+
" AND "+activityExpr+" > CAST(? AS TIMESTAMP)"+
" AND "+activityExpr+" <= CAST(? AS TIMESTAMP))")
args = append(args,
staleCutoff.Format(time.RFC3339),
activeCutoff.Format(time.RFC3339),
)
case "unclean":
parts = append(parts, "("+flagged+
" AND "+activityExpr+" <= CAST(? AS TIMESTAMP))")
args = append(args, staleCutoff.Format(time.RFC3339))
case "clean":
parts = append(parts, statusExpr+" = 'clean'")
case "awaiting_user":
parts = append(parts, statusExpr+" = 'awaiting_user'")
}
}
if len(parts) == 0 {
return "", nil
}
return "(" + strings.Join(parts, " OR ") + ")", args
}
func duckAnalyticsTimeMatches(t time.Time, f db.AnalyticsFilter) bool {
if f.DayOfWeek != nil {
dow := (int(t.Weekday()) + 6) % 7
if dow != *f.DayOfWeek {
return false
}
}
if f.Hour != nil && t.Hour() != *f.Hour {
return false
}
return true
}
func analyticsDateTime(r duckAnalyticsSession) string {
if r.startedAt != "" {
return r.startedAt
}
return r.createdAt
}
func analyticsLocalDate(ts, tz string) string {
t, ok := parseAnalyticsTime(ts)
if !ok {
return ""
}
return t.In(analyticsLocation(tz)).Format("2006-01-02")
}
func analyticsLocation(tz string) *time.Location {
if tz == "" {
return time.UTC
}
loc, err := time.LoadLocation(tz)
if err != nil {
return time.UTC
}
return loc
}
func parseAnalyticsTime(ts string) (time.Time, bool) {
if t, ok := parseTimestamp(ts); ok {
return t, true
}
layouts := []string{
"2006-01-02 15:04:05.999999-07",
"2006-01-02 15:04:05.999999",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if t, err := time.Parse(layout, ts); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
func median(values []int) int {
if len(values) == 0 {
return 0
}
n := len(values)
if n%2 == 0 {
return (values[n/2-1] + values[n/2]) / 2
}
return values[n/2]
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if v != "" {
return v
}
}
return ""
}
func round1(v float64) float64 { return math.Round(v*10) / 10 }
func (s *Store) getAnalyticsModelsForSessionIDs(
ctx context.Context, sessionIDs []string,
) ([]string, error) {
if len(sessionIDs) == 0 {
return []string{}, nil
}
models := map[string]bool{}
err := duckQueryChunked(sessionIDs, func(chunk []string) error {
ph, args := duckInPlaceholders(chunk)
rows, err := s.queryContext(ctx, `
SELECT DISTINCT model
FROM messages
WHERE session_id IN `+ph+`
AND COALESCE(model, '') <> ''
ORDER BY model`, args...)
if err != nil {
return fmt.Errorf("querying duckdb analytics models: %w", err)
}
defer rows.Close()
for rows.Next() {
var model string
if err := rows.Scan(&model); err != nil {
return fmt.Errorf("scanning duckdb analytics model: %w", err)
}
models[model] = true
}
return rows.Err()
})
if err != nil {
return nil, err
}
return sortedBoolKeys(models), nil
}
func (s *Store) getAnalyticsModelsForSessionIDsFiltered(
ctx context.Context,
sessionIDs []string,
f db.AnalyticsFilter,
) ([]string, error) {
if len(sessionIDs) == 0 {
return []string{}, nil
}
seen := make(map[string]struct{}, len(sessionIDs))
unique := make([]string, 0, len(sessionIDs))
for _, sessionID := range sessionIDs {
if _, ok := seen[sessionID]; ok {
continue
}
seen[sessionID] = struct{}{}
unique = append(unique, sessionID)
}
filterModels := duckAnalyticsCSVValues(f.Model)
allowedModels := make(map[string]struct{}, len(filterModels))
for _, model := range filterModels {
allowedModels[model] = struct{}{}
}
loc := analyticsLocation(f.Timezone)
models := map[string]bool{}
err := duckQueryChunked(unique, func(chunk []string) error {
ph, args := duckInPlaceholders(chunk)
rows, err := s.queryContext(ctx, `
SELECT model, timestamp
FROM messages
WHERE session_id IN `+ph+`
AND COALESCE(model, '') <> ''`, args...)
if err != nil {
return fmt.Errorf("querying duckdb filtered analytics models: %w", err)
}
defer rows.Close()
for rows.Next() {
var model string
var ts any
if err := rows.Scan(&model, &ts); err != nil {
return fmt.Errorf("scanning duckdb filtered analytics model: %w", err)
}
if len(allowedModels) > 0 {
if _, ok := allowedModels[model]; !ok {
continue
}
}
if f.HasTimeFilter() {
t, ok := parseAnalyticsTime(formatDBTime(ts))
if !ok || !duckAnalyticsTimeMatches(t.In(loc), f) {
continue
}
}
models[model] = true
}
return rows.Err()
})
if err != nil {
return nil, err
}
return sortedBoolKeys(models), nil
}
func (s *Store) getAnalyticsFilteredMessageStats(
ctx context.Context,
sessionIDs []string,
f db.AnalyticsFilter,
) (map[string]db.MessageStats, error) {
scope, err := s.resolveAnalyticsMessageScope(ctx, sessionIDs, f, false)
if err != nil {
return nil, err
}
if scope == nil {
return map[string]db.MessageStats{}, nil
}
return scope.StatsBySession(), nil
}
func (s *Store) analyticsSessionsWithModelMessageCounts(
ctx context.Context, f db.AnalyticsFilter,
) ([]duckAnalyticsSession, error) {
sessions, err := s.analyticsSessions(ctx, f)
if err != nil || strings.TrimSpace(f.Model) == "" || len(sessions) == 0 {
return sessions, err
}
sessionIDs := make([]string, 0, len(sessions))
for _, session := range sessions {
sessionIDs = append(sessionIDs, session.id)
}
stats, err := s.getAnalyticsFilteredMessageStats(
ctx, sessionIDs, f,
)
if err != nil {
return nil, err
}
for i := range sessions {
stat := stats[sessions[i].id]
sessions[i].messageCount = stat.Messages
sessions[i].totalOutputTokens = stat.OutputTokens
sessions[i].hasTotalOutputTokens = stat.HasOutputTokens
}
return sessions, nil
}
func (s *Store) getAnalyticsSummaryWithModelCounts(
ctx context.Context, f db.AnalyticsFilter,
) (db.AnalyticsSummary, error) {
sessions, err := s.analyticsSessionsWithModelMessageCounts(ctx, f)
if err != nil {
return db.AnalyticsSummary{}, err
}
resp := db.AnalyticsSummary{
Agents: map[string]*db.AgentSummary{},
Models: []string{},
}
if len(sessions) == 0 {
return resp, nil
}
days := map[string]bool{}
projects := map[string]int{}
msgCounts := make([]int, 0, len(sessions))
sessionIDs := make([]string, 0, len(sessions))
for _, session := range sessions {
date := analyticsLocalDate(analyticsDateTime(session), f.Timezone)
resp.TotalSessions++
resp.TotalMessages += session.messageCount
if session.hasTotalOutputTokens {
resp.TotalOutputTokens += session.totalOutputTokens
resp.TokenReportingSessions++
}
days[date] = true
projects[session.project] += session.messageCount
msgCounts = append(msgCounts, session.messageCount)
sessionIDs = append(sessionIDs, session.id)
if resp.Agents[session.agent] == nil {
resp.Agents[session.agent] = &db.AgentSummary{}
}
resp.Agents[session.agent].Sessions++
resp.Agents[session.agent].Messages += session.messageCount
}
var models []string
if strings.TrimSpace(f.Model) != "" {
models, err = s.getAnalyticsModelsForSessionIDsFiltered(
ctx, sessionIDs, f,
)
} else {
models, err = s.getAnalyticsModelsForSessionIDs(ctx, sessionIDs)
}
if err != nil {
return db.AnalyticsSummary{}, err
}
resp.Models = models
resp.ActiveProjects = len(projects)
resp.ActiveDays = len(days)
resp.AvgMessages = round1(float64(resp.TotalMessages) / float64(resp.TotalSessions))
sort.Ints(msgCounts)
resp.MedianMessages = median(msgCounts)
if n := len(msgCounts); n > 0 {
resp.P90Messages = msgCounts[min(int(math.Floor(float64(n)*0.9))+1, n)-1]
}
maxMsgs := -1
for _, name := range sortedKeys(projects) {
if projects[name] > maxMsgs {
maxMsgs = projects[name]
resp.MostActive = name
}
}
if resp.TotalMessages > 0 {
counts := make([]int, 0, len(projects))
for _, count := range projects {
counts = append(counts, count)
}
sort.Sort(sort.Reverse(sort.IntSlice(counts)))
topSum := 0
for _, count := range counts[:min(3, len(counts))] {
topSum += count
}
resp.Concentration = math.Round(
float64(topSum)/float64(resp.TotalMessages)*1000,
) / 1000
}
return resp, nil
}
func (s *Store) GetAnalyticsSummary(
ctx context.Context, f db.AnalyticsFilter,
) (db.AnalyticsSummary, error) {
// Sum/count aggregate: count subagent sessions (mirrors SQLite).
f.IncludeSubagents = true
if strings.TrimSpace(f.Model) != "" {
return s.getAnalyticsSummaryWithModelCounts(ctx, f)
}
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.", true, true)
localDate, localDateArgs := duckAnalyticsLocalDateExpr(
"COALESCE(s.started_at, s.created_at)", f)
queryArgs := append([]any{}, localDateArgs...)
queryArgs = append(queryArgs, args...)
query := `
WITH filtered AS (
SELECT s.id, s.project, s.agent, s.message_count,
s.total_output_tokens, s.has_total_output_tokens,
` + localDate + ` AS local_date
FROM sessions s
WHERE ` + where + `
),
ranked AS (
SELECT message_count,
ROW_NUMBER() OVER (ORDER BY message_count ASC) AS rn,
COUNT(*) OVER () AS n
FROM filtered
),
project_totals AS (
SELECT project, SUM(message_count) AS messages
FROM filtered
GROUP BY project
)
SELECT
COUNT(*) AS total_sessions,
COALESCE(SUM(message_count), 0) AS total_messages,
COALESCE(SUM(CASE WHEN has_total_output_tokens
THEN total_output_tokens ELSE 0 END), 0) AS total_output_tokens,
COALESCE(SUM(CASE WHEN has_total_output_tokens
THEN 1 ELSE 0 END), 0) AS token_reporting_sessions,
COUNT(DISTINCT project) AS active_projects,
COUNT(DISTINCT local_date) AS active_days,
COALESCE(ROUND(AVG(message_count), 1), 0) AS avg_messages,
COALESCE((
SELECT CAST(FLOOR(AVG(message_count)) AS INTEGER)
FROM ranked
WHERE rn IN (
CAST(FLOOR((n + 1) / 2.0) AS BIGINT),
CAST(FLOOR((n + 2) / 2.0) AS BIGINT)
)
), 0) AS median_messages,
COALESCE((
SELECT message_count
FROM ranked
WHERE rn = LEAST(CAST(FLOOR(n * 0.9) AS BIGINT) + 1, n)
LIMIT 1
), 0) AS p90_messages,
COALESCE((
SELECT project
FROM project_totals
ORDER BY messages DESC, project ASC
LIMIT 1
), '') AS most_active,
COALESCE(ROUND((
SELECT SUM(messages)
FROM (
SELECT messages
FROM project_totals
ORDER BY messages DESC
LIMIT 3
) top_projects
)::DOUBLE / NULLIF(SUM(message_count), 0), 3), 0) AS concentration
FROM filtered`
rows, err := s.queryContext(ctx, query, queryArgs...)
if err != nil {
return db.AnalyticsSummary{}, fmt.Errorf("querying duckdb analytics summary: %w", err)
}
resp := db.AnalyticsSummary{Agents: map[string]*db.AgentSummary{}}
if !rows.Next() {
rows.Close()
return resp, nil
}
if err := rows.Scan(
&resp.TotalSessions,
&resp.TotalMessages,
&resp.TotalOutputTokens,
&resp.TokenReportingSessions,
&resp.ActiveProjects,
&resp.ActiveDays,
&resp.AvgMessages,
&resp.MedianMessages,
&resp.P90Messages,
&resp.MostActive,
&resp.Concentration,
); err != nil {
rows.Close()
return db.AnalyticsSummary{}, fmt.Errorf("scanning duckdb analytics summary: %w", err)
}
if err := rows.Err(); err != nil {
rows.Close()
return db.AnalyticsSummary{}, fmt.Errorf("iterating duckdb analytics summary: %w", err)
}
if err := rows.Close(); err != nil {
return db.AnalyticsSummary{}, fmt.Errorf("closing duckdb analytics summary rows: %w", err)
}
agentRows, err := s.queryContext(ctx, `
WITH filtered AS (
SELECT s.agent, s.message_count
FROM sessions s
WHERE `+where+`
)
SELECT agent, COUNT(*), COALESCE(SUM(message_count), 0)
FROM filtered
GROUP BY agent`,
args...,
)
if err != nil {
return db.AnalyticsSummary{}, fmt.Errorf("querying duckdb analytics summary agents: %w", err)
}
defer agentRows.Close()
for agentRows.Next() {
var agent string
var summary db.AgentSummary
if err := agentRows.Scan(&agent, &summary.Sessions, &summary.Messages); err != nil {
return db.AnalyticsSummary{}, fmt.Errorf("scanning duckdb analytics summary agent: %w", err)
}
resp.Agents[agent] = &summary
}
if err := agentRows.Err(); err != nil {
return db.AnalyticsSummary{}, fmt.Errorf("iterating duckdb analytics summary agents: %w", err)
}
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.AnalyticsSummary{}, err
}
sessionIDs := make([]string, 0, len(sessions))
for _, sess := range sessions {
sessionIDs = append(sessionIDs, sess.id)
}
var models []string
if f.HasTimeFilter() {
models, err = s.getAnalyticsModelsForSessionIDsFiltered(
ctx, sessionIDs, f,
)
} else {
models, err = s.getAnalyticsModelsForSessionIDs(
ctx, sessionIDs,
)
}
if err != nil {
return db.AnalyticsSummary{}, err
}
resp.Models = models
return resp, nil
}
func (s *Store) getAnalyticsFilteredToolCallCounts(
ctx context.Context,
sessionIDs []string,
f db.AnalyticsFilter,
) (map[string]int, error) {
counts := make(map[string]int, len(sessionIDs))
if len(sessionIDs) == 0 || strings.TrimSpace(f.Model) == "" {
return counts, nil
}
allowedModels := make(map[string]struct{})
for _, model := range duckAnalyticsCSVValues(f.Model) {
allowedModels[model] = struct{}{}
}
loc := analyticsLocation(f.Timezone)
err := duckQueryChunked(sessionIDs, func(chunk []string) error {
ph, args := duckInPlaceholders(chunk)
rows, err := s.queryContext(ctx, `
SELECT tc.session_id, m.model, m.timestamp, COUNT(*)
FROM tool_calls tc
JOIN messages m
ON m.session_id = tc.session_id
AND m.id = tc.message_id
WHERE tc.session_id IN `+ph+`
GROUP BY tc.session_id, m.model, m.timestamp`, args...)
if err != nil {
return fmt.Errorf(
"querying duckdb filtered analytics tool calls: %w",
err,
)
}
defer rows.Close()
for rows.Next() {
var sessionID, model string
var ts any
var count int
if err := rows.Scan(&sessionID, &model, &ts, &count); err != nil {
return fmt.Errorf(
"scanning duckdb filtered analytics tool calls: %w",
err,
)
}
if _, ok := allowedModels[model]; !ok {
continue
}
if f.HasTimeFilter() {
t, ok := parseAnalyticsTime(formatDBTime(ts))
if !ok || !duckAnalyticsTimeMatches(t.In(loc), f) {
continue
}
}
counts[sessionID] += count
}
return rows.Err()
})
if err != nil {
return nil, err
}
return counts, nil
}
func (s *Store) getAnalyticsActivityFilteredByModelTime(
ctx context.Context, f db.AnalyticsFilter, granularity string,
) (db.ActivityResponse, error) {
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.ActivityResponse{}, err
}
sessionIDs := make([]string, 0, len(sessions))
for _, session := range sessions {
sessionIDs = append(sessionIDs, session.id)
}
messageStats, err := s.getAnalyticsFilteredMessageStats(
ctx, sessionIDs, f,
)
if err != nil {
return db.ActivityResponse{}, err
}
toolCounts, err := s.getAnalyticsFilteredToolCallCounts(
ctx, sessionIDs, f,
)
if err != nil {
return db.ActivityResponse{}, err
}
out := db.ActivityResponse{Granularity: granularity}
buckets := map[string]*db.ActivityEntry{}
for _, session := range sessions {
date := bucketAnalyticsDate(
analyticsLocalDate(analyticsDateTime(session), f.Timezone),
granularity,
)
entry := buckets[date]
if entry == nil {
entry = &db.ActivityEntry{
Date: date,
ByAgent: map[string]int{},
}
buckets[date] = entry
}
entry.Sessions++
stat := messageStats[session.id]
entry.Messages += stat.Messages
entry.UserMessages += stat.UserMessages
entry.AssistantMessages += stat.AssistantMessages
entry.ThinkingMessages += stat.ThinkingMessages
entry.ToolCalls += toolCounts[session.id]
entry.ByAgent[session.agent] += stat.Messages
}
for _, key := range sortedKeys(buckets) {
entry := buckets[key]
if entry == nil {
continue
}
out.Series = append(out.Series, *entry)
}
return out, nil
}
func (s *Store) GetAnalyticsActivity(
ctx context.Context, f db.AnalyticsFilter, granularity string,
) (db.ActivityResponse, error) {
if granularity == "" {
granularity = "day"
}
if strings.TrimSpace(f.Model) != "" {
return s.getAnalyticsActivityFilteredByModelTime(
ctx, f, granularity,
)
}
buckets, err := s.queryActivityBuckets(ctx, f, granularity)
if err != nil {
return db.ActivityResponse{}, err
}
if err := s.addActivityAgentCounts(ctx, f, granularity, buckets); err != nil {
return db.ActivityResponse{}, err
}
out := db.ActivityResponse{Granularity: granularity}
keys := sortedKeys(buckets)
for _, key := range keys {
entry, ok := buckets[key]
if !ok || entry == nil {
continue
}
out.Series = append(out.Series, *entry)
}
return out, nil
}
func (s *Store) queryActivityBuckets(
ctx context.Context, f db.AnalyticsFilter, granularity string,
) (map[string]*db.ActivityEntry, error) {
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.", true, true)
localDate, localDateArgs := duckAnalyticsLocalDateExpr(
"COALESCE(s.started_at, s.created_at)", f)
bucketExpr := duckAnalyticsBucketExpr("local_date", granularity)
queryArgs := append([]any{}, localDateArgs...)
queryArgs = append(queryArgs, args...)
if _, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model); len(modelArgs) > 0 {
queryArgs = append(queryArgs, modelArgs...)
queryArgs = append(queryArgs, modelArgs...)
}
rows, err := s.queryContext(ctx, `
WITH filtered_sessions AS (
SELECT s.id, s.message_count, `+localDate+` AS local_date
FROM sessions s
WHERE `+where+`
),
session_rows AS (
SELECT `+bucketExpr+` AS bucket,
COUNT(*) AS sessions
FROM filtered_sessions
GROUP BY bucket
),
message_rows AS (
SELECT `+bucketExpr+` AS bucket,
COUNT(*) AS messages,
COUNT(*) FILTER (WHERE m.role = 'user' AND m.is_system = FALSE) AS user_messages,
COUNT(*) FILTER (WHERE m.role = 'assistant') AS assistant_messages,
COUNT(*) FILTER (WHERE m.has_thinking = TRUE) AS thinking_messages
FROM filtered_sessions fs
JOIN messages m ON m.session_id = fs.id
`+duckAnalyticsMessageFilterClause("m.model", f.Model)+`
GROUP BY bucket
),
tool_rows AS (
SELECT `+bucketExpr+` AS bucket, COUNT(*) AS tool_calls
FROM filtered_sessions fs
JOIN tool_calls tc ON tc.session_id = fs.id
`+duckAnalyticsToolMessageJoin("tc", f.Model)+`
`+duckAnalyticsMessageFilterClause("m.model", f.Model)+`
GROUP BY bucket
)
SELECT COALESCE(sr.bucket, mr.bucket, tr.bucket) AS bucket,
COALESCE(sr.sessions, 0) AS sessions,
COALESCE(mr.messages, 0) AS messages,
COALESCE(mr.user_messages, 0) AS user_messages,
COALESCE(mr.assistant_messages, 0) AS assistant_messages,
COALESCE(mr.thinking_messages, 0) AS thinking_messages,
COALESCE(tr.tool_calls, 0) AS tool_calls
FROM session_rows sr
FULL OUTER JOIN message_rows mr USING (bucket)
FULL OUTER JOIN tool_rows tr
ON tr.bucket = COALESCE(sr.bucket, mr.bucket)
ORDER BY bucket`,
queryArgs...,
)
if err != nil {
return nil, fmt.Errorf("querying duckdb analytics activity buckets: %w", err)
}
defer rows.Close()
buckets := map[string]*db.ActivityEntry{}
for rows.Next() {
entry := db.ActivityEntry{ByAgent: map[string]int{}}
if err := rows.Scan(
&entry.Date,
&entry.Sessions,
&entry.Messages,
&entry.UserMessages,
&entry.AssistantMessages,
&entry.ThinkingMessages,
&entry.ToolCalls,
); err != nil {
return nil, fmt.Errorf("scanning duckdb analytics activity bucket: %w", err)
}
buckets[entry.Date] = &entry
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating duckdb analytics activity buckets: %w", err)
}
return buckets, nil
}
func (s *Store) addActivityAgentCounts(
ctx context.Context, f db.AnalyticsFilter, granularity string,
buckets map[string]*db.ActivityEntry,
) error {
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.", true, true)
localDate, localDateArgs := duckAnalyticsLocalDateExpr(
"COALESCE(s.started_at, s.created_at)", f)
bucketExpr := duckAnalyticsBucketExpr("local_date", granularity)
queryArgs := append([]any{}, localDateArgs...)
queryArgs = append(queryArgs, args...)
if _, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model); len(modelArgs) > 0 {
queryArgs = append(queryArgs, modelArgs...)
}
rows, err := s.queryContext(ctx, `
WITH filtered_sessions AS (
SELECT s.id, s.agent, `+localDate+` AS local_date
FROM sessions s
WHERE `+where+`
)
SELECT `+bucketExpr+` AS bucket, fs.agent, COUNT(*) AS messages
FROM filtered_sessions fs
JOIN messages m ON m.session_id = fs.id
`+duckAnalyticsMessageFilterClause("m.model", f.Model)+`
GROUP BY bucket, fs.agent
ORDER BY bucket, fs.agent`,
queryArgs...,
)
if err != nil {
return fmt.Errorf("querying duckdb analytics activity agents: %w", err)
}
defer rows.Close()
for rows.Next() {
var bucket, agent string
var count int
if err := rows.Scan(&bucket, &agent, &count); err != nil {
return fmt.Errorf("scanning duckdb analytics activity agent: %w", err)
}
if entry, ok := buckets[bucket]; ok {
entry.ByAgent[agent] = count
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("iterating duckdb analytics activity agents: %w", err)
}
return nil
}
func bucketAnalyticsDate(date, granularity string) string {
t, err := time.Parse("2006-01-02", date)
if err != nil {
return date
}
switch granularity {
case "week":
dow := int(t.Weekday())
if dow == 0 {
dow = 7
}
return t.AddDate(0, 0, -(dow - 1)).Format("2006-01-02")
case "month":
return t.Format("2006-01") + "-01"
default:
return date
}
}
func duckAnalyticsBucketExpr(dateExpr, granularity string) string {
switch granularity {
case "week":
return "strftime(date_trunc('week', CAST(" + dateExpr + " AS DATE)), '%Y-%m-%d')"
case "month":
return "strftime(date_trunc('month', CAST(" + dateExpr + " AS DATE)), '%Y-%m-%d')"
default:
return dateExpr
}
}
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
func duckAnalyticsMessageFilterClause(col, raw string) string {
pred, _ := duckAnalyticsCSVPredicate(col, raw)
if pred == "" {
return ""
}
return "WHERE " + pred
}
func duckAnalyticsAndClause(pred string) string {
if pred == "" {
return ""
}
return " AND " + pred
}
func duckAnalyticsToolMessageJoin(
toolAlias string, model string,
) string {
if model == "" {
return ""
}
return `
JOIN messages m
ON m.session_id = ` + toolAlias + `.session_id
AND m.id = ` + toolAlias + `.message_id`
}
func (s *Store) GetAnalyticsHeatmap(
ctx context.Context, f db.AnalyticsFilter, metric string,
) (db.HeatmapResponse, error) {
if metric == "" {
metric = "messages"
}
if strings.TrimSpace(f.Model) != "" &&
(metric == "messages" || metric == "output_tokens" ||
metric == "sessions") {
sessions, err := s.analyticsSessionsWithModelMessageCounts(ctx, f)
if err != nil {
return db.HeatmapResponse{}, err
}
counts := map[string]int{}
for _, session := range sessions {
date := analyticsLocalDate(analyticsDateTime(session), f.Timezone)
switch metric {
case "sessions":
counts[date]++
case "output_tokens":
if session.hasTotalOutputTokens {
counts[date] += session.totalOutputTokens
}
default:
counts[date] += session.messageCount
}
}
entriesFrom := duckClampHeatmapFrom(f.From, f.To)
values := []int{}
for date, v := range counts {
if v > 0 && date >= entriesFrom && date <= f.To {
values = append(values, v)
}
}
sort.Ints(values)
levels := duckComputeHeatmapLevels(values)
entries := duckBuildHeatmapEntries(entriesFrom, f.To, counts, levels)
if metric == "output_tokens" && len(counts) == 0 {
return db.HeatmapResponse{
Metric: metric,
EntriesFrom: entriesFrom,
}, nil
}
return db.HeatmapResponse{
Metric: metric, Entries: entries,
Levels: levels,
EntriesFrom: entriesFrom,
}, nil
}
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.", true, true)
localDate, localDateArgs := duckAnalyticsLocalDateExpr(
"COALESCE(s.started_at, s.created_at)", f)
valueExpr := "COALESCE(SUM(s.message_count), 0)"
switch metric {
case "sessions":
valueExpr = "COUNT(*)"
case "output_tokens":
where += " AND s.has_total_output_tokens = TRUE"
valueExpr = "COALESCE(SUM(s.total_output_tokens), 0)"
}
queryArgs := append([]any{}, localDateArgs...)
queryArgs = append(queryArgs, args...)
rows, err := s.queryContext(ctx, `
SELECT `+localDate+` AS local_date, `+valueExpr+` AS value
FROM sessions s
WHERE `+where+`
GROUP BY local_date
ORDER BY local_date`,
queryArgs...,
)
if err != nil {
return db.HeatmapResponse{}, fmt.Errorf("querying duckdb analytics heatmap: %w", err)
}
defer rows.Close()
counts := map[string]int{}
for rows.Next() {
var date string
var value int
if err := rows.Scan(&date, &value); err != nil {
return db.HeatmapResponse{}, fmt.Errorf("scanning duckdb analytics heatmap: %w", err)
}
counts[date] = value
}
if err := rows.Err(); err != nil {
return db.HeatmapResponse{}, fmt.Errorf("iterating duckdb analytics heatmap: %w", err)
}
if metric == "output_tokens" && len(counts) == 0 {
return db.HeatmapResponse{
Metric: metric,
EntriesFrom: duckClampHeatmapFrom(f.From, f.To),
}, nil
}
entriesFrom := duckClampHeatmapFrom(f.From, f.To)
values := []int{}
for date, v := range counts {
if v > 0 && date >= entriesFrom && date <= f.To {
values = append(values, v)
}
}
sort.Ints(values)
levels := duckComputeHeatmapLevels(values)
entries := duckBuildHeatmapEntries(entriesFrom, f.To, counts, levels)
return db.HeatmapResponse{
Metric: metric, Entries: entries,
Levels: levels,
EntriesFrom: entriesFrom,
}, nil
}
const duckMaxHeatmapDays = 366
func duckClampHeatmapFrom(from, to string) string {
start, err := time.Parse("2006-01-02", from)
if err != nil {
return from
}
end, err := time.Parse("2006-01-02", to)
if err != nil {
return from
}
earliest := end.AddDate(0, 0, -(duckMaxHeatmapDays - 1))
if start.Before(earliest) {
return earliest.Format("2006-01-02")
}
return from
}
func duckComputeHeatmapLevels(sorted []int) db.HeatmapLevels {
if len(sorted) == 0 {
return db.HeatmapLevels{L1: 1, L2: 2, L3: 3, L4: 4}
}
n := len(sorted)
return db.HeatmapLevels{
L1: sorted[0],
L2: sorted[n/4],
L3: sorted[n/2],
L4: sorted[n*3/4],
}
}
func duckHeatmapLevel(value int, levels db.HeatmapLevels) int {
if value <= 0 {
return 0
}
if value <= levels.L2 {
return 1
}
if value <= levels.L3 {
return 2
}
if value <= levels.L4 {
return 3
}
return 4
}
func duckBuildHeatmapEntries(
from, to string, values map[string]int, levels db.HeatmapLevels,
) []db.HeatmapEntry {
start, err := time.Parse("2006-01-02", from)
if err != nil {
return nil
}
end, err := time.Parse("2006-01-02", to)
if err != nil {
return nil
}
entries := []db.HeatmapEntry{}
for d := start; !d.After(end); d = d.AddDate(0, 0, 1) {
date := d.Format("2006-01-02")
v := values[date]
entries = append(entries, db.HeatmapEntry{
Date: date,
Value: v,
Level: duckHeatmapLevel(v, levels),
})
}
return entries
}
func (s *Store) GetAnalyticsProjects(
ctx context.Context, f db.AnalyticsFilter,
) (db.ProjectsAnalyticsResponse, error) {
// Per-project aggregate: count subagent sessions (mirrors SQLite).
f.IncludeSubagents = true
sessions, err := s.analyticsSessionsWithModelMessageCounts(ctx, f)
if err != nil {
return db.ProjectsAnalyticsResponse{}, err
}
type acc struct {
row db.ProjectAnalytics
counts []int
days map[string]int
}
byProject := map[string]*acc{}
for _, r := range sessions {
a := byProject[r.project]
if a == nil {
a = &acc{
row: db.ProjectAnalytics{Name: r.project, Agents: map[string]int{}},
days: map[string]int{},
}
byProject[r.project] = a
}
date := analyticsLocalDate(analyticsDateTime(r), f.Timezone)
if a.row.FirstSession == "" || date < a.row.FirstSession {
a.row.FirstSession = date
}
if date > a.row.LastSession {
a.row.LastSession = date
}
a.row.Sessions++
a.row.Messages += r.messageCount
a.row.Agents[r.agent]++
a.counts = append(a.counts, r.messageCount)
a.days[date] += r.messageCount
}
resp := db.ProjectsAnalyticsResponse{}
for _, name := range sortedKeys(byProject) {
a, ok := byProject[name]
if !ok || a == nil {
continue
}
sort.Ints(a.counts)
a.row.AvgMessages = round1(float64(a.row.Messages) / float64(a.row.Sessions))
a.row.MedianMessages = median(a.counts)
if len(a.days) > 0 {
a.row.DailyTrend = round1(float64(a.row.Messages) / float64(len(a.days)))
}
resp.Projects = append(resp.Projects, a.row)
}
sort.Slice(resp.Projects, func(i, j int) bool {
if resp.Projects[i].Messages != resp.Projects[j].Messages {
return resp.Projects[i].Messages > resp.Projects[j].Messages
}
return resp.Projects[i].Name < resp.Projects[j].Name
})
return resp, nil
}
func (s *Store) GetAnalyticsHourOfWeek(
ctx context.Context, f db.AnalyticsFilter,
) (db.HourOfWeekResponse, error) {
if strings.TrimSpace(f.Model) != "" {
return s.getAnalyticsHourOfWeekFilteredByModel(ctx, f)
}
sessionFilter := f
sessionFilter.DayOfWeek = nil
sessionFilter.Hour = nil
where, args := duckBuildAnalyticsWhere(
sessionFilter, "COALESCE(s.started_at, s.created_at)", "s.", true, false)
localTime, localTimeArgs := duckAnalyticsLocalTimeExpr("m.timestamp", f)
queryArgs := append([]any{}, args...)
queryArgs = append(queryArgs, localTimeArgs...)
rows, err := s.queryContext(ctx, `
WITH filtered_sessions AS (
SELECT s.id
FROM sessions s
WHERE `+where+`
),
message_times AS (
SELECT `+localTime+` AS local_ts
FROM messages m
JOIN filtered_sessions fs ON fs.id = m.session_id
WHERE m.timestamp IS NOT NULL
),
message_buckets AS (
SELECT ((CAST(strftime(local_ts, '%w') AS INTEGER) + 6) % 7) AS day_of_week,
CAST(strftime(local_ts, '%H') AS INTEGER) AS hour
FROM message_times
WHERE local_ts IS NOT NULL
)
SELECT day_of_week, hour, COUNT(*)
FROM message_buckets
GROUP BY day_of_week, hour
ORDER BY day_of_week, hour`,
queryArgs...,
)
if err != nil {
return db.HourOfWeekResponse{}, fmt.Errorf("querying duckdb analytics hour-of-week: %w", err)
}
defer rows.Close()
var grid [7][24]int
for rows.Next() {
var day, hour, messages int
if err := rows.Scan(&day, &hour, &messages); err != nil {
return db.HourOfWeekResponse{}, fmt.Errorf("scanning duckdb analytics hour-of-week: %w", err)
}
grid[day][hour] = messages
}
if err := rows.Err(); err != nil {
return db.HourOfWeekResponse{}, fmt.Errorf("iterating duckdb analytics hour-of-week: %w", err)
}
return db.HourOfWeekResponseFromGrid(grid), nil
}
// getAnalyticsHourOfWeekFilteredByModel buckets model-scoped messages by
// day-of-week and hour. It pairs empty-model user turns with their
// selected-model assistant via the shared scope reducer, so those turns appear
// in the heatmap consistently with the summary, activity, velocity, and trends
// panels. The heatmap is the control that sets the day/hour filter, so it
// clears DayOfWeek/Hour before scoping to keep showing the full grid, matching
// the no-model path.
func (s *Store) getAnalyticsHourOfWeekFilteredByModel(
ctx context.Context, f db.AnalyticsFilter,
) (db.HourOfWeekResponse, error) {
sessions, err := s.analyticsSessionsFiltered(ctx, f, true, false)
if err != nil {
return db.HourOfWeekResponse{}, err
}
sessionIDs := make([]string, 0, len(sessions))
for _, session := range sessions {
sessionIDs = append(sessionIDs, session.id)
}
scopeFilter := f
scopeFilter.DayOfWeek = nil
scopeFilter.Hour = nil
scope, err := s.resolveAnalyticsMessageScope(
ctx, sessionIDs, scopeFilter, false,
)
if err != nil {
return db.HourOfWeekResponse{}, err
}
var grid [7][24]int
if scope != nil {
for _, msgs := range scope.MessagesBySession() {
for _, m := range msgs {
if !m.HasLocalTime {
continue
}
dow := (int(m.LocalTime.Weekday()) + 6) % 7
grid[dow][m.LocalTime.Hour()]++
}
}
}
return db.HourOfWeekResponseFromGrid(grid), nil
}
func (s *Store) GetAnalyticsSessionShape(
ctx context.Context, f db.AnalyticsFilter,
) (db.SessionShapeResponse, error) {
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.SessionShapeResponse{}, err
}
modelFilter := strings.TrimSpace(f.Model) != ""
lengths := map[string]int{}
durations := map[string]int{}
ids := []string{}
for _, r := range sessions {
ids = append(ids, r.id)
if !modelFilter {
lengths[lengthBucket(r.messageCount)]++
}
if start, okS := parseAnalyticsTime(r.startedAt); okS {
if end, okE := parseAnalyticsTime(r.endedAt); okE && !end.Before(start) {
durations[durationBucket(end.Sub(start).Minutes())]++
}
}
}
autonomy := map[string]int{}
if modelFilter && len(ids) > 0 {
stats, err := s.getAnalyticsFilteredMessageStats(ctx, ids, f)
if err != nil {
return db.SessionShapeResponse{}, err
}
lengths = map[string]int{}
for _, r := range sessions {
stat := stats[r.id]
lengths[lengthBucket(stat.Messages)]++
if stat.UserMessages > 0 {
ratio := float64(stat.ToolUseMessages) /
float64(stat.UserMessages)
autonomy[autonomyBucket(ratio)]++
}
}
} else {
autonomy, err = s.analyticsAutonomyBuckets(ctx, ids)
if err != nil {
return db.SessionShapeResponse{}, err
}
}
return db.SessionShapeResponse{
Count: len(sessions),
LengthDistribution: mapBuckets(lengths, lengthOrder()),
DurationDistribution: mapBuckets(durations, durationOrder()),
AutonomyDistribution: mapBuckets(autonomy, autonomyOrder()),
}, nil
}
func lengthBucket(mc int) string {
switch {
case mc <= 5:
return "1-5"
case mc <= 15:
return "6-15"
case mc <= 30:
return "16-30"
case mc <= 60:
return "31-60"
case mc <= 120:
return "61-120"
default:
return "121+"
}
}
func durationBucket(mins float64) string {
switch {
case mins < 5:
return "<5m"
case mins < 15:
return "5-15m"
case mins < 30:
return "15-30m"
case mins < 60:
return "30-60m"
case mins < 120:
return "1-2h"
default:
return "2h+"
}
}
func lengthOrder() map[string]int {
return map[string]int{"1-5": 0, "6-15": 1, "16-30": 2, "31-60": 3, "61-120": 4, "121+": 5}
}
func durationOrder() map[string]int {
return map[string]int{"<5m": 0, "5-15m": 1, "15-30m": 2, "30-60m": 3, "1-2h": 4, "2h+": 5}
}
func autonomyBucket(ratio float64) string {
switch {
case ratio < 0.5:
return "<0.5"
case ratio < 1:
return "0.5-1"
case ratio < 2:
return "1-2"
case ratio < 5:
return "2-5"
case ratio < 10:
return "5-10"
default:
return "10+"
}
}
func autonomyOrder() map[string]int {
return map[string]int{"<0.5": 0, "0.5-1": 1, "1-2": 2, "2-5": 3, "5-10": 4, "10+": 5}
}
func mapBuckets(values map[string]int, order map[string]int) []db.DistributionBucket {
out := make([]db.DistributionBucket, 0, len(values))
for label, count := range values {
out = append(out, db.DistributionBucket{Label: label, Count: count})
}
sort.Slice(out, func(i, j int) bool {
return order[out[i].Label] < order[out[j].Label]
})
return out
}
func (s *Store) analyticsAutonomyBuckets(
ctx context.Context, sessionIDs []string,
) (map[string]int, error) {
counts := map[string]int{}
if len(sessionIDs) == 0 {
return counts, nil
}
args := make([]any, len(sessionIDs))
placeholders := make([]string, len(sessionIDs))
for i, id := range sessionIDs {
args[i] = id
placeholders[i] = "?"
}
rows, err := s.queryContext(ctx, `
SELECT session_id,
SUM(CASE WHEN role = 'user' AND is_system = FALSE THEN 1 ELSE 0 END) AS user_count,
SUM(CASE WHEN role = 'assistant' AND has_tool_use = TRUE THEN 1 ELSE 0 END) AS tool_count
FROM messages
WHERE session_id IN (`+strings.Join(placeholders, ",")+`)
GROUP BY session_id`,
args...,
)
if err != nil {
return nil, fmt.Errorf("querying duckdb autonomy: %w", err)
}
defer rows.Close()
for rows.Next() {
var sessionID string
var userCount, toolCount int
if err := rows.Scan(&sessionID, &userCount, &toolCount); err != nil {
return nil, fmt.Errorf("scanning duckdb autonomy: %w", err)
}
if userCount > 0 {
counts[autonomyBucket(float64(toolCount)/float64(userCount))]++
}
}
return counts, rows.Err()
}
// duckMaxSQLVars bounds the IN-list size per query to stay well under
// driver bind-variable limits; larger ID sets are split into chunks.
const duckMaxSQLVars = 900
func duckInPlaceholders(ids []string) (string, []any) {
ph := make([]string, len(ids))
args := make([]any, len(ids))
for i, id := range ids {
ph[i] = "?"
args[i] = id
}
return "(" + strings.Join(ph, ",") + ")", args
}
func duckQueryChunked(ids []string, fn func(chunk []string) error) error {
for i := 0; i < len(ids); i += duckMaxSQLVars {
end := min(i+duckMaxSQLVars, len(ids))
if err := fn(ids[i:end]); err != nil {
return err
}
}
return nil
}
func (s *Store) GetAnalyticsTools(
ctx context.Context, f db.AnalyticsFilter,
) (db.ToolsAnalyticsResponse, error) {
sessions, err := s.analyticsSessionsFiltered(
ctx, f, false, false,
)
if err != nil {
return db.ToolsAnalyticsResponse{}, err
}
meta := map[string]duckAnalyticsSession{}
var ids []string
for _, r := range sessions {
meta[r.id] = r
ids = append(ids, r.id)
}
if len(ids) == 0 {
return db.BuildToolsAnalytics(nil), nil
}
var toolRows []db.ToolAnalyticsRow
err = duckQueryChunked(ids, func(chunk []string) error {
ph, args := duckInPlaceholders(chunk)
modelPred, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model)
args = append(args, modelArgs...)
query := `SELECT tc.session_id, tc.category,
TRIM(COALESCE(tc.tool_name, '')), COUNT(*),
m.timestamp
FROM tool_calls tc
LEFT JOIN messages m
ON m.session_id = tc.session_id
AND m.id = tc.message_id
WHERE tc.session_id IN ` + ph
if modelPred != "" {
query += `
AND ` + modelPred
}
query += `
GROUP BY tc.session_id, tc.category,
TRIM(COALESCE(tc.tool_name, '')), m.timestamp`
rows, qErr := s.queryContext(ctx, query, args...)
if qErr != nil {
return qErr
}
defer rows.Close()
for rows.Next() {
var sid, cat, toolName string
var ts any
var count int
if err := rows.Scan(&sid, &cat, &toolName, &count, &ts); err != nil {
return err
}
r, ok := meta[sid]
if !ok {
continue
}
_, date, keep := f.ResolveSkillRowTime(
formatDBTime(ts), analyticsDateTime(r),
)
if !keep {
continue
}
toolRows = append(toolRows, db.ToolAnalyticsRow{
SessionID: sid,
Category: cat,
ToolName: toolName,
Agent: r.agent,
Count: count,
Date: date,
})
}
return rows.Err()
})
if err != nil {
return db.ToolsAnalyticsResponse{}, err
}
return db.BuildToolsAnalytics(toolRows), nil
}
// GetAnalyticsSkills returns skill usage analytics. granularity picks
// the trend bucket size (day, week, or month); empty defaults to week.
func (s *Store) GetAnalyticsSkills(
ctx context.Context, f db.AnalyticsFilter, granularity string,
) (db.SkillsAnalyticsResponse, error) {
sessions, err := s.analyticsSessionsFiltered(ctx, f, false, false)
if err != nil {
return db.SkillsAnalyticsResponse{}, err
}
meta := map[string]duckAnalyticsSession{}
var ids []string
for _, r := range sessions {
meta[r.id] = r
ids = append(ids, r.id)
}
if len(ids) == 0 {
return db.BuildSkillsAnalytics(
nil, f.From, f.To, granularity,
), nil
}
var skillRows []db.SkillAnalyticsRow
err = duckQueryChunked(ids, func(chunk []string) error {
ph, args := duckInPlaceholders(chunk)
modelPred, modelArgs := duckAnalyticsCSVPredicate("m.model", f.Model)
args = append(args, modelArgs...)
rows, qErr := s.queryContext(ctx,
`SELECT tc.session_id, TRIM(COALESCE(tc.skill_name, '')),
COUNT(*), m.timestamp
FROM tool_calls tc
LEFT JOIN messages m
ON m.session_id = tc.session_id
AND m.id = tc.message_id
WHERE tc.session_id IN `+ph+`
AND TRIM(COALESCE(tc.skill_name, '')) != ''
`+duckAnalyticsAndClause(modelPred)+`
GROUP BY tc.session_id, TRIM(COALESCE(tc.skill_name, '')),
m.timestamp`, args...)
if qErr != nil {
return qErr
}
defer rows.Close()
for rows.Next() {
var sid, skill string
var count int
var msgTS any
if err := rows.Scan(&sid, &skill, &count, &msgTS); err != nil {
return err
}
r, ok := meta[sid]
if !ok {
continue
}
usedTS, date, keep := f.ResolveSkillRowTime(
formatDBTime(msgTS), analyticsDateTime(r),
)
if !keep {
continue
}
skillRows = append(skillRows, db.SkillAnalyticsRow{
SessionID: sid,
SkillName: skill,
Agent: r.agent,
Project: r.project,
Date: date,
LastUsedAt: usedTS,
Count: count,
})
}
return rows.Err()
})
if err != nil {
return db.SkillsAnalyticsResponse{}, err
}
return db.BuildSkillsAnalytics(
skillRows, f.From, f.To, granularity,
), nil
}
func (s *Store) GetAnalyticsVelocity(
ctx context.Context, f db.AnalyticsFilter,
) (db.VelocityResponse, error) {
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.VelocityResponse{}, err
}
if len(sessions) == 0 {
return db.VelocityResponse{
ByAgent: []db.VelocityBreakdown{},
ByComplexity: []db.VelocityBreakdown{},
}, nil
}
sessionIDs := make([]string, 0, len(sessions))
sessionInfo := make(map[string]duckVelocitySession, len(sessions))
for _, sess := range sessions {
sessionIDs = append(sessionIDs, sess.id)
sessionInfo[sess.id] = duckVelocitySession{
agent: sess.agent,
mc: sess.messageCount,
}
}
if strings.TrimSpace(f.Model) != "" {
stats, err := s.getAnalyticsFilteredMessageStats(
ctx, sessionIDs, f,
)
if err != nil {
return db.VelocityResponse{}, err
}
for _, sid := range sessionIDs {
info := sessionInfo[sid]
info.mc = stats[sid].Messages
sessionInfo[sid] = info
}
}
var sessionMsgs map[string][]duckVelocityMsg
if strings.TrimSpace(f.Model) != "" {
sessionMsgs, err = s.filteredVelocityMessages(
ctx, sessionIDs, f,
)
} else {
sessionMsgs, err = s.velocityMessages(
ctx, sessionIDs, analyticsLocation(f.Timezone),
)
}
if err != nil {
return db.VelocityResponse{}, err
}
var toolCounts map[string]int
if strings.TrimSpace(f.Model) != "" {
toolCounts, err = s.getAnalyticsFilteredToolCallCounts(
ctx, sessionIDs, f,
)
} else {
toolCounts, err = s.velocityToolCounts(ctx, sessionIDs)
}
if err != nil {
return db.VelocityResponse{}, err
}
overall := &duckVelocityAccumulator{}
byAgent := make(map[string]*duckVelocityAccumulator)
byComplexity := make(map[string]*duckVelocityAccumulator)
for _, sid := range sessionIDs {
msgs := sessionMsgs[sid]
if len(msgs) < 2 {
continue
}
info := sessionInfo[sid]
agentKey := info.agent
compKey := duckComplexityBucket(info.mc)
if byAgent[agentKey] == nil {
byAgent[agentKey] = &duckVelocityAccumulator{}
}
if byComplexity[compKey] == nil {
byComplexity[compKey] = &duckVelocityAccumulator{}
}
processDuckSessionVelocity(
[]*duckVelocityAccumulator{overall, byAgent[agentKey], byComplexity[compKey]},
msgs,
toolCounts[sid],
)
}
resp := db.VelocityResponse{
Overall: overall.computeOverview(),
ByAgent: []db.VelocityBreakdown{},
ByComplexity: []db.VelocityBreakdown{},
}
for _, key := range sortedKeys(byAgent) {
acc := byAgent[key]
if acc == nil {
continue
}
resp.ByAgent = append(resp.ByAgent, db.VelocityBreakdown{
Label: key,
Sessions: acc.sessions,
Overview: acc.computeOverview(),
})
}
compOrder := map[string]int{"1-15": 0, "16-60": 1, "61+": 2}
compKeys := sortedKeys(byComplexity)
sort.Slice(compKeys, func(i, j int) bool {
return compOrder[compKeys[i]] < compOrder[compKeys[j]]
})
for _, key := range compKeys {
acc := byComplexity[key]
if acc == nil {
continue
}
resp.ByComplexity = append(resp.ByComplexity, db.VelocityBreakdown{
Label: key,
Sessions: acc.sessions,
Overview: acc.computeOverview(),
})
}
return resp, nil
}
type duckVelocitySession struct {
agent string
mc int
}
type duckVelocityMsg struct {
role string
ts time.Time
valid bool
contentLength int
}
type duckVelocityAccumulator struct {
turnCycles []float64
firstResponses []float64
totalMsgs int
totalChars int
totalToolCalls int
activeMinutes float64
sessions int
}
func (s *Store) velocityMessages(
ctx context.Context,
sessionIDs []string,
loc *time.Location,
) (map[string][]duckVelocityMsg, error) {
out := make(map[string][]duckVelocityMsg, len(sessionIDs))
if len(sessionIDs) == 0 {
return out, nil
}
args, placeholders := stringInArgs(sessionIDs)
rows, err := s.queryContext(ctx, `
SELECT session_id, ordinal, role, timestamp, content_length
FROM messages
WHERE session_id IN (`+strings.Join(placeholders, ",")+`)
ORDER BY session_id, ordinal`,
args...,
)
if err != nil {
return nil, fmt.Errorf("querying duckdb velocity messages: %w", err)
}
defer rows.Close()
for rows.Next() {
var sid, role string
var ordinal int
var ts any
var contentLength int
if err := rows.Scan(&sid, &ordinal, &role, &ts, &contentLength); err != nil {
return nil, fmt.Errorf("scanning duckdb velocity message: %w", err)
}
parsed, ok := duckLocalTime(formatDBTime(ts), loc)
out[sid] = append(out[sid], duckVelocityMsg{
role: role,
ts: parsed,
valid: ok,
contentLength: contentLength,
})
}
return out, rows.Err()
}
func (s *Store) filteredVelocityMessages(
ctx context.Context,
sessionIDs []string,
f db.AnalyticsFilter,
) (map[string][]duckVelocityMsg, error) {
out := make(map[string][]duckVelocityMsg, len(sessionIDs))
if len(sessionIDs) == 0 {
return out, nil
}
scope, err := s.resolveAnalyticsMessageScope(ctx, sessionIDs, f, false)
if err != nil {
return nil, err
}
if scope == nil {
return out, nil
}
for sessionID, rows := range scope.TimingBySession() {
for _, row := range rows {
out[sessionID] = append(out[sessionID], duckVelocityMsg{
role: row.Role,
ts: row.Time,
valid: row.Valid,
contentLength: row.ContentLength,
})
}
}
return out, nil
}
func (s *Store) velocityToolCounts(
ctx context.Context,
sessionIDs []string,
) (map[string]int, error) {
out := make(map[string]int, len(sessionIDs))
if len(sessionIDs) == 0 {
return out, nil
}
args, placeholders := stringInArgs(sessionIDs)
rows, err := s.queryContext(ctx, `
SELECT session_id, COUNT(*)
FROM tool_calls
WHERE session_id IN (`+strings.Join(placeholders, ",")+`)
GROUP BY session_id`,
args...,
)
if err != nil {
return nil, fmt.Errorf("querying duckdb velocity tool calls: %w", err)
}
defer rows.Close()
for rows.Next() {
var sid string
var count int
if err := rows.Scan(&sid, &count); err != nil {
return nil, fmt.Errorf("scanning duckdb velocity tool call count: %w", err)
}
out[sid] = count
}
return out, rows.Err()
}
func stringInArgs(values []string) ([]any, []string) {
args := make([]any, len(values))
placeholders := make([]string, len(values))
for i, value := range values {
args[i] = value
placeholders[i] = "?"
}
return args, placeholders
}
func duckLocalTime(ts string, loc *time.Location) (time.Time, bool) {
t, ok := parseAnalyticsTime(ts)
if !ok {
return time.Time{}, false
}
return t.In(loc), true
}
func duckComplexityBucket(mc int) string {
switch {
case mc <= 15:
return "1-15"
case mc <= 60:
return "16-60"
default:
return "61+"
}
}
func processDuckSessionVelocity(
accums []*duckVelocityAccumulator,
msgs []duckVelocityMsg,
toolCount int,
) {
const maxCycleSec = 1800.0
// Shared with the Top Sessions "active duration" SQL so the two
// "active" definitions stay in lockstep.
const maxGapSec = db.ActiveGapCapSec
for _, acc := range accums {
acc.sessions++
}
for i := 1; i < len(msgs); i++ {
prev := msgs[i-1]
cur := msgs[i]
if !prev.valid || !cur.valid {
continue
}
if prev.role == "user" && cur.role == "assistant" {
delta := cur.ts.Sub(prev.ts).Seconds()
if delta > 0 && delta <= maxCycleSec {
for _, acc := range accums {
acc.turnCycles = append(acc.turnCycles, delta)
}
}
}
}
var firstUser, firstAsst *duckVelocityMsg
firstUserIdx := -1
for i := range msgs {
if msgs[i].role == "user" && msgs[i].valid {
firstUser = &msgs[i]
firstUserIdx = i
break
}
}
if firstUserIdx >= 0 {
for i := firstUserIdx + 1; i < len(msgs); i++ {
if msgs[i].role == "assistant" && msgs[i].valid {
firstAsst = &msgs[i]
break
}
}
}
if firstUser != nil && firstAsst != nil {
delta := firstAsst.ts.Sub(firstUser.ts).Seconds()
if delta < 0 {
delta = 0
}
for _, acc := range accums {
acc.firstResponses = append(acc.firstResponses, delta)
}
}
activeSec := 0.0
assistantChars := 0
for i, msg := range msgs {
if msg.role == "assistant" {
assistantChars += msg.contentLength
}
if i > 0 && msgs[i-1].valid && msg.valid {
gap := msg.ts.Sub(msgs[i-1].ts).Seconds()
if gap > 0 {
if gap > maxGapSec {
gap = maxGapSec
}
activeSec += gap
}
}
}
activeMinutes := activeSec / 60
if activeMinutes > 0 {
for _, acc := range accums {
acc.totalMsgs += len(msgs)
acc.totalChars += assistantChars
acc.totalToolCalls += toolCount
acc.activeMinutes += activeMinutes
}
}
}
func (a *duckVelocityAccumulator) computeOverview() db.VelocityOverview {
sort.Float64s(a.turnCycles)
sort.Float64s(a.firstResponses)
out := db.VelocityOverview{}
out.TurnCycleSec = db.Percentiles{
P50: round1(percentileFloat(a.turnCycles, 0.5)),
P90: round1(percentileFloat(a.turnCycles, 0.9)),
}
out.FirstResponseSec = db.Percentiles{
P50: round1(percentileFloat(a.firstResponses, 0.5)),
P90: round1(percentileFloat(a.firstResponses, 0.9)),
}
if a.activeMinutes > 0 {
out.MsgsPerActiveMin = round1(float64(a.totalMsgs) / a.activeMinutes)
out.CharsPerActiveMin = round1(float64(a.totalChars) / a.activeMinutes)
out.ToolCallsPerActiveMin = round1(float64(a.totalToolCalls) / a.activeMinutes)
}
return out
}
func percentileFloat(values []float64, p float64) float64 {
if len(values) == 0 {
return 0
}
idx := int(float64(len(values)) * p)
if idx >= len(values) {
idx = len(values) - 1
}
return round1(values[idx])
}
func (s *Store) GetAnalyticsTopSessions(
ctx context.Context, f db.AnalyticsFilter, metric string,
) (db.TopSessionsResponse, error) {
switch metric {
case "", "messages":
metric = "messages"
case "duration", "output_tokens":
default:
metric = "messages"
}
if strings.TrimSpace(f.Model) != "" &&
(metric == "messages" || metric == "output_tokens") {
sessions, err := s.analyticsSessionsWithModelMessageCounts(ctx, f)
if err != nil {
return db.TopSessionsResponse{}, err
}
sort.SliceStable(sessions, func(i, j int) bool {
if metric == "output_tokens" {
if sessions[i].totalOutputTokens != sessions[j].totalOutputTokens {
return sessions[i].totalOutputTokens >
sessions[j].totalOutputTokens
}
} else if sessions[i].messageCount != sessions[j].messageCount {
return sessions[i].messageCount >
sessions[j].messageCount
}
return sessions[i].id < sessions[j].id
})
out := db.TopSessionsResponse{Metric: metric}
for i := range sessions {
if metric == "output_tokens" &&
!sessions[i].hasTotalOutputTokens {
continue
}
if len(out.Sessions) >= 10 {
break
}
startedAt := sessions[i].startedAt
endedAt := sessions[i].endedAt
out.Sessions = append(out.Sessions, db.TopSession{
ID: sessions[i].id,
Project: sessions[i].project,
FirstMessage: sessions[i].firstMessage,
DisplayName: sessions[i].displayName,
MessageCount: sessions[i].messageCount,
OutputTokens: sessions[i].totalOutputTokens,
DurationMin: duckSessionDurationMinutes(sessions[i]),
StartedAt: &startedAt,
EndedAt: &endedAt,
TerminationStatus: sessions[i].terminationStatus,
})
}
return out, nil
}
includeTime := true
var pairedSet map[string]bool
if f.HasTimeFilter() && strings.TrimSpace(f.Model) != "" {
// Filter the scoped session set in Go rather than binding every
// paired ID into one IN (...) predicate, which would exceed the
// driver bind-variable cap for large result sets. Mirrors the
// SQLite/PostgreSQL top-sessions Go path under a model filter: load
// the model+date candidates, then keep only the paired sessions and
// limit in Go. The in-SQL ORDER BY still ranks them by the metric.
paired, err := s.analyticsSessionsModelTimeFiltered(ctx, f, true)
if err != nil {
return db.TopSessionsResponse{}, err
}
if len(paired) == 0 {
return db.TopSessionsResponse{Metric: metric}, nil
}
pairedSet = make(map[string]bool, len(paired))
for _, session := range paired {
pairedSet[session.id] = true
}
includeTime = false
}
where, args := duckBuildAnalyticsWhere(
f, "COALESCE(s.started_at, s.created_at)", "s.", true, includeTime)
durationExpr := "(epoch(s.ended_at) - epoch(s.started_at)) / 60.0"
durationSelectExpr := "COALESCE(" + durationExpr + ", 0)"
activeDurationExpr := fmt.Sprintf(`
(
SELECT COALESCE(SUM(
CASE
WHEN inner2.delta_ms <= 0 THEN 0
WHEN inner2.delta_ms > %[1]d THEN %[1]d
ELSE inner2.delta_ms
END), 0) / 60000.0
FROM (
SELECT CAST(
ROUND(epoch(
LEAD(m2.timestamp) OVER (ORDER BY m2.ordinal)
- m2.timestamp
) * 1000) AS BIGINT
) AS delta_ms
FROM messages m2
WHERE m2.session_id = s.id
) inner2
)`, db.ActiveGapCapMs)
activeDurationSelectExpr := "COALESCE(" + activeDurationExpr + ", 0)"
orderExpr := "s.message_count DESC, s.id ASC"
switch metric {
case "duration":
where += " AND s.started_at IS NOT NULL AND s.ended_at IS NOT NULL AND s.ended_at >= s.started_at"
orderExpr = activeDurationSelectExpr + " DESC, s.id ASC"
case "output_tokens":
where += " AND s.has_total_output_tokens = TRUE"
orderExpr = "s.total_output_tokens DESC, s.id ASC"
}
// When filtering the scoped set in Go (model+time), drop the SQL LIMIT so
// the paired sessions aren't truncated before the Go filter; the in-SQL
// ORDER BY keeps them ranked and the top 10 is taken after filtering.
limitClause := "\n\t\tLIMIT 10"
if pairedSet != nil {
limitClause = ""
}
query := `
SELECT s.id, s.project, s.first_message, s.message_count,
s.total_output_tokens, ` + durationSelectExpr + ` AS duration_min,
` + activeDurationSelectExpr + ` AS active_duration_min,
s.started_at, s.ended_at, s.termination_status
FROM sessions s
WHERE ` + where + `
ORDER BY ` + orderExpr + limitClause
rows, err := s.queryContext(ctx, query, args...)
if err != nil {
return db.TopSessionsResponse{}, fmt.Errorf("querying duckdb analytics top sessions: %w", err)
}
defer rows.Close()
out := db.TopSessionsResponse{Metric: metric}
for rows.Next() {
var row db.TopSession
var startedRaw, endedRaw any
if err := rows.Scan(
&row.ID, &row.Project, &row.FirstMessage, &row.MessageCount,
&row.OutputTokens, &row.DurationMin, &row.ActiveDurationMin,
&startedRaw, &endedRaw,
&row.TerminationStatus,
); err != nil {
return db.TopSessionsResponse{}, fmt.Errorf("scanning duckdb analytics top session: %w", err)
}
if pairedSet != nil && !pairedSet[row.ID] {
continue
}
startedAt := formatDBTime(startedRaw)
endedAt := formatDBTime(endedRaw)
row.StartedAt = &startedAt
row.EndedAt = &endedAt
row.DurationMin = round1(row.DurationMin)
row.ActiveDurationMin = round1(row.ActiveDurationMin)
out.Sessions = append(out.Sessions, row)
if pairedSet != nil && len(out.Sessions) >= 10 {
break
}
}
if err := rows.Err(); err != nil {
return db.TopSessionsResponse{}, fmt.Errorf("iterating duckdb analytics top sessions: %w", err)
}
return out, nil
}
func duckSessionDurationMinutes(session duckAnalyticsSession) float64 {
startedAt, okStart := parseAnalyticsTime(session.startedAt)
endedAt, okEnd := parseAnalyticsTime(session.endedAt)
if !okStart || !okEnd || endedAt.Before(startedAt) {
return 0
}
return round1(endedAt.Sub(startedAt).Minutes())
}
// GetAnalyticsSignals returns aggregated session signal data. Signals stay
// session-scoped under a model filter (totals are session-level aggregates
// over sessions that used the model, not re-attributed per model); see the
// SQLite GetAnalyticsSignals for the rationale.
func (s *Store) GetAnalyticsSignals(
ctx context.Context, f db.AnalyticsFilter,
) (db.SignalsAnalyticsResponse, error) {
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.SignalsAnalyticsResponse{}, err
}
rows := duckSignalRowsFromSessions(sessions, f)
if err := s.duckPopulateFrustrationMarkers(ctx, rows); err != nil {
return db.SignalsAnalyticsResponse{}, err
}
return db.AggregateSignals(rows), nil
}
func (s *Store) GetAnalyticsSignalSessions(
ctx context.Context,
f db.AnalyticsFilter,
signal string,
limit int,
) (db.SignalSessionsResponse, error) {
if !db.IsSupportedAnalyticsSignal(signal) {
return db.SignalSessionsResponse{}, db.ErrUnsupportedAnalyticsSignal
}
if limit <= 0 || limit > 20 {
limit = 10
}
sessions, err := s.analyticsSessions(ctx, f)
if err != nil {
return db.SignalSessionsResponse{}, err
}
rows := duckSignalRowsFromSessions(sessions, f)
if err := s.duckPopulateFrustrationMarkers(ctx, rows); err != nil {
return db.SignalSessionsResponse{}, err
}
candidates := db.SignalCandidates(rows, signal, limit)
messages, err := s.duckSignalMessages(ctx, candidates, f)
if err != nil {
return db.SignalSessionsResponse{}, err
}
return db.SignalSessionsResponse{
Signal: signal,
Sessions: db.BuildSignalExamples(candidates, messages, signal),
}, nil
}
func duckSignalRowsFromSessions(
sessions []duckAnalyticsSession,
f db.AnalyticsFilter,
) []db.SignalRow {
rows := make([]db.SignalRow, 0, len(sessions))
for _, r := range sessions {
rows = append(rows, db.SignalRow{
ID: r.id,
Agent: r.agent,
Project: r.project,
FirstMessage: r.firstMessage,
IsAutomated: r.isAutomated,
Date: analyticsLocalDate(analyticsDateTime(r), f.Timezone),
HealthScore: r.healthScore,
HealthGrade: r.healthGrade,
Outcome: r.outcome,
OutcomeConfidence: r.outcomeConfidence,
ToolFailureSignalCount: r.toolFailures,
ToolRetryCount: r.toolRetries,
EditChurnCount: r.editChurn,
CompactionCount: r.compactions,
MidTaskCompactionCount: r.midTaskCompactions,
ContextPressureMax: r.contextPressureMax,
QualitySignalVersion: r.qualitySignalVersion,
ShortPromptCount: r.shortPromptCount,
UnstructuredStart: r.unstructuredStart,
MissingSuccessCriteriaCount: r.missingSuccessCriteriaCount,
MissingVerificationCount: r.missingVerificationCount,
DuplicatePromptCount: r.duplicatePromptCount,
NoCodeContextCount: r.noCodeContextCount,
RunawayToolLoopCount: r.runawayToolLoopCount,
FrustrationMarkerCount: r.frustrationMarkerCount,
})
}
return rows
}
func (s *Store) duckPopulateFrustrationMarkers(
ctx context.Context,
rows []db.SignalRow,
) error {
if len(rows) == 0 {
return nil
}
idx := make(map[string]int, len(rows))
placeholders := make([]string, len(rows))
args := make([]any, len(rows))
for i := range rows {
idx[rows[i].ID] = i
placeholders[i] = "?"
args[i] = rows[i].ID
}
q := `SELECT session_id, content, is_system
FROM messages
WHERE role = 'user' AND session_id IN (` +
strings.Join(placeholders, ",") + `)`
msgRows, err := s.queryContext(ctx, q, args...)
if err != nil {
return fmt.Errorf("querying duckdb frustration markers: %w", err)
}
defer msgRows.Close()
for msgRows.Next() {
var sessionID, content string
var isSystem bool
if err := msgRows.Scan(
&sessionID, &content, &isSystem,
); err != nil {
return fmt.Errorf("scanning duckdb frustration marker: %w", err)
}
i, ok := idx[sessionID]
if !ok || isSystem {
continue
}
if signals.IsFrustrationMarker(content) {
rows[i].FrustrationMarkerCount++
}
}
if err := msgRows.Err(); err != nil {
return fmt.Errorf("iterating duckdb frustration markers: %w", err)
}
return nil
}
func (s *Store) duckSignalMessages(
ctx context.Context,
rows []db.SignalRow,
f db.AnalyticsFilter,
) (map[string][]db.SignalMessage, error) {
out := make(map[string][]db.SignalMessage, len(rows))
if len(rows) == 0 {
return out, nil
}
if strings.TrimSpace(f.Model) != "" {
ids := make([]string, 0, len(rows))
for _, r := range rows {
ids = append(ids, r.ID)
}
scope, err := s.resolveAnalyticsMessageScope(ctx, ids, f, true)
if err != nil {
return nil, err
}
if scope != nil {
for sessionID, scopedRows := range scope.MessagesBySession() {
for _, row := range scopedRows {
out[sessionID] = append(out[sessionID], db.SignalMessage{
SessionID: row.SessionID,
Ordinal: row.Ordinal,
Role: row.Role,
Content: row.Content,
Timestamp: row.Timestamp,
IsSystem: row.IsSystem,
HasToolUse: row.HasToolUse,
})
}
}
}
return out, nil
}
placeholders := make([]string, len(rows))
args := make([]any, len(rows))
for i, r := range rows {
placeholders[i] = "?"
args[i] = r.ID
}
filterModels := duckAnalyticsCSVValues(f.Model)
q := `SELECT session_id, ordinal, role, content,
timestamp, is_system, has_tool_use
FROM messages
WHERE session_id IN (` + strings.Join(placeholders, ",") + `)`
if len(filterModels) == 1 {
q += ` AND model = ?`
args = append(args, filterModels[0])
} else if len(filterModels) > 1 {
modelPlaceholders := make([]string, len(filterModels))
for i, model := range filterModels {
modelPlaceholders[i] = "?"
args = append(args, model)
}
q += ` AND model IN (` + strings.Join(modelPlaceholders, ",") + `)`
}
q += `
ORDER BY session_id, ordinal`
msgRows, err := s.queryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("querying duckdb signal messages: %w", err)
}
defer msgRows.Close()
for msgRows.Next() {
var m db.SignalMessage
var ts any
if err := msgRows.Scan(
&m.SessionID, &m.Ordinal, &m.Role,
&m.Content, &ts,
&m.IsSystem, &m.HasToolUse,
); err != nil {
return nil, fmt.Errorf("scanning duckdb signal message: %w", err)
}
m.Timestamp = formatDBTime(ts)
out[m.SessionID] = append(out[m.SessionID], m)
}
if err := msgRows.Err(); err != nil {
return nil, fmt.Errorf("iterating duckdb signal messages: %w", err)
}
return out, nil
}
func (s *Store) GetTrendsTerms(
ctx context.Context, f db.AnalyticsFilter,
terms []db.TrendTermInput, granularity string,
) (db.TrendsTermsResponse, error) {
if granularity == "" {
granularity = "week"
}
buckets := db.TrendBucketRange(f.From, f.To, granularity)
index := map[string]int{}
for i, bucket := range buckets {
index[bucket.Date] = i
}
counts := make([][]int, len(terms))
for i := range counts {
counts[i] = make([]int, len(buckets))
}
messageCounts := make([]int, len(buckets))
sessionFilter := f
sessionFilter.From = ""
sessionFilter.To = ""
sessionFilter.Model = ""
sessionFilter.DayOfWeek = nil
sessionFilter.Hour = nil
sessions, err := s.analyticsSessions(ctx, sessionFilter)
if err != nil {
return db.TrendsTermsResponse{}, err
}
allowedSessions := make(map[string]bool, len(sessions))
for _, sess := range sessions {
allowedSessions[sess.id] = true
}
if len(allowedSessions) == 0 {
return db.BuildTrendsTermsResponse(
f.From, f.To, granularity, buckets, terms, counts, messageCounts,
), nil
}
loc := analyticsLocation(f.Timezone)
flt := messageScopeFilter(f)
modelFiltering := len(flt.Models) > 0
trendLocal := func(msgTS, startedAt, createdAt any) (time.Time, bool) {
ts := firstNonEmpty(formatDBTime(msgTS), formatDBTime(startedAt), formatDBTime(createdAt))
t, ok := parseAnalyticsTime(ts)
if !ok {
return time.Time{}, false
}
return t.In(loc), true
}
rows, err := s.queryContext(ctx, `
SELECT m.session_id, m.ordinal, m.role, m.is_system,
COALESCE(m.model, ''), m.content, m.timestamp,
s.started_at, s.created_at
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE s.deleted_at IS NULL
AND m.role IN ('user', 'assistant')
AND m.is_system = FALSE
AND `+db.DuckDBSystemPrefixSQL("m.content", "m.role")+`
ORDER BY m.session_id, m.ordinal`)
if err != nil {
return db.TrendsTermsResponse{}, err
}
defer rows.Close()
type trendRow struct {
sessionID string
role string
isSystem bool
model string
content string
msgTS any
startedAt any
createdAt any
}
processRow := func(sessionID, content string, local time.Time) {
if !allowedSessions[sessionID] {
return
}
date := local.Format("2006-01-02")
if f.From != "" && date < f.From {
return
}
if f.To != "" && date > f.To {
return
}
bucket := bucketAnalyticsDate(date, granularity)
pos, ok := index[bucket]
if !ok {
return
}
messageCounts[pos]++
for i, term := range terms {
counts[i][pos] += db.CountTrendOccurrences(content, term)
}
}
emit := func(m db.ScopedMessage) {
if !m.HasLocalTime {
return
}
processRow(m.SessionID, m.Content, m.LocalTime)
}
reducer := db.NewScopeReducer(flt, emit)
for rows.Next() {
var row trendRow
var ordinal int
if err := rows.Scan(&row.sessionID, &ordinal, &row.role, &row.isSystem, &row.model, &row.content, &row.msgTS, &row.startedAt, &row.createdAt); err != nil {
return db.TrendsTermsResponse{}, err
}
local, has := trendLocal(row.msgTS, row.startedAt, row.createdAt)
if !modelFiltering {
if has && flt.MatchesDayHour(local, true) {
processRow(row.sessionID, row.content, local)
}
continue
}
if err := reducer.Push(db.MessageInput{
SessionID: row.sessionID,
Ordinal: ordinal,
Role: row.role,
Model: row.model,
IsSystem: row.isSystem,
LocalTime: local,
HasLocalTime: has,
Content: row.content,
}); err != nil {
return db.TrendsTermsResponse{}, err
}
}
if err := rows.Err(); err != nil {
return db.TrendsTermsResponse{}, err
}
return db.BuildTrendsTermsResponse(
f.From, f.To, granularity, buckets, terms, counts, messageCounts,
), nil
}
type duckRates struct {
input float64
output float64
cacheCreation float64
cacheRead float64
updatedAt *time.Time
source export.PricingRowSource
}
func (s *Store) loadPricing(ctx context.Context) (map[string]duckRates, error) {
rows, err := s.queryContext(ctx, `
SELECT model_pattern, input_per_mtok, output_per_mtok,
cache_creation_per_mtok, cache_read_per_mtok, updated_at
FROM model_pricing`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]duckRates{}
count := 0
for rows.Next() {
var model string
var rates duckRates
var updatedAt string
if err := rows.Scan(
&model, &rates.input, &rates.output,
&rates.cacheCreation, &rates.cacheRead, &updatedAt,
); err != nil {
return nil, err
}
if strings.HasPrefix(model, "_") {
continue
}
if parsed, err := time.Parse(time.RFC3339Nano, updatedAt); err == nil {
t := parsed.UTC()
rates.updatedAt = &t
}
rates.source = duckPricingSource(model, rates)
out[model] = rates
count++
}
if err := rows.Err(); err != nil {
return nil, err
}
if count == 0 {
out = duckFallbackPricingMap()
}
for model, custom := range s.customPricing {
rates := duckRates{
input: custom.Input, output: custom.Output,
cacheCreation: custom.CacheCreation, cacheRead: custom.CacheRead,
}
rates.source = duckCustomPricingSource()
out[model] = rates
}
return out, nil
}
func duckCustomPricingSource() export.PricingRowSource {
return export.PricingRowSourceCustom
}
func duckFallbackPricingMap() map[string]duckRates {
prices := pricingpkg.FallbackPricing()
out := make(map[string]duckRates, len(prices))
for _, p := range prices {
if strings.HasPrefix(p.ModelPattern, "_") {
continue
}
out[p.ModelPattern] = duckRates{
input: p.InputPerMTok,
output: p.OutputPerMTok,
cacheCreation: p.CacheCreationPerMTok,
cacheRead: p.CacheReadPerMTok,
source: export.PricingRowSourceEmbedded,
}
}
return out
}
func duckPricingSource(model string, rates duckRates) export.PricingRowSource {
fallback := duckFallbackPricingMap()
if f, ok := fallback[model]; ok &&
f.input == rates.input &&
f.output == rates.output &&
f.cacheCreation == rates.cacheCreation &&
f.cacheRead == rates.cacheRead {
return export.PricingRowSourceEmbedded
}
return export.PricingRowSourceFetched
}
func duckPricingRows(
in map[string]duckRates,
) []export.EffectivePricingRow {
out := make([]export.EffectivePricingRow, 0, len(in))
for pattern, rates := range in {
source := rates.source
if source == "" {
source = duckPricingSource(pattern, rates)
}
out = append(out, export.EffectivePricingRow{
ModelPattern: pattern,
Rates: export.ModelRates{
InputPerMTok: rates.input,
OutputPerMTok: rates.output,
CacheWritePerMTok: rates.cacheCreation,
CacheReadPerMTok: rates.cacheRead,
UpdatedAt: rates.updatedAt,
Source: source,
},
})
}
return out
}
type duckUsageBounds struct {
from string
to string
}
func duckUsagePaddedUTCBound(ts string, hours int) string {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return t.Add(time.Duration(hours) * time.Hour).Format(time.RFC3339)
}
func duckUsageBoundsForFilter(f db.UsageFilter) duckUsageBounds {
var b duckUsageBounds
if f.From != "" {
b.from = duckUsagePaddedUTCBound(f.From+"T00:00:00Z", -14)
}
if f.To != "" {
b.to = duckUsagePaddedUTCBound(f.To+"T23:59:59Z", 14)
}
return b
}
func appendDuckUsageColumnBounds(
where, col string, b duckUsageBounds, args []any,
) (string, []any) {
if b.from != "" {
where += "\n\t\t\tAND " + col + " >= CAST(? AS TIMESTAMP)"
args = append(args, b.from)
}
if b.to != "" {
where += "\n\t\t\tAND " + col + " <= CAST(? AS TIMESTAMP)"
args = append(args, b.to)
}
return where, args
}
func appendDuckUsageCSVFilter(
where string, args []any, col, csv string, include bool,
) (string, []any) {
if csv == "" {
return where, args
}
parts := strings.Split(csv, ",")
vals := make([]string, 0, len(parts))
for _, value := range parts {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
vals = append(vals, trimmed)
}
}
return appendDuckUsageValuesFilter(where, args, col, vals, include)
}
func appendDuckUsageValuesFilter(
where string, args []any, col string, vals []string, include bool,
) (string, []any) {
if len(vals) == 0 {
return where, args
}
op := "IN"
if !include {
op = "NOT IN"
}
if len(vals) == 1 {
if include {
where += "\n\t\t\tAND " + col + " = ?"
} else {
where += "\n\t\t\tAND " + col + " != ?"
}
args = append(args, vals[0])
return where, args
}
ph := make([]string, len(vals))
for i, value := range vals {
ph[i] = "?"
args = append(args, value)
}
where += "\n\t\t\tAND " + col + " " + op +
" (" + strings.Join(ph, ",") + ")"
return where, args
}
func appendDuckUsageSourceFilterClauses(
where string, args []any, modelCol string, f db.UsageFilter,
) (string, []any) {
where, args = appendDuckUsageCSVFilter(where, args, modelCol, f.Model, true)
return appendDuckUsageCSVFilter(where, args, modelCol, f.ExcludeModel, false)
}
func appendDuckUsageSessionFilterClauses(
where string, args []any, f db.UsageFilter, sessionID string,
) (string, []any) {
where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.Agent, true)
where, args = appendDuckUsageValuesFilter(
where, args, "s.project", f.ProjectFilterLabels(), true,
)
where, args = appendDuckUsageCSVFilter(where, args, "s.machine", f.Machine, true)
if f.GitBranch != "" {
var clause string
clause, args = db.BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args)
where += "\n\t\t\tAND " + clause
}
where, args = appendDuckUsageValuesFilter(
where, args, "s.project", f.ExcludedProjectFilterLabels(), false,
)
where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.ExcludeAgent, false)
if sessionID != "" {
where += "\n\t\t\tAND s.id = ?"
args = append(args, sessionID)
}
if f.MinUserMessages > 0 {
where += "\n\t\t\tAND s.user_message_count >= ?"
args = append(args, f.MinUserMessages)
}
scope := duckNormalizeAutomatedScope(
f.AutomatedScope, f.ExcludeAutomated)
if f.ExcludeOneShot {
if scope == "human" {
where += "\n\t\t\tAND s.user_message_count > 1"
} else {
where += "\n\t\t\tAND (s.user_message_count > 1 OR COALESCE(s.is_automated, FALSE) = TRUE)"
}
}
if pred := duckAutomatedScopePredicate(
scope, "COALESCE(s.is_automated, FALSE)"); pred != "" {
where += "\n\t\t\tAND " + pred
}
if f.ActiveSince != "" {
where += "\n\t\t\tAND COALESCE(s.ended_at, s.started_at, s.created_at) >= CAST(? AS TIMESTAMP)"
args = append(args, f.ActiveSince)
}
if pred, predArgs := duckUsageTerminationPred(f.Termination); pred != "" {
where += "\n\t\t\tAND " + pred
args = append(args, predArgs...)
}
return where, args
}
func duckUsageTerminationPred(status string) (string, []any) {
return duckAnalyticsTerminationPred(
status,
"COALESCE(s.ended_at, s.started_at, s.created_at)",
"s.termination_status",
)
}
const duckDailyCursorUsageRowsSQLTemplate = `
SELECT
'' AS session_id,
NULL AS message_ordinal,
'cursor' AS source,
cu.occurred_at AS ts,
cu.model AS model,
'' AS token_json,
'' AS claude_message_id,
'' AS claude_request_id,
'' AS source_uuid,
cu.dedup_key AS usage_dedup_key,
cu.input_tokens AS input_tokens,
cu.output_tokens AS output_tokens,
cu.cache_write_tokens AS cache_create,
cu.cache_read_tokens AS cache_read,
0 AS reasoning_tokens,
cu.charged_cents / 100.0 AS cost_usd,
'' AS project,
'cursor' AS agent,
'' AS machine,
0 AS user_message_count,
cu.is_headless AS is_automated,
'' AS display_name,
NULL AS started_at,
cu.occurred_at AS activity_at
FROM cursor_usage_events cu
WHERE %s`
const duckUsageMessageEligibility = `
m.token_usage != ''
AND m.model != ''
AND m.model != '<synthetic>'
AND s.deleted_at IS NULL`
// duckUsageMatchingMessageSourceEligibility is the message-only half of
// duckUsageMessageEligibility with the token-presence requirement removed
// and the model-presence requirement relaxed to a role check, for
// GetUsageMatchingSessionCount. See the usageMatchingMessageEligibility
// doc comment in internal/db.
const duckUsageMatchingMessageSourceEligibility = `
m.role = 'assistant'
AND m.model != '<synthetic>'`
const duckUsageMatchingMessageEligibility = duckUsageMatchingMessageSourceEligibility + `
AND s.deleted_at IS NULL`
const duckUsageEventSourceEligibility = `
ue.model != ''`
const duckUsageEventEligibility = duckUsageEventSourceEligibility + `
AND s.deleted_at IS NULL`
// duckUsageSourceWheres builds the message/event WHERE clauses shared by
// duckUsageRawSQL and duckMatchingUsageRawSQL; the two callers differ only
// in the message eligibility predicate.
func duckUsageSourceWheres(
f db.UsageFilter, sessionID, messageEligibility string, b duckUsageBounds,
) (string, []any, string, []any) {
messageWhere := messageEligibility
var messageArgs []any
messageWhere, messageArgs = appendDuckUsageSourceFilterClauses(
messageWhere, messageArgs, "m.model", f)
messageWhere, messageArgs = appendDuckUsageSessionFilterClauses(
messageWhere, messageArgs, f, sessionID)
messageWhere, messageArgs = appendDuckUsageColumnBounds(
messageWhere, "COALESCE(m.timestamp, s.started_at)", b, messageArgs)
eventWhere := duckUsageEventEligibility
var eventArgs []any
eventWhere, eventArgs = appendDuckUsageSourceFilterClauses(
eventWhere, eventArgs, "ue.model", f)
eventWhere, eventArgs = appendDuckUsageSessionFilterClauses(
eventWhere, eventArgs, f, sessionID)
eventWhere, eventArgs = appendDuckUsageColumnBounds(
eventWhere, "COALESCE(ue.occurred_at, s.started_at)", b, eventArgs)
return messageWhere, messageArgs, eventWhere, eventArgs
}
func duckUsageRawSQL(f db.UsageFilter, sessionID string) (string, []any) {
messageWhere, messageArgs, eventWhere, eventArgs := duckUsageSourceWheres(
f, sessionID, duckUsageMessageEligibility, duckUsageBoundsForFilter(f))
query := fmt.Sprintf(`
SELECT m.session_id AS session_id, m.ordinal AS message_ordinal,
'message' AS source, COALESCE(m.timestamp, s.started_at) AS ts,
m.model AS model, m.token_usage AS token_json,
m.claude_message_id AS claude_message_id,
m.claude_request_id AS claude_request_id,
m.source_uuid AS source_uuid,
'' AS usage_dedup_key,
0 AS input_tokens, 0 AS output_tokens,
0 AS cache_create, 0 AS cache_read,
COALESCE(TRY_CAST(json_extract_string(m.token_usage, '$.reasoning_tokens') AS BIGINT), 0) AS reasoning_tokens,
NULL AS cost_usd,
s.project AS project, s.agent AS agent, s.machine AS machine,
s.user_message_count AS user_message_count, s.is_automated AS is_automated,
COALESCE(s.display_name, s.session_name, s.first_message, s.project, s.id) AS display_name,
s.started_at AS started_at,
COALESCE(s.ended_at, s.started_at, s.created_at) AS activity_at
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE %s
UNION ALL
SELECT ue.session_id AS session_id, ue.message_ordinal AS message_ordinal,
ue.source AS source, COALESCE(ue.occurred_at, s.started_at) AS ts,
ue.model AS model, '' AS token_json,
'' AS claude_message_id, '' AS claude_request_id,
'' AS source_uuid,
CASE
WHEN ue.dedup_key != '' THEN ue.session_id || ':' || ue.source || ':' || ue.dedup_key
ELSE ue.session_id || ':' || ue.source || ':id:' || CAST(ue.id AS VARCHAR)
END AS usage_dedup_key,
ue.input_tokens AS input_tokens, ue.output_tokens AS output_tokens,
ue.cache_creation_input_tokens AS cache_create,
ue.cache_read_input_tokens AS cache_read,
ue.reasoning_tokens AS reasoning_tokens,
ue.cost_usd AS cost_usd,
s.project AS project, s.agent AS agent, s.machine AS machine,
s.user_message_count AS user_message_count, s.is_automated AS is_automated,
COALESCE(s.display_name, s.session_name, s.first_message, s.project, s.id) AS display_name,
s.started_at AS started_at,
COALESCE(s.ended_at, s.started_at, s.created_at) AS activity_at
FROM usage_events ue
JOIN sessions s ON s.id = ue.session_id
WHERE %s`,
messageWhere, eventWhere)
args := make([]any, 0, len(messageArgs)+len(eventArgs))
args = append(args, messageArgs...)
args = append(args, eventArgs...)
return query, args
}
// duckMatchingUsageRawSQL builds the bounded-range row source for
// GetUsageMatchingSessionCount. It shares duckUsageRawSQL's WHERE
// assembly (via duckUsageSourceWheres) but relaxes the message predicate:
// no token_usage requirement (Copilot messages never populate it) and no
// model-presence requirement (some Copilot assistant messages parse
// before a model name is known), scoping to assistant rows via m.role
// instead. Model/ExcludeModel filters are still applied per-row, same as
// duckUsageRawSQL.
func duckMatchingUsageRawSQL(f db.UsageFilter) (string, []any) {
messageWhere, messageArgs, eventWhere, eventArgs := duckUsageSourceWheres(
f, "", duckUsageMatchingMessageEligibility, duckUsageBoundsForFilter(f))
query := fmt.Sprintf(`
SELECT m.session_id AS session_id,
COALESCE(m.timestamp, s.started_at) AS ts
FROM messages m
JOIN sessions s ON s.id = m.session_id
WHERE %s
UNION ALL
SELECT ue.session_id AS session_id,
COALESCE(ue.occurred_at, s.started_at) AS ts
FROM usage_events ue
JOIN sessions s ON s.id = ue.session_id
WHERE %s`,
messageWhere, eventWhere)
args := make([]any, 0, len(messageArgs)+len(eventArgs))
args = append(args, messageArgs...)
args = append(args, eventArgs...)
return query, args
}
func duckCursorUsageRowsSQLForBounds(
f db.UsageFilter, b duckUsageBounds,
) (string, []any, bool) {
hasTermFilter := f.Termination != "" && f.Termination != "all"
// Cursor usage rows carry no project or git branch and bypass the session
// filter, so any filter they cannot satisfy (project, machine, branch)
// must exclude them entirely rather than let them leak into totals.
if len(f.ProjectFilterLabels()) > 0 ||
len(f.ExcludedProjectFilterLabels()) > 0 ||
f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 ||
f.ExcludeOneShot || hasTermFilter ||
f.ActiveSince != "" {
return "", nil, false
}
if f.Agent != "" {
vals := strings.Split(f.Agent, ",")
for i := range vals {
vals[i] = strings.TrimSpace(vals[i])
}
if !slices.Contains(vals, "cursor") {
return "", nil, false
}
}
if f.ExcludeAgent != "" {
vals := strings.Split(f.ExcludeAgent, ",")
for i := range vals {
vals[i] = strings.TrimSpace(vals[i])
}
if slices.Contains(vals, "cursor") {
return "", nil, false
}
}
where := "cu.model != ''"
var args []any
scope := duckNormalizeAutomatedScope(f.AutomatedScope, f.ExcludeAutomated)
if pred := duckAutomatedScopePredicate(scope, "cu.is_headless"); pred != "" {
where += "\n\tAND " + pred
}
where, args = appendDuckUsageSourceFilterClauses(
where, args, "cu.model", f,
)
where, args = appendDuckUsageColumnBounds(
where, "cu.occurred_at", b, args,
)
return fmt.Sprintf(duckDailyCursorUsageRowsSQLTemplate, where), args, true
}
func duckDailyUsageRawSQL(f db.UsageFilter) (string, []any) {
bounds := duckUsageBoundsForFilter(f)
sessionRowsSQL, sessionArgs := duckUsageRawSQL(f, "")
cursorRowsSQL, cursorArgs, ok := duckCursorUsageRowsSQLForBounds(f, bounds)
if !ok {
return sessionRowsSQL, sessionArgs
}
rowsSQL := sessionRowsSQL + "\n\t\tUNION ALL\n" + cursorRowsSQL
args := make([]any, 0, len(sessionArgs)+len(cursorArgs))
args = append(args, sessionArgs...)
args = append(args, cursorArgs...)
return rowsSQL, args
}
func duckUsageLocalDateSQL(f db.UsageFilter) (string, any) {
if f.Timezone != "" {
return "COALESCE(strftime(timezone(?, timezone('UTC', ts)), '%Y-%m-%d'), '')", f.Timezone
}
ref := time.Now().UTC()
if f.From != "" {
if t, err := time.Parse(time.RFC3339, f.From+"T12:00:00Z"); err == nil {
ref = t
}
}
_, offset := ref.In(time.Local).Zone()
return "COALESCE(strftime(ts + (? * INTERVAL 1 SECOND), '%Y-%m-%d'), '')", offset
}
func duckUsageCTE(f db.UsageFilter, sessionID string) (string, []any) {
rawSQL, args := duckUsageRawSQL(f, sessionID)
return duckUsageCTEFromRaw(f, rawSQL, args)
}
func duckDailyUsageCTE(f db.UsageFilter) (string, []any) {
rawSQL, args := duckDailyUsageRawSQL(f)
return duckUsageCTEFromRaw(f, rawSQL, args)
}
func duckUsageCTEFromRaw(
f db.UsageFilter, rawSQL string, args []any,
) (string, []any) {
localDateSQL, localDateArg := duckUsageLocalDateSQL(f)
// Apply the local-date window BEFORE deduping so an out-of-range
// duplicate (pulled in by the padded UTC bounds) cannot win
// dedup_rank = 1 and suppress the in-range row. Mirrors the
// dedup-after-date-filter order in internal/db/usage.go.
datePred := "TRUE"
var dateArgs []any
if f.From != "" {
datePred += " AND local_date >= ?"
dateArgs = append(dateArgs, f.From)
}
if f.To != "" {
datePred += " AND local_date <= ?"
dateArgs = append(dateArgs, f.To)
}
query := fmt.Sprintf(`
WITH usage_raw AS (
%[1]s
),
usage_normalized AS (
SELECT *,
CASE
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.input_tokens') AS BIGINT), 0), 0), %[4]d)
WHEN source = 'session' THEN GREATEST(input_tokens, 0)
ELSE LEAST(GREATEST(input_tokens, 0), %[4]d)
END AS input_tokens_norm,
CASE
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.output_tokens') AS BIGINT), 0), 0), %[4]d)
WHEN source = 'session' THEN GREATEST(output_tokens, 0)
ELSE LEAST(GREATEST(output_tokens, 0), %[4]d)
END AS output_tokens_norm,
CASE
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.cache_creation_input_tokens') AS BIGINT), 0), 0), %[4]d)
WHEN source = 'session' THEN GREATEST(cache_create, 0)
ELSE LEAST(GREATEST(cache_create, 0), %[4]d)
END AS cache_create_norm,
CASE
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.cache_read_input_tokens') AS BIGINT), 0), 0), %[4]d)
WHEN source = 'session' THEN GREATEST(cache_read, 0)
ELSE LEAST(GREATEST(cache_read, 0), %[4]d)
END AS cache_read_norm,
CASE
WHEN source = 'message' THEN LEAST(GREATEST(COALESCE(TRY_CAST(json_extract_string(token_json, '$.reasoning_tokens') AS BIGINT), 0), 0), %[4]d)
WHEN source = 'session' THEN GREATEST(reasoning_tokens, 0)
ELSE LEAST(GREATEST(reasoning_tokens, 0), %[4]d)
END AS reasoning_tokens_norm,
CASE
WHEN claude_message_id != '' AND claude_request_id != ''
THEN 'claude:' || claude_message_id || ':' || claude_request_id
WHEN source = 'message' AND agent != '' AND source_uuid != ''
THEN 'source:' || agent || ':' || source_uuid
WHEN usage_dedup_key != ''
THEN 'usage:' || usage_dedup_key
ELSE 'row:' || session_id || ':' || source || ':' ||
COALESCE(CAST(message_ordinal AS VARCHAR), '') || ':' ||
COALESCE(CAST(ts AS VARCHAR), '') || ':' || model
END AS dedup_group,
%[2]s AS local_date
FROM usage_raw
),
usage_windowed AS (
SELECT *
FROM usage_normalized
WHERE %[3]s
),
usage_ranked AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY dedup_group
ORDER BY ts ASC, session_id ASC, COALESCE(message_ordinal, -1) ASC
) AS dedup_rank
FROM usage_windowed
),
usage_localized AS (
SELECT *
FROM usage_ranked
WHERE dedup_rank = 1
)`, rawSQL, localDateSQL, datePred, db.MaxPlausibleTokens)
args = append(args, localDateArg)
args = append(args, dateArgs...)
return query, args
}
type duckUsageBucket struct {
inputTok int
outputTok int
cacheCr int
cacheRd int
cost float64
}
type duckUsageAggregateRow struct {
date string
sessionID string
project string
agent string
model string
displayName string
startedAt string
inputTok int
outputTok int
cacheCr int
cacheRd int
billableInput int
// Output-rate billable tokens. SQL folds reasoning-only rows into this
// value before grouping because reasoning is otherwise a row-level choice.
billableOutput int
billableReason int
billableCacheCr int
billableCacheRd int
explicitCost float64
reportedCostRows int
}
type duckSessionUsageRow struct {
sessionID string
messageOrdinal sql.NullInt64
source string
ts string
model string
inputTok int
outputTok int
cacheCr int
cacheRd int
reasoningTok int
costUSD sql.NullFloat64
}
func duckUsageAggregateCost(
model string,
inputTok, outputTok, cacheCr, cacheRd int,
billableInput, billableOutput, billableReasoning, billableCacheCr, billableCacheRd int,
explicitCost float64,
hasReportedCost bool,
pricing *export.PricingResolver,
) (float64, float64, bool, bool) {
hasBillableTokens := billableInput != 0 || billableOutput != 0 ||
billableReasoning != 0 || billableCacheCr != 0 || billableCacheRd != 0
if !hasReportedCost &&
explicitCost == 0 &&
inputTok == 0 && outputTok == 0 && cacheCr == 0 && cacheRd == 0 &&
!hasBillableTokens {
pricing.RecordComputed(model, pricing.Lookup(model))
return 0, 0, true, false
}
lookup := pricing.Lookup(model)
rates := lookup.Rates
cost := explicitCost +
rates.CostForTokens(
billableInput, billableOutput, billableReasoning,
billableCacheCr, billableCacheRd)
if hasReportedCost {
pricing.RecordReported(model, lookup)
}
if hasBillableTokens {
pricing.RecordComputed(model, lookup)
}
readDelta := float64(cacheRd) * (rates.InputPerMTok - rates.CacheReadPerMTok) / 1_000_000
createDelta := float64(cacheCr) * (rates.InputPerMTok - rates.CacheWritePerMTok) / 1_000_000
priced := lookup.OK
if !hasBillableTokens && hasReportedCost {
priced = true
}
return cost, readDelta + createDelta, priced, true
}
func duckSessionUsageRowCost(
r duckSessionUsageRow, pricing map[string]duckRates,
) (float64, bool, bool) {
if r.costUSD.Valid {
return r.costUSD.Float64, true, true
}
if r.inputTok == 0 && r.outputTok == 0 && r.reasoningTok == 0 &&
r.cacheCr == 0 && r.cacheRd == 0 {
return 0, true, false
}
rates, priced := pricingpkg.Resolve(pricing, r.model)
if !priced {
return 0, false, true
}
// Reasoning is a breakdown of output, not additional billable
// output; reasoning-only rows bill at the output rate. Mirrors
// export.ModelRates.CostForTokens and the aggregate SQL fold.
billableOutput := r.outputTok
if billableOutput == 0 {
billableOutput = r.reasoningTok
}
cost := (float64(r.inputTok)*rates.input +
float64(billableOutput)*rates.output +
float64(r.cacheCr)*rates.cacheCreation +
float64(r.cacheRd)*rates.cacheRead) / 1_000_000
return cost, true, true
}
func duckSessionUsageBreakdownEntry(
r duckSessionUsageRow,
ordinal int,
cost float64,
priced bool,
) db.SessionUsageBreakdownEntry {
entry := db.SessionUsageBreakdownEntry{
Ordinal: ordinal,
Source: r.source,
Label: duckSessionUsageBreakdownLabel(r),
Timestamp: r.ts,
Model: r.model,
InputTokens: r.inputTok,
OutputTokens: r.outputTok,
CacheCreationInputTokens: r.cacheCr,
CacheReadInputTokens: r.cacheRd,
CostUSD: cost,
HasCost: priced,
}
if r.messageOrdinal.Valid {
messageOrdinal := int(r.messageOrdinal.Int64)
entry.MessageOrdinal = &messageOrdinal
}
return entry
}
func duckSessionUsageBreakdownLabel(r duckSessionUsageRow) string {
if r.messageOrdinal.Valid {
if r.source == "message" {
return fmt.Sprintf("Prompt %d", r.messageOrdinal.Int64+1)
}
return fmt.Sprintf("Step %d", r.messageOrdinal.Int64+1)
}
if r.source != "" {
return r.source
}
return "usage"
}
func (s *Store) dailyUsageAggregateRows(
ctx context.Context, f db.UsageFilter,
) ([]duckUsageAggregateRow, error) {
cte, args := duckDailyUsageCTE(f)
query := cte + `
SELECT local_date, project, agent, model,
SUM(input_tokens_norm) AS input_tokens,
SUM(output_tokens_norm) AS output_tokens,
SUM(cache_create_norm) AS cache_creation_tokens,
SUM(cache_read_norm) AS cache_read_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN input_tokens_norm ELSE 0 END) AS billable_input_tokens,
SUM(CASE
WHEN cost_usd IS NOT NULL THEN 0
WHEN output_tokens_norm = 0 THEN reasoning_tokens_norm
ELSE output_tokens_norm
END) AS billable_output_tokens,
CAST(0 AS BIGINT) AS billable_reasoning_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN cache_create_norm ELSE 0 END) AS billable_cache_creation_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN cache_read_norm ELSE 0 END) AS billable_cache_read_tokens,
COALESCE(SUM(cost_usd), 0) AS explicit_cost,
COUNT(cost_usd) AS reported_cost_rows
FROM usage_localized
GROUP BY local_date, project, agent, model
ORDER BY local_date ASC, project ASC, agent ASC, model ASC`
rows, err := s.queryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying duckdb daily usage aggregates: %w", err)
}
defer rows.Close()
var out []duckUsageAggregateRow
for rows.Next() {
var r duckUsageAggregateRow
if err := rows.Scan(
&r.date, &r.project, &r.agent, &r.model,
&r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd,
&r.billableInput, &r.billableOutput, &r.billableReason,
&r.billableCacheCr, &r.billableCacheRd,
&r.explicitCost, &r.reportedCostRows,
); err != nil {
return nil, fmt.Errorf("scanning duckdb daily usage aggregate: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetDailyUsage(
ctx context.Context, f db.UsageFilter,
) (db.DailyUsageResult, error) {
pricing, err := s.loadPricing(ctx)
if err != nil {
return db.DailyUsageResult{}, err
}
rateResolver := export.NewPricingResolver(duckPricingRows(pricing))
rows, err := s.dailyUsageAggregateRows(ctx, f)
if err != nil {
return db.DailyUsageResult{}, err
}
type usageAccumKey struct {
date string
project string
agent string
model string
}
accum := map[usageAccumKey]*duckUsageBucket{}
projectLabels := map[string]bool{}
totalSavings := 0.0
for _, r := range rows {
key := usageAccumKey{date: r.date, project: r.project, agent: r.agent, model: r.model}
if r.project != "" {
projectLabels[r.project] = true
}
b := accum[key]
if b == nil {
b = &duckUsageBucket{}
accum[key] = b
}
cost, savings, _, _ := duckUsageAggregateCost(
r.model,
r.inputTok, r.outputTok, r.cacheCr, r.cacheRd,
r.billableInput, r.billableOutput, r.billableReason,
r.billableCacheCr, r.billableCacheRd,
r.explicitCost,
r.reportedCostRows > 0,
rateResolver,
)
totalSavings += savings
b.inputTok += r.inputTok
b.outputTok += r.outputTok
b.cacheCr += r.cacheCr
b.cacheRd += r.cacheRd
b.cost += cost
}
type dayMaps struct {
models map[string]duckUsageBucket
projects map[string]duckUsageBucket
agents map[string]duckUsageBucket
}
days := map[string]*dayMaps{}
for key, b := range accum {
day := days[key.date]
if day == nil {
day = &dayMaps{
models: map[string]duckUsageBucket{},
projects: map[string]duckUsageBucket{},
agents: map[string]duckUsageBucket{},
}
days[key.date] = day
}
addUsageBucket(day.models, key.model, *b)
if f.Breakdowns {
addUsageBucket(day.projects, key.project, *b)
addUsageBucket(day.agents, key.agent, *b)
}
}
var result db.DailyUsageResult
for _, date := range sortedKeys(days) {
day := days[date]
if day == nil {
continue
}
entry := db.DailyUsageEntry{Date: date}
modelNames := sortedUsageBucketKeys(day.models)
entry.ModelsUsed = modelNames
for _, model := range modelNames {
b := day.models[model]
entry.InputTokens += b.inputTok
entry.OutputTokens += b.outputTok
entry.CacheCreationTokens += b.cacheCr
entry.CacheReadTokens += b.cacheRd
entry.TotalCost += b.cost
entry.ModelBreakdowns = append(entry.ModelBreakdowns, db.ModelBreakdown{
ModelName: model,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: roundCost(b.cost),
})
}
if f.Breakdowns {
for _, project := range sortedUsageBucketKeys(day.projects) {
b := day.projects[project]
entry.ProjectBreakdowns = append(entry.ProjectBreakdowns, db.ProjectBreakdown{
Project: project,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: roundCost(b.cost),
})
}
for _, agent := range sortedUsageBucketKeys(day.agents) {
b := day.agents[agent]
entry.AgentBreakdowns = append(entry.AgentBreakdowns, db.AgentBreakdown{
Agent: agent,
InputTokens: b.inputTok,
OutputTokens: b.outputTok,
CacheCreationTokens: b.cacheCr,
CacheReadTokens: b.cacheRd,
Cost: roundCost(b.cost),
})
}
}
entry.TotalCost = roundCost(entry.TotalCost)
result.Daily = append(result.Daily, entry)
result.Totals.InputTokens += entry.InputTokens
result.Totals.OutputTokens += entry.OutputTokens
result.Totals.CacheCreationTokens += entry.CacheCreationTokens
result.Totals.CacheReadTokens += entry.CacheReadTokens
result.Totals.TotalCost += entry.TotalCost
}
result.Totals.CacheSavings = roundCost(totalSavings)
result.Totals.TotalCost = roundCost(result.Totals.TotalCost)
var aiCredits float64
for key, b := range accum {
aiCredits += db.AICreditsFromCost(key.agent, b.cost)
}
if aiCredits > 0 {
result.Totals.CopilotAICredits = aiCredits
}
if result.Daily == nil {
result.Daily = []db.DailyUsageEntry{}
}
result.SchemaVersion = export.UsageDailySchemaVersion
pricingBlock, err := rateResolver.BuildBlock()
if err != nil {
return db.DailyUsageResult{}, fmt.Errorf(
"building pricing block: %w", err)
}
result.Pricing = &pricingBlock
projects, err := s.BuildProjectIdentityMap(ctx, sortedBoolKeys(projectLabels))
if err != nil {
return db.DailyUsageResult{}, err
}
result.Projects = export.ProjectMapForWire(projects)
if !f.SkipSessionCounts {
counts, err := s.GetUsageSessionCounts(ctx, f)
if err != nil {
return db.DailyUsageResult{}, err
}
result.SessionCounts = counts
}
db.SanitizeDailyUsageProjectLabelsWithCatalog(&result, projects)
return result, nil
}
func addUsageBucket(m map[string]duckUsageBucket, key string, b duckUsageBucket) {
cur := m[key]
cur.inputTok += b.inputTok
cur.outputTok += b.outputTok
cur.cacheCr += b.cacheCr
cur.cacheRd += b.cacheRd
cur.cost += b.cost
m[key] = cur
}
func sortedUsageBucketKeys(m map[string]duckUsageBucket) []string {
out := make([]string, 0, len(m))
for key := range m {
out = append(out, key)
}
sort.Slice(out, func(i, j int) bool {
left := m[out[i]]
right := m[out[j]]
if left.cost != right.cost {
return left.cost > right.cost
}
return out[i] < out[j]
})
return out
}
func sortedBoolKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func roundCost(v float64) float64 { return math.Round(v*1_000_000) / 1_000_000 }
func (s *Store) sessionUsageAggregateRows(
ctx context.Context, f db.UsageFilter, sessionID string,
) ([]duckUsageAggregateRow, error) {
cte, args := duckUsageCTE(f, sessionID)
query := cte + `
SELECT session_id, project, agent, model,
ANY_VALUE(display_name) AS display_name,
ANY_VALUE(started_at) AS started_at,
SUM(input_tokens_norm) AS input_tokens,
SUM(output_tokens_norm) AS output_tokens,
SUM(cache_create_norm) AS cache_creation_tokens,
SUM(cache_read_norm) AS cache_read_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN input_tokens_norm ELSE 0 END) AS billable_input_tokens,
SUM(CASE
WHEN cost_usd IS NOT NULL THEN 0
WHEN output_tokens_norm = 0 THEN reasoning_tokens_norm
ELSE output_tokens_norm
END) AS billable_output_tokens,
CAST(0 AS BIGINT) AS billable_reasoning_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN cache_create_norm ELSE 0 END) AS billable_cache_creation_tokens,
SUM(CASE WHEN cost_usd IS NULL THEN cache_read_norm ELSE 0 END) AS billable_cache_read_tokens,
COALESCE(SUM(cost_usd), 0) AS explicit_cost,
COUNT(cost_usd) AS reported_cost_rows
FROM usage_localized
GROUP BY session_id, project, agent, model
ORDER BY session_id ASC, model ASC`
rows, err := s.queryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying duckdb session usage aggregates: %w", err)
}
defer rows.Close()
var out []duckUsageAggregateRow
for rows.Next() {
var r duckUsageAggregateRow
var startedAt any
if err := rows.Scan(
&r.sessionID, &r.project, &r.agent, &r.model,
&r.displayName, &startedAt,
&r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd,
&r.billableInput, &r.billableOutput, &r.billableReason,
&r.billableCacheCr, &r.billableCacheRd,
&r.explicitCost, &r.reportedCostRows,
); err != nil {
return nil, fmt.Errorf("scanning duckdb session usage aggregate: %w", err)
}
r.startedAt = formatDBTime(startedAt)
out = append(out, r)
}
return out, rows.Err()
}
// sessionUsageRowCount counts the deduped usage rows that would
// contribute breakdown entries, mirroring duckSessionUsageRowCost's
// contributes rule (an explicit cost or any nonzero token counter)
// without shipping the rows.
func (s *Store) sessionUsageRowCount(
ctx context.Context, sessionID string,
) (int, error) {
cte, args := duckUsageCTE(db.UsageFilter{}, sessionID)
query := cte + `
SELECT COUNT(*)
FROM usage_localized
WHERE cost_usd IS NOT NULL
OR input_tokens_norm != 0
OR output_tokens_norm != 0
OR cache_create_norm != 0
OR cache_read_norm != 0
OR reasoning_tokens_norm != 0`
var count int
if err := s.queryRowContext(ctx, query, args...).
Scan(&count); err != nil {
return 0, fmt.Errorf(
"counting duckdb session usage rows: %w", err)
}
return count, nil
}
func (s *Store) sessionUsageRows(
ctx context.Context, sessionID string,
) ([]duckSessionUsageRow, error) {
cte, args := duckUsageCTE(db.UsageFilter{}, sessionID)
query := cte + `
SELECT session_id, message_ordinal, source, ts, model,
input_tokens_norm, output_tokens_norm,
cache_create_norm, cache_read_norm,
reasoning_tokens_norm, cost_usd
FROM usage_localized
ORDER BY ts ASC, session_id ASC,
COALESCE(message_ordinal, -1) ASC,
source ASC,
usage_dedup_key ASC`
rows, err := s.queryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("querying duckdb session usage rows: %w", err)
}
defer rows.Close()
var out []duckSessionUsageRow
for rows.Next() {
var r duckSessionUsageRow
var ts any
if err := rows.Scan(
&r.sessionID, &r.messageOrdinal, &r.source, &ts, &r.model,
&r.inputTok, &r.outputTok, &r.cacheCr, &r.cacheRd,
&r.reasoningTok, &r.costUSD,
); err != nil {
return nil, fmt.Errorf("scanning duckdb session usage row: %w", err)
}
r.ts = formatDBTime(ts)
out = append(out, r)
}
return out, rows.Err()
}
func (s *Store) GetTopSessionsByCost(
ctx context.Context, f db.UsageFilter, limit int,
) ([]db.TopSessionEntry, error) {
if limit <= 0 {
limit = 20
}
pricing, err := s.loadPricing(ctx)
if err != nil {
return nil, err
}
rateResolver := export.NewPricingResolver(duckPricingRows(pricing))
rows, err := s.sessionUsageAggregateRows(ctx, f, "")
if err != nil {
return nil, err
}
type acc struct {
row db.TopSessionEntry
tokens int
cost float64
}
bySession := map[string]*acc{}
for _, r := range rows {
a := bySession[r.sessionID]
if a == nil {
a = &acc{row: db.TopSessionEntry{
SessionID: r.sessionID, DisplayName: r.displayName,
Agent: r.agent, Project: r.project, StartedAt: r.startedAt,
}}
bySession[r.sessionID] = a
}
cost, _, _, _ := duckUsageAggregateCost(
r.model,
r.inputTok, r.outputTok, r.cacheCr, r.cacheRd,
r.billableInput, r.billableOutput, r.billableReason,
r.billableCacheCr, r.billableCacheRd,
r.explicitCost,
r.reportedCostRows > 0,
rateResolver,
)
a.tokens += r.inputTok + r.outputTok + r.cacheCr + r.cacheRd
a.cost += cost
}
out := make([]db.TopSessionEntry, 0, len(bySession))
for _, a := range bySession {
a.row.TotalTokens = a.tokens
a.row.Cost = roundCost(a.cost)
out = append(out, a.row)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Cost != out[j].Cost {
return out[i].Cost > out[j].Cost
}
return out[i].SessionID < out[j].SessionID
})
if len(out) > limit {
out = out[:limit]
}
return out, nil
}
func (s *Store) GetUsageSessionCounts(
ctx context.Context, f db.UsageFilter,
) (db.UsageSessionCounts, error) {
rows, err := s.sessionUsageAggregateRows(ctx, f, "")
if err != nil {
return db.UsageSessionCounts{}, err
}
type sessionInfo struct {
project string
agent string
}
seen := map[string]sessionInfo{}
for _, r := range rows {
seen[r.sessionID] = sessionInfo{
project: r.project,
agent: r.agent,
}
}
out := db.UsageSessionCounts{ByProject: map[string]int{}, ByAgent: map[string]int{}}
for _, r := range seen {
out.Total++
out.ByProject[r.project]++
out.ByAgent[r.agent]++
}
return out, nil
}
// appendDuckUsageMatchingActivityClauses requires the session to have at
// least one row that GetUsageMatchingSessionCount's bounded branch would
// count, mirroring appendUsageMatchingActivityClauses in internal/db so
// bounded and unbounded requests agree on which sessions match.
func appendDuckUsageMatchingActivityClauses(
where string, args []any, f db.UsageFilter,
) (string, []any) {
var messageArgs []any
messageWhere, messageArgs := appendDuckUsageSourceFilterClauses(
duckUsageMatchingMessageSourceEligibility, messageArgs, "m.model", f,
)
var eventArgs []any
eventWhere, eventArgs := appendDuckUsageSourceFilterClauses(
duckUsageEventSourceEligibility, eventArgs, "ue.model", f,
)
where += `
AND (
EXISTS (
SELECT 1
FROM messages m
WHERE m.session_id = s.id
AND ` + messageWhere + `
)
OR EXISTS (
SELECT 1
FROM usage_events ue
WHERE ue.session_id = s.id
AND ` + eventWhere + `
)
)`
args = append(args, messageArgs...)
args = append(args, eventArgs...)
return where, args
}
// GetUsageMatchingSessionCount counts sessions that match the usage filter
// even when they have no token-bearing usage rows. Bounded ranges are
// resolved against message/usage_events timestamps (falling back to
// s.started_at), the same shape duckUsageRawSQL already uses for the
// normal usage query, so a session whose activity falls outside the
// window but whose message timestamp falls inside it is still counted.
func (s *Store) GetUsageMatchingSessionCount(
ctx context.Context, f db.UsageFilter,
) (int, error) {
if f.From == "" && f.To == "" {
where, args := appendDuckUsageSessionFilterClauses(
"s.deleted_at IS NULL", nil, f, "")
where, args = appendDuckUsageMatchingActivityClauses(where, args, f)
var count int
err := s.duck.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM sessions s WHERE `+where, args...).Scan(&count)
if err != nil {
return 0, fmt.Errorf("querying matching usage sessions: %w", err)
}
return count, nil
}
query, args := duckMatchingUsageRawSQL(f)
rows, err := s.duck.QueryContext(ctx, query, args...)
if err != nil {
return 0, fmt.Errorf("querying matching usage sessions: %w", err)
}
defer rows.Close()
seen := make(map[string]struct{})
for rows.Next() {
var (
id string
ts any
)
if err := rows.Scan(&id, &ts); err != nil {
return 0, fmt.Errorf("scanning matching usage session: %w", err)
}
date := analyticsLocalDate(formatDBTime(ts), f.Timezone)
if date == "" {
continue
}
if f.From != "" && date < f.From {
continue
}
if f.To != "" && date > f.To {
continue
}
seen[id] = struct{}{}
}
if err := rows.Err(); err != nil {
return 0, fmt.Errorf("iterating matching usage sessions: %w", err)
}
return len(seen), nil
}
func (s *Store) GetSessionUsage(
ctx context.Context, sessionID string, includeBreakdown bool,
) (*db.SessionUsage, error) {
sess, err := s.GetSession(ctx, sessionID)
if err != nil || sess == nil {
return nil, err
}
pricing, err := s.loadPricing(ctx)
if err != nil {
return nil, err
}
rateResolver := export.NewPricingResolver(duckPricingRows(pricing))
rows, err := s.sessionUsageAggregateRows(ctx, db.UsageFilter{}, sessionID)
if err != nil {
return nil, err
}
var breakdownRows []duckSessionUsageRow
breakdownCount := 0
if includeBreakdown {
breakdownRows, err = s.sessionUsageRows(ctx, sessionID)
} else {
breakdownCount, err = s.sessionUsageRowCount(ctx, sessionID)
}
if err != nil {
return nil, err
}
models := map[string]bool{}
unpriced := map[string]bool{}
totalCost := 0.0
hasRows := false
for _, r := range rows {
hasRows = true
models[r.model] = true
cost, _, priced, contributes := duckUsageAggregateCost(
r.model,
r.inputTok, r.outputTok, r.cacheCr, r.cacheRd,
r.billableInput, r.billableOutput, r.billableReason,
r.billableCacheCr, r.billableCacheRd,
r.explicitCost,
r.reportedCostRows > 0,
rateResolver,
)
if !contributes {
continue
}
totalCost += cost
if !priced {
unpriced[r.model] = true
}
}
breakdown := make([]db.SessionUsageBreakdownEntry, 0, len(breakdownRows))
for _, r := range breakdownRows {
cost, priced, contributes := duckSessionUsageRowCost(r, pricing)
if !contributes {
continue
}
breakdown = append(breakdown, duckSessionUsageBreakdownEntry(
r, len(breakdown)+1, cost, priced))
}
if includeBreakdown {
breakdownCount = len(breakdown)
}
out := &db.SessionUsage{
SessionID: sessionID, Agent: sess.Agent, Project: sess.Project,
TotalOutputTokens: sess.TotalOutputTokens,
PeakContextTokens: sess.PeakContextTokens,
HasTokenData: hasRows || sess.HasTotalOutputTokens || sess.HasPeakContextTokens,
Models: sortedBoolKeys(models),
UnpricedModels: sortedBoolKeys(unpriced),
BreakdownCount: breakdownCount,
Breakdown: breakdown,
}
if len(unpriced) == 0 && hasRows {
out.HasCost = true
out.CostUSD = roundCost(totalCost)
out.AICredits = db.AICreditsFromCost(sess.Agent, out.CostUSD)
}
return out, nil
}