mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-04 17:18:35 +02:00
* feat(usage): add error/failure visibility to the Usage dashboard The Usage page could only answer "how much did we spend"; nothing on it showed how often agents fail, what kind of failure it was, or which agent is responsible. Operators had to open failed tasks one at a time to spot a pattern. `agent_task_queue.failure_reason` already carries the refined 21-value taxonomy from server/pkg/taskfailure, so this is a read path over data that already exists. Backend — two rollups, both scoped by workspace/project/window like the existing dashboard endpoints: GET /api/dashboard/failures/daily per-(date, failure_reason) GET /api/dashboard/failures/by-agent per-(agent, failure_reason) They return every terminal task, not just failures: the `failure_reason: ""` row carries the succeeded count. That is what makes the error rate's denominator share filters with its numerator. The run-time rollups can't serve as that denominator — they require `started_at IS NOT NULL`, so a task that expired in the queue (the signature of a runtime outage) contributes nothing to their failed_count. A failed row with an empty reason column lands in an `unclassified` bucket rather than being mistaken for a success. Frontend: - "Errors" joins the trend toggle, daily and weekly, stacked by failure class with the bucket's error rate in the tooltip. - An Errors card breaks the window down by class and by agent, with the raw failure_reason strings behind a disclosure (unlocalised — an operator pastes them into a log search). Each agent row links to its Work tab, which lists the actual failed runs. - The 21 backend reasons fold into 7 display classes in @multica/core/dashboard. Unknown reasons — including ones from a backend newer than the client — land in "other" instead of being dropped, so the class totals always reconcile with the failure count. The Tasks KPI tile is deliberately left alone: its value counts started tasks only, so quoting the failure rollup's larger count there would put two denominators in one tile. The Errors card states its rate with the denominator spelled out instead. Migration 225 adds a partial index on agent_task_queue(completed_at) for terminal statuses. The table had no completed_at index at all, so the two pre-existing run-time rollups were already scanning it; these two new queries would have doubled that. Closes #4429 (MUL-5352) Co-authored-by: multica-agent <github@multica.ai> * fix(usage): correct the Errors drill-down, window and agent exposure Review findings on PR #5991. 1. The drill-down pointed at the wrong page. `?view=work` renders ActorIssuesPanel — the issues assigned to the agent — while its runs live in the Overview pane's ActivityTab. Link to Overview. That page also could not show why a run failed: `failureReasonLabel` was a `Record<TaskFailureReason, string>` indexed with a cast to the old 6-value coarse enum, so every refined reason the backend has written since MUL-1949 resolved to `undefined`. It is now a function over the full 21-value taxonomy plus the legacy coarse values, falling back to the raw wire string for anything newer than the client. Fixes the issue execution log too, which had the same cast. 2. The Errors card covered one more calendar day than the chart above it. `parseSinceParamInTZ` returns N+1 days of headroom on purpose and the dashboard trims the surplus client-side — but only a series carrying a date can be trimmed that way. Totals / classes / reasons now derive from the date-bucketed rollup after that trim, and the per-agent rollup (which has no date to trim on) closes its window server-side via a new `parseExactSinceParamInTZ`. At days=1 the card previously reported yesterday's failures beside a chart showing none. 3. The top-offenders list leaked agents the viewer cannot see. The failure rollups are workspace-scoped and deliberately skip per-agent visibility, but the agent list they are joined against does not — members only see a private agent when they own it or are owner/admin. `name ?? row.agentId` therefore rendered a bare UUID along with that agent's failure count, rate and dominant error class. Unresolvable agents now fold into one anonymous row, and the renderer never falls back to an id. Stricter than `bucketUnknownAgentRows` while the agent list loads: a transient flash of UUIDs is the leak, not a cosmetic glitch. Also from the review: the Errors tooltip echoed the raw Recharts dataKey ("rate_limit") instead of the translated label the legend already carries. Not changed — the schema's `failure_reason` default stays `""`. Defaulting a missing field to a failure bucket guards against a deflated rate, but the realistic drift is `omitempty` on the Go struct tag, which would strip the field from exactly the SUCCESS rows and read as a 100% error rate. Added TestDashboardFailureWireContractKeepsEmptyReason to pin that the server always emits the field, which is the assumption the default rests on. Co-authored-by: multica-agent <github@multica.ai> * fix(usage): renumber migration and fix the anonymous bucket's failure class Review findings on PR #5991, round 2. 1. Migration prefix 225 collided with `225_chat_message_channel_media_pending`, which landed on main while this branch was open — backend CI failed on TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Merged main and renumbered to 231; main now carries 225 through 230, so 226 is taken too. 2. The anonymous "Other agents" bucket could announce the wrong failure class. It merged rows that had ALREADY collapsed to one dominant class per agent, then credited each agent's entire failure count to that class. An agent failing auth 6 / timeout 5 contributed 11 to auth and 0 to timeout, so a bucket whose real composition was timeout 15 / auth 6 rendered as Auth. Fixed by anonymizing the raw per-(agent, reason) rows instead: the sentinel becomes just another agent_id and `aggregateAgentFailures` computes its classes from real counts. That also deletes the parallel bucketing pass — one identity rewrite replaces it. `knownAgentIds` moves up to where both consumers can see it. Also from the review: - The wire-contract test decoded both payloads into one map. json.Unmarshal merges into a non-nil map rather than resetting it, so a residual failure_reason from the first case could have masked an omitempty regression in the second — exactly what the test is meant to catch. Now table-driven with a fresh map per case. - A test comment still described the drill-down as pointing at the Work tab. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Bohan-J <bohan@devv.ai> Co-authored-by: multica-agent <github@multica.ai>
626 lines
23 KiB
Go
626 lines
23 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: task_usage.sql
|
|
|
|
package db
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const getIssueUsageSummary = `-- name: GetIssueUsageSummary :one
|
|
SELECT
|
|
COALESCE(SUM(tu.input_tokens), 0)::bigint AS total_input_tokens,
|
|
COALESCE(SUM(tu.output_tokens), 0)::bigint AS total_output_tokens,
|
|
COALESCE(SUM(tu.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(tu.cache_write_tokens), 0)::bigint AS total_cache_write_tokens,
|
|
COALESCE(SUM(tu.cost_usd_ticks), 0)::bigint AS total_cost_usd_ticks,
|
|
COALESCE(SUM(tu.input_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_input_tokens,
|
|
COALESCE(SUM(tu.output_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_output_tokens,
|
|
COALESCE(SUM(tu.cache_read_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_read_tokens,
|
|
COALESCE(SUM(tu.cache_write_tokens) FILTER (WHERE tu.cost_usd_ticks IS NULL), 0)::bigint AS uncosted_cache_write_tokens,
|
|
COUNT(DISTINCT tu.task_id)::int AS task_count
|
|
FROM task_usage tu
|
|
JOIN agent_task_queue atq ON atq.id = tu.task_id
|
|
WHERE atq.issue_id = $1
|
|
`
|
|
|
|
type GetIssueUsageSummaryRow struct {
|
|
TotalInputTokens int64 `json:"total_input_tokens"`
|
|
TotalOutputTokens int64 `json:"total_output_tokens"`
|
|
TotalCacheReadTokens int64 `json:"total_cache_read_tokens"`
|
|
TotalCacheWriteTokens int64 `json:"total_cache_write_tokens"`
|
|
TotalCostUsdTicks int64 `json:"total_cost_usd_ticks"`
|
|
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
|
|
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
|
|
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
|
|
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
func (q *Queries) GetIssueUsageSummary(ctx context.Context, issueID pgtype.UUID) (GetIssueUsageSummaryRow, error) {
|
|
row := q.db.QueryRow(ctx, getIssueUsageSummary, issueID)
|
|
var i GetIssueUsageSummaryRow
|
|
err := row.Scan(
|
|
&i.TotalInputTokens,
|
|
&i.TotalOutputTokens,
|
|
&i.TotalCacheReadTokens,
|
|
&i.TotalCacheWriteTokens,
|
|
&i.TotalCostUsdTicks,
|
|
&i.UncostedInputTokens,
|
|
&i.UncostedOutputTokens,
|
|
&i.UncostedCacheReadTokens,
|
|
&i.UncostedCacheWriteTokens,
|
|
&i.TaskCount,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const getTaskUsage = `-- name: GetTaskUsage :many
|
|
SELECT id, task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, created_at, updated_at, cost_usd_ticks FROM task_usage
|
|
WHERE task_id = $1
|
|
ORDER BY model
|
|
`
|
|
|
|
func (q *Queries) GetTaskUsage(ctx context.Context, taskID pgtype.UUID) ([]TaskUsage, error) {
|
|
rows, err := q.db.Query(ctx, getTaskUsage, taskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []TaskUsage{}
|
|
for rows.Next() {
|
|
var i TaskUsage
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.TaskID,
|
|
&i.Provider,
|
|
&i.Model,
|
|
&i.InputTokens,
|
|
&i.OutputTokens,
|
|
&i.CacheReadTokens,
|
|
&i.CacheWriteTokens,
|
|
&i.CreatedAt,
|
|
&i.UpdatedAt,
|
|
&i.CostUsdTicks,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardAgentRunTime = `-- name: ListDashboardAgentRunTime :many
|
|
SELECT
|
|
atq.agent_id,
|
|
COALESCE(
|
|
SUM(EXTRACT(EPOCH FROM (atq.completed_at - atq.started_at)))::bigint,
|
|
0
|
|
)::bigint AS total_seconds,
|
|
COUNT(*)::int AS task_count,
|
|
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count
|
|
FROM agent_task_queue atq
|
|
JOIN agent a ON a.id = atq.agent_id
|
|
LEFT JOIN issue i ON i.id = atq.issue_id
|
|
WHERE a.workspace_id = $1
|
|
AND atq.status IN ('completed', 'failed')
|
|
AND atq.started_at IS NOT NULL
|
|
AND atq.completed_at IS NOT NULL
|
|
AND atq.completed_at >= $2::timestamptz
|
|
AND ($3::uuid IS NULL OR i.project_id = $3)
|
|
GROUP BY atq.agent_id
|
|
ORDER BY total_seconds DESC
|
|
`
|
|
|
|
type ListDashboardAgentRunTimeParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardAgentRunTimeRow struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// Per-agent total task run time and task count for the workspace, optionally
|
|
// scoped to a single project. Counts only terminal runs (completed or failed)
|
|
// with both started_at and completed_at populated — queued/running tasks have
|
|
// no finite duration. Anchored on completed_at so the window matches the
|
|
// token cost window (which is anchored on tu.created_at, ~= completion time).
|
|
//
|
|
// No date bucketing, so no @tz — but @since is the viewer's local
|
|
// start-of-day-(N) so the "last N days" window lines up with the per-agent
|
|
// cost card; passed straight through without re-truncation.
|
|
func (q *Queries) ListDashboardAgentRunTime(ctx context.Context, arg ListDashboardAgentRunTimeParams) ([]ListDashboardAgentRunTimeRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardAgentRunTime, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardAgentRunTimeRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardAgentRunTimeRow
|
|
if err := rows.Scan(
|
|
&i.AgentID,
|
|
&i.TotalSeconds,
|
|
&i.TaskCount,
|
|
&i.FailedCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardFailuresByAgent = `-- name: ListDashboardFailuresByAgent :many
|
|
SELECT
|
|
atq.agent_id,
|
|
CASE
|
|
WHEN atq.status = 'failed'
|
|
THEN COALESCE(NULLIF(atq.failure_reason, ''), 'unclassified')
|
|
ELSE ''
|
|
END AS failure_reason,
|
|
COUNT(*)::int AS task_count
|
|
FROM agent_task_queue atq
|
|
JOIN agent a ON a.id = atq.agent_id
|
|
LEFT JOIN issue i ON i.id = atq.issue_id
|
|
WHERE a.workspace_id = $1
|
|
AND atq.status IN ('completed', 'failed')
|
|
AND atq.completed_at IS NOT NULL
|
|
AND atq.completed_at >= $2::timestamptz
|
|
AND ($3::uuid IS NULL OR i.project_id = $3)
|
|
GROUP BY atq.agent_id, 2
|
|
ORDER BY atq.agent_id, 2
|
|
`
|
|
|
|
type ListDashboardFailuresByAgentParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardFailuresByAgentRow struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
FailureReason string `json:"failure_reason"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// Per-(agent, failure_reason) terminal-task counts — the "top offenders"
|
|
// half of the dashboard's errors breakdown. Same `failure_reason = ”`
|
|
// succeeded-bucket convention as ListDashboardFailuresDaily, so the client
|
|
// can rank agents by failure rate rather than raw count.
|
|
//
|
|
// No date bucketing, so no @tz — @since is the viewer's local
|
|
// start-of-day-(N) so the window lines up with the per-agent run-time card.
|
|
func (q *Queries) ListDashboardFailuresByAgent(ctx context.Context, arg ListDashboardFailuresByAgentParams) ([]ListDashboardFailuresByAgentRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardFailuresByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardFailuresByAgentRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardFailuresByAgentRow
|
|
if err := rows.Scan(&i.AgentID, &i.FailureReason, &i.TaskCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardFailuresDaily = `-- name: ListDashboardFailuresDaily :many
|
|
SELECT
|
|
DATE(atq.completed_at AT TIME ZONE $2::text) AS date,
|
|
CASE
|
|
WHEN atq.status = 'failed'
|
|
THEN COALESCE(NULLIF(atq.failure_reason, ''), 'unclassified')
|
|
ELSE ''
|
|
END AS failure_reason,
|
|
COUNT(*)::int AS task_count
|
|
FROM agent_task_queue atq
|
|
JOIN agent a ON a.id = atq.agent_id
|
|
LEFT JOIN issue i ON i.id = atq.issue_id
|
|
WHERE a.workspace_id = $1
|
|
AND atq.status IN ('completed', 'failed')
|
|
AND atq.completed_at IS NOT NULL
|
|
AND atq.completed_at >= $3::timestamptz
|
|
AND ($4::uuid IS NULL OR i.project_id = $4)
|
|
GROUP BY 1, 2
|
|
ORDER BY 1 DESC, 2
|
|
`
|
|
|
|
type ListDashboardFailuresDailyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Tz string `json:"tz"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardFailuresDailyRow struct {
|
|
Date pgtype.Date `json:"date"`
|
|
FailureReason string `json:"failure_reason"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// Daily per-(date, failure_reason) terminal-task counts for the workspace,
|
|
// optionally scoped to a single project. Powers the workspace dashboard's
|
|
// "Errors" trend and the errors-by-class breakdown.
|
|
//
|
|
// Shape note: this returns EVERY terminal task, not just the failures. The
|
|
// `failure_reason = ”` row of each date carries that date's succeeded
|
|
// count, which is the denominator the client needs for an error rate. A
|
|
// failed row whose failure_reason column is NULL or empty (pre-MUL-1949
|
|
// rows, or a failure path that forgot to classify) collapses into the
|
|
// 'unclassified' bucket so it stays countable instead of masquerading as a
|
|
// success. Cardinality is bounded by days x (21 reasons + 2), so the whole
|
|
// window fits in one small payload.
|
|
//
|
|
// Unlike ListDashboardRunTimeDaily this does NOT require started_at — a task
|
|
// that expired in the queue (failure_reason='queued_expired') never started
|
|
// but is unambiguously a failure, and dropping it would under-report exactly
|
|
// the outage the Errors chart exists to surface. Every failure path sets
|
|
// completed_at, so bucketing on it covers all of them.
|
|
//
|
|
// @since is already the viewer's local start-of-day-(N) (parseSinceParamInTZ)
|
|
// — passed straight through, NOT re-truncated; see ListDashboardUsageDaily.
|
|
func (q *Queries) ListDashboardFailuresDaily(ctx context.Context, arg ListDashboardFailuresDailyParams) ([]ListDashboardFailuresDailyRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardFailuresDaily,
|
|
arg.WorkspaceID,
|
|
arg.Tz,
|
|
arg.Since,
|
|
arg.ProjectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardFailuresDailyRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardFailuresDailyRow
|
|
if err := rows.Scan(&i.Date, &i.FailureReason, &i.TaskCount); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardRunTimeDaily = `-- name: ListDashboardRunTimeDaily :many
|
|
SELECT
|
|
DATE(atq.completed_at AT TIME ZONE $2::text) AS date,
|
|
COALESCE(
|
|
SUM(EXTRACT(EPOCH FROM (atq.completed_at - atq.started_at)))::bigint,
|
|
0
|
|
)::bigint AS total_seconds,
|
|
COUNT(*)::int AS task_count,
|
|
COUNT(*) FILTER (WHERE atq.status = 'failed')::int AS failed_count
|
|
FROM agent_task_queue atq
|
|
JOIN agent a ON a.id = atq.agent_id
|
|
LEFT JOIN issue i ON i.id = atq.issue_id
|
|
WHERE a.workspace_id = $1
|
|
AND atq.status IN ('completed', 'failed')
|
|
AND atq.started_at IS NOT NULL
|
|
AND atq.completed_at IS NOT NULL
|
|
AND atq.completed_at >= $3::timestamptz
|
|
AND ($4::uuid IS NULL OR i.project_id = $4)
|
|
GROUP BY DATE(atq.completed_at AT TIME ZONE $2::text)
|
|
ORDER BY DATE(atq.completed_at AT TIME ZONE $2::text) DESC
|
|
`
|
|
|
|
type ListDashboardRunTimeDailyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Tz string `json:"tz"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardRunTimeDailyRow struct {
|
|
Date pgtype.Date `json:"date"`
|
|
TotalSeconds int64 `json:"total_seconds"`
|
|
TaskCount int32 `json:"task_count"`
|
|
FailedCount int32 `json:"failed_count"`
|
|
}
|
|
|
|
// Daily per-date run time + task counts for the workspace, optionally
|
|
// scoped to a single project. Powers the workspace dashboard's "Time"
|
|
// and "Tasks" metrics on the same toggle as Tokens / Cost. Bucketed by
|
|
// completed_at (terminal time) sliced into calendar days under the
|
|
// caller-supplied @tz — same Viewing-tz treatment as ListDashboardUsageDaily
|
|
// so the Time / Tasks tabs cut their day boundary identically to the
|
|
// Cost / Tokens tabs (a viewer east of UTC would otherwise see the four
|
|
// tabs disagree on a "1d" window). Only terminal tasks (completed or
|
|
// failed) with both started_at and completed_at populated contribute.
|
|
//
|
|
// @since is already the viewer's local start-of-day-(N) (parseSinceParamInTZ)
|
|
// — passed straight through, NOT re-truncated; see ListDashboardUsageDaily.
|
|
func (q *Queries) ListDashboardRunTimeDaily(ctx context.Context, arg ListDashboardRunTimeDailyParams) ([]ListDashboardRunTimeDailyRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardRunTimeDaily,
|
|
arg.WorkspaceID,
|
|
arg.Tz,
|
|
arg.Since,
|
|
arg.ProjectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardRunTimeDailyRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardRunTimeDailyRow
|
|
if err := rows.Scan(
|
|
&i.Date,
|
|
&i.TotalSeconds,
|
|
&i.TaskCount,
|
|
&i.FailedCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardUsageByAgent = `-- name: ListDashboardUsageByAgent :many
|
|
SELECT
|
|
agent_id,
|
|
LOWER(provider) AS provider,
|
|
model,
|
|
SUM(input_tokens)::bigint AS input_tokens,
|
|
SUM(output_tokens)::bigint AS output_tokens,
|
|
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
|
|
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
|
|
SUM(cost_usd_ticks)::bigint AS cost_usd_ticks,
|
|
SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens,
|
|
SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens,
|
|
SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens,
|
|
SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens,
|
|
SUM(task_count)::int AS task_count
|
|
FROM task_usage_hourly
|
|
WHERE workspace_id = $1
|
|
AND bucket_hour >= $2::timestamptz
|
|
AND ($3::uuid IS NULL OR project_id = $3)
|
|
GROUP BY agent_id, LOWER(provider), model
|
|
ORDER BY agent_id, LOWER(provider), model
|
|
`
|
|
|
|
type ListDashboardUsageByAgentParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardUsageByAgentRow struct {
|
|
AgentID pgtype.UUID `json:"agent_id"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
CostUsdTicks int64 `json:"cost_usd_ticks"`
|
|
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
|
|
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
|
|
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
|
|
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// Per-(agent, provider, model) token aggregates from `task_usage_hourly`. No
|
|
// date grouping in the result, so this query takes no `@tz` — the
|
|
// @since cutoff is a raw timestamptz the Go layer has already computed
|
|
// in the viewer's tz. Model dimension is preserved so the client can
|
|
// compute cost from its per-model pricing table; the client folds rows
|
|
// by agent for the "by agent" list on the dashboard.
|
|
//
|
|
// task_count is summed across hourly buckets — one task that spans
|
|
// multiple hours lands in multiple buckets, so this over-counts by
|
|
// hour the same way the daily version over-counted by day. The
|
|
// frontend prefers `ListDashboardAgentRunTime` for the user-facing
|
|
// "tasks" column, so this stays informational only.
|
|
// provider is LOWER()-normalized so mixed-case historical rows merge with
|
|
// new rows (see ListDashboardUsageDaily).
|
|
func (q *Queries) ListDashboardUsageByAgent(ctx context.Context, arg ListDashboardUsageByAgentParams) ([]ListDashboardUsageByAgentRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardUsageByAgent, arg.WorkspaceID, arg.Since, arg.ProjectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardUsageByAgentRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardUsageByAgentRow
|
|
if err := rows.Scan(
|
|
&i.AgentID,
|
|
&i.Provider,
|
|
&i.Model,
|
|
&i.InputTokens,
|
|
&i.OutputTokens,
|
|
&i.CacheReadTokens,
|
|
&i.CacheWriteTokens,
|
|
&i.CostUsdTicks,
|
|
&i.UncostedInputTokens,
|
|
&i.UncostedOutputTokens,
|
|
&i.UncostedCacheReadTokens,
|
|
&i.UncostedCacheWriteTokens,
|
|
&i.TaskCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listDashboardUsageDaily = `-- name: ListDashboardUsageDaily :many
|
|
SELECT
|
|
DATE(bucket_hour AT TIME ZONE $2::text) AS date,
|
|
LOWER(provider) AS provider,
|
|
model,
|
|
SUM(input_tokens)::bigint AS input_tokens,
|
|
SUM(output_tokens)::bigint AS output_tokens,
|
|
SUM(cache_read_tokens)::bigint AS cache_read_tokens,
|
|
SUM(cache_write_tokens)::bigint AS cache_write_tokens,
|
|
SUM(cost_usd_ticks)::bigint AS cost_usd_ticks,
|
|
SUM(COALESCE(uncosted_input_tokens, input_tokens))::bigint AS uncosted_input_tokens,
|
|
SUM(COALESCE(uncosted_output_tokens, output_tokens))::bigint AS uncosted_output_tokens,
|
|
SUM(COALESCE(uncosted_cache_read_tokens, cache_read_tokens))::bigint AS uncosted_cache_read_tokens,
|
|
SUM(COALESCE(uncosted_cache_write_tokens, cache_write_tokens))::bigint AS uncosted_cache_write_tokens,
|
|
SUM(task_count)::int AS task_count
|
|
FROM task_usage_hourly
|
|
WHERE workspace_id = $1
|
|
AND bucket_hour >= $3::timestamptz
|
|
AND ($4::uuid IS NULL OR project_id = $4)
|
|
GROUP BY DATE(bucket_hour AT TIME ZONE $2::text), LOWER(provider), model
|
|
ORDER BY DATE(bucket_hour AT TIME ZONE $2::text) DESC, LOWER(provider), model
|
|
`
|
|
|
|
type ListDashboardUsageDailyParams struct {
|
|
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
|
Tz string `json:"tz"`
|
|
Since pgtype.Timestamptz `json:"since"`
|
|
ProjectID pgtype.UUID `json:"project_id"`
|
|
}
|
|
|
|
type ListDashboardUsageDailyRow struct {
|
|
Date pgtype.Date `json:"date"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
CostUsdTicks int64 `json:"cost_usd_ticks"`
|
|
UncostedInputTokens int64 `json:"uncosted_input_tokens"`
|
|
UncostedOutputTokens int64 `json:"uncosted_output_tokens"`
|
|
UncostedCacheReadTokens int64 `json:"uncosted_cache_read_tokens"`
|
|
UncostedCacheWriteTokens int64 `json:"uncosted_cache_write_tokens"`
|
|
TaskCount int32 `json:"task_count"`
|
|
}
|
|
|
|
// Daily per-(date, provider, model) token aggregates for the workspace, served
|
|
// from the UTC-bucketed `task_usage_hourly` table and
|
|
// sliced to calendar days under the caller-supplied @tz. Optionally
|
|
// scoped to a single project via sqlc.narg('project_id'). Powers the
|
|
// workspace dashboard's daily cost chart.
|
|
// The viewer's tz is applied here at query time, so a viewer in
|
|
// Asia/Shanghai gets their "today" cut at +08 and one in
|
|
// America/Los_Angeles gets theirs at -08 against the same UTC rows.
|
|
//
|
|
// @since is already the viewer's local start-of-day-(N) as a UTC
|
|
// instant (computed by parseSinceParamInTZ). It must NOT be re-truncated
|
|
// with DATE_TRUNC here — DATE_TRUNC operates in the session tz and would
|
|
// snap the cutoff back to UTC midnight, dragging in an extra partial
|
|
// local day for any non-UTC viewer.
|
|
// provider is LOWER()-normalized so mixed-case historical rows (written
|
|
// before the handler lowercased provider on write) merge with new rows
|
|
// instead of forming a separate case-variant bucket.
|
|
func (q *Queries) ListDashboardUsageDaily(ctx context.Context, arg ListDashboardUsageDailyParams) ([]ListDashboardUsageDailyRow, error) {
|
|
rows, err := q.db.Query(ctx, listDashboardUsageDaily,
|
|
arg.WorkspaceID,
|
|
arg.Tz,
|
|
arg.Since,
|
|
arg.ProjectID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
items := []ListDashboardUsageDailyRow{}
|
|
for rows.Next() {
|
|
var i ListDashboardUsageDailyRow
|
|
if err := rows.Scan(
|
|
&i.Date,
|
|
&i.Provider,
|
|
&i.Model,
|
|
&i.InputTokens,
|
|
&i.OutputTokens,
|
|
&i.CacheReadTokens,
|
|
&i.CacheWriteTokens,
|
|
&i.CostUsdTicks,
|
|
&i.UncostedInputTokens,
|
|
&i.UncostedOutputTokens,
|
|
&i.UncostedCacheReadTokens,
|
|
&i.UncostedCacheWriteTokens,
|
|
&i.TaskCount,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const upsertTaskUsage = `-- name: UpsertTaskUsage :exec
|
|
INSERT INTO task_usage (task_id, provider, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, cost_usd_ticks, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, now())
|
|
ON CONFLICT (task_id, provider, model)
|
|
DO UPDATE SET
|
|
input_tokens = EXCLUDED.input_tokens,
|
|
output_tokens = EXCLUDED.output_tokens,
|
|
cache_read_tokens = EXCLUDED.cache_read_tokens,
|
|
cache_write_tokens = EXCLUDED.cache_write_tokens,
|
|
cost_usd_ticks = EXCLUDED.cost_usd_ticks,
|
|
updated_at = now()
|
|
`
|
|
|
|
type UpsertTaskUsageParams struct {
|
|
TaskID pgtype.UUID `json:"task_id"`
|
|
Provider string `json:"provider"`
|
|
Model string `json:"model"`
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadTokens int64 `json:"cache_read_tokens"`
|
|
CacheWriteTokens int64 `json:"cache_write_tokens"`
|
|
CostUsdTicks pgtype.Int8 `json:"cost_usd_ticks"`
|
|
}
|
|
|
|
// Bumps `updated_at` on INSERT and on conflict so the hourly-rollup worker
|
|
// detects the row as dirty and re-aggregates its bucket.
|
|
// Without the conflict-side bump, a correction to historical token counts
|
|
// would never propagate to the rollup.
|
|
// cost_usd_ticks is the provider's own price for this usage (1e-10 USD), NULL
|
|
// when it reports none. It is overwritten like the token counters so a
|
|
// corrected report replaces the previous figure rather than accumulating.
|
|
func (q *Queries) UpsertTaskUsage(ctx context.Context, arg UpsertTaskUsageParams) error {
|
|
_, err := q.db.Exec(ctx, upsertTaskUsage,
|
|
arg.TaskID,
|
|
arg.Provider,
|
|
arg.Model,
|
|
arg.InputTokens,
|
|
arg.OutputTokens,
|
|
arg.CacheReadTokens,
|
|
arg.CacheWriteTokens,
|
|
arg.CostUsdTicks,
|
|
)
|
|
return err
|
|
}
|