Skip to content

Fix session analytics accuracy: dedupe usage, current pricing, active time - #106

Merged
JayantDevkar merged 6 commits into
mainfrom
fix/session-analytics-accuracy
Oct 12, 2026
Merged

JayantDevkar merged 6 commits into
mainfrom
fix/session-analytics-accuracy

Conversation

@JayantDevkar

@JayantDevkar JayantDevkar commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Problem

The session Analytics tab overstated cost and tokens 2–6x. Claude Code writes one JSONL line per content block and repeats the response's usage on each, and we summed every line. Pricing also stopped at the 4.6 models, so Opus 4.8 was billed as Opus 4 ($15/$75) and Fable 5.1 as Sonnet 4.6. Resumed sessions showed durations of up to 109 days.

Changes

  • UsageAccumulator counts usage once per API response; the last line wins because streamed output counts grow.
  • Pricing covers Claude 5, Opus 4.7/4.8 and Haiku 5.5. Cache reads use per-model rates, and 1-hour cache writes are priced at 2x. Model IDs are parsed into family and version; an unknown version bills like the nearest known version in the same major version and logs one warning, so an unseen Haiku 4.x gets Haiku 4.5 rates rather than Haiku 5.5.
  • Duration is active time, with idle gaps capped at 30 min, everywhere a duration is shown: sessions, subagents, and the JSONL fallback paths. The "Active time over …" footnote appears only when more than a minute of idle time was trimmed.
  • The session Analytics tab is session-only; its tool table uses the by_session split already in the /tools response. The Overview tab and live polling still count subagent tool calls. Each subagent's usage is on its own analytics page.
  • Project and global totals include subagents for both cost and tokens (sessions.subagent_cost and subagent_*_tokens), since that is real spend. Per-session rows stay session-only, so they don't sum to the project total.
  • New sessions columns are ensured on every startup before the version check, so DBs stamped higher by another branch (feat(sync): P2P session sync via Syncthing — v5 revival of #45 with 11 root-cause fixes + UX layer #85 uses v24) still get them. Schema v23 forces the re-pricing reindex. Rebased onto main's v22 (Fix orphaned child rows and keep FK enforcement on after migrations (#107) #108).
  • Not caused by this PR, found by it: StatsCard rendered descriptions with no space before them.

Component

  • API (FastAPI backend)
  • Frontend (SvelteKit dashboard)

Type

  • Bug fix

Testing

  • API tests pass: 1917 passed, 6 skipped, run with HOME isolated so the real DB is untouched
  • Frontend type-checks (0 errors), lints clean (0 errors), vitest 274 passed
  • Reindex of a copy of my real DB on this head: 1000 sessions, 0 errors, 36 s
  • Manually tested in browser (light, dark, 390px, agent page) on the pre-rebase head

Checked against an independent recount of the raw JSONL, after a local reindex of 978 sessions:

Before After (recount)
Fable 5.1 session, 45 subagents $377.82 $137.85 (subagents $47.47 on their own pages)
Opus 4.8 session $117.26, 109 days $20.25, 5h 59m
All sessions $20,987.74 $10,286.56 incl. subagents

Follow-ups

  • Global analytics "Compute DNA" files Fable under "Other" (_categorize_model).
  • subagent_cost only refreshes when the parent JSONL changes, so a background subagent that keeps writing after the parent's last turn is under-counted until the next parent write.
  • Older sessions that write subagent turns inline with isSidechain count that cost in the session-only tab but not its tool calls. Not reproducible on my data.
  • Project totals' model mix and tool counts still cover the main session only.
  • The session tab bar overflows sideways at phone width.

@JayantDevkar JayantDevkar left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline context for reviewers, one note per change area.

Comment thread api/models/usage.py
if msg.usage is None:
return
key = msg.message_id or msg.request_id or msg.uuid
self._responses[key] = (msg.usage, msg.model)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Last line wins, not first: in 16,207 local duplicate lines the later one always had more output tokens, because streaming updates the count. Keeping the first line undercounted subagent output 4.4x on the 45-subagent session.

Comment thread api/models/usage.py
"""Extract (family, major, minor) from a model ID; date suffixes are not versions."""

def version(part: Optional[str]) -> Optional[int]:
return int(part) if part and len(part) < 8 else None

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parts of 8+ digits are date suffixes, not versions, so claude-opus-5-20261201 resolves to Opus 5 rather than falling back to the newest Opus. The known-version table is built from MODEL_PRICING keys, so adding a model there is the only edit needed.

Comment thread api/models/usage.py
pricing.get("output_long", pricing["output"]) if is_long else pricing["output"]
)

