fix(core): add task dimension to command-center token grouping (#1909)
## The bug `GET /api/command-center/tokens` documents and accepts `groupBy=task`, but the `task` dimension was never actually wired through the aggregator. Four enumeration sites all stopped at `model | provider | node | agent`: - `TokenGroupBy` (core `token-analytics.ts`) did not include `"task"`. - `groupKeyFor()` had no `case "task"`. - `VALID_GROUP_BY` (dashboard route) rejected the value, so `resolveGroupBy()` returned `undefined`. - `groupAttributes()` (core `otel-metrics.ts`) emitted no attribute for it. Net effect: a caller asking for a per-task rollup silently fell back to **ungrouped grand totals** — the per-task breakdown returned zero groups, even though every task carries `tokenUsage*` and the data was right there. ## The fix Thread `"task"` through all four sites (5 functional lines + doc/test): - Add `"task"` to the `TokenGroupBy` union — this makes the two `switch` statements **compiler-exhaustive**, so `tsc` forces the two new cases (no silent gaps). - `groupKeyFor`: task rows group by their task id; chat rows have no task and return `null`, mirroring the existing `node` case. - `groupAttributes`: emit `task.id` for OTLP export, matching `node.id` / `agent.id`. - `VALID_GROUP_BY`: accept `"task"`. No schema or migration change — the task id is already on the row. ## Verification - `pnpm --filter @fusion/core typecheck` and `@fusion/dashboard typecheck` — clean. - Extended the existing `groups by provider, node, agent` core test with a `groupBy: "task"` assertion (two tasks → two groups keyed by task id, 100 / 200 tokens). Full suites green: **core 25/25**, **dashboard 325/325**. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added task-based token analytics grouping, alongside existing grouping options. * Analytics views and metrics now include task-level breakdowns when available. * **Bug Fixes** * Improved grouping behavior so task totals are reported correctly in analytics results. * **Documentation** * Updated supported analytics options to reflect task grouping in endpoint and metric descriptions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -423,7 +423,7 @@ describe("token-analytics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("groups by provider, node, and agent", () => {
|
||||
it("groups by provider, node, agent, and task", () => {
|
||||
insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelProvider: "anthropic", nodeId: "node-1", agentId: "agent-x" });
|
||||
insertTask(db, { id: "t2", inputTokens: 200, totalTokens: 200, lastUsedAt: "2026-03-02T00:00:00.000Z", modelProvider: "openai", nodeId: "node-1", agentId: "agent-y" });
|
||||
|
||||
@@ -441,6 +441,11 @@ describe("token-analytics", () => {
|
||||
expect(new Map(byAgent.groups.map((g) => [g.key, g.totalTokens]))).toEqual(
|
||||
new Map([["agent-x", 100], ["agent-y", 200]]),
|
||||
);
|
||||
|
||||
const byTask = aggregateTokenAnalytics(db, { groupBy: "task" });
|
||||
expect(new Map(byTask.groups.map((g) => [g.key, g.totalTokens]))).toEqual(
|
||||
new Map([["t1", 100], ["t2", 200]]),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes chat token usage in mixed task and chat totals exactly once", () => {
|
||||
|
||||
@@ -161,7 +161,7 @@ function gauge(
|
||||
/**
|
||||
* Attributes for a token group. The grouped dimension is reflected by the key
|
||||
* the aggregator chose (`groupBy`); we tag it with the matching attribute name
|
||||
* so a collector sees `model` / `provider` / `node.id` / `agent.id`.
|
||||
* so a collector sees `model` / `provider` / `node.id` / `agent.id` / `task.id`.
|
||||
*/
|
||||
function groupAttributes(
|
||||
groupBy: TokenAnalytics["groupBy"],
|
||||
@@ -177,6 +177,8 @@ function groupAttributes(
|
||||
return [attr("node.id", key)];
|
||||
case "agent":
|
||||
return [attr("agent.id", key)];
|
||||
case "task":
|
||||
return [attr("task.id", key)];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { TaskTokenUsagePerModel } from "./types.js";
|
||||
*/
|
||||
|
||||
/** Dimension to group token totals by. */
|
||||
export type TokenGroupBy = "model" | "provider" | "node" | "agent";
|
||||
export type TokenGroupBy = "model" | "provider" | "node" | "agent" | "task";
|
||||
|
||||
/** Bucket size for optional token-usage time-series analytics. */
|
||||
export type TokenTimeGranularity = "hour" | "day" | "week";
|
||||
@@ -156,6 +156,8 @@ function groupKeyFor(row: TokenContributionRow, groupBy: TokenGroupBy): string |
|
||||
return row.contributionKind === "task" ? row.checkoutNodeId : null;
|
||||
case "agent":
|
||||
return row.contributionKind === "task" ? row.assignedAgentId : row.agentId;
|
||||
case "task":
|
||||
return row.contributionKind === "task" ? row.id : null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ const VALID_GROUP_BY: ReadonlySet<string> = new Set<TokenGroupBy>([
|
||||
"provider",
|
||||
"node",
|
||||
"agent",
|
||||
"task",
|
||||
]);
|
||||
|
||||
const VALID_TOKEN_GRANULARITY: ReadonlySet<string> = new Set<TokenTimeGranularity>([
|
||||
@@ -174,7 +175,7 @@ export const registerCommandCenterRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
/**
|
||||
* GET /api/command-center/tokens
|
||||
* Token consumption + derived USD cost (U2 + U3) over a date range.
|
||||
* Query: from, to (ISO-8601), groupBy (model|provider|node|agent).
|
||||
* Query: from, to (ISO-8601), groupBy (model|provider|node|agent|task).
|
||||
*/
|
||||
router.get("/command-center/tokens", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user