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
+218 -7
View File
@@ -1,16 +1,26 @@
/**
* @file Rules-based alerting engine. Evaluates user-defined alert rules against
* live activity: event-driven rules (event_pattern, token_threshold) run on
* every hook ingest, time-based rules (inactivity, status_duration) run on a
* periodic sweep. Fired alerts are persisted to alert_events with per-scope
* cooldown dedup and broadcast to clients as `alert_triggered`.
* live activity: event-driven rules (event_pattern, token_threshold,
* cost_threshold) run on every hook ingest, time-based rules (inactivity,
* status_duration, budget) run on a periodic sweep. Fired alerts are persisted
* to alert_events with per-scope cooldown dedup — a budget instead fires once
* per threshold per period — and broadcast to clients as `alert_triggered`.
* `previewRule` replays a rule over recorded history without firing anything.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { db, stmts } = require("../db");
const { broadcast } = require("../websocket");
const RULE_TYPES = ["event_pattern", "inactivity", "status_duration", "token_threshold"];
const RULE_TYPES = [
"event_pattern",
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
const AGENT_STATUSES = ["working", "waiting"];
// Enabled-rules cache. Hook ingest is hot — re-querying alert_rules on every
@@ -98,6 +108,47 @@ function validateRuleConfig(ruleType, config) {
}
return { ok: true, config: { total_tokens: total } };
}
case "cost_threshold": {
const usd = num(cfg.usd);
if (!usd) return { ok: false, error: "usd must be a positive number" };
return { ok: true, config: { usd } };
}
case "budget": {
const { PERIODS } = require("./budgets");
if (!PERIODS.includes(cfg.period)) {
return { ok: false, error: `period must be one of: ${PERIODS.join(", ")}` };
}
const limit = num(cfg.limit_usd);
if (!limit) return { ok: false, error: "limit_usd must be a positive number" };
const thresholds = cfg.thresholds == null ? [80, 100] : cfg.thresholds;
if (
!Array.isArray(thresholds) ||
thresholds.length === 0 ||
thresholds.length > 10 ||
!thresholds.every((t) => Number.isInteger(t) && t > 0 && t <= 1000)
) {
return { ok: false, error: "thresholds must be 1–10 integer percents in 1..1000" };
}
const out = { period: cfg.period, limit_usd: limit, thresholds: [...new Set(thresholds)] };
if (cfg.tool != null) {
if (typeof cfg.tool !== "string" || !TOOL_RE.test(cfg.tool)) {
return { ok: false, error: "tool must be an AI tool id" };
}
out.tool = cfg.tool;
}
if (cfg.lane != null) {
if (!Number.isInteger(cfg.lane) || cfg.lane <= 0) {
return { ok: false, error: "lane must be a workspace id" };
}
out.lane = cfg.lane;
}
const tz = cfg.tz_offset == null ? 0 : cfg.tz_offset;
if (!Number.isInteger(tz) || Math.abs(tz) > 14 * 60) {
return { ok: false, error: "tz_offset must be minutes within ±840" };
}
out.tz_offset = tz;
return { ok: true, config: out };
}
default:
return { ok: false, error: "unsupported rule_type" };
}
@@ -108,8 +159,11 @@ function validateRuleConfig(ruleType, config) {
* its cooldown window. Persists the alert row and broadcasts it. Returns the
* inserted row, or null when suppressed by cooldown.
*/
function fireAlert(rule, { sessionId = null, agentId = null, message, details = null }) {
const last = stmts.lastAlertFor.get(rule.id, sessionId, agentId);
function fireAlert(
rule,
{ sessionId = null, agentId = null, message, details = null, ignoreCooldown = false }
) {
const last = ignoreCooldown ? null : stmts.lastAlertFor.get(rule.id, sessionId, agentId);
if (last) {
const elapsedMs = Date.now() - new Date(last.triggered_at).getTime();
if (elapsedMs < rule.cooldown_seconds * 1000) return null;
@@ -184,6 +238,23 @@ function matchesPattern(event, cfg) {
// query for the rest of the event stream.
const TOKEN_BEARING_EVENTS = new Set(["PostToolUse", "Stop", "SubagentStop", "SessionEnd"]);
// A budget threshold already fired in this period (details carry both).
const budgetFiredStmt = db.prepare(
`SELECT 1 FROM alert_events WHERE rule_id = ?
AND json_extract(details, '$.period_start') = ? AND json_extract(details, '$.threshold') = ?`
);
/** A session's cost, priced as of its start date like GET /api/pricing/cost/:id. */
function sessionCost(sessionId, rules) {
const { calculateCost } = require("../routes/pricing");
const started = stmts.getSession.get(sessionId)?.started_at;
return calculateCost(
stmts.getTokensBySession.all(sessionId),
rules || stmts.listPricing.all(),
started ? String(started).slice(0, 10) : undefined
).total_cost;
}
// Sweep queries are static — prepare once at module load instead of on every
// 60s tick. The time window arrives as a strftime modifier parameter.
const staleSessionsStmt = db.prepare(
@@ -248,6 +319,15 @@ function evaluateEvent(event) {
message: `${rule.name}: session used ${total.toLocaleString()} tokens (threshold ${rule.config.total_tokens.toLocaleString()})`,
details: { total_tokens: total, threshold: rule.config.total_tokens },
});
} else if (rule.rule_type === "cost_threshold") {
if (!TOKEN_BEARING_EVENTS.has(event.event_type)) continue;
const cost = sessionCost(event.session_id);
if (cost < rule.config.usd) continue;
fireAlert(rule, {
sessionId: event.session_id,
message: `${rule.name}: session cost $${cost.toFixed(2)} (threshold $${rule.config.usd.toFixed(2)})`,
details: { cost_usd: cost, threshold_usd: rule.config.usd },
});
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" evaluation failed:`, err?.message || err);
@@ -297,6 +377,8 @@ function sweepTimeRules() {
details: { status: rule.config.status, minutes: rule.config.minutes },
});
}
} else if (rule.rule_type === "budget") {
sweepBudget(rule);
}
} catch (err) {
console.warn(`[ALERTS] rule "${rule.name}" sweep failed:`, err?.message || err);
@@ -304,6 +386,133 @@ function sweepTimeRules() {
}
}
/** Fires each newly crossed threshold of one budget rule, once per period. */
function sweepBudget(rule, now = Date.now()) {
const { budgetSpend, crossed } = require("./budgets");
const s = budgetSpend(rule.config, { now });
for (const threshold of crossed(rule.config, s.pct)) {
if (budgetFiredStmt.get(rule.id, s.period_start, threshold)) continue;
fireAlert(rule, {
ignoreCooldown: true,
message: `${rule.name}: ${threshold}% of the ${rule.config.period} budget — $${s.spend.toFixed(2)} of $${rule.config.limit_usd.toFixed(2)}`,
details: {
period_start: s.period_start,
threshold,
spend: s.spend,
limit_usd: rule.config.limit_usd,
projected: s.projected,
},
});
}
}
const PREVIEW_SAMPLES = 10;
/**
* Replays a rule over recorded history — what it WOULD have fired — without
* writing or broadcasting anything. `days` bounds the window (default 7).
* `status_duration` cannot be replayed (agent status history is not stored)
* and answers `supported: false`.
* @returns {{supported: boolean, window_days: number, fires: number,
* sessions: number, samples: Array<object>, periods?: Array<object>}}
*/
function previewRule(ruleType, config, { days = 7, cooldownSeconds = 300, now = Date.now() } = {}) {
const since = new Date(now - days * 86_400_000).toISOString();
const out = { supported: true, window_days: days, fires: 0, sessions: 0, samples: [] };
const fired = new Set();
const fire = (sessionId, at, detail) => {
out.fires++;
fired.add(sessionId);
if (out.samples.length < PREVIEW_SAMPLES)
out.samples.push({ session_id: sessionId, at, ...detail });
};
if (ruleType === "event_pattern") {
const where = ["created_at >= ?"];
const params = [since];
if (config.event_type) {
where.push("event_type = ?");
params.push(config.event_type);
}
if (config.tool_name) {
where.push("tool_name = ?");
params.push(config.tool_name);
}
if (config.summary_contains) {
where.push("LOWER(COALESCE(summary, '')) LIKE ?");
params.push(`%${config.summary_contains.toLowerCase()}%`);
}
const windowMs = (config.window_minutes || 0) * 60_000;
const recent = new Map(); // session → match times inside the window
const lastFire = new Map();
const rows = db
.prepare(
`SELECT session_id, created_at FROM events WHERE ${where.join(" AND ")} ORDER BY created_at, id`
)
.iterate(...params);
for (const r of rows) {
const t = Date.parse(r.created_at);
let seen = 1;
if (config.count > 1) {
const list = (recent.get(r.session_id) || []).filter((x) => t - x <= windowMs);
list.push(t);
recent.set(r.session_id, list);
seen = list.length;
if (seen < config.count) continue;
}
const last = lastFire.get(r.session_id);
if (last !== undefined && t - last < cooldownSeconds * 1000) continue;
lastFire.set(r.session_id, t);
fire(r.session_id, r.created_at, { observed_count: seen });
}
} else if (ruleType === "inactivity") {
// A gap longer than `minutes` between two events of one session.
const gapMs = config.minutes * 60_000;
const rows = db
.prepare(
`SELECT session_id, created_at,
LAG(created_at) OVER (PARTITION BY session_id ORDER BY created_at, id) AS prev
FROM events WHERE created_at >= ?`
)
.iterate(since);
for (const r of rows) {
if (!r.prev) continue;
const gap = Date.parse(r.created_at) - Date.parse(r.prev);
if (gap > gapMs) fire(r.session_id, r.prev, { gap_minutes: Math.round(gap / 60_000) });
}
} else if (ruleType === "token_threshold" || ruleType === "cost_threshold") {
const rules = stmts.listPricing.all();
const sessions = db
.prepare("SELECT id, updated_at FROM sessions WHERE updated_at >= ? ORDER BY updated_at")
.all(since);
for (const s of sessions) {
if (ruleType === "token_threshold") {
const t = stmts.sessionTokenTotals.get(s.id);
const total = t.input_tokens + t.output_tokens + t.cache_read_tokens + t.cache_write_tokens;
if (total >= config.total_tokens) fire(s.id, s.updated_at, { total_tokens: total });
} else {
const cost = sessionCost(s.id, rules);
if (cost >= config.usd) fire(s.id, s.updated_at, { cost_usd: cost });
}
}
} else if (ruleType === "budget") {
// The current period and the five before it.
const { budgetSpend, crossed } = require("./budgets");
const rules = stmts.listPricing.all();
out.periods = [];
for (let back = 5; back >= 0; back--) {
const s = budgetSpend(config, { now, back, rules });
const hit = crossed(config, s.pct);
out.fires += hit.length;
out.periods.push({ ...s, crossed: hit });
}
} else {
out.supported = false;
}
out.sessions = fired.size;
return out;
}
// Periodic sweep for the time-based rules. unref'd so it never keeps the
// process (or the test runner) alive — same pattern as the hooks watchdog.
const SWEEP_INTERVAL_MS = 60_000;
@@ -317,4 +526,6 @@ module.exports = {
sweepTimeRules,
fireAlert,
invalidateRuleCache,
previewRule,
sweepBudget,
};