Files
AgentHub/docs/plans/analytics-parity
nntrivi2001 4aae61297f 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.
2026-10-06 17:12:28 +07:00
..

Analytics parity with the upstream agent monitor (hoangsonww)

Baseline compared: upstream hoangsonww's upstream agent monitor at 523170e (= tag v2.2.6, 2026-10-04) plus its open PRs, against AgentHub main at 151dc8a. Agreed with the user on 2026-10-05: phases 1–6 in full, plus Cursor CLI, a small MCP server, a statusline script and Grafana boards. NOT doing: the deep workflow charts and the web Kanban (dropped on purpose in 4a0f995), SSE (the websocket covers it), extra UI locales.

Each phase ships on its own, with tests, docs (update-project-docs) and the CLAUDE.md marker. Nothing is committed without the user's ask.

Phase 1 — data hygiene ✅

  • events.data trimmed at every insert through the SQL function trim_event_data(?) (server/lib/event-payload.js, registered in server/db.js): Edit/MultiEdit/NotebookEdit/Write drop originalFile, oldString/newString (Write also content); Read drops file.content and file.base64; every string in tool_input/tool_response over 8 KB keeps a 6 KB head + 2 KB tail; structuredPatch and everything outside those two fields stay whole; _trimmed names what was dropped. The full text stays in the transcript and its snapshot under <data dir>/transcripts/.
  • One-time sweep of stored rows (sweepEventPayloads, user_version 4, after marker 3), 200 rows per write transaction. No VACUUM (user's choice). Measured on the user's DB: 18,518 rows, 595 MB → 86 MB.
  • The session list prices every column /api/pricing/cost/:id does (tokensForSessions in server/routes/sessions.js).

Phase 2 — cost by tool ✅

  • ?tool= on /api/pricing/cost, /api/analytics, /api/stats, /api/workflows (+ /skill-usage): parseTool/parseScope in server/lib/source-filter.js (id-shaped, else 400), scoped-stats.js now takes a {where, params} predicate over sessions.
  • by_tool in the cost response; Analytics gets an AI-tool select (applies to every sub-tab, Workflow included) and a Cost by AI tool card.
  • /api/analytics's total_cost now equals /api/pricing/cost (it had ignored the compaction baselines and used UTC start dates).
  • Codex service tier read from thread_settings_applied: fast/priority → speed = fast priced with LiteLLM's *_priority rates (synced into the fast columns for non-Claude models), flex → service_tier = flex at half price.
  • Prometheus: agenthub_cost_usd_total{tool,model}.
  • DEFERRED (with the reason in pricing-sync.js): long-context (>200k/>272k) rates. No current Claude model has one, Codex here runs a 258k window (0 of 17,701 turns over 272k), so a token_usage primary-key rebuild would change no number today.

Phase 3 — budgets, burn rate, forecast ✅ (budgets as alert rules)

  • budgets table: name, period (day/week/month), limit USD, scope (all / tool / workspace), thresholds (%), enabled. /api/budgets CRUD + GET /api/budgets/status (spend this period, %, projected end-of-period).
  • Evaluated on the alert beat; a crossed threshold fires once per period through the existing alerts feed → webhooks → push.
  • Alert rule type cost_threshold (USD per session / per day).
  • POST /api/alerts/rules/preview: dry-run a rule over recorded history.
  • UI: Settings → Budgets page; burn rate + month-end forecast tiles on Analytics → Cost; budget chip in the status bar when ≥ a threshold.
  • As built: a budget is an alert rule of type budget (server/lib/budgets.js; config period / limit_usd / thresholds / tool / lane / tz_offset), so there is no budgets table, no /api/budgets CRUD and no Settings → Budgets page: budgets are created and edited with the other rules in Settings → Notifications → Alert rules (editable in place since v2.3), and GET /api/budgets/status is the one budgets route. cost_threshold, the preview, the Spend pace + Budgets cards and the status-bar chip are as planned.

Phase 4 — analytics UX ✅

  • Date range. parseRange (server/lib/source-filter.js): from/to are local YYYY-MM-DD, inclusive, read with tz_offset, either end open; a bad date (or year 9999, or from > to) is 400 INVALID_INPUT. GET /api/pricing/cost keeps only the cost rows of those spend days for total_cost / breakdown / by_tool (the existing spend-day split); daily_costs stays the whole series so the 30-day charts and the spend pace keep their windows. GET /api/analytics counts sessions/agents by started_at and events by created_at (an optional range on the scoped-stats.js functions), and takes tokens and cost from the same cost rows (token_usage has no date); daily_events/daily_sessions keep 365 days. GET /api/workflows (+ /skill-usage): sessions started in the range. The bodies are now computeAnalytics / computeCost (exported). Client: a range select on the Analytics toolbar beside the AI-tool select — 7d / 30d / 90d / All (default) / Custom with two <input type="date"> — in ?range= (+ from/to), client/src/lib/dateRange.ts; it also reaches the Workflow sub-tab.
  • Query Explorer. GET /api/query (server/routes/query.js): entity sessions / events / agents, q (literal substring), tool, status, from/to, cwd (folder subtree), sources; sort/dir from a per-entity allowlist (anything else 400 — no request text reaches SQL), ties by id; pages of ≤ 500; format=csv|json exports the whole filter capped at 50,000 rows (X-Total-Count, X-Truncated). CSV: RFC 4180, CRLF, BOM, ' before a cell starting with = + - @ tab CR. Page /query (client/src/pages/QueryExplorer.tsx), sidebar entry Query Explorer (the sidebar has no analytics item of its own; it sits under Sessions). Column headers show the raw field names, which are also the CSV header.
  • Scheduled reports. Tables report_definitions / report_runs; server/lib/reports.js + /api/reports (CRUD, POST /:id/run, runs, GET /:id/runs/:runId/:format with the path built from ids + the stored stamp). A run covers the window_days full local days BEFORE the run day, computed with computeAnalytics + computeCost, plus the current budget status; files <data dir>/reports/<id>/<stamp>.{html,json}, HTML fully escaped. Scheduler: unref'd interval in startBackgroundServices (DASHBOARD_REPORTS_MS, default 60 s, 0 = off), each slot claimed by one conditional UPDATE (two dashboards on one DB run it once); a slot missed while down runs once on the next start; create/edit/re-enable claims the slot already past. Weekly reports have a weekday (default Monday) — needed for "weekly", not in the original list. Writes same-origin. Definitions AND runs survive Clear Data (like alert rules and snapshots). UI: Settings → Reports (components/settings/ReportSettings.tsx).
  • Not done: OpenAPI entries for the new routes (snapshots/budgets have none either); report runs are never pruned (one file per run per format).
  • Upstream's PR branches (#286/#172 feat/query-explorer, #174 feat/scheduled-reports) were not in the reference clone; built from the plan above.
  • Tests: analytics-range, query-explorer, reports in server/__tests__/; client dateRange, QueryExplorer, ReportSettings, and a ?range= case in screens.snapshot.test.tsx.

Phase 5 — sessions ✅

  • Replay: a Replay tab on /sessions/:id beside Agents / Conversation / Timeline (client/src/components/EventReplay.tsx). It reads the session's events through GET /api/events?session_id= in pages of 500 (the newest 5,000 at most, with a note when there are more), oldest first, on the tab's first visit. A range slider over the event SEQUENCE (not the wall clock — an hour-long pause does not stall it), play/pause at 0.5×/1×/2×/5× (one event per 500 ms at 1×), step back/forward, jump start/end, keys ←/→/Home/End/Space, a mini-map of lifecycle markers (SessionStart, UserPromptSubmit, Compaction, SubagentStop, APIError, Stop) that jump the cursor, a state panel derived up to the cursor (status, tool in flight, prompts, errors, compactions, elapsed, tool-call counts) and the selected event through EventDetail. The pure derivations live in client/src/lib/replay.ts (unit-tested).
  • Snapshots: POST /api/snapshots {session_id, redact?: {file_paths?}} freezes {session, agents, events, stats, cost} into an immutable session_snapshots row (a trigger refuses UPDATE) under a 24-byte base64url token; GET /api/snapshots (no payload), GET /api/snapshots/:token, DELETE /api/snapshots/:id; writes behind sameOriginGuard. Redaction runs at capture: path-valued keys → [redacted], the session cwd / home dir in any other string → <cwd> / ~. Snapshots survive Clear Data, like alert rules. UI: a Snapshot action in the session header (confirm with a "redact file paths" box, default on; copies the link), the read-only page /snapshot/:token (client/src/pages/SnapshotView.tsx: SessionOverview fed the frozen stats via its new stats prop, the agents, Replay and Timeline — the Timeline list now client/src/components/EventTimeline.tsx, shared with the analysis page), and Settings → Snapshots (/settings/snapshots, components/settings/SnapshotSettings.tsx: list, open, copy link, delete with confirm, the same on a right-click menu).
  • Upstream's PR branches (#265 feat/session-replay, #173 feat/session-snapshots) were not in the reference clone; built from the plan above.

Phase 6 — ingest breadth ✅

  • Remote Codex. A remote source has an opt-in sync_codex (+ optional remote_codex_home, default ~/.codex, wsl:~/.codex beside a wsl: Claude home) — two additive columns on remote_sources, a checkbox in the source form, --codex/--codex-home on agenthub remote-sources add. A sync mirrors sessions/ with mtimes kept (scp -r -p, or tar from WSL) plus session_index.jsonl into <data dir>/remote-sources/<id>/codex/, then runs ingestCodexUsage({home, source, activeMs}) — the same journal reader as local Codex (server/lib/codex-usage.js), so sessions arrive as tool = 'codex' rows tagged with the source id, with tokens and cost. A pass only ever writes rows of its own source; the local settle never touches a remote row; the remote activity window is DASHBOARD_REMOTE_ACTIVE_WINDOW_MS. With Codex on, a machine may have only one CLI: one half failing is ok + last_error. Differs from upstream (which always tries both): opt-in, and no separate Codex import pipeline — AgentHub's journal reader is reused.
  • Push ingest. POST /api/hooks/ingest-batch — body {source, events: [{hook_type, data, tool?}]}, each event through the SAME function as POST /api/hooks/event (ingestHook in server/routes/hooks.js). Off (503) until Settings → Data → Push ingest creates a token: 32 random bytes, shown once, stored only as its SHA-256 (AGENTHUB_PUSH_TOKEN_SHA256 in the settings file), checked in constant time; rotate/revoke; GET never returns it. Sessions tagged push:<source>; a pushed hook may only extend a session its own source created (EFOREIGN); transcript_paths are dropped (they name the sender's files); no lane/pane binding; 500 events / 1 MB. The UI shows a forwarder script keeping the token in a curl header file, not argv. Differs from upstream: upstream's wire format is a collector's own session/ token/turn records; AgentHub takes plain hook payloads, so a pushed session has events and live status but no token cost (that needs transcripts).
  • Live Codex overlay — not built. Upstream's include_transient (codex-process-overlay.js) scans ps for an interactive codex TUI that has not written a rollout yet, i.e. one started but never prompted. In AgentHub a Codex pane started from the dashboard already shows (its tab, and New session in the sidebar from its run), and anything that has done work has a journal the 15 s poll reads (or a SessionStart hook with Codex hooks on). The only row it would add is an idle Codex in an outside terminal with no prompt and no cost — not worth a per-tick process scan.
  • Privacy (server/lib/privacy.js, Settings → Data → Privacy, GET/PUT /api/settings/privacy, both off by default, persisted to the settings file, env-pinnable): (a) Redact secrets (AGENTHUB_REDACT_SECRETS=1) — chained in front of the trim in trim_event_data (so a key is redacted before a trim could cut it) plus a new redact_text(?) on every events summary insert; known key shapes (Anthropic, OpenAI, GitHub, GitLab, Slack, AWS, Google, npm, JWT), bearer/basic headers, PEM private keys, password=/*_TOKEN:-style values, and any string under a secret-named JSON key → [redacted], with _redacted: <n> on the payload. New data only; the transcript is untouched; session NAMES (first prompt) are not redacted. (b) Ignored folders (AGENTHUB_IGNORE_CWD, path.delimiter-separated, path-boundary match): hooks dropped whole (ignored: true), and the history import and the Codex reader skip those sessions too.

Phase 7 — extras chosen by the user

  • Cursor CLI: session + token ingest from ~/.cursor, Cursor pricing rows.
  • MCP server (stdio, read-only, ~15 tools: stats, cost, sessions, session detail, analytics, workflows, budgets, alerts).
  • statusline/ script for Claude Code (model, branch, context %, session $, today $ from the dashboard).
  • monitoring/ Grafana dashboards + Prometheus rules for /api/metrics.

Phase 7 status — MCP, statusline, monitoring ✅; Cursor ✅ (no cost)

  • MCP (mcp/server.js, agenthub mcp, docs/MCP.md): JSON-RPC over stdio written directly (no SDK), 15 GET-only tools (health, info, stats, total / session / lane cost, sessions, session, session stats, analytics, workflows, alerts, lanes, pricing, budget status). AGENTHUB_URL, else the CLI's discovery, now shared as server/lib/dashboard-url.js. A missing route — 404 or the production SPA page served with 200 — reads "Not available on this dashboard", which is what get_budget_status says until Phase 3 lands. NOT auto-registered by the plugin: claude mcp add agenthub -- agenthub mcp.
  • Statusline (statusline/statusline.js, agenthub statusline install|uninstall [--force], docs/STATUSLINE.md): model · branch · context bar · session · today (/api/pricing/cost, 1 s timeout, 30 s cache in the OS temp dir, failures cached). Install refuses a foreign statusLine without --force and backs settings.json up first; a plugin install registers the ~/.local/bin/agenthub launcher, not the cache dir.
  • Monitoring (monitoring/, docker-compose.monitoring.yml, docs/MONITORING.md): scrape config, agenthub:* recording rules, boards Overview / Sessions & agents / Tokens & cost; the spend panels read Phase 2's agenthub_cost_usd_total{tool,model} and say so.
  • Tests: mcp-server, mcp-server-live, statusline, monitoring in server/__tests__/.
  • Cursor (server/lib/cursor-usage.js): agent transcripts under ~/.cursor/projects/<slug>/agent-transcripts/ become tool = 'cursor' sessions (cursor_transcripts table, DASHBOARD_CURSOR_POLL_MS, AGENTHUB_CURSOR_DIR). NOT built: tokens, model and cost — the transcripts record none, and state.vscdb (which has them) also holds auth tokens, so it is never read; hence no Cursor pricing rows either.

Ported onto main

Phases 2–7 were built in a separate worktree on 138e706 and ported onto v2.3.1 on 2026-10-06 (phase 1 had shipped on its own), released as v2.4.0.