Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion scripts/run-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,9 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$HERE"
HOST="${HOST:-127.0.0.1}"
PORT="${PORT:-8732}"
exec uv run python -m uvicorn tracker.api:app --host "$HOST" --port "$PORT"
# --reload watches tracker/ and web/ so editing source rebuilds the running process.
# Set RELOAD=0 to opt out (e.g. when running under launchd).
RELOAD_FLAG="--reload"
[ "${RELOAD:-1}" = "0" ] && RELOAD_FLAG=""
exec uv run python -m uvicorn tracker.api:app --host "$HOST" --port "$PORT" \
$RELOAD_FLAG --reload-dir tracker --reload-dir web
132 changes: 123 additions & 9 deletions tracker/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from .db import DEFAULT_DB_PATH, connect, init
from .ingest import run as run_ingest
from .pricing import _lookup as _price_lookup
from .pricing import _lookup as _price_lookup, reload as _price_reload
from .recompute_costs import run as run_recompute

ROOT = Path(__file__).resolve().parent.parent
Expand All @@ -29,8 +29,12 @@ def db():
conn.close()


def _filter_clause(tool, model, project, start, end, prefix="m"):
"""Returns (sql_fragment, params). All filters optional."""
def _filter_clause(tool, model, project, start, end, agent=None, entrypoint=None, prefix="m"):
"""Returns (sql_fragment, params). All filters optional.

`agent` semantics: None = no filter; "main" = agent_type IS NULL (top-level session
turns); any other value = exact match on agent_type.
"""
clauses = []
params: list = []
if tool:
Expand All @@ -48,6 +52,14 @@ def _filter_clause(tool, model, project, start, end, prefix="m"):
if project:
clauses.append("s.cwd = ?")
params.append(project)
if agent == "main":
clauses.append(f"{prefix}.agent_type IS NULL")
elif agent:
clauses.append(f"{prefix}.agent_type = ?")
params.append(agent)
if entrypoint:
clauses.append("s.entrypoint = ?")
params.append(entrypoint)
where = " AND ".join(clauses)
return (f" WHERE {where}" if where else ""), params

Expand All @@ -73,12 +85,18 @@ def filters():
"SELECT DISTINCT model FROM messages WHERE model IS NOT NULL ORDER BY model")]
projects = [r[0] for r in c.execute(
"SELECT DISTINCT cwd FROM sessions WHERE cwd IS NOT NULL ORDER BY cwd")]
agents = [r[0] for r in c.execute(
"SELECT DISTINCT agent_type FROM messages WHERE agent_type IS NOT NULL ORDER BY agent_type")]
entrypoints = [r[0] for r in c.execute(
"SELECT DISTINCT entrypoint FROM sessions WHERE entrypoint IS NOT NULL ORDER BY entrypoint")]
date_range = c.execute(
"SELECT MIN(ts), MAX(ts) FROM messages").fetchone()
return {
"tools": tools,
"models": models,
"projects": projects,
"agents": agents,
"entrypoints": entrypoints,
"date_range": {"min": date_range[0], "max": date_range[1]},
}

Expand Down Expand Up @@ -121,10 +139,12 @@ def stats(
project: str | None = Query(None),
start: str | None = Query(None),
end: str | None = Query(None),
agent: str | None = Query(None),
entrypoint: str | None = Query(None),
granularity: str = Query("auto"),
):
"""Totals + time-series + breakdowns. granularity: auto|minute|hour|day|week|month."""
where, params = _filter_clause(tool, model, project, start, end)
where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint)
join = "FROM messages m JOIN sessions s ON s.id = m.session_id" + where

gran = granularity if granularity in _GRANULARITY else _pick_granularity(start, end)
Expand All @@ -147,6 +167,27 @@ def stats(
{join}""", params).fetchone()
totals = dict(totals_row)

# Cost breakdown by bucket. Group tokens by (tool, model), apply per-bucket rates,
# then sum. Lets the UI show "where the $ went" (cache reads almost always dominate).
by_tm = c.execute(
f"""SELECT m.tool, m.model,
SUM(m.input_tokens) AS in_tok,
SUM(m.output_tokens) AS out_tok,
SUM(m.cache_read) AS cr_tok,
SUM(m.cache_write_5m) AS cw5_tok,
SUM(m.cache_write_1h) AS cw1_tok
{join}
GROUP BY m.tool, m.model""", params).fetchall()
cb = {"input": 0.0, "output": 0.0, "cache_read": 0.0, "cache_write_5m": 0.0, "cache_write_1h": 0.0}
for r in by_tm:
p = _price_lookup(r["tool"], r["model"])
cb["input"] += (r["in_tok"] or 0) * p.get("input", 0) / 1_000_000
cb["output"] += (r["out_tok"] or 0) * p.get("output", 0) / 1_000_000
cb["cache_read"] += (r["cr_tok"] or 0) * p.get("cache_read", 0) / 1_000_000
cb["cache_write_5m"] += (r["cw5_tok"] or 0) * p.get("cache_write_5m", 0) / 1_000_000
cb["cache_write_1h"] += (r["cw1_tok"] or 0) * p.get("cache_write_1h", 0) / 1_000_000
totals["cost_breakdown"] = {k: round(v, 4) for k, v in cb.items()}

# Active hours: sum over sessions of (max(ts) - min(ts)) within the filter window.
# This excludes pure idle gaps between sessions and gives a more meaningful rate.
active_row = c.execute(
Expand Down Expand Up @@ -205,6 +246,39 @@ def stats(
GROUP BY m.model, m.tool
ORDER BY cost_usd DESC""", params).fetchall()]

