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:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+116 -40
View File
@@ -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;