cache_write_1h = min(self.cache_creation_1h_input_tokens, self.cache_creation_input_tokens)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The min() guards against a 1h count larger than the total. Usage with no cache_creation breakdown (older transcripts) prices everything at the 5-minute 1.25x rate, as before.

@the-non-expert

Copy link
Copy Markdown
Collaborator

@JayantDevkar I reviewed this PR. Fixing the duplicate usage counting and the pricing is the right call, and checking it against an independent recount was well done. The PR's own tests pass (395 across the touched suites), and ruff shows nothing new.

I found the following issues. Could you work through them, starting with #1, which blocks the merge?

Blocker

1. The v20/v21 migrations are skipped on any DB whose version is already 21 or higher (api/db/schema.py:795)
The migrations, including the ALTER that adds sessions.subagent_cost, only run when the DB version is below 21. I reproduced this locally: my ~/.claude_karma/metadata.db is at v24 from sync-v5-ux (#85, which also claims v20 and above). On that DB, ensure_schema returns early, and every _index_session INSERT fails with no such column: subagent_cost. That error is logged only at debug level, so nothing gets reindexed and costs stay inflated. query_analytics and query_dashboard_stats also fail on SUM(... subagent_cost ...) and quietly drop to their fallbacks.
Fix: guard the column with PRAGMA table_info on every startup, the way _TICKETS_SCHEMA_SQL and _SHELLS_CRON_SCHEMA_SQL already do, so the column no longer depends on the version number. #85 and this PR will collide on schema numbers whichever merges first, so we'll need to coordinate the renumbering either way.

Should fix before merge

2. The Overview tab changes too, not just Analytics (frontend/src/routes/projects/[project_id]/[session_slug]/+page.server.ts:104)
The page loads the tool list once with include_subagents=false, and that list also feeds the Overview tab. On a session with many subagents, Overview's "Tool Calls" card and "Tools Used" chips (ConversationOverview.svelte:393/461) silently leave out all subagent tool calls. The live-poll change detection (getCurrentPollData toolCount) also stops noticing new subagent tool activity. Either fetch a separate session-only list for the Analytics tab, or say in the PR that Overview changes as well.

3. Totals mix scopes (api/db/queries.py:458, also :401)
total_cost now includes subagent spend, but total_input, total_output, total_cache_read and total_tokens in the same query cover the main session only. Cost per token reads wrong, and the per-session rows (queries.py:209) no longer add up to the project total.

4. The unknown-version fallback can cross major versions (api/models/usage.py:135)
I checked this by running _resolve_model. claude-haiku-4 resolves to claude-haiku-5-5, which bills $0.10/$0.50 instead of $1/$5, 10x too low. claude-opus-4-9 and claude-sonnet-4-7 resolve to the 5.5 models rather than the nearest 4.x rate. The fallback should stay within the same major version, and log a warning when it guesses.

5. Active-time duration is only applied in one place (api/models/session_index.py:58)
Only Session.duration_seconds changed. The fast analytics path (_calculate_analytics_from_index → SessionIndexEntry.duration_seconds) still uses end minus start, so the 109-day session is still counted in full there. routers/sessions.py:649 falls back to end minus start when meta._session is None, which mixes both kinds of duration in one list. Subagent durations (subagent_sessions.py:88, indexer.py:549) are also still end minus start.

Lower priority

6. subagent_cost is recomputed only when the parent JSONL's mtime changes (api/db/indexer.py:382). A run_in_background subagent that keeps writing after the parent's last turn stays under-counted in the totals.

7. In older-format sessions, where subagent turns are written inline with isSidechain set, the "session-only" Analytics tab still counts their cost and tokens but excludes their tool calls (api/models/session.py:470).

8. On the JSONL fallback, get_subagent_total_cost parses every subagent file, and then the existing iter_messages loop reads the same files again for tool counts (api/routers/analytics.py:543).

9. v20 is redundant: v21 also lowers jsonl_mtime by 1 to force a reindex. Merging them into one migration frees a schema number and shrinks the collision with #85.


Not covered by this review: how long the forced reindex takes on a large ~/.claude, and the test-isolation issue (metadata.db) you already listed as a follow-up.

Happy to pair on #1 if useful.

JayantDevkar added a commit that referenced this pull request Oct 12, 2026
…108)