# by entrypoint (cli / sdk-cli / codex / …). Tells "interactive REPL" from "spawned SDK runs".
by_entrypoint = [dict(r) for r in c.execute(
f"""SELECT COALESCE(s.entrypoint,'(unknown)') AS entrypoint,
COUNT(*) msgs,
COUNT(DISTINCT m.session_id) sessions,
SUM(m.input_tokens) input_tokens,
SUM(m.output_tokens) output_tokens,
SUM(m.cache_read) cache_hit,
SUM(m.cache_write_5m) cache_write_5m,
SUM(m.cache_write_1h) cache_write_1h,
SUM(m.est_cost_usd) cost_usd
{join}
GROUP BY s.entrypoint
ORDER BY cost_usd DESC""", params).fetchall()]

# by agent INVOCATION (one row per sub-agent run, identified by agent_id).
# All main-session turns (agent_type IS NULL) collapse into a single aggregate row.
by_agent = [dict(r) for r in c.execute(
f"""SELECT COALESCE(m.agent_type,'main session') AS agent_type,
m.agent_id,
m.agent_desc,
COUNT(*) msgs,
COUNT(DISTINCT m.session_id) sessions,
SUM(m.input_tokens) input_tokens,
SUM(m.output_tokens) output_tokens,
SUM(m.cache_read) cache_hit,
SUM(m.cache_write_5m) cache_write_5m,
SUM(m.cache_write_1h) cache_write_1h,
SUM(m.est_cost_usd) cost_usd
{join}
GROUP BY m.agent_type, m.agent_id, m.agent_desc
ORDER BY cost_usd DESC""", params).fetchall()]

