Skip to content

Usage instrumentation + externalize chat session state to Postgres - #50

Merged
derekl-beep merged 2 commits into
mainfrom
claude/usage-instrumentation
Jul 16, 2026
Merged

Usage instrumentation + externalize chat session state to Postgres#50
derekl-beep merged 2 commits into
mainfrom
claude/usage-instrumentation

Conversation

@derekl-beep

Copy link
Copy Markdown
Owner

Summary

Two related backend infra changes, from the "Now" tier of a prioritized team review of the app (instrumentation + session durability were flagged as the two highest-value/lowest-risk items to unblock everything else).

Usage instrumentation

  • New append-only usage_events table + record_usage() helper — every tool call gets logged (_run_tools), plus every chat/UI mutation that bypasses the agent entirely (expense/income edit/delete, CSV export, budget set/delete).
  • Chat requests now carry an optional source tag (chip:<label>, command:<cmd>, or omitted for freeform typing) threaded from Chat.jsx, so a suggestion-chip click is distinguishable from someone typing the same words.
  • No third-party analytics vendor — this is household financial data, so the log stays in our own Postgres and records only structural event names/sources, never message content or expense/income descriptions.
  • scripts/usage_report.py gives a simple breakdown for periodic review instead of building a dashboard UI nobody's asked for yet.

Session state externalization

  • Chat history lived in an in-process dict keyed by user_id, so every redeploy/restart silently dropped every in-progress conversation. Adds a chat_sessions table (JSONB messages, one row per user); chat()/stream_chat() load at the start of a turn and save after every mutation point, so a crash mid-turn is recoverable across restarts the same way _repair_dangling_tool_use already recovered in-process failures.
  • Assistant turns previously stored live Anthropic SDK response objects directly. Persisting requires plain dicts, so content blocks are normalized via a duck-typed _serialize_block() at append time — this also exposed a latent bug in _repair_dangling_tool_use's type check (getattr()-based, which only ever worked because in-memory sessions were never reloaded from a serialized form). Fixed to dict-style access.
  • Added a per-user threading.Lock around each turn so two overlapping requests from the same user (double-tap, two tabs) can't silently clobber each other's saved history — this only covers same-instance concurrency (the actual deployment today); true cross-instance locking is a scale problem for later.
  • tests/test_agent.py's session-corruption tests previously poked main._sessions directly and used made-up user_ids; chat_sessions has a real FK to users like every other user-scoped table, so those are migrated to a new user_id_factory fixture.

Test plan

  • uv run pytest tests/ — 180 passed
  • uv run ruff check . — clean
  • npm run build / npm run lint — clean
  • Full npx playwright test — 78 passed, mobile + desktop (chat-loop internals changed, so the whole suite was run, not just targeted specs)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY


Generated by Claude Code

claude added 2 commits July 16, 2026 01:19
Every keep-vs-cut decision on the roadmap so far has been a guess. Adds
an append-only usage_events table + record_usage() helper, logged from
every agent tool call (agent/main.py's _run_tools) and every UI-only
mutation that bypasses the agent entirely (expense/income edit/delete,
CSV export, budget set/delete in api/server.py).

Chat requests now carry an optional `source` tag (chip:<label>,
command:<cmd>, or omitted for freeform typing) threaded from Chat.jsx,
so a suggestion-chip click is distinguishable from someone typing the
same words — the two are identical server-side otherwise.

No third-party analytics vendor: this is household financial data, so
the log stays in our own Postgres, records only structural event names/
sources, and never message content or expense/income descriptions.
scripts/usage_report.py gives a simple breakdown for periodic review
instead of building a dashboard UI nobody's asked for yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY
Chat history lived in an in-process dict keyed by user_id, so every
redeploy or restart silently dropped every in-progress conversation —
users would just see the agent "forget" everything with no error. Adds
a chat_sessions table (one row per user, messages as JSONB) and
load/save/clear functions in agent/db.py; chat()/stream_chat() now load
at the start of a turn and save after every mutation point, so a crash
mid-turn is recoverable the same way _repair_dangling_tool_use already
recovers in-process failures — now across restarts too, which was the
actual point of this change.

Two things fell out of doing this properly rather than as a drop-in
storage swap:

- Assistant turns previously stored live Anthropic SDK response objects
  directly. Persisting requires plain dicts, so content blocks are now
  normalized via a duck-typed _serialize_block() at append time. That
  also exposed a latent bug in _repair_dangling_tool_use: its type check
  used getattr(), which only ever worked because in-memory sessions were
  never reloaded from a serialized form. Fixed to dict-style access.
- A naive load-then-save round trip can lose messages if the same user
  fires two overlapping requests (double-tap, two tabs) — today's
  in-memory dict can't, since both requests share the same list object.
  Added a per-user threading.Lock around each turn to close the
  same-instance case, which is the actual deployment; true cross-instance
  locking would need a DB-level lock and isn't worth it before this
  scales past a household.

tests/test_agent.py's session-corruption tests previously poked
main._sessions directly and used made-up user_ids; chat_sessions has a
real FK to users like every other user-scoped table, so those are
migrated to a new user_id_factory fixture and to seeding state via
db.save_chat_session instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qz6JBNFwhgem5GY3i2BEMY
@derekl-beep
derekl-beep merged commit a945a6b into main Jul 16, 2026
3 checks passed
derekl-beep pushed a commit that referenced this pull request Jul 16, 2026
main moved forward with PRs #50 (usage instrumentation + session
persistence) and #51 (proactive nudges) since this branch was cut.
Conflicts were both cases of two features adding adjacent schema/config
blocks — resolved by keeping both sides, not choosing one:

- agent/db.py: usage_events + chat_sessions table creation (from main)
  alongside savings_goals (from this branch).
- tests/conftest.py and scripts/seed_e2e_data.py: TRUNCATE lists needed
  every new table name from both sides, not just one.

No logic changes beyond the merge itself.
derekl-beep pushed a commit that referenced this pull request Jul 16, 2026
main moved forward with PRs #50 (usage instrumentation + session
persistence) and #51 (proactive nudges) since this branch was cut.
The one real conflict was tests/test_agent.py, where both this branch
(a Sentry capture_exception test) and main (serialize_block/session_lock
tests) added new tests at the same location — kept both, no logic
changes beyond the merge itself.
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