/** * @file Express router for analytics endpoints, providing aggregated statistics on token usage, tool usage, daily events/sessions, agent types, and more, narrowed by data scope, AI tool and an optional `from`/`to` date range. `computeAnalytics` is the same bundle for scheduled reports. * @author Nguyễn Ngọc Trí Vĩ */ const { Router } = require("express"); const { stmts, db } = require("../db"); const { parseScope, parseTool, BAD_TOOL, parseRange, inRange, BAD_RANGE, } = require("../lib/source-filter"); const scoped = require("../lib/scoped-stats"); const { runRead } = require("../lib/read-pool"); const { calculateCost, dailyTokenRows, costWhere } = require("./pricing"); const router = Router(); // Every session, for the scoped path when only a date range narrows. const ALL = { where: "1=1", params: [] }; const USAGE_TOKEN_COLS = { total_input: "input_tokens", total_output: "output_tokens", total_cache_read: "cache_read_tokens", total_cache_write: "cache_write_tokens", }; /** * The analytics bundle for a data scope (`parseScope`'s predicate or null) and * a date range (`parseRange`'s result or null). Also used by scheduled reports. * With a range, counts follow it and tokens/cost come from the cost rows of the * days in it (`token_usage` has no date); the daily charts keep 365 days. */ function computeAnalytics( { scope = null, range = null, tzModifier = "+0 minutes" } = {}, events = scoped.eventAggregates(db, scope || (range ? ALL : null), range, tzModifier) ) { const sc = scope || (range ? ALL : null); const { toolUsage, dailyEvents, eventTypes, avgEvents } = events; const dailySessions = sc ? scoped.dailySessionCounts(db, sc, tzModifier) : stmts.dailySessionCounts.all(tzModifier); const agentTypes = sc ? scoped.agentTypeDistribution(db, sc, range) : stmts.agentTypeDistribution.all(); const overview = sc ? scoped.statsOverview(db, sc, range) : stmts.stats.get(); const agentsByStatus = sc ? scoped.agentStatusCounts(db, sc, range) : stmts.agentStatusCounts.all(); const sessionsByStatus = sc ? scoped.sessionStatusCounts(db, sc, range) : stmts.sessionStatusCounts.all(); const totalSubagents = sc ? scoped.totalSubagentCount(db, sc, range) : stmts.totalSubagentCount.get(); // Total cost exactly as GET /api/pricing/cost computes it (baselines, the // per-day rate, the same scope and range), so the two numbers always agree. // A session that started after the range cannot have spent in it. const extra = range?.end ? [{ clause: "s.started_at < ?", params: [range.end] }] : []; const where = costWhere({ scope, extra }); const rows = dailyTokenRows(tzModifier, where.whereClause, where.params).filter((r) => inRange(range, r.date) ); const totalCost = calculateCost(rows, stmts.listPricing.all()).total_cost; let tokens; if (range) { tokens = {}; for (const [k, c] of Object.entries(USAGE_TOKEN_COLS)) { tokens[k] = Math.round(rows.reduce((n, r) => n + r[c], 0)); } } else { const t = scope ? scoped.tokenTotals(db, scope) : stmts.getTokenTotals.get(); tokens = Object.fromEntries(Object.keys(USAGE_TOKEN_COLS).map((k) => [k, t?.[k] ?? 0])); } return { tokens, total_cost: totalCost, tool_usage: toolUsage, daily_events: dailyEvents, daily_sessions: dailySessions, agent_types: agentTypes, event_types: eventTypes, avg_events_per_session: avgEvents?.avg ?? 0, total_subagents: totalSubagents?.count ?? 0, overview, agents_by_status: Object.fromEntries(agentsByStatus.map((r) => [r.status, r.count])), sessions_by_status: Object.fromEntries(sessionsByStatus.map((r) => [r.status, r.count])), }; } router.get("/", (req, res) => { // Client sends tz_offset (minutes from getTimezoneOffset(), e.g. 420 for PDT) // Negate it to get the SQLite modifier: 420 → '-420 minutes' const rawOffset = parseInt(req.query.tz_offset, 10); const tzModifier = Number.isFinite(rawOffset) ? `${-rawOffset} minutes` : "+0 minutes"; // Data-scope: restrict every metric to a subset of source machines and/or one // AI tool when the user has chosen one, and to `?from=&to=` (local dates). if (parseTool(req) === false) return res.status(400).json(BAD_TOOL); const range = parseRange(req); if (range === false) return res.status(400).json(BAD_RANGE); const scope = parseScope(req); // The events scans run in the read worker (a 365-day daily count is ~0.5 s // on a large DB), and a 10 s memo lets every open dashboard share one run. runRead( "analyticsEvents", { scope: scope || (range ? ALL : null), range, tzModifier }, { maxAgeMs: 10_000 } ) .then((events) => res.json(computeAnalytics({ scope, range, tzModifier }, events))) .catch((err) => res .status(err.code === "EREADTIMEOUT" ? 503 : 500) .json({ error: { code: err.code, message: err.message } }) ); }); module.exports = router; module.exports.computeAnalytics = computeAnalytics;