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.
193 lines
6.6 KiB
JavaScript
193 lines
6.6 KiB
JavaScript
/**
|
|
* @file The Analytics date range: `parseRange` (local dates → UTC instants,
|
|
* 400 on bad input), `?from=&to=` on GET /api/pricing/cost (totals, breakdown
|
|
* and by_tool by spend day; daily_costs whole), GET /api/analytics (counts by
|
|
* started_at / created_at, tokens and cost from the days in the range) and
|
|
* GET /api/workflows.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
"use strict";
|
|
|
|
const { describe, it, before, after } = 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(), "analytics-range-")));
|
|
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 { parseRange, inRange } = require("../lib/source-filter");
|
|
|
|
const M = 1_000_000;
|
|
let server;
|
|
let base;
|
|
|
|
async function get(url) {
|
|
const res = await fetch(`${base}${url}`);
|
|
return { status: res.status, body: await res.json() };
|
|
}
|
|
|
|
function session(id, startedAt, tool = "claude") {
|
|
db.prepare(
|
|
"INSERT INTO sessions (id, name, status, started_at, updated_at, tool) VALUES (?, ?, 'completed', ?, ?, ?)"
|
|
).run(id, id, startedAt, startedAt, tool);
|
|
}
|
|
const usage = (id, input) =>
|
|
stmts.setTokenUsage.run(
|
|
id,
|
|
"range-model",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
input,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
const hour = (id, h, input) =>
|
|
stmts.upsertHourlyUsage.run(
|
|
id,
|
|
h,
|
|
"range-model",
|
|
"standard",
|
|
"global",
|
|
"standard",
|
|
input,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0,
|
|
0
|
|
);
|
|
const event = (sid, at, tool) =>
|
|
db
|
|
.prepare(
|
|
"INSERT INTO events (session_id, event_type, tool_name, created_at) VALUES (?, 'PreToolUse', ?, ?)"
|
|
)
|
|
.run(sid, tool, at);
|
|
|
|
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 ('range-model%', 'R', 1, 0, 0, 0)"
|
|
).run();
|
|
// "a": started 2026-09-01, 1 M on the 1st, 2 M on the 10th (hourly rows).
|
|
session("a", "2026-09-01T10:00:00.000Z");
|
|
usage("a", 3 * M);
|
|
hour("a", "2026-09-01T10", 1 * M);
|
|
hour("a", "2026-09-10T10", 2 * M);
|
|
// "b": a codex session started 2026-09-20 with 4 M and no hourly rows.
|
|
session("b", "2026-09-20T10:00:00.000Z", "codex");
|
|
usage("b", 4 * M);
|
|
event("a", "2026-09-01T11:00:00.000Z", "Read");
|
|
event("a", "2026-09-10T11:00:00.000Z", "Edit");
|
|
event("b", "2026-09-20T11:00:00.000Z", "Bash");
|
|
});
|
|
|
|
after(() => {
|
|
server.close();
|
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("parseRange", () => {
|
|
const q = (query) => parseRange({ query });
|
|
it("is null with no range and false on bad input", () => {
|
|
assert.equal(q({}), null);
|
|
assert.equal(q({ from: "", to: "" }), null);
|
|
assert.equal(q({ from: "2026-02-30" }), false);
|
|
assert.equal(q({ from: "09/01/2026" }), false);
|
|
assert.equal(q({ from: "2026-09-10", to: "2026-09-01" }), false);
|
|
assert.equal(q({ to: ["2026-09-01"] }), false);
|
|
assert.equal(q({ to: "9999-12-31" }), false);
|
|
});
|
|
it("turns local dates into UTC instants with tz_offset, end exclusive", () => {
|
|
assert.deepEqual(q({ from: "2026-09-01", to: "2026-09-02", tz_offset: "-420" }), {
|
|
from: "2026-09-01",
|
|
to: "2026-09-02",
|
|
start: "2026-08-31T17:00:00.000Z",
|
|
end: "2026-09-02T17:00:00.000Z",
|
|
});
|
|
const open = q({ to: "2026-09-02" });
|
|
assert.equal(open.start, null);
|
|
assert.equal(open.end, "2026-09-03T00:00:00.000Z");
|
|
assert.ok(inRange(open, "2026-01-01"));
|
|
assert.ok(!inRange(open, "2026-09-03"));
|
|
});
|
|
});
|
|
|
|
describe("GET /api/pricing/cost?from=&to=", () => {
|
|
it("prices only the spend days in the range and keeps the whole daily series", async () => {
|
|
const all = await get("/api/pricing/cost?tz_offset=0");
|
|
assert.equal(all.body.total_cost, 7);
|
|
const r = await get("/api/pricing/cost?tz_offset=0&from=2026-09-05&to=2026-09-25");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.total_cost, 6); // 2 M on the 10th + b's 4 M on the 20th
|
|
assert.deepEqual(
|
|
r.body.by_tool.map((x) => [x.tool, x.cost]),
|
|
[
|
|
["codex", 4],
|
|
["claude", 2],
|
|
]
|
|
);
|
|
assert.equal(r.body.breakdown[0].input_tokens, 6 * M);
|
|
assert.deepEqual(r.body.daily_costs, all.body.daily_costs);
|
|
});
|
|
it("answers 400 for a bad range", async () => {
|
|
const r = await get("/api/pricing/cost?from=2026-13-01");
|
|
assert.equal(r.status, 400);
|
|
assert.equal(r.body.error.code, "INVALID_INPUT");
|
|
});
|
|
});
|
|
|
|
describe("GET /api/analytics?from=&to=", () => {
|
|
it("leaves the unranged answer as it was", async () => {
|
|
const r = await get("/api/analytics?tz_offset=0");
|
|
assert.equal(r.body.overview.total_sessions, 2);
|
|
assert.equal(r.body.overview.total_events, 3);
|
|
assert.equal(r.body.tokens.total_input, 7 * M);
|
|
assert.equal(r.body.total_cost, 7);
|
|
});
|
|
it("counts sessions and events in the range, tokens and cost by spend day", async () => {
|
|
const r = await get("/api/analytics?tz_offset=0&from=2026-09-05&to=2026-09-15");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.overview.total_sessions, 0); // "a" started before
|
|
assert.equal(r.body.overview.total_events, 1);
|
|
assert.deepEqual(r.body.tool_usage, [{ tool_name: "Edit", count: 1 }]);
|
|
assert.equal(r.body.tokens.total_input, 2 * M);
|
|
assert.equal(r.body.total_cost, 2);
|
|
// The daily chart keeps its own window.
|
|
assert.equal(r.body.daily_events.length, 3);
|
|
});
|
|
it("combines with ?tool= and answers 400 for a bad range", async () => {
|
|
const r = await get("/api/analytics?tz_offset=0&tool=codex&from=2026-09-01&to=2026-09-30");
|
|
assert.equal(r.body.overview.total_sessions, 1);
|
|
assert.equal(r.body.total_cost, 4);
|
|
assert.equal((await get("/api/analytics?to=nope")).status, 400);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/workflows?from=&to=", () => {
|
|
it("counts the sessions started in the range", async () => {
|
|
const r = await get("/api/workflows?from=2026-09-15&to=2026-09-30");
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.body.stats.totalSessions, 1);
|
|
assert.equal((await get("/api/workflows?from=x")).status, 400);
|
|
});
|
|
});
|