4aae61297f
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.
238 lines
8.1 KiB
JavaScript
238 lines
8.1 KiB
JavaScript
/**
|
|
* @file Tests for spend budgets and dollar alerts: period bounds in local time,
|
|
* the `budget` and `cost_threshold` rule types (validation, firing once per
|
|
* threshold per period), GET /api/budgets/status and the read-only rule
|
|
* preview POST /api/alerts/rules/preview.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
"use strict";
|
|
|
|
const { describe, it, before, after, beforeEach } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const http = require("node:http");
|
|
|
|
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "budgets-")));
|
|
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
|
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
|
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
|
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
|
|
|
const { createApp } = require("../index");
|
|
const { db, stmts } = require("../db");
|
|
const { periodBounds, budgetSpend } = require("../lib/budgets");
|
|
const alerts = require("../lib/alerts");
|
|
|
|
const M = 1_000_000;
|
|
let server;
|
|
let base;
|
|
|
|
function call(method, url, body) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = http.request(
|
|
`${base}${url}`,
|
|
{ method, headers: body ? { "content-type": "application/json" } : {} },
|
|
(res) => {
|
|
let text = "";
|
|
res.on("data", (c) => (text += c));
|
|
res.on("end", () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
|
}
|
|
);
|
|
req.on("error", reject);
|
|
if (body) req.write(JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
const iso = (ms) => new Date(ms).toISOString();
|
|
function session(id, tool, startedAt, updatedAt = startedAt) {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
|
).run(id, id, startedAt, updatedAt, tool);
|
|
}
|
|
const spend = (id, input) =>
|
|
stmts.setTokenUsage.run(
|
|
id,
|
|
"budget-model",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
input,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
|
|
before(async () => {
|
|
server = http.createServer(createApp());
|
|
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
db.prepare(
|
|
"INSERT OR REPLACE INTO model_pricing (model_pattern, display_name, input_per_mtok, output_per_mtok, cache_read_per_mtok, cache_write_per_mtok) VALUES ('budget-model%', 'B', 1, 0, 0, 0)"
|
|
).run();
|
|
});
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
beforeEach(() => {
|
|
db.exec(
|
|
"DELETE FROM alert_events; DELETE FROM alert_rules; DELETE FROM token_usage; DELETE FROM sessions;"
|
|
);
|
|
alerts.invalidateRuleCache();
|
|
});
|
|
|
|
describe("periodBounds", () => {
|
|
// 2026-10-05 20:00 UTC = 2026-10-06 03:00 in UTC+7 (tz_offset -420), a Tuesday.
|
|
const now = Date.parse("2026-10-05T20:00:00Z");
|
|
it("starts a day at local midnight", () => {
|
|
const b = periodBounds("day", now, -420);
|
|
assert.equal(b.start, "2026-10-06");
|
|
assert.equal(b.end, "2026-10-06");
|
|
assert.equal(b.startUtc, "2026-10-05T17:00:00.000Z");
|
|
assert.equal(b.days, 1);
|
|
assert.ok(Math.abs(b.elapsed - 3 / 24) < 1e-9);
|
|
});
|
|
it("starts a week on Monday and a month on the 1st", () => {
|
|
assert.equal(periodBounds("week", now, -420).start, "2026-10-05");
|
|
assert.equal(periodBounds("week", now, -420).end, "2026-10-11");
|
|
const m = periodBounds("month", now, -420);
|
|
assert.equal(m.start, "2026-10-01");
|
|
assert.equal(m.end, "2026-10-31");
|
|
assert.equal(m.days, 31);
|
|
});
|
|
it("walks back whole periods, which count as fully elapsed", () => {
|
|
const m = periodBounds("month", now, 0, 1);
|
|
assert.equal(m.start, "2026-09-01");
|
|
assert.equal(m.end, "2026-09-30");
|
|
assert.equal(m.elapsed, 1);
|
|
});
|
|
});
|
|
|
|
describe("budget rules", () => {
|
|
it("validates the config", async () => {
|
|
const bad = await call("POST", "/api/alerts/rules", {
|
|
name: "b",
|
|
rule_type: "budget",
|
|
config: { period: "year", limit_usd: 5 },
|
|
});
|
|
assert.equal(bad.status, 400);
|
|
const ok = await call("POST", "/api/alerts/rules", {
|
|
name: "b",
|
|
rule_type: "budget",
|
|
config: { period: "day", limit_usd: 5, tool: "codex", tz_offset: -420 },
|
|
});
|
|
assert.equal(ok.status, 201);
|
|
assert.deepEqual(ok.body.rule.config, {
|
|
period: "day",
|
|
limit_usd: 5,
|
|
thresholds: [80, 100],
|
|
tool: "codex",
|
|
tz_offset: -420,
|
|
});
|
|
});
|
|
|
|
it("measures the period's spend in scope and projects it", () => {
|
|
const now = Date.now();
|
|
session("today", "codex", iso(now - 60_000));
|
|
spend("today", 2 * M); // $2
|
|
session("other-tool", "claude", iso(now - 60_000));
|
|
spend("other-tool", 9 * M);
|
|
session("yesterday", "codex", iso(now - 3 * 86_400_000));
|
|
spend("yesterday", 7 * M);
|
|
const s = budgetSpend(
|
|
{ period: "day", limit_usd: 4, thresholds: [50], tool: "codex" },
|
|
{ now }
|
|
);
|
|
assert.equal(s.spend, 2);
|
|
assert.equal(s.pct, 50);
|
|
assert.ok(s.projected >= 2);
|
|
});
|
|
|
|
it("fires each crossed threshold once per period, and the status lists it", async () => {
|
|
const now = Date.now();
|
|
session("s1", "claude", iso(now - 60_000));
|
|
spend("s1", 9 * M); // $9 of $10
|
|
const created = await call("POST", "/api/alerts/rules", {
|
|
name: "Daily",
|
|
rule_type: "budget",
|
|
config: { period: "day", limit_usd: 10, thresholds: [50, 80, 100] },
|
|
});
|
|
alerts.sweepTimeRules();
|
|
alerts.sweepTimeRules();
|
|
const fired = db.prepare("SELECT details FROM alert_events ORDER BY id").all();
|
|
assert.deepEqual(
|
|
fired.map((f) => JSON.parse(f.details).threshold),
|
|
[50, 80]
|
|
);
|
|
const status = await call("GET", "/api/budgets/status");
|
|
const b = status.body.budgets.find((x) => x.id === created.body.rule.id);
|
|
assert.equal(b.spend, 9);
|
|
assert.equal(b.pct, 90);
|
|
assert.deepEqual(b.crossed, [50, 80]);
|
|
});
|
|
});
|
|
|
|
describe("cost_threshold", () => {
|
|
it("fires when a session's cost reaches the threshold", async () => {
|
|
session("pricey", "claude", iso(Date.now()));
|
|
spend("pricey", 3 * M);
|
|
await call("POST", "/api/alerts/rules", {
|
|
name: "Expensive",
|
|
rule_type: "cost_threshold",
|
|
config: { usd: 2.5 },
|
|
});
|
|
alerts.evaluateEvent({ session_id: "pricey", event_type: "Stop" });
|
|
const a = db.prepare("SELECT message, details FROM alert_events").get();
|
|
assert.match(a.message, /\$3\.00/);
|
|
assert.equal(JSON.parse(a.details).cost_usd, 3);
|
|
});
|
|
});
|
|
|
|
describe("rule preview", () => {
|
|
it("replays an event pattern with its count window and cooldown, firing nothing", async () => {
|
|
session("p1", "claude", iso(Date.now() - 3_600_000));
|
|
const t0 = Date.now() - 30 * 60_000;
|
|
for (const [i, m] of [0, 1, 2, 20].entries()) {
|
|
db.prepare(
|
|
"INSERT INTO events (session_id, event_type, tool_name, summary, created_at) VALUES ('p1', 'PostToolUse', 'Bash', ?, ?)"
|
|
).run(`e${i}`, iso(t0 + m * 60_000));
|
|
}
|
|
const r = await call("POST", "/api/alerts/rules/preview", {
|
|
rule_type: "event_pattern",
|
|
config: { tool_name: "Bash", count: 2, window_minutes: 5 },
|
|
cooldown_seconds: 300,
|
|
});
|
|
assert.equal(r.status, 200);
|
|
// e1 (2 in window) fires; e2 is inside the cooldown; e20 is alone in its window.
|
|
assert.equal(r.body.fires, 1);
|
|
assert.equal(r.body.sessions, 1);
|
|
assert.equal(db.prepare("SELECT COUNT(*) c FROM alert_events").get().c, 0);
|
|
});
|
|
it("lists past budget periods and refuses what cannot be replayed", async () => {
|
|
const b = await call("POST", "/api/alerts/rules/preview", {
|
|
rule_type: "budget",
|
|
config: { period: "week", limit_usd: 1 },
|
|
});
|
|
assert.equal(b.body.periods.length, 6);
|
|
const s = await call("POST", "/api/alerts/rules/preview", {
|
|
rule_type: "status_duration",
|
|
config: { status: "working", minutes: 5 },
|
|
});
|
|
assert.equal(s.body.supported, false);
|
|
const bad = await call("POST", "/api/alerts/rules/preview", {
|
|
rule_type: "budget",
|
|
config: { period: "week", limit_usd: 1 },
|
|
days: 0,
|
|
});
|
|
assert.equal(bad.status, 400);
|
|
});
|
|
});
|