# by project
by_project = [dict(r) for r in c.execute(
f"""SELECT COALESCE(s.cwd,'(unknown)') AS project,
Expand Down Expand Up @@ -235,17 +309,21 @@ def stats(
"by_tool": by_tool,
"by_model": by_model,
"by_project": by_project,
"by_agent": by_agent,
"by_entrypoint": by_entrypoint,
}


@app.get("/api/breakdown_series")
def breakdown_series(
group: str = Query("model", pattern="^(tool|model|project|session|server|mcp_tool)$"),
group: str = Query("model", pattern="^(tool|model|project|session|server|mcp_tool|agent|entrypoint)$"),
tool: str | None = Query(None),
model: str | None = Query(None),
project: str | None = Query(None),
start: str | None = Query(None),
end: str | None = Query(None),
agent: str | None = Query(None),
entrypoint: str | None = Query(None),
granularity: str = Query("auto"),
limit: int = Query(5, ge=1, le=10),
):
Expand All @@ -262,6 +340,16 @@ def breakdown_series(
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.model = ?)")
params.append(model)
if agent == "main":
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.agent_type IS NULL)")
elif agent:
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.agent_type = ?)")
params.append(agent)
if entrypoint:
clauses.append("s.entrypoint = ?")
params.append(entrypoint)
if project:
clauses.append("s.cwd = ?"); params.append(project)
if start:
Expand Down Expand Up @@ -309,10 +397,18 @@ def breakdown_series(
"COALESCE(m.model,'(unknown)') || ' · ' || m.tool"),
"project": ("COALESCE(s.cwd,'(unknown)')", "COALESCE(s.cwd,'(unknown)')"),
"session": ("s.id", "s.tool || ' · ' || COALESCE(s.cwd, s.id)"),
"agent": (
"COALESCE(m.agent_id, COALESCE(m.agent_type,'main session'))",
"CASE WHEN m.agent_id IS NULL THEN COALESCE(m.agent_type,'main session') "
" ELSE COALESCE(m.agent_type,'') || ' · ' || substr(m.agent_id,1,8) || "
" ' · ' || COALESCE(m.agent_desc,'(no description)') END",
),
"entrypoint": ("COALESCE(s.entrypoint,'(unknown)')",
"COALESCE(s.entrypoint,'(unknown)')"),
}
key_expr, label_expr = group_exprs[group]
bucket = _GRANULARITY[gran]
where, params = _filter_clause(tool, model, project, start, end)
where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint)
join = "FROM messages m JOIN sessions s ON s.id = m.session_id" + where

with db() as c:
Expand Down Expand Up @@ -366,10 +462,12 @@ def sessions(
project: str | None = Query(None),
start: str | None = Query(None),
end: str | None = Query(None),
agent: str | None = Query(None),
entrypoint: str | None = Query(None),
limit: int = Query(200, ge=1, le=2000),
sort: str = Query("cost", pattern="^(cost|recent|messages)$"),
):
where, params = _filter_clause(tool, model, project, start, end)
where, params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint)
order = {
"cost": "est_cost_usd DESC",
"recent": "ended_at DESC",
Expand All @@ -383,6 +481,7 @@ def sessions(
s.session_uuid,
s.cwd,
s.model,
s.entrypoint,
MIN(m.ts) AS started_at,
MAX(m.ts) AS ended_at,
COUNT(*) AS msg_count,
Expand All @@ -409,8 +508,10 @@ def session_detail(
project: str | None = Query(None),
start: str | None = Query(None),
end: str | None = Query(None),
agent: str | None = Query(None),
entrypoint: str | None = Query(None),
):
filters, filter_params = _filter_clause(tool, model, project, start, end)
filters, filter_params = _filter_clause(tool, model, project, start, end, agent=agent, entrypoint=entrypoint)
where = " WHERE m.session_id = ?" + filters.replace(" WHERE", " AND", 1)
params = [session_id, *filter_params]
with db() as c:
Expand Down Expand Up @@ -440,7 +541,8 @@ def session_detail(
).fetchone()
msgs = [dict(r) for r in c.execute(
f"""SELECT m.ts, m.model, m.input_tokens, m.output_tokens, m.cache_read,
m.cache_write_5m, m.cache_write_1h, m.reasoning_tokens, m.est_cost_usd
m.cache_write_5m, m.cache_write_1h, m.reasoning_tokens, m.est_cost_usd,
m.agent_type, m.agent_desc, m.agent_id
FROM messages m JOIN sessions s ON s.id = m.session_id
{where}
ORDER BY m.ts""",
Expand Down Expand Up @@ -496,6 +598,8 @@ def mcp(
project: str | None = Query(None),
start: str | None = Query(None),
end: str | None = Query(None),
agent: str | None = Query(None),
entrypoint: str | None = Query(None),
):
"""MCP usage breakdown: by server, by server+tool_name. Cost attribution explained
inline: result tokens × cache_read rate of the session's model (since MCP results
Expand All @@ -509,6 +613,16 @@ def mcp(
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.model = ?)")
params.append(model)
if agent == "main":
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.agent_type IS NULL)")
elif agent:
clauses.append("EXISTS (SELECT 1 FROM messages mm WHERE mm.source_file=mc.source_file "
"AND mm.source_line=mc.source_line AND mm.agent_type = ?)")
params.append(agent)
if entrypoint:
clauses.append("s.entrypoint = ?")
params.append(entrypoint)
if project:
clauses.append("s.cwd = ?"); params.append(project)
if start:
Expand Down
18 changes: 18 additions & 0 deletions tracker/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
session_uuid TEXT NOT NULL,
cwd TEXT,
model TEXT, -- last model observed in session
entrypoint TEXT, -- 'cli' (interactive), 'sdk-cli', 'codex', ... — how the run was launched
started_at TEXT,
ended_at TEXT,
msg_count INTEGER NOT NULL DEFAULT 0,
Expand All @@ -37,12 +38,16 @@
est_cost_usd REAL NOT NULL DEFAULT 0,
source_file TEXT NOT NULL,
source_line INTEGER NOT NULL,
agent_type TEXT, -- e.g. 'Explore', 'Plan' for sub-agents; NULL for the main session
agent_desc TEXT, -- per-invocation description from the meta.json sidecar
agent_id TEXT, -- per-invocation hash from the JSONL filename (the sub-agent "PID")
UNIQUE(source_file, source_line)
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
CREATE INDEX IF NOT EXISTS idx_messages_ts ON messages(ts);
CREATE INDEX IF NOT EXISTS idx_messages_tool ON messages(tool);
CREATE INDEX IF NOT EXISTS idx_messages_model ON messages(model);
-- idx_messages_agent_type is created in init() after the column-add migration runs.

CREATE TABLE IF NOT EXISTS mcp_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
Expand Down Expand Up @@ -99,6 +104,19 @@ def init(db_path: Path | str = DEFAULT_DB_PATH) -> None:
conn = connect(db_path)
try:
conn.executescript(SCHEMA)
# Lightweight column migrations for existing DBs (CREATE TABLE IF NOT EXISTS won't add new columns).
existing = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
if "agent_type" not in existing:
conn.execute("ALTER TABLE messages ADD COLUMN agent_type TEXT")
if "agent_desc" not in existing:
conn.execute("ALTER TABLE messages ADD COLUMN agent_desc TEXT")
if "agent_id" not in existing:
conn.execute("ALTER TABLE messages ADD COLUMN agent_id TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_agent_type ON messages(agent_type)")
existing_s = {row["name"] for row in conn.execute("PRAGMA table_info(sessions)")}
if "entrypoint" not in existing_s:
conn.execute("ALTER TABLE sessions ADD COLUMN entrypoint TEXT")
conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_entrypoint ON sessions(entrypoint)")
conn.commit()
finally:
conn.close()
Expand Down
Loading