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:
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* @file Session snapshots: `POST /api/snapshots` freezes a session — its row,
|
||||
* agents, events (oldest first), the SessionOverview stats and the token cost —
|
||||
* into one immutable `session_snapshots` row read back by an unguessable token
|
||||
* (`GET /api/snapshots/:token`). File paths can be redacted at capture, so
|
||||
* nothing redacted is ever stored. Writes are same-origin only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
const os = require("node:os");
|
||||
const { Router } = require("express");
|
||||
const { db, stmts } = require("../db");
|
||||
const { sameOriginGuard } = require("../lib/same-origin");
|
||||
const { calculateCost, attachAgentCosts } = require("./pricing");
|
||||
const { sessionStats } = require("./sessions");
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** Keys whose string value is a path on this machine — replaced whole. */
|
||||
const PATH_KEYS = new Set([
|
||||
"cwd",
|
||||
"transcript_path",
|
||||
"agent_transcript_path",
|
||||
"file_path",
|
||||
"filePath",
|
||||
"notebook_path",
|
||||
"path",
|
||||
]);
|
||||
const REDACTED = "[redacted]";
|
||||
/** 24 random bytes, base64url. */
|
||||
const TOKEN_RE = /^[A-Za-z0-9_-]{32}$/;
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* A redactor for one session: path-valued keys become `[redacted]`, and the
|
||||
* session's cwd / the home dir inside any other string (a Bash command, a
|
||||
* summary) become `<cwd>` / `~`.
|
||||
*/
|
||||
function pathRedactor(cwd) {
|
||||
const swaps = [
|
||||
[cwd, "<cwd>"],
|
||||
[os.homedir(), "~"],
|
||||
]
|
||||
.filter(([p]) => typeof p === "string" && p.length > 1)
|
||||
.map(([p, to]) => [new RegExp(`${escapeRe(p)}(?![\\w.-])`, "g"), to]);
|
||||
const str = (s) => swaps.reduce((acc, [re, to]) => acc.replace(re, to), s);
|
||||
const walk = (v, key) => {
|
||||
if (typeof v === "string") return PATH_KEYS.has(key) ? REDACTED : str(v);
|
||||
if (Array.isArray(v)) return v.map((x) => walk(x));
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const [k, x] of Object.entries(v)) out[k] = walk(x, k);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
// A column holding JSON text (events.data, *.metadata) is redacted as JSON.
|
||||
const json = (text) => {
|
||||
if (typeof text !== "string") return text;
|
||||
try {
|
||||
return JSON.stringify(walk(JSON.parse(text)));
|
||||
} catch {
|
||||
return str(text);
|
||||
}
|
||||
};
|
||||
return { walk, json };
|
||||
}
|
||||
|
||||
function capture(sessionId, redactPaths) {
|
||||
const session = stmts.getSession.get(sessionId);
|
||||
if (!session) return null;
|
||||
const agents = attachAgentCosts(stmts.listAgentsBySession.all(sessionId));
|
||||
const events = stmts.listEventsBySession.all(sessionId).reverse();
|
||||
const cost = calculateCost(
|
||||
stmts.getTokensBySession.all(sessionId),
|
||||
stmts.listPricing.all(),
|
||||
session.started_at ? String(session.started_at).slice(0, 10) : undefined
|
||||
);
|
||||
const payload = {
|
||||
version: 1,
|
||||
captured_at: new Date().toISOString(),
|
||||
redacted: { file_paths: redactPaths },
|
||||
session,
|
||||
agents,
|
||||
events,
|
||||
stats: sessionStats(sessionId),
|
||||
cost,
|
||||
};
|
||||
if (!redactPaths) return payload;
|
||||
const r = pathRedactor(session.cwd);
|
||||
const row = (x) => {
|
||||
const out = r.walk(x);
|
||||
if ("data" in x) out.data = r.json(x.data);
|
||||
if ("metadata" in x) out.metadata = r.json(x.metadata);
|
||||
return out;
|
||||
};
|
||||
return {
|
||||
...payload,
|
||||
session: row(session),
|
||||
agents: agents.map(row),
|
||||
events: events.map(row),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/snapshots — every snapshot, newest first, without its payload.
|
||||
router.get("/", (_req, res) => {
|
||||
const snapshots = db
|
||||
.prepare(
|
||||
`SELECT id, token, session_id, created_at,
|
||||
json_extract(payload, '$.session.name') AS session_name,
|
||||
json_extract(payload, '$.redacted.file_paths') AS redacted,
|
||||
json_array_length(payload, '$.events') AS event_count,
|
||||
length(payload) AS bytes
|
||||
FROM session_snapshots ORDER BY id DESC`
|
||||
)
|
||||
.all()
|
||||
.map((s) => ({ ...s, redacted: s.redacted === 1 }));
|
||||
res.json({ snapshots });
|
||||
});
|
||||
|
||||
// POST /api/snapshots {session_id, redact?: {file_paths?: boolean}}
|
||||
router.post("/", sameOriginGuard, (req, res) => {
|
||||
const sessionId = req.body?.session_id;
|
||||
if (typeof sessionId !== "string" || !sessionId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: { code: "INVALID_INPUT", message: "session_id is required" } });
|
||||
}
|
||||
const payload = capture(sessionId, req.body?.redact?.file_paths === true);
|
||||
if (!payload) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Session not found" } });
|
||||
}
|
||||
const token = crypto.randomBytes(24).toString("base64url");
|
||||
const { lastInsertRowid } = db
|
||||
.prepare("INSERT INTO session_snapshots (token, session_id, payload) VALUES (?, ?, ?)")
|
||||
.run(token, sessionId, JSON.stringify(payload));
|
||||
const row = db
|
||||
.prepare("SELECT id, token, session_id, created_at FROM session_snapshots WHERE id = ?")
|
||||
.get(lastInsertRowid);
|
||||
res.status(201).json({ snapshot: row });
|
||||
});
|
||||
|
||||
// GET /api/snapshots/:token — the frozen payload, sent as stored.
|
||||
router.get("/:token", (req, res) => {
|
||||
const token = req.params.token;
|
||||
const row = TOKEN_RE.test(token)
|
||||
? db
|
||||
.prepare(
|
||||
"SELECT id, token, session_id, created_at, payload FROM session_snapshots WHERE token = ?"
|
||||
)
|
||||
.get(token)
|
||||
: null;
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
|
||||
}
|
||||
const { payload, ...meta } = row;
|
||||
res.type("application/json").send(`{"snapshot":${JSON.stringify(meta)},"payload":${payload}}`);
|
||||
});
|
||||
|
||||
// DELETE /api/snapshots/:id
|
||||
router.delete("/:id", sameOriginGuard, (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { changes } = Number.isInteger(id)
|
||||
? db.prepare("DELETE FROM session_snapshots WHERE id = ?").run(id)
|
||||
: { changes: 0 };
|
||||
if (!changes) {
|
||||
return res.status(404).json({ error: { code: "NOT_FOUND", message: "Snapshot not found" } });
|
||||
}
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.pathRedactor = pathRedactor;
|
||||
Reference in New Issue
Block a user