feat: user differentiation — identify, filter, and label users across the gateway - #580
PeterStoica wants to merge 28 commits into
Conversation
Add extract_user_hash() to pipeline/session.py. Parses API key mode user_id (user_<hash>_account__session_<uuid>) to extract the stable user hash; falls back to SHA-256 of the credential value for OAuth mode and other cases where no structured user ID is present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move test file to pipeline/ subdir to mirror source structure - Add Args: section to extract_user_hash docstring - Extract [:16] truncation into _CREDENTIAL_HASH_LENGTH constant - Add test for OAuth mode without credential (returns None) - Add test for unrecognized user_id format falling back to credential Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wire user_hash alongside session_id through the request processing pipeline so it reaches the database in both conversation_calls and request_logs tables. - Extract user_hash in _process_request(), resolve credential fallback in process_anthropic_request() - Add user_hash to all emitter.record() event data dicts - Store user_hash in conversation_calls upsert (emitter.py) - Store user_hash in request_logs INSERT (recorder.py) - Propagate user_hash from inbound to outbound request logs - Set luthien.user_hash span attribute for OTel tracing - Update existing tests for new return value and constructor arg Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add user_hash field to RequestLogEntry and RequestLogDetailResponse models, add user_hash filter parameter to list_request_logs() service function, and expose it as a query param on GET /request-logs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Design for Fix 4 from the load investigation — buffer DB writes off the request hot path with a bounded queue and background batch drain loop. Addresses the OOM crash on AWS (gateway hit 1 GB cgroup limit from unbounded asyncio.Task accumulation). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace unbounded fire-and-forget asyncio.create_task per event with: - Bounded asyncio.Queue (default 10K items, ~10-30 MB ceiling) - Single background drain task with batch DB writes - Inline stdout + SSE publishing (unchanged latency) - Queue overflow drops newest event with throttled warning log This directly addresses the AWS OOM crash where the gateway hit the 1 GB cgroup limit from unbounded task accumulation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Call emitter.start() on gateway startup, shutdown() on teardown - Remove old _write_db() and _write_stdout() async methods (replaced by _write_db_batch and _write_stdout_sync) - Remove unused asyncpg import - Update tests to use _write_db_batch instead of removed _write_db Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load investigation Fix 1: the events page requested up to 10,000 sessions even though there are typically a few dozen. This caused unnecessary Pydantic object construction and JSON encoding overhead. At c=16 under DebugLogging, this alone cuts events page latency from 525ms to 207ms (-60%). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load investigation Fix 5: under chatty policies the SSE stream delivers ~366 events/sec, overwhelming the browser DOM. Events are now batched in 100ms intervals with a 500-event pending cap. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load investigation Fix 3: replace expensive GROUP BY over entire conversation_events table with a pre-computed session_summaries table. Updated incrementally by the EventEmitter drain loop. - Add migration 013 for both Postgres and SQLite with backfill - Update _write_db_batch to incrementally maintain summaries - Rewrite both PG and SQLite fetch_session_list to query summaries - Update test fixtures to populate session_summaries Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…story page The session list endpoint was making 2 expensive queries against conversation_events per page load (model names + preview messages), using only a single-column session_id index. On Postgres with large datasets this caused 7+ second load times. Now: models_used and preview_message are stored directly in session_summaries, populated at write time by the EventEmitter drain loop. The session list is a single indexed query. Also adds a composite index on (session_id, event_type) as a safety net. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
CHANGELOG reminder — This PR has no changelog fragment. Add a file to |
PR Review: feat: user differentiationOverall this is a well-structured, substantial feature PR. The user hash extraction, session summaries materialization, bounded EventEmitter, and user labeling are all solid additions. Here's a detailed review: Code Quality & ArchitectureStrengths:
Issues to address:
Potential Bugs
Security
Performance
Test CoverageGood coverage for:
Missing test coverage:
These are all new public-facing code paths that should have unit tests per the repo's testing requirements. Minor Nits
SummaryThis is solid foundational work for user differentiation. The main concerns are:
None of these are blocking, but items 2 and 3 should ideally be addressed before merge. |
- Unify duplicate PG/SQLite _fetch_session_list functions into one
- Cap URL-prefix usernames at 64 chars in UserPrefixMiddleware
- Cache auto-label writes to avoid redundant INSERTs per request
- Replace busy-wait in emit() with asyncio.Event signaling
- Use proper `import re` instead of __import__("re")
- Filter empty text blocks when joining Anthropic content for preview
- Deduplicate models_used against existing CSV before upsert
- Add tests for UserPrefixMiddleware, user label routes, and
_extract_session_metadata
- Add changelog fragment
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review: PR #580 — User DifferentiationThanks for a substantial feature set. The user hash extraction, session summaries materialization, and bounded EventEmitter are well-motivated and address real production issues (the OOM from unbounded task accumulation, the 10s history page query). Below is structured feedback. Scope concern — One PR = One ConcernThis PR bundles at least 4 independently reviewable/mergeable concerns:
Per the repo's own CLAUDE.md guidelines, bug fixes bundled into feature PRs bypass the COE process. Items 2–4 are infrastructure/performance fixes that could each be their own PR with independent review and rollback. Bugs / Correctness1. 2. Memory leak: 3. N+1 query in 4. models_used CSV concatenation can produce duplicates across batches 5. Inconsistent preview truncation length 6. Security7. No length validation on 8. User hash regex extracts unsanitized content 9. Auto-label silently swallows all exceptions Performance10. 11. Two separate queries for count + paginated list 12. Session summaries Migration Quality13. Postgres/SQLite parity is correctly handled — the SQL dialect differences ( 14. Migration 014 doesn't backfill Test Coverage15. Missing test: user_hash filtering in 16. Missing test: 17. Good coverage overall — The Minor / Style
SummaryMust fix before merge:
Strongly recommended:
Consider for follow-up:
|
- Move dropped_db_writes from class to instance variable to prevent cross-instance/cross-test state pollution - Cap UserLabelRequest.display_name at 255 chars - Bound _labeled_user_hashes cache at 10k entries with FIFO eviction - Collapse N+1 SELECT in _update_session_summaries into one IN query - Make emit() wait for exactly one drain cycle (with timeout) instead of polling queue.empty() — previously could block indefinitely under concurrent record() calls - Paginate /api/history/users (default 500, max 5000) - Raise auto-label exception log level from DEBUG to WARNING so DB misconfigurations aren't silently swallowed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR #580 Code Review — User DifferentiationThis is a substantial feature PR (3175 additions, 39 files). The core design is sound — extracting user hashes from request metadata, materializing session summaries for fast history queries, and adding bounded queue semantics to the EventEmitter are all well-motivated. Below are the issues found, grouped by severity. Critical / Must-Fix1. EventEmitter shutdown can lose events (emitter.py:241-260)After Suggestion: Instead of self._shutting_down = True
# Signal the drain loop to exit after processing remaining items
await self._drain_task # no cancel — loop checks flag and returns2.
|
Real bugs: - EventEmitter.shutdown() no longer cancels the drain task mid-write. Uses a _shutting_down flag; drain loop exits after the queue empties. Falls back to cancel only if the drain exceeds shutdown_drain_timeout_s. Previously, events consumed from the queue but not yet committed to DB were silently lost on shutdown. - list_users labels query is now scoped to the returned user hashes via an IN clause; previously every call fetched every row in user_labels. - Auto-label cache no longer has a check-then-act race across its await. Mark the cache entry optimistically before the DB call, pop it on failure so concurrent coroutines don't all issue duplicate INSERTs. - editUserLabel JS now checks response.ok, rolls back the optimistic client-side update on failure, and surfaces an error alert. - User badge click handler moved from inline onclick='...' string interpolation to a data-user-hash attribute + event-delegated addEventListener. escapeHtml() does not escape single quotes, so the interpolation form was a latent defense-in-depth issue. Tests: - Regression test for shutdown flushing remaining queued events. - user_hash filter coverage in fetch_session_list SQLite tests. - list_users labels-scoped-to-returned-users regression test. Verified false positives from the review (not fixed): - `CREATE INDEX CONCURRENTLY` in migration 014: psql -f runs each statement in autocommit by default; the migration runner does not wrap in a transaction, so this works as-is. - SQLite ALTER TABLE ADD COLUMN lacks IF NOT EXISTS: SQLite does not support that syntax, and the migration runner tracks applied migrations by filename so re-runs are already prevented. Pre-existing on main, out of scope for this PR: - XSS in activity_monitor.js filter dropdown innerHTML (line 134/148 exists on main) - LIKE wildcard escaping in request_log search (unchanged by this branch; only the user_hash filter field was added) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR Review: feat: user differentiation — identify, filter, and label users across the gatewayOverall AssessmentThis is a well-structured PR that adds multi-user identification and filtering across the gateway. The architecture is sound — extracting user hashes from request metadata, propagating them through the event pipeline, and materializing session summaries for fast queries. The test coverage is thorough and the migration pairs (Postgres + SQLite) are correctly matched. That said, I have concerns ranging from moderate bugs to security considerations. See below. Issues1. Global mutable state for auto-labeling cache (moderate concern)
_LABEL_CACHE_MAX_SIZE = 10_000
_labeled_user_hashes: OrderedDict[tuple[str, str], None] = OrderedDict()This module-level In practice this is low-severity (the ON CONFLICT upsert is idempotent), but the stale cache entries could grow unbounded across process restarts. Consider whether this cache should live on the 2.
|
Fixes found by local self-review before pushing:
- **Username character validation** (main.py): restrict `/u/{name}/`
username to `[A-Za-z0-9._-]+` before storing on request.state.
Defense-in-depth against stored-XSS via the display_name column
even though the SQL is parameterized and the UI uses escapeHtml.
Path is still stripped when the username is invalid so routing
continues to work; the label is simply not recorded.
- **History page pagination + user filter** (history_list.html,
history/routes.py): the fetch limit=50 optimization meant the
client-side user filter only saw the first 50 sessions, so users
whose sessions weren't in the first page were invisible. Fix:
populate the user-filter dropdown from `/api/history/users`
(full user list) and refetch `/api/history/sessions?user_hash=`
server-side when a user is selected. Add a "showing X of Y"
indicator so the remaining client-side date/search filter
scope is obvious. Also switch list_users to query
session_summaries (one row per session, indexed) instead of
conversation_calls (one row per call), significantly faster on
deployments with many calls per session.
- **Cache rollback on upsert failure** (anthropic_processor.py):
previously popped the cache entry in the except branch, which
in a rare case where another coroutine had already written
successfully would force an unnecessary repeat write. Since the
upsert is idempotent and retrying on every subsequent request
would hammer the DB during an outage, we now keep the cache
entry on failure and accept losing at most one label attempt.
The label can be reapplied on process restart (in-memory cache)
or manually via the history UI.
- **emit() simplification** (emitter.py): the `_batch_drained`
event added to replace the earlier spin-wait had a subtle race —
a concurrent drain-loop `set()` between `clear()` and `record()`
could cause `emit()` to return before its own record was
written. Since emit() has no production callers (only tests,
which explicitly sleep afterwards), collapse it to a thin
record() + `asyncio.sleep(0)` wrapper with a docstring
explaining that callers must wait for drain themselves. Remove
the unused `_batch_drained` field and drain-loop signaling.
- **Migration 014 CONCURRENTLY comment**: add a SQL comment
explaining why `CREATE INDEX CONCURRENTLY` is safe with our
migration runner (docker/run-migrations.sh runs each file via
`psql -f` in autocommit mode — each statement is its own
top-level transaction, satisfying the CONCURRENTLY requirement).
This is not a behavior change; it documents a genuine false
positive that the bot reviewer has flagged repeatedly.
- **New middleware tests**: parametrized tests for invalid
usernames (dropped but path still stripped) and valid ones
(stored verbatim).
Verified locally before push:
- 1997 unit tests pass
- pyright: 0 errors on all modified files
- ruff check and format pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review: PR #580 — User DifferentiationSubstantial, well-scoped PR. The user-hash propagation, bounded EventEmitter, and materialized Security — please address1. Stored XSS via user_hash (HIGH). …which gets stored as-is in `<span class=\"user-badge\" data-user-hash=\"${escapeHtml(session.user_hash)}\" ...>`
2. Stored XSS via session_id (MEDIUM). OAuth format ( Correctness3. Auto-label overwrites manual labels. In
Also worth noting: any authenticated client who controls their own 4. 5. Migration 014 uses 6. Migration 014 backfill only populates Performance / quality7. Drain-loop writes are serialized per-batch. 8. 9. Dropped-event log message ( Minor
What's good
Overall: block on (1) and (3), strongly recommend fixes for (2), (4), (5); the rest is polish. |
Security fixes: - escapeHtml: add " and ' escaping (textContent/innerHTML only escapes < > &, leaving attribute-context XSS via " breakout) - _USER_HASH_PATTERN: restrict capture group to [A-Za-z0-9_-] so adversarial metadata.user_id can't inject XSS into stored user_hash - viewSession: replace inline onclick with data-session-id + addEventListener (inline JS in HTML attributes is unsafe even with HTML-entity escaping because the browser decodes before eval) Correctness fixes: - Auto-label: ON CONFLICT DO NOTHING instead of DO UPDATE, so manually-set labels from the admin UI are never overwritten - GREATEST() SQLite translation: add to _translate_params alongside the existing LEAST() → MIN() translation - JSONL export: sanitize error detail to "Session not found." matching the markdown export (was leaking raw ValueError) Documentation: - Migration 014: document intentional omission of preview_message backfill (JSON parsing too expensive in SQL) Tests: - test_adversarial_user_id_rejects_special_chars - test_api_key_mode_with_dashes_in_hash Verified: 1999 unit tests pass, 0 pyright errors, ruff clean. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code Review — PR #580Thorough, well-tested work that materially improves observability and fixes a real OOM bug. A few things stood out. Strengths
Concerns1. User-hash spoofing in API-key mode (security). 2. Unsalted credential-hash fallback (security). 3. PR scope violates "One PR = One Concern" (CLAUDE.md). This bundles user differentiation + bounded EventEmitter + session_summaries materialization + AWS deploy script + user labeling UI. Each is substantial and could be reviewed/merged independently. The CLAUDE.md explicitly flags this pattern ("Bug fixes bundled into feature PRs bypass the COE process" — the bounded emitter is a fix for a production OOM). 4. Module-level 5. 6. Batch writes loop 7. Auto-label via 8. Backfill in migration 013 is a single blocking statement. On a large Nits
Overall this is solid work — the materialization rewrite and bounded emitter are both correct and well-tested. The user-hash spoofability is the one that deserves a response before merge. |
Address the blocking merge concern from PR #580 review round 6: - Document that user_hash is client-asserted (not cryptographically bound to the credential) and used only for observability grouping, not access control. Note HMAC as a future hardening option. - Add inline comment explaining the intentional DO NOTHING vs DO UPDATE asymmetry between auto-label and the PUT endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Code reviewOverall a well-documented feature with strong test coverage on the happy paths, solid security hygiene (allowlisted username regex, parameterized SQL, documented trust model for client-asserted Scope — violates "One PR = One Concern"
Each could be reviewed and merged independently. Any one of them reverting would be painful right now. The PR description also lists changes that are not in the diff (config system overhaul, Bugs / correctnessHistory UI pagination regression.
Auto-label path is untested. Auto-label revival after admin delete. Module-level global state leaks across tests. Migrations
Performance
SecurityNicely handled overall:
Test coverageStrong on the pieces that were tested:
Gaps:
Nits
SummaryStrong feature work with a real gap between what was asked (user differentiation) and what was shipped (user differentiation + emitter rewrite + deploy script). I'd push for at least splitting the emitter work into its own PR so it gets an independent review pass, and for adding tests on the auto-label hot path before merge. The pagination regression is the most user-visible issue and should be resolved before release. |
|
Closing this as the vehicle for user differentiation — it's become a poor one. This was a 40-file kitchen-sink PR, and roughly half of its diff has since landed on main independently: Already merged separately (now redundant here):
Still unmerged — the actual user-differentiation core:
Resurrecting this branch would mean rebasing 40 files across 600+ commits where half the changes conflict-or-no-op against work that already landed. Cleaner to reimplement just the user-differentiation slice on current main. A focused replacement PR is being prepared now and will be linked here. The Posted by Claude Code (Opus 4.8) |
…summaries - 019: user_id column + index on request_logs (mirrors conversation_calls) - 020: user_labels table (user_id -> display_name) - 021: session_summaries materialized table (counts, models_used, preview_message, user_id) with backfill of counts/models/user_id Adapts #580's user_hash design to current main, which already landed user_id on conversation_calls (PR #743). Uses user_id throughout for consistency. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Replacement PR opened: #780 — reimplements the user-differentiation core on current Posted by Claude Code (Opus 4.8) |
…nResearch#580) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
conversation_calls,request_logs,session_summariesuser_hashquery parameteruser_labelstablesession_summariestable with denormalizedmodelsandpreviewcolumns eliminates expensive JOINs for the history page (query dropped from ~10s to <100ms at scale)config_fields.pyas single source of truth,config_registry.pywith CLI > env > DB > default resolution and provenance tracking, auto-generatedsettings.pyand.env.example, plus/configadmin dashboardluthien restart,luthien agent-tutorialcommandsARCHITECTURE.md, canonicaldev-README.md, admin auth docs audit, policy authoring skillTest plan
uv run pytest tests/luthien_proxy/unit_tests./scripts/run_e2e.sh sqlite./scripts/run_e2e.sh mock/configshows all fields with provenancedev_checks.shpasses clean🤖 Generated with Claude Code