The v19 migration re-enabled PRAGMA foreign_keys inside an open
transaction, where SQLite ignores it, so the writer connection ran with
cascades disabled for the rest of the process. Combined with delete paths
that relied on the cascade, child rows could outlive their parents.

- schema: commit before toggling foreign_keys in v19; re-enable after the
  final commit; v22 migration deletes orphaned rows in every child table
- indexer: one explicit delete helper for the empty-session and stale
  paths (covers shells/cron too); session row uses ON CONFLICT DO UPDATE
  so re-indexing no longer cascade-deletes children; health check reports
  orphan counts
- tests: upgrade paths keep FK on, delete paths work with FK off, repair
  migration, health metric, re-index preserves child ids

v20/v21 are reserved for PR #106; this must land after it.
… time

Claude Code repeats a response's usage on every content-block line, so
tokens and cost were summed 2.5-3x. Usage is now counted once per API
response (last line wins), priced with current models, per-model cache
read rates and 2x 1-hour cache writes. Session duration is active time
(idle gaps capped at 30 min). Session cards and project/global totals
include subagent cost. Schema v20/v21 force a reindex.
Subagents have their own analytics pages, so the session cards no longer
fold in subagent tokens, cost or cache hit rate. Drops the subagent_*
fields from SessionDetail and the multi-file ETag that only they needed.
Project and global totals still include subagent cost.
GET /sessions/{uuid}/tools takes include_subagents (default true, so
other callers are unchanged); the session page passes false, so Tools
Used and the tool table match the other session-only cards.
…t subagent_cost

- Unknown model versions bill like the nearest known version at or below
  them (oldest known if none), instead of the newest model in the family
- Index each subagent file once per session: the cost sum and the
  invocation rows share one list_subagents() call
- Add subagent_cost to the session upsert SET list (lost in the rebase
  onto the ON CONFLICT upsert), so re-indexing refreshes it
- "Active time over ..." footnote compares seconds with a 60s threshold
  instead of formatted strings
@JayantDevkar
JayantDevkar force-pushed the fix/session-analytics-accuracy branch from 11e91cd to e994853 Compare October 12, 2026 14:58

@JayantDevkar JayantDevkar left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline context for reviewers, one note per change area.

Comment thread api/db/schema.py
logger.info("Migrating → v22: remove orphaned child rows (issue #107)")
_delete_orphan_rows(conn)

if current_version < 23:

@JayantDevkar JayantDevkar Oct 12, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One v23 block for the re-pricing reindex. The column adds moved out of the version gate into _ensure_session_columns (see the note there), so this block only nudges mtimes, and skips that when the guard already did.

Comment thread api/models/usage.py Outdated
_KNOWN_VERSIONS, _VERSIONS_BY_FAMILY = _index_known_versions()


def _nearest_known(parsed: ModelVersion) -> str:

@JayantDevkar JayantDevkar Oct 12, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefers the same major version: claude-haiku-4 bills as Haiku 4.5 and claude-sonnet-4-9 as Sonnet 4.6, not the 5.5 models. Only with no known version in that major does it fall back across majors, and each guessed model logs one warning.

Comment thread api/db/indexer.py
if subagents_dir.exists():
subagent_count = len(list(subagents_dir.glob("agent-*.jsonl")))
# One parse of each subagent file feeds both the cost sum and the invocation rows below
subagents = session.list_subagents() if subagent_count else []

@JayantDevkar JayantDevkar Oct 12, 2026 •

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to my earlier note: Agent metadata is cached by file path, so the old code wasn't re-parsing subagent files. This listing now feeds the cost, the token totals and the invocation rows; the change is about one listing, not about parse count.

Comment thread api/db/indexer.py Outdated
"slug project_encoded_name project_path start_time end_time message_count "
"duration_seconds input_tokens output_tokens cache_creation_tokens cache_read_tokens "
"total_cost initial_prompt git_branch models_used session_titles is_continuation_marker "
"total_cost subagent_cost initial_prompt git_branch models_used session_titles is_continuation_marker "

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added subagent_cost here. Main's ON CONFLICT DO UPDATE statement enumerates the columns to refresh, and the rebase merged the INSERT list but not this SET list, so a re-indexed session would have kept its old cost. test_reindex_overwrites_stale_subagent_cost covers it.

…icing, active time

- Sessions columns added after v19 are ensured on every startup, before the
  version check, so a DB stamped higher by another branch still gets them;
  adding one forces a reindex
- Overview and live polling use the default /tools response again; the
  Analytics tab derives session-only rows from by_session (include_subagents
  param removed)
- Store subagent token totals per session; project/global token totals include
  them, matching the cost that already did (SQLite and JSONL fallback paths)
- Unknown model versions stay within their major version and log one warning
- Session, Agent, index entries and the JSONL sessions list share one
  active-time calculation; subagent durations are active time

@JayantDevkar JayantDevkar left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline context for reviewers, one note per change area.

Comment thread api/db/schema.py
]


def _ensure_session_columns(conn: sqlite3.Connection) -> bool:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runs on every startup before the version check, like the tickets and shells tables, so a DB stamped higher by another branch (e.g. v24 from #85) still gets these columns. Adding a column also forces one reindex so the new values get filled in.

Comment thread api/models/active_time.py
return
if self._latest is None:
self._latest = timestamp
elif timestamp > self._latest:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active time advances from the max timestamp seen, so an out-of-order line can't add a negative gap or double-count one. Session and Agent now share this, which is what makes subagent durations active time too.

Comment thread api/models/session.py
}


def active_duration_seconds(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For callers that only know a session's span (stale sessions-index.json entries, the JSONL sessions list). Under 30 minutes the wall clock is exact, so the file is only parsed for longer spans; a missing file returns None rather than an inflated wall-clock number.

let totalToolCalls = $derived(toolsArray.reduce((acc, t) => acc + t.count, 0));

// Overview counts subagent tool calls; the Analytics tab is the session's own work
let analyticsTools = $derived(

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The page is back on the default /tools response, so Overview and live-poll change detection see subagent tool calls again. The Analytics tab derives its session-only rows from by_session, which that response already carried, so there is no second fetch.

@JayantDevkar

JayantDevkar commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner Author

@the-non-expert thanks for the thorough pass. Items 1–5 are fixed in 4e98db7c; 6–9 status below.

  1. Fixed. Sessions columns added after v19 are ensured on every startup before the version check, the same way the tickets and shells tables are, and adding one forces a reindex. Tested against a DB stamped v24 with the columns missing: columns appear, indexing works, and the reindex happens once.
  2. Fixed. The page is back on the default /tools response, so Overview and live-poll change detection include subagent tool calls again. The Analytics tab derives its rows from by_session, which that response already had. I removed the include_subagents param since nothing uses it now.
  3. Fixed for tokens. Sessions now store subagent token totals next to subagent_cost, and the totals in query_analytics, query_dashboard_stats and the JSONL fallback include them. Per-session rows stay session-only by design, so they still won't add up to the project total; the model mix and tool counts in those totals are unchanged.
  4. Fixed. Unknown versions stay in their major version (claude-haiku-4 → Haiku 4.5, claude-sonnet-4-7 → Sonnet 4.6) and log one warning per model.
  5. Fixed. Session and Agent share one active-time calculation, so subagent durations (indexer and the subagent page) are active time. Index entries and the JSONL sessions list use a helper that parses the file only when the span is over the 30-minute cap, and returns None if the file is gone instead of the wall clock.
  6. Open, moved to Follow-ups.
  7. Open, moved to Follow-ups. No session on my machine has inline sidechain lines, so I couldn't reproduce it.
  8. Partly not real: Agent metadata is cached by file path, so cost and usage aren't re-parsed. The second read is the iter_messages pass for tool counts, which is inherent to that fallback.
  9. Done earlier: one v23 block.

Verification on 4e98db7c, local macOS (the later 93a2a755 only moves one test into tests/api/; CI is green on it, 20/20):

Check Result
pytest (HOME isolated) 1917 passed, 6 skipped
vitest / svelte-check 274 passed / 0 errors
Reindex of a copy of my real DB (v22 → v23) 1000 sessions, 0 errors, 36 s; analytics cost = main + subagent; no subagent duration over 6 h; 0 FK violations

Could you take another look?

@JayantDevkar
JayantDevkar merged commit cc689a8 into main Oct 12, 2026
20 checks passed
@JayantDevkar

Copy link
Copy Markdown
Owner Author

@the-non-expert heads up: I merged this before your re-review so it ships with the schema fix. Your items 1–5 are addressed as described above. If anything in that reply looks off, comment here or open an issue and I'll follow up in a new PR.

@JayantDevkar
JayantDevkar deleted the fix/session-analytics-accuracy branch October 12, 2026 15:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants