feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)
Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files
Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
+116
-40
@@ -5,7 +5,14 @@
|
||||
|
||||
const { Router } = require("express");
|
||||
const { stmts, db } = require("../db");
|
||||
const { parseSources, sourceColumnClause } = require("../lib/source-filter");
|
||||
const {
|
||||
parseScope,
|
||||
parseTool,
|
||||
BAD_TOOL,
|
||||
parseRange,
|
||||
inRange,
|
||||
BAD_RANGE,
|
||||
} = require("../lib/source-filter");
|
||||
const { quotas } = require("../lib/quota");
|
||||
const {
|
||||
WEB_SEARCH_PER_1K_SEARCHES,
|
||||
@@ -26,7 +33,7 @@ const round4 = (n) => Math.round(n * 10000) / 10000;
|
||||
* - Fast mode: premium input/output rates; cache rates scale with the fast
|
||||
* input base (the standard caching multipliers ride on top of fast pricing).
|
||||
* - Data residency "us": 1.1x across every category.
|
||||
* - Batch tier: 50% off across every category.
|
||||
* - Batch tier (and OpenAI's flex tier): 50% off across every category.
|
||||
* Older buckets default to speed=standard / geo=global / tier=standard, so they
|
||||
* resolve to exactly the standard rates — historical sessions price unchanged.
|
||||
*/
|
||||
@@ -65,7 +72,7 @@ function ratesForBucket(rule, row, asOf) {
|
||||
r5m *= m;
|
||||
r1h *= m;
|
||||
}
|
||||
if (row.service_tier === "batch") {
|
||||
if (row.service_tier === "batch" || row.service_tier === "flex") {
|
||||
const m = BATCH_DISCOUNT_MULTIPLIER;
|
||||
rIn *= m;
|
||||
rOut *= m;
|
||||
@@ -241,12 +248,13 @@ const BUCKET_COLS = ["model", "speed", "inference_geo", "service_tier"];
|
||||
* rows fall on; whatever the total holds beyond them (sessions recorded before
|
||||
* that table, sub-agent files, Codex/Antigravity) goes to the session's start
|
||||
* date, as it always did. Hours above the total (never expected) are scaled
|
||||
* down to it, so the days always add up to the total.
|
||||
* down to it, so the days always add up to the total. Each row also carries
|
||||
* its session's `tool`, for the per-tool split.
|
||||
*/
|
||||
function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
const totals = db
|
||||
.prepare(
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date,
|
||||
`SELECT tu.session_id, DATE(s.started_at, ?) as date, s.tool,
|
||||
${BUCKET_COLS.map((c) => `tu.${c}`).join(", ")},
|
||||
tu.input_tokens + tu.baseline_input as input_tokens,
|
||||
tu.output_tokens + tu.baseline_output as output_tokens,
|
||||
@@ -283,10 +291,14 @@ function dailyTokenRows(tzModifier, whereClause, params) {
|
||||
|
||||
const out = new Map();
|
||||
const add = (date, bucket, values) => {
|
||||
const k = [date, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
const k = [date, bucket.tool, ...BUCKET_COLS.map((c) => bucket[c])].join("\u0001");
|
||||
let row = out.get(k);
|
||||
if (!row) {
|
||||
row = { date, ...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])) };
|
||||
row = {
|
||||
date,
|
||||
tool: bucket.tool,
|
||||
...Object.fromEntries(BUCKET_COLS.map((c) => [c, bucket[c]])),
|
||||
};
|
||||
for (const c of USAGE_COLS) row[c] = 0;
|
||||
out.set(k, row);
|
||||
}
|
||||
@@ -464,49 +476,110 @@ router.delete("/:pattern", (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions (`?lane=<id>`: the
|
||||
// sessions that workspace owns).
|
||||
// Honors the global data-scope: `?sources=<csv>` narrows the aggregate to those
|
||||
// origin machines (see server/lib/source-filter.js), matching the sessions /
|
||||
// stats / analytics endpoints so the Dashboard "total cost" tracks the selector.
|
||||
/**
|
||||
* The `WHERE` over `token_usage tu JOIN sessions s` for a session predicate
|
||||
* (`parseScope`'s `{where, params}`, or null), a workspace id (the sessions it
|
||||
* owns — the rule of GET /api/sessions?lane=) and `extra` clauses on `s`.
|
||||
*/
|
||||
function costWhere({ scope = null, lane, extra = [] } = {}) {
|
||||
const params = scope ? [...scope.params] : [];
|
||||
const where = scope ? [`s.id IN (SELECT id FROM sessions WHERE ${scope.where})`] : [];
|
||||
if (lane !== undefined && lane !== null) {
|
||||
where.push(
|
||||
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
for (const e of extra) {
|
||||
where.push(e.clause);
|
||||
params.push(...e.params);
|
||||
}
|
||||
return { whereClause: where.length ? `WHERE ${where.join(" AND ")}` : "", params };
|
||||
}
|
||||
|
||||
/**
|
||||
* `costWhere` for a cost request (`?sources=`, `?tool=`, `?lane=`). Null when
|
||||
* the request is malformed (the 400 has already been sent).
|
||||
*/
|
||||
function costScope(req, res) {
|
||||
const lane = req.query.lane;
|
||||
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||
res.status(400).json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
return null;
|
||||
}
|
||||
if (parseTool(req) === false) {
|
||||
res.status(400).json(BAD_TOOL);
|
||||
return null;
|
||||
}
|
||||
return costWhere({ scope: parseScope(req), lane });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cost per AI tool (`sessions.tool`), most expensive first. Each tool is priced
|
||||
* on its own rows, so the code-execution free allowance applies per tool and
|
||||
* the parts can sum to slightly less than `total_cost` when it is exceeded.
|
||||
*/
|
||||
function costByTool(dailyTokens, rules) {
|
||||
const byTool = new Map();
|
||||
for (const r of dailyTokens) {
|
||||
const t = r.tool || "claude";
|
||||
if (!byTool.has(t)) byTool.set(t, []);
|
||||
byTool.get(t).push(r);
|
||||
}
|
||||
return [...byTool.entries()]
|
||||
.map(([tool, rows]) => ({ tool, cost: calculateCost(rows, rules).total_cost }))
|
||||
.sort((a, b) => b.cost - a.cost);
|
||||
}
|
||||
|
||||
/**
|
||||
* The cost response for a `costWhere` result and a date range (`parseRange`,
|
||||
* or null). The range narrows the total, breakdown and `by_tool` to the local
|
||||
* days in it; `daily_costs` stays the whole series, so the trend charts and
|
||||
* the spend pace keep their own windows. Also used by scheduled reports.
|
||||
*/
|
||||
function computeCost(
|
||||
{ whereClause = "", params = [] } = {},
|
||||
range = null,
|
||||
tzModifier = "+0 minutes"
|
||||
) {
|
||||
const dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const inside = range ? dailyTokens.filter((r) => inRange(range, r.date)) : dailyTokens;
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
// Coverage equals the undated aggregate — token_usage cascades with sessions,
|
||||
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
|
||||
const result = calculateCost(inside, rules);
|
||||
const daily_costs = calculateDailyCosts(dailyTokens, rules);
|
||||
return { ...result, daily_costs, by_tool: costByTool(inside, rules) };
|
||||
}
|
||||
|
||||
// GET /api/pricing/cost - Get total cost across all sessions.
|
||||
// Honors the global data-scope — `?sources=<csv>` (origin machines, see
|
||||
// server/lib/source-filter.js) and `?tool=<id>` — matching the sessions / stats
|
||||
// / analytics endpoints so the Dashboard "total cost" tracks the selector, plus
|
||||
// `?lane=<id>` (the sessions that workspace owns), `?from=&to=` (local
|
||||
// dates, see computeCost) and `?date=` (one local day, its `daily_costs` too —
|
||||
// the status bar's Today). `by_tool` splits the total.
|
||||
router.get("/cost", (req, res) => {
|
||||
const rawOffset = parseInt(req.query.tz_offset, 10);
|
||||
const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes";
|
||||
const lane = req.query.lane;
|
||||
if (lane !== undefined && !/^\d+$/.test(String(lane))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "lane must be a lane id" } });
|
||||
}
|
||||
const date = req.query.date;
|
||||
if (date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(date))) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "date must be YYYY-MM-DD" } });
|
||||
}
|
||||
const scope = sourceColumnClause(parseSources(req), "s.source");
|
||||
const params = [...scope.params];
|
||||
const where = scope.clause ? [scope.clause] : [];
|
||||
if (lane !== undefined) {
|
||||
// The sessions the workspace owns — the same rule as GET /api/sessions?lane=.
|
||||
where.push(
|
||||
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||
);
|
||||
params.push(Number(lane), Number(lane));
|
||||
}
|
||||
const whereClause = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
||||
|
||||
let dailyTokens = dailyTokenRows(tzModifier, whereClause, params);
|
||||
const range = parseRange(req);
|
||||
if (range === false) return res.status(400).json(BAD_RANGE);
|
||||
const scope = costScope(req, res);
|
||||
if (!scope) return;
|
||||
// `date=`: only that local day (the status bar's Today and its per-model list).
|
||||
if (date !== undefined) dailyTokens = dailyTokens.filter((r) => r.date === date);
|
||||
const rules = stmts.listPricing.all();
|
||||
// Price the date-split rows so each day's usage bills at the rate effective on
|
||||
// that date (e.g. Sonnet 5's intro discount before 2026-08-31, standard after).
|
||||
// Coverage equals the undated aggregate — token_usage cascades with sessions,
|
||||
// so the INNER JOIN drops nothing — and the breakdown re-collapses per model.
|
||||
const result = calculateCost(dailyTokens, rules);
|
||||
const daily_costs = calculateDailyCosts(dailyTokens, rules);
|
||||
res.json({ ...result, daily_costs });
|
||||
if (date !== undefined) {
|
||||
const day = computeCost(scope, { from: date, to: date }, tzModifier);
|
||||
return res.json({ ...day, daily_costs: day.daily_costs.filter((d) => d.date === date) });
|
||||
}
|
||||
res.json(computeCost(scope, range, tzModifier));
|
||||
});
|
||||
|
||||
// GET /api/pricing/quota - plan quota spent per native tool (Claude, Codex):
|
||||
@@ -568,5 +641,8 @@ function attachAgentCosts(agents) {
|
||||
|
||||
module.exports = router;
|
||||
module.exports.calculateCost = calculateCost;
|
||||
module.exports.dailyTokenRows = dailyTokenRows;
|
||||
module.exports.costWhere = costWhere;
|
||||
module.exports.computeCost = computeCost;
|
||||
module.exports.agentOwnCost = agentOwnCost;
|
||||
module.exports.attachAgentCosts = attachAgentCosts;
|
||||
|
||||
Reference in New Issue
Block a user