Skip to content

feat(ui): cursor pagination + lazy loading for admin dashboard - #752

Open
PaoloC68 wants to merge 59 commits into
mainfrom
perf-baseline
Open

feat(ui): cursor pagination + lazy loading for admin dashboard#752
PaoloC68 wants to merge 59 commits into
mainfrom
perf-baseline

Conversation

@PaoloC68

@PaoloC68 PaoloC68 commented May 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cursor-paginated infinite scroll for the history page. Fixes the admin dashboard slowness reported by Sami.

Depends on: PR #753 (perf harness + Server-Timing middleware)

Changes

Backend

  • Cursor pagination helpers (utils/cursor.py): HMAC-signed opaque base64url cursors for composite (last_ts, session_id) pagination
  • Fragment endpoints: GET /ui/fragments/sessions and GET /ui/fragments/sessions/{id}/turns — cursor-paginated HTML fragments (turns endpoint ships for future use; not yet wired into the conversation viewer)
  • Enriched session data: Fragment endpoint returns turn count, models used, policy interventions, formatted timestamps
  • Quick filters: filter=30days and filter=claude supported server-side
  • Postgres UUID fix: fetch_session_turns_page now binds cursor event ID as uuid.UUID type

Frontend

  • History page (history_list.html): Replaced ?limit=10000 fetch with Alpine AJAX infinite scroll (20 sessions/page). Session cards restored with full metadata + click-through links.
  • Conversation viewer (conversation_live.html + conversation_live.js): Restored full conversationViewer Alpine component. loadInitial() fetches the full session JSON via /api/history/sessions/{id} and renders structured turns. Conversation-page lazy loading (paginated turns on scroll) is deferred to a follow-up PR.
  • Debounced filter: 300ms debounce + server-side filtering
  • Memory fix: rawEvents[callId] capped at 50 events per call_id (FIFO)
  • Vendored JS: Alpine AJAX 0.12.7 + Alpine Intersect 3.15.12

Config

  • CURSOR_HMAC_KEY env var — auto-provisioned as a random secret on first boot; startup warning logged when the dev default is in use

Changelogs

  • changelog.d/perf-fix.md

@github-actions

Copy link
Copy Markdown
Contributor

CHANGELOG reminder — This PR has no changelog fragment.

Add a file to changelog.d/ (see changelog.d/README.md for format).
If this is a chore/infra-only change, add the skip-changelog or chore label to suppress this reminder.

@claude

claude Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review: nothing to review yet

This PR currently contains only the objective-setting empty commit (ce7649c) per the workflow in CLAUDE.md+0/-0 lines, no files changed. The objective per the commit message is "admin UI performance investigation and fix (PR #1 baseline)", but no investigation findings or code changes are present in the diff.

What I checked

  • gh pr diff 752 — empty
  • gh pr view 752 --json filesfiles: [], additions: 0, deletions: 0
  • Single commit on the branch is the chore: set objective ... empty commit

Suggested next steps

  1. Push the actual investigation/fix commits to perf-baseline so there's something to review.
  2. If this PR is intentionally the "baseline measurement" half of a two-PR split (baseline → fix), please:
    • Add a PR description explaining what "baseline" means here (e.g., baseline benchmarks/profiling data committed under dev/scratch/ or dev/archive/, or a recorded measurement methodology).
    • Note in the description what the companion PR will contain and how reviewers should evaluate the pair.
  3. Per CLAUDE.md → "One PR = One Concern": if the investigation surfaces a bug fix plus refactors plus a feature, plan to split them now rather than bundling — bug fixes bundled into feature PRs bypass the COE process.
  4. Add a changelog fragment to changelog.d/perf-baseline.md before marking ready (see changelog.d/README.md).

I'll re-review once there are real changes on the branch. Ping me (or re-trigger the review workflow) after pushing.

@claude

claude Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review: PR #752 — admin UI performance investigation (baseline)

This is foundational infrastructure for a perf-test suite — seeding, a Server-Timing middleware, an EXPLAIN dump script, and a runner. The structure is solid and the isolation discipline around the perf DB is excellent. A few concrete issues worth addressing before this is built on.

Blocking-ish

1. ServerTimingMiddleware is not wired into the app.
The middleware module is well-written and well-tested, but nothing in src/luthien_proxy/main.py adds it. As shipped, it emits no headers in production. If the intent is "land the infra, wire it later," that should be explicit in the PR body and tracked. Otherwise, this is dead code.

2. tests/luthien_proxy/perf_tests/conftest.py fixtures are stubs that return None.
Every fixture (perf_db_path, perf_gateway_url, browser, page, measure_time, sami_fixture_data) is just pass. Any perf test that requests them will get Nonewith measure_time() as timer: will raise AttributeError. The docstrings say "P9 will create...". Suggest: remove the stubs entirely until they're implemented, or make them pytest.skip("not yet implemented") so a perf test referencing them fails fast and loud.

3. Seeding payload sizes don't match docstrings.
src/luthien_proxy/perf/seeding.py claims ~5 KB JSON string for request and ~20 KB JSON string for response, but with _REQ_PAD = "A" * 50 and _RESP_PAD = "B" * 100 the actual payloads are ~750B and ~400B. Real conversation events on streaming responses are often 5–100KB+. Either bump the padding to match the docstring, or update the comments. As-is, baseline numbers from this seed under-represent real load by ~10×, which undermines the point of a baseline.

4. Missing changelog fragment.
CLAUDE.md is explicit: "Add a changelog fragment to changelog.d/" when wrapping up an objective. None was added for perf-baseline.

Code quality

5. Importing private symbol _apply_sqlite_migrations from another module.
src/luthien_proxy/perf/db.py does from luthien_proxy.utils.migration_check import _apply_sqlite_migrations. The leading underscore signals internal-only — coupling to it means a future refactor in migration_check.py silently breaks the perf subsystem. Either promote it to a public API in migration_check, or have the perf module own a tiny SQLite-migrations runner.

6. Index drop/recreate list in seeder drifts from schema.
_seed_sqlite drops 7 indexes before bulk insert. Migrations actually create at least 10 indexes on these two tables — you're missing idx_conversation_events_call_sequence, idx_conversation_events_session_id_btree, and idx_conversation_events_final_model. The bulk insert keeps writing into those 3 indexes, partially defeating the optimization, and the list will silently drift every time someone adds a new index. Better: introspect sqlite_master to discover and drop all indexes on the two tables, then recreate from sqlite_master.sql (or just re-run migrations on a fresh DB).

7. playwright install chromium --with-deps 2>/dev/null || true hides real failures.
scripts/run_perf.sh swallows stderr and ignores exit codes. If Playwright install fails (likely in CI without root for --with-deps), the script proceeds and the next command crashes with an inscrutable error. Drop the silencing, or log a warning when install fails.

8. psycopg2 is used in run_perf.sh --clean for postgres but isn't a dep.
The inline script does import psycopg2; it's not in pyproject.toml's dev group. The rest of the codebase uses asyncpg. Either switch the inline cleaner to asyncpg (matching drop_perf_db in db.py) or add psycopg2-binary to dev deps. As-is, anyone running --clean --backend postgres for the first time hits an install-it-yourself prompt.

Smaller things

  • scripts/perf_explain.py — the postgres branch prints SKIPPED and exits 0. A non-zero exit (or NotImplementedError) prevents a future CI workflow from passing silently against postgres.
  • src/luthien_proxy/perf/timing_middleware.py has no test for "time_phase called outside a middleware request silently discards." That behavior is documented and worth one assertion.
  • .sisyphus/evidence/baseline-query-plans.md introduces a .sisyphus/ directory with no README. Future readers will wonder what tool/convention this is. One sentence in the dir or in dev/context/ would help.
  • dev/context/migration_concurrent.md is a nicely-written audit but it's a one-off investigation snapshot, not persistent codebase knowledge. Per CLAUDE.md, dev/context/ is for patterns/architecture; this fits dev/archive/ better.
  • pytest-playwright>=0.5.0 plus playwright==1.50.0: pytest-playwright 0.7.2 (what locked) recently bumped its playwright floor — worth confirming compatibility, or pinning pytest-playwright too.
  • _BASE_TS = datetime(2025, 1, 1, ...) — shifting to a clearly-synthetic year (e.g. 2099) makes accidental contamination obvious in any debugger. Belt-and-suspenders since perf-seed-{tier}- already prefixes session ids.

What's good

  • Isolation enforcement (ensure_perf_isolation, refusing local.db, postgres perf_test schema requirement) is layered correctly: shell script, db.py, and seeding.py all check independently. Right paranoia for a tool that writes 500K+ rows.
  • Deterministic seeding (seeded random.Random) means drop+reseed is reproducible — exactly what a baseline needs.
  • ContextVar-based phase isolation in ServerTimingMiddleware is the correct primitive for concurrent ASGI, and test_concurrent_isolation is a great test.
  • Path-prefix filter on the middleware (excluding /v1/messages) avoids hot-path overhead.
  • The --seed-only, --clean, --assert-slo, --throttled flag matrix is well-thought-out.
  • Test coverage for the implemented modules (db.py, seeding.py, timing_middleware.py) is solid.

Summary

Land after: wiring the middleware (or splitting it into its own follow-up PR), removing/skip-ing the stub fixtures, fixing the payload-size mismatch, and adding the changelog fragment. The seeder's index-list drift and the _apply_sqlite_migrations import are fixable in a follow-up but worth a tracking ticket.

@claude

claude Bot commented May 14, 2026

Copy link
Copy Markdown
Contributor

Review: PR #752 — perf-baseline (Server-Timing + perf-tests tier)

Solid scaffolding overall. The isolation safeguards on the perf DB, the deterministic seeding, the cold/warm split in n_runs, and the contextvar-based timing isolation (with an explicit concurrent-isolation test) all reflect careful design. Notes below are mostly cleanup/risk callouts — nothing blocks the perf workflow from doing its job.

Bugs / Concerns

1. Unit-test tier imports Playwright at collection time

tests/luthien_proxy/unit_tests/perf/test_harness_helpers.py:5 does:

from tests.luthien_proxy.perf_tests.conftest import (
    PageLoadMetrics, ScrollFPSMetrics, _percentile, n_runs,
)

That conftest does from playwright.async_api import Browser, Page, async_playwright at module level (tests/luthien_proxy/perf_tests/conftest.py:22). Because the file lives under unit_tests/, it is not filtered by -m 'not perf' (it has no perf marker) and runs as part of the default suite. Any environment that installs the project without the dev dependency group (or where Playwright fails to import) will fail at collection. Either move these helpers into a shared, dep-free module, or push the test file into perf_tests/ with @pytest.mark.perf.

Additionally, n_runs / _percentile / dataclasses live in conftest.py, which is a pytest plugin file — importing it as a library is fragile. Recommend extracting PageLoadMetrics, ScrollFPSMetrics, RunStats, n_runs, _percentile into tests/luthien_proxy/perf_tests/harness.py (or src/luthien_proxy/perf/harness.py) and re-exporting from conftest.

2. Perf gateway fixture's event-loop cleanup is unsafe

tests/luthien_proxy/perf_tests/conftest.py:226-289:

db_pool = DatabasePool(perf_db_url)
cleanup_loop = asyncio.new_event_loop()
...
cleanup_loop.run_until_complete(db_pool.close())

The DB pool is created here but its internal connections are opened by the gateway's lifespan, running on uvicorn's loop in the daemon thread. Calling db_pool.close() from a different loop (cleanup_loop) is undefined behavior for asyncpg/aiosqlite — typically yields got Future ... attached to a different loop warnings or hangs at process exit. Cleaner pattern: stop the server (server.should_exit = True, join the thread), then close the pool inside the same thread via asyncio.run_coroutine_threadsafe on uvicorn's loop, or just let process exit handle it for the session-scoped fixture.

3. BaseHTTPMiddleware adds non-trivial overhead on every request

src/luthien_proxy/perf/timing_middleware.py:93. BaseHTTPMiddleware wraps each request in a streaming-buffer task (the well-known Starlette overhead is ~0.5–2ms per request and it can break upstream streaming back-pressure). Since this middleware is added on the hot path (app.add_middleware(ServerTimingMiddleware) in main.py:446), every /v1/messages request pays that tax just to do path.startswith(_TIMED_PREFIXES).

Recommend rewriting as a pure ASGI middleware (no BaseHTTPMiddleware) and short-circuiting non-matching paths to a direct await self.app(scope, receive, send). That's the standard "free fast path" pattern, and it also avoids the streaming-buffer bug.

4. Reaching into private migration internals

src/luthien_proxy/perf/db.py:115:

from luthien_proxy.utils.migration_check import _apply_sqlite_migrations

Calling a single-underscore private function from another package couples perf-tests to migration-runner implementation details. Either promote _apply_sqlite_migrations to a public name (apply_sqlite_migrations) or expose a small public wrapper.

5. get_perf_db_url("postgres") URL composition is brittle

src/luthien_proxy/perf/db.py:33:

separator = "&" if "?" in base_url else "?"
return f"{base_url}{separator}options=-csearch_path=perf_test"

This breaks if the URL already contains an options= query (overrides, ignored, or duplicated by the driver — depends on backend). It also assumes the URL has no fragment. Use urllib.parse.urlparse + parse_qs + urlencode to merge query params robustly.

6. Postgres surface is partly aspirational, partly wired up

  • migrate_perf_db("postgres")NotImplementedError (perf/db.py:109)
  • seed_sessions("sqlite", ...) is the only real seeder path; "postgres" raises NotImplementedError
  • scripts/run_perf.sh and scripts/perf_explain.py both accept --backend postgres, run migrate_perf_db, and would crash before doing anything useful

It's fine to ship Postgres as a stub, but the script --help text reads as if both backends work. Either flag postgres as "not yet implemented" in --help / argparse choices, or fail earlier in run_perf.sh for --backend postgres (outside of --clean).

7. Session-scoped seeded fixture uses real ~/.luthien/perf.db

tests/luthien_proxy/perf_tests/test_api_contract.py:22-34 constructs the DB path from Path.home() / ".luthien" / "perf.db" directly (not tmp_path or a fixture that yields the path). Combined with the session-scoped perf_gateway_url, this means the contract tests share state with the developer's real perf DB. The "check count == 0 then seed" guard avoids re-seeding, but it also means whoever runs perf tests first sets the fixture data forever — a stale or partially seeded DB silently invalidates subsequent runs.

Suggest seeding into a tmp dir or expose a "fresh seed" mode (e.g., reuse drop_perf_db before seeding when PERF_FRESH=1).

8. seed_sessions uses PRAGMA synchronous=OFF

perf/seeding.py:143. Fine for perf seeding, but worth a comment that a crash mid-seed can corrupt the file (current comment mentions WAL + bulk insert reasoning but not the durability tradeoff).

9. Snapshot tests record shape but don't enforce list-element uniformity

tests/luthien_proxy/perf_tests/test_api_contract.py:62 records the type of obj[0] only. If an endpoint starts returning a heterogeneous list, the snapshot still passes. Probably fine for now, but worth knowing — recommend at least walking all list elements and asserting they share a shape.

Smaller nits

  • scripts/perf_explain.py:22: sys.path.insert(0, str(_REPO_ROOT / "src")) is unnecessary when running with uv run — the package is already importable. Remove the path hack.
  • perf/seeding.py:241: total_rows = n_calls_total + 2 * n_calls_total reads awkwardly; 3 * n_calls_total (with a comment "calls + 2 events per call") is clearer.
  • tests/luthien_proxy/unit_tests/perf/test_harness_helpers.py:42-44: import asyncio inside the test body — move to module top.
  • perf/db.py:78-86: drop_perf_db("postgres") uses asyncio.run. Calling this from any code already inside a loop will raise. Document this or guard with a get_event_loop() check.
  • tests/luthien_proxy/perf_tests/conftest.py:171: page.set_default_timeout(10_000) mutates page state inside a measurement helper — surprising side effect. Pass timeout as a parameter or restore the prior value.
  • tests/luthien_proxy/perf_tests/conftest.py:134: wait_until="networkidle" will hang up to 30s on pages that hold open SSE streams (the admin UI does for activity events). Consider "domcontentloaded" plus an explicit mutation wait — which the harness already installs.
  • src/luthien_proxy/debug/service.py:344-356: MAX(session_id) as session_id inside a GROUP BY call_id works only because (call_id, session_id) is effectively 1:1. Worth a comment, or use ANY_VALUE(session_id) / SQLite's implicit first-row behavior with an explicit constraint comment.

Things that look good

  • The isolation gates (ensure_perf_isolation, the explicit local.db substring check, the Postgres perf_test schema requirement, and the shell-level guards in run_perf.sh) are layered defense — easy to praise.
  • test_concurrent_isolation in test_timing_middleware.py actually exercises the contextvar boundary — this is the right test to write and most authors skip it.
  • Cold/warm separation in n_runs is exactly right for benchmarking and the test (test_n_runs_separates_cold_cache) pins the behavior.
  • _seed_sqlite drops indexes before bulk insert and recreates after — significant win on seed throughput and the right idiom.
  • dev/context/migration_concurrent.md is exactly the kind of artifact that should live in dev/context/ per CLAUDE.md guidance.

Test coverage

  • perf/db.py, perf/seeding.py, perf/timing_middleware.py all have direct unit tests.
  • Integration coverage exists for the middleware's filtering behavior (test_server_timing.py).
  • Missing: a test for ensure_perf_isolation against local.db substring embedded mid-path (e.g., sqlite:///tmp/foo-local.db-backup) — current implementation would false-positive (it's a deliberately conservative substring check, but worth pinning in a test).
  • Missing: a unit test exercising time_phase outside a request context, asserting the silent-discard path is hit (the docstring promises it; no test pins it).

🤖 Generated with Claude Code

@PaoloC68
PaoloC68 marked this pull request as ready for review May 15, 2026 17:44
@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Review: PR #752 — perf-baseline (follow-up notes)

Two earlier reviews on this PR already cover most of the structural and code-quality issues — BaseHTTPMiddleware overhead, the _apply_sqlite_migrations private-import, the playwright-at-collection-time import, the conftest cross-loop db_pool.close(), the URL composition edge case for postgres, the ~/.luthien/perf.db session-fixture sharing dev state, the unused cleanup_loop, the index-list drift in _seed_sqlite, the MAX(session_id) GROUP BY in debug/service.py, etc. Rather than restating those, the notes below are things I'd flag that don't appear in those reviews.

New / unflagged issues

1. run_perf.sh examples use a malformed SQLite URL.
scripts/run_perf.sh:71-75 show DATABASE_URL=sqlite://\$HOME/.luthien/perf.db (two slashes). The error messages on lines 161/170 and the help text on line 68 use the correct sqlite:///\$HOME/... (three slashes). _sqlite_path() in perf/seeding.py:113 requires the sqlite:/// prefix and would raise ValueError. The shell isolation check only inspects for local.db so it passes; the fixture in conftest.py doesn't actually consume DATABASE_URL (it builds the URL itself via get_perf_db_url(\"sqlite\")), so the bad example happens to "work" today — but the moment anyone refactors the fixture to honor DATABASE_URL, the example will silently break. Fix the examples in the Examples: block to match the others.

2. Integration test asserts only the absence path.
tests/luthien_proxy/integration_tests/test_server_timing.py exercises /v1/messages and /health and verifies the header is not set. Nothing in the integration tier verifies the header is set on a real /api/history/* request through the full create_app(...) stack. The unit tests cover this with a synthetic FastAPI app, but the integration test is the place to catch "ServerTimingMiddleware was added in the wrong order" — e.g., if a future middleware short-circuits responses before reaching it. Suggest adding one positive case (call /api/history/sessions and assert \"Server-Timing\" in response.headers).

3. perf_report.py hardcodes "hotspot" candidates as if they're findings.
scripts/perf_report.py:248-258 embeds a list of suspected hotspots referencing specific line numbers in history_list.html and conversation_live.js. These are presented under "Top Hotspots" → "Known candidates (from code review)" — but in any report where NO_DATA_YET applies, this section reads as authoritative findings. Two problems: (a) the line numbers will rot the moment those files change (they're already stale-prone), and (b) baseline reports should record measurement output, not pre-judged conclusions. Move the candidates list into the PR description or dev/scratch/, and let the report render only data-driven hotspots.

4. Heap-growth threshold (50%) is recorded but doesn't match the suspected-leak claim.
tests/luthien_proxy/perf_tests/test_sse_memory.py:122 asserts heap growth < 50% over a 60s hold when PERF_ASSERT_MEMORY=1. For the "unbounded rawEvents[callId] accumulation" hypothesis to be visible at 60s on a 442-message session, the rate of incoming SSE deltas matters a lot — a fresh load fetching existing history once will not exhibit accumulation; only a live session receiving streaming deltas will. As written, this measures heap-after-static-load, not heap growth under streaming load. Either inject simulated SSE events during the hold (e.g., kick off a /v1/messages request that streams) or rename the test/assertion to "heap stability while idle" so future-you isn't fooled by a low number that doesn't actually validate the suspected leak.

5. cleanup_loop is allocated and closed unconditionally even though it's only used on one error path.
tests/luthien_proxy/perf_tests/conftest.py:234: cleanup_loop = asyncio.new_event_loop() is created at fixture start and used in both the startup-failure error path and the normal teardown. Combined with the prior-flagged cross-loop close issue, this loop is doing nothing useful that asyncio.run(db_pool.close()) couldn't do in-place. Drop the variable when fixing the cross-loop issue.

6. "count > 0 means already seeded" gates are too loose across tests.
tests/luthien_proxy/perf_tests/test_api_contract.py:30-32, test_sse_memory.py:42-50, and test_transcript_open.py:82-86 each do "count rows; skip seeding if any." The sami and transcript fixtures correctly check their own prefix (perf-seed-sami-%, perf-seed-100-%), but seeded_perf_db in test_api_contract.py counts the entire conversation_calls table. If seeded_sami_sse (or any other prefix-aware fixture) seeds first, then seeded_perf_db sees nonzero rows and never seeds its own tier-100 set, so /api/history/sessions?limit=20 returns sami-like data and the contract snapshot may not match. Make the api-contract gate check for perf-seed-100-% specifically.

7. Payload-size SLOs in AGENTS.md aren't tested.
tests/luthien_proxy/perf_tests/AGENTS.md defines payload-size SLOs (50 KB / 100 KB gzipped) and _section_payload_size in perf_report.py knows how to render them, but no test actually measures gzipped response sizes. PR #1 is "record baseline," so this might be intentionally deferred — worth either (a) adding a minimal contract test that records len(gzip.compress(response.content)) to a perf-results JSON, or (b) explicitly noting in AGENTS.md / changelog that payload-size SLOs are P-next.

8. test_throttle_actually_throttles lacks a negative control.
The throttle-sanity test confirms throttling is active (TTFB > 100 ms after configuring 300 ms latency). There's no symmetric "unthrottled TTFB is under N ms on the same route" assertion to catch the case where Chromium silently ignores the CDP call (which has happened across Chromium versions). Add one cheap unthrottled-page baseline call alongside, or accept this as out of scope.

Smaller things

  • scripts/perf_report.py:54-58 tries sysctl -n hw.memsize (macOS) first, then falls back to /proc/meminfo (Linux). It works, but the order assumes the developer machine is a Mac. One-line comment ("sysctl path is macOS-only; Linux falls through to /proc") helps the next reader.
  • tests/luthien_proxy/perf_tests/test_api_contract.py:24-25 imports sqlite3 and from pathlib import Path inside the fixture body even though Path is already imported at module level on line 12. Inline import is unnecessary.
  • scripts/perf_report.py:341 fixes only the timestamp in --deterministic-mode; git_sha, playwright_version, and _ram_info() still vary, so byte-identical output is impossible. Either make those configurable, or rename the flag to --deterministic-timestamp.
  • src/luthien_proxy/history/service.py wraps multi-statement blocks in time_phase(\"db\") (the session list does fetchval + main query + user_ids lookup all under one phase). That's fine for top-line "db" duration, but sub-phases (db.count, db.list, db.user_ids) will be useful once you're looking at hotspots. Probably P-next.
  • src/luthien_proxy/perf/seeding.py:241 — prior review flagged the readability of n_calls_total + 2 * n_calls_total; agreed, prefer 3 * n_calls_total with the comment.

What's good (additions to prior reviews)

  • Choosing /health as the absence-check route was thoughtful — it's a common false-positive surface for "did I leak middleware behavior to a public endpoint."
  • Snapshot-shape tests via _extract_shape (types not values) is a nice middle ground between "no shape check" and "fragile equality check."
  • The add_init_script MutationObserver pattern in both measure_page_load and test_transcript_open.py is the right approach for capturing pre-JS first-paint — easy to get wrong, well-implemented here.
  • The CDP throttle sanity-check test is a great test to have written — most authors skip it.
  • Cold/warm separation in n_runs plus a unit test pinning the behavior is a careful baseline design.

Summary

Land-blockers from the prior reviews still stand: the playwright-at-collection-time import (second review's issue #1), the cross-loop db_pool.close(), the shared-state ~/.luthien/perf.db session fixture, and #6 above (loose seed gate). The other new items mostly become follow-up tickets — but #1 (URL typo) and #6 are easy wins to fix in this PR.

🤖 Generated with Claude Code

@claude

claude Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor

Review — admin UI performance investigation and fix

Thorough perf work — clear isolation contract for the perf DB, good test scaffolding, useful evidence artifacts. A few real issues to address before merge.

🔴 Bugs

1. Broken placeholder remapping when q + cursor are combined (postgres path)src/luthien_proxy/history/service.py:1186-1192

cursor_filter = cursor_filter.replace(\"\$2\", \"\$3\").replace(\"\$3\", \"\$4\")

Chained .replace() runs on the prior result, so ($2, $3) becomes ($3, $3)($4, $4). The cursor_ts placeholder is lost; both slots end up bound to cursor_sid. Under postgres this likely errors out (comparing timestamp against a string), or silently returns wrong pages.

Test coverage gap: test_filter_q exercises q without cursor, test_fragment_sessions_pagination exercises cursor without q. There's no test for the combination — this is the only state a user searching for sessions on page 2+ ever hits. Suggest: rebuild the filter from a single template using two named placeholders, or build the params list and assign indices in one place.

2. Infinite scroll only fires oncesrc/luthien_proxy/static/history_list.html:381 and src/luthien_proxy/static/conversation_live.html:925

<div x-intersect.once=\"loadNextPage()\" x-show=\"cursor !== null\" ...></div>

x-intersect.once binds for the element's lifetime. Once page 2 loads, the same sentinel node stays mounted; subsequent intersections never trigger loadNextPage. Users will see page 1 + page 2, then nothing — even though the new server-rendered sentinels carry valid cursors.

Either (a) drop .once and dedup loads via a flag, or (b) remove/replace the sentinel node after each load so a fresh x-intersect.once element gets observed. Bonus: conversation_live.html reads window.__turnsCursor from x-show — that's not reactive; Alpine won't re-evaluate on assignment.

3. Hardcoded HMAC key for signed cursorssrc/luthien_proxy/perf/cursor.py:17

_CURSOR_HMAC_KEY = b\"luthien-perf-cursor-key-dev\"

The comment acknowledges this should come from settings. With a static, public key, the signature provides no real tamper protection — anyone reading the repo can forge cursors. Pagination cursors aren't catastrophic to forge (just timestamps + session_ids), but if the intent is integrity, pull this from Settings (similar to existing credential_encryption_key) and fail loud if unset in production mode.

🟡 Code quality

4. Schema drift risk in seedingsrc/luthien_proxy/perf/seeding.py:150-234

The seeder hardcodes a list of indexes to drop/recreate. This duplicates knowledge of production migrations. If a new migration adds/removes an index, the seeder's perf measurements diverge from real-world behavior without anyone noticing. Consider re-running _apply_sqlite_migrations after bulk-insert to recreate indexes from the migrations themselves (a bit slower, but truthful).

5. Inconsistent time_phase(\"db\") coveragesrc/luthien_proxy/history/service.py

fetch_session_list, fetch_session_detail, fetch_call_events, etc. wrap their DB block in time_phase(\"db\"), but the new _fetch_session_turns_page and _fetch_sessions_page don't. These are the very endpoints the perf work targets — they should be measured consistently.

6. conversation_live.js blast radius is large — that file is now mixing initial-load HTML fragment fetching, SSE handling, presentation pipeline state, and an ad-hoc window.__turnsCursor global. Functional, but window.__sessionId and window.__turnsCursor as cross-module communication channels will bite later. Worth moving to component state on the Alpine conversationViewer instance.

7. Postgres branch unimplemented in perf modulemigrate_perf_db and seed_sessions raise NotImplementedError for postgres, but get_perf_db_url(\"postgres\") is fully wired. Anyone running run_perf.sh --backend postgres will get a confusing failure rather than at a config check. Either implement it or gate --backend postgres upfront with a clear message.

8. Cursor module nameluthien_proxy.perf.cursor is imported and used by history/service.py and ui/routes.py for production pagination, not just perf testing. The signed-cursor utility doesn't belong under perf/. Consider utils/cursor.py (and the perf-isolation/seeding code can stay under perf/).

🟢 What's working well

  • The ensure_perf_isolation contract with explicit local.db refusal is exactly the right safety gate, and --clean semantics are conservative (idempotent, IF EXISTS).
  • The ServerTimingMiddleware uses a ContextVar and only attaches to specific path prefixes — no impact on the hot /v1/messages path.
  • Index-drop-then-recreate during bulk seed is the right tradeoff.
  • The xss/escaping story in the new Jinja fragments is clean: {% autoescape true %} blocks, and _render_*_fragment is called with already-typed dicts.
  • Good test infrastructure: snapshot contracts, throttled-network harness, SSE memory test.

Test coverage gaps to plug

  • q + cursor combination on /ui/fragments/sessions (postgres + sqlite).
  • 3+ page infinite-scroll integration test (currently broken — see Remove PolicyEngine and inline Redis setup #2).
  • Tamper-proofing test currently flips one base64 char; consider also flipping a payload byte to assert the HMAC actually rejects payload tampering, not just truncation.

Happy to pair on any of these if helpful.

@claude

claude Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Review — admin UI performance investigation and fix

The previous four reviews on this PR cover the main structural issues well (signed-cursor key, BaseHTTPMiddleware overhead, q+cursor placeholder bug, _apply_sqlite_migrations private import, Postgres aspirational paths, schema-drift in the seeder, cross-loop db_pool.close(), etc.) — I won't restate them. The notes below are issues I didn't see flagged.

🔴 New bugs

1. filter query param is sent by the JS but the endpoint never reads it.
src/luthien_proxy/static/history_list.html:421 sends filter: this.currentFilter on every /ui/fragments/sessions request. src/luthien_proxy/ui/routes.py:262-269 only accepts limit, cursor, q — FastAPI silently drops the unknown param. So the three pill buttons (All / Last 30 days / Claude Code) at lines 368–370 update currentFilter, retrigger the fetch, and get back identical, unfiltered sessions. The button highlight toggles but nothing else changes.

Either wire filter through _fetch_sessions_page (server-side time/source filtering), or remove the buttons until they do something — leaving live UI controls that no-op is worse than hiding them.

2. Fragment template's CSS classes don't match the styled card classes — rendered rows are unstyled and the count bookkeeping is broken.
src/luthien_proxy/templates/fragments/sessions.html:4-6 outputs:

```html

... ...
\`\`\`

But history_list.html defines no .session-row or .session-id styling — only .session-card, .session-main, .session-meta, .session-time, .session-arrow, .session-preview (used for a different layout). Loaded sessions will render as plain unstyled text with no card chrome, no hover state, no timestamp/arrow column, no link affordance.

Worse, the JS counter at history_list.html:446 queries for the wrong class:

```javascript
const newSessions = Array.from(container.querySelectorAll('.session-card')).length - this.sessions.length;
for(let i=0; i<newSessions; i++) this.sessions.push({});
```

Since fragments produce .session-row (not .session-card), newSessions is always 0 - this.sessions.length (≤ 0), the loop never runs, this.sessions stays empty, and the empty-state at line 380 (x-show="!loading && sessions.length === 0 && !error") shows "No sessions found" rendered on top of the freshly loaded session list.

Suggest: rewrite fragments/sessions.html to emit .session-card with .session-main / .session-preview / .session-meta / .session-time / .session-arrow matching the existing CSS, and drop the brittle querySelectorAll('.session-card').length counter in favor of the server-rendered count of rows in the appended fragment.

3. Potential XSS in refreshTurns().
src/luthien_proxy/static/conversation_live.js:234:

```javascript
turnDiv.innerHTML = `active${callId}`;
```

callId flows from handleSSEEvent's event.call_id || event.id at line 185, which comes from the activity SSE stream. The events ultimately originate from request payloads, and the gateway doesn't constrain call_id to a UUID shape on every code path. Interpolating untrusted strings into innerHTML is unsafe even if the current emit-side happens to produce UUIDs. Use textContent and append element nodes instead.

4. Turns infinite-scroll mechanism in conversation_live.html is non-functional.
conversation_live.html:922-929:

```html

\`\`\`

Three compounding problems:

  • The inline x-data="{ cursor: null }" creates a local Alpine scope. \$data resolves to the local scope; \$data.loadMoreTurns is undefined. Even though parent conversationViewer() has loadMoreTurns, the \$data. prefix scopes the lookup. Use loadMoreTurns() (no \$data.) to let Alpine walk up to the parent scope — or drop the unused inline x-data entirely.
  • x-show="window.__turnsCursor !== null" is not Alpine-reactive — window.* writes don't trigger re-evaluation. Initial state: null !== null is false, so the sentinel is display:none from the start. The intersect handler will never fire because the element has zero box.
  • x-intersect.once (acknowledged by prior reviews on the sessions sentinel) is the wrong primitive for paginated scroll. After page 2, the same element with .once consumed won't trigger again.

Cumulative effect: turns pagination loads 0 additional pages.

5. cursor_ts.isoformat() may need an explicit cast under Postgres.
history/service.py:1099 passes cursor_ts.isoformat() as the \$2 parameter to (created_at, id) > (\$2, \$3). asyncpg infers parameter types from the prepared statement, and the inferred type for \$2 will be text, not timestamptz. Postgres rejects timestamptz > text without a cast. Either bind the datetime directly (let asyncpg encode it), or write (\$2::timestamptz, \$3). There is no integration test that exercises this branch under Postgres so the failure would be silent until production.

🟡 Smaller / observability

6. x-data instantiation style is inconsistent between the two pages.
history_list.html:341 uses x-data="sessionsList" (registered Alpine.data factory, no parens) while conversation_live.html:888 uses x-data="conversationViewer()" (called). Both work, but pick one — mixed style invites future bugs where someone copies the wrong pattern.

7. The "No sessions found" empty-state element overlays the fragment container.
history_list.html:374-381 nests fragments inside #sessions-list (line 375), then renders the empty-state div and load-more sentinel as siblings of that div. With issue #2 above, this guarantees "No sessions found" appears alongside loaded sessions. Even after fixing #2, mixing imperative DOM updates (insertAdjacentHTML into #sessions-list) with Alpine reactive sessions.length checks is a maintenance trap — pick one model.

8. fragment_session_turns and fragment_sessions decode the cursor twice.
ui/routes.py:240-244, 274-278 calls decode_cursor(cursor) purely to validate, then _fetch_session_turns_page / _fetch_sessions_page calls it again internally (history/service.py:1073, 1143, 1177). One decode is enough — pass the decoded (ts, id) tuple to the service, or do the validation inside the service and catch ValueError at the route.

9. The fragment endpoints have no time_phase("db") despite that being the entire point of this PR.
ui/routes.py:250-252, 284-286 wrap only the Jinja render in time_phase("render"). The underlying DB calls inside _fetch_sessions_page / _fetch_session_turns_page have no time_phase("db") block. The Server-Timing header on /ui/fragments/sessions will report only render;dur=... — the perf-investigation premise (DB hotspots in the session list) won't show up in the same observable. Add time_phase("db") around the conn.fetch(...) calls in both functions to match fetch_session_list / fetch_session_detail.

🟢 What's good (not already in prior reviews)

  • The fragment templates use {% autoescape true %} blocks explicitly, on top of select_autoescape(["html"]) in _jinja_env — belt-and-suspenders against XSS in server-rendered data.
  • _jinja_env is constructed module-level — avoids per-request template parsing overhead, which matters for the perf-tier routes.
  • Cursor validation happens at the route boundary with a clear 400 error message, so clients tampering with cursors don't get a 500.

Test coverage gaps to consider

🤖 Generated with Claude Code

PaoloC68 and others added 3 commits May 19, 2026 00:47
…ursor_hmac.key

auto_provision_defaults() was generating a fresh random key on every startup and writing it only to os.environ. Consequences: (1) every restart invalidated outstanding pagination cursors; (2) multi-worker deployments produced different keys per worker, causing intermittent 400s when a cursor minted by worker A was validated by worker B.

Fix: read the key from ~/.luthien/cursor_hmac.key on startup; generate and persist it there on first boot (mode 0o600). Subsequent restarts and sibling workers sharing the same filesystem get the same key.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ser_id guard

Two fixes in fetch_sessions_page:

1. SQLite 30days filter: created_at can be stored as '2025-01-15T10:00:00' (ISO T separator, as integration tests write) or '2025-01-15 10:00:00' (space separator, as the perf seeder writes). String comparison against datetime('now', '-30 days') output (space format) is alphabetically wrong for T-format timestamps because ' ' < 'T'. Wrap last_ts in datetime() to normalize both formats before comparison.

2. user_id parameter: add user_id: str | None = None to the signature for API symmetry with fetch_session_list. Raise NotImplementedError if a non-None value is passed — this makes the missing scoping an explicit contract violation rather than a silent data leak if the endpoint is ever wired to a non-admin context.

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@claude

claude Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

PR #752 Review

Thanks for the substantial work here — overall a solid, well-tested PR. The cursor pagination design is clean, the SQL-injection invariants are well-documented with inline comments, and the test coverage is good (cursor tamper, SQL injection through user_id, no-duplicates-across-pages with a tricky aggregation case, ContextVar isolation for timing). Auto-provisioning CURSOR_HMAC_KEY with a fallback warning is the right ergonomic choice. Concerns below, ordered roughly by impact.

Bugs / correctness

  1. Unvalidated UUID parse can produce 500 instead of 400src/luthien_proxy/history/service.py:1120

    cursor_id_param: str | _UUID = _UUID(cursor_event_id) if not db_pool.is_sqlite else cursor_event_id

    If a (legitimately HMAC-signed) cursor was generated against one backend and used against another (e.g. SQLite cursor reused after a Postgres migration, or a hand-crafted-for-test cursor with a non-UUID sid), _UUID(...) raises ValueError, which is not caught and surfaces as 500 from the /ui/fragments/sessions/{id}/turns route. Either wrap in try/except → HTTPException(400, "Invalid cursor"), or push backend-aware validation into decode_cursor (e.g. decode_cursor(token, expect_uuid_sid=True)) so all cursors flow through one validation point.

  2. The _UUID(...) code path has no test coverage. test_fragment_turns.py only exercises SQLite (the branch is bypassed there), and there is no unit test that constructs a Postgres-bound fetch_session_turns_page call. Worth a direct unit test or a mocked Postgres test — this is the named "Postgres UUID fix" in the PR description.

  3. Frontend search/filter racestatic/history_list.html:414 early-returns if this.loading is true, silently dropping the new request:

    async loadPage(cursor, clearList = false) {
        if (this.loading) return;
        ...
    }

    With the 300ms debounce + \$watch, the user can type a new query while a previous fetch is in flight; the new loadPage(null, true) is dropped. Track the latest pending search state and re-issue once the in-flight load completes, or use an AbortController to cancel and replace.

  4. snapshotExpandState scans the whole documentstatic/conversation_live.js:343-350 uses document.querySelectorAll('.visible[id]') etc., not scoped to #conversation-container. If any unrelated nav/header element ever picks up a .visible/.expanded/.open class, snapshot/restore will include or clobber it. Tighten the selectors to the container.

Performance

  1. fetch_sessions_page preview query fetches all matching events per session. service.py:1328-1338 selects (session_id, payload) from conversation_events for event_type = 'transaction.request_recorded', then _extract_preview_message is called only for the first row per session in Python (if sid not in previews). For a long session already on the page, that transfers a lot of redundant payload JSON over the wire. In Postgres consider DISTINCT ON (session_id) … ORDER BY session_id, created_at; in SQLite, a correlated subquery with LIMIT 1 per session (or a windowed ROW_NUMBER()). The SQLite preview lookup in _fetch_session_list_sqlite has the same shape. Pre-existing in spirit; worth a follow-up.

  2. filter=claude payload substring scan is acknowledged in the changelog, but '%claude-code%' will also match any user content containing the literal text "claude-code". A more reliable long-term signal is the user-agent header (stored on conversation_calls) or a structured client_type column.

  3. Conversation viewer refetches the full session JSON on every debounced event tick (conversation_live.js:195, 1s debounce). For a 442-call session that payload is large; on a noisy stream this can mean megabyte-scale fetches every second. Acknowledged as deferred in the PR description; flagging it as a real concern to track.

Security

  1. CURSOR_HMAC_KEY is auto-provisioned per-instance (main.py:730-741, into ~/.luthien/cursor_hmac.key). On multi-replica deployments that do not explicitly inject the env var, each replica gets a different key — cursors issued by replica A will 400 against replica B. Worth a one-liner in the warning ("In multi-replica deployments, set CURSOR_HMAC_KEY explicitly so cursors validate across replicas") and/or a note in .env.example.

  2. 8-byte HMAC truncation is fine given the documented threat model (admin-auth-gated, position-only) — comment in utils/cursor.py:40-43 is well-reasoned. No change needed.

  3. No upper bound on cursor token length in decode_cursor. Not exploitable in practice (admin-gated; FastAPI's default URL/header limits cap it), but a len(token) > _MAX_CURSOR_LEN short-circuit would make the error path cleaner if a misbehaving client floods with junk.

Style / nits

  1. Duplicate cursor decoderoutes.py:243-248 decodes purely for validation, then fetch_sessions_page / fetch_session_turns_page decodes again. Cheap, but the second decode could just trust the route-level check (or vice versa). Not worth holding the PR for.

  2. logger.debug(f"... {repr(e)}") (e.g. service.py:159) — minor; prefer logger.debug("...", exc_info=True) or logger.debug("...: %r", e) to let logging handle formatting / avoid formatting on disabled levels. Pre-existing pattern in this file.

  3. fetch_sessions_page(..., user_id=...) raises NotImplementedError unconditionally — nice defensive guard, but the parameter is otherwise dead code. Consider either landing user-scoping in this PR or dropping the parameter until needed (per CLAUDE.md "don't design for hypothetical future requirements"). Borderline — the docstring spells out the contract clearly.

Things I checked and liked

  • Composite (ts, sid) < (cursor_ts, cursor_sid) cursor semantics correctly handle tied timestamps; test_fetch_sessions_page_no_duplicates_across_pages catches the subtle CTE-filter-vs-aggregation trap.
  • ServerTimingMiddleware uses ContextVar.set/reset correctly for per-request isolation under concurrent load; restricted to /api/history/, /api/debug/, /ui/fragments/ so the /v1/messages hot path is untouched (with an integration test asserting absence).
  • Jinja2 fragment templates wrap output in {% autoescape true %} even though autoescape is also set on the Environment — belt-and-braces, good.
  • SQL-injection invariants on user_id are bound parameters, with explicit # SECURITY INVARIANT: comments and a regression test (test_fetch_session_list_user_filter_sql_injection).
  • Migration audit doc (dev/context/migration_concurrent.md) is exactly the kind of artifact that pays off across sessions.

Happy to dig deeper on any of the above — items 1–4 are the ones I'd want fixed before merge.

PaoloC68 added 2 commits May 19, 2026 23:32
- Catch ValueError from UUID parse in fetch_session_turns_page at the
  route level (ui/routes.py) so a cross-backend cursor returns 400
  instead of 500
- Fix search/filter race in history_list.html: store pending load state
  instead of silently dropping requests that arrive while a fetch is
  in-flight; re-issue after the current load completes
- Scope snapshotExpandState selectors to #conversation-container to
  avoid clobbering unrelated elements with .visible/.expanded/.open
- Add multi-replica note to .env.example for CURSOR_HMAC_KEY
The multi-replica warning belongs in config_fields.py (the source of
truth), not hand-edited into .env.example. Update the description and
regenerate so the test_env_example_matches_generator guard passes.
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #752 (Cursor pagination + lazy loading for admin dashboard)

Did a deep read of the new cursor module, history-service additions, fragment routes, JS, and tests. Overall this is a careful piece of work — strong security mindset around cursor signing and SQL injection, a regression test for ordering edge cases (test_fetch_sessions_page_no_duplicates_across_pages is exactly the right test to write), and the changelog candidly calls out the LIKE-scan known limitations. Most notes below are nits or hardening suggestions; only #1 is something I'd want fixed before merge.

🔴 High — fetch_session_turns_page rejects non-UUID event ids

src/luthien_proxy/history/service.py:1120-1123 unconditionally calls _UUID(cursor_event_id) on the cursor's event-id component:

try:
    cursor_id_param: str | _UUID = _UUID(cursor_event_id)
except ValueError as exc:
    raise ValueError(f"Invalid cursor: event id is not a valid UUID: {exc}") from exc

But conversation_events.id is TEXT PRIMARY KEY in the SQLite schema (migrations/sqlite/003_add_conversation_tables.sql:24) with no default — there is no guarantee the value parses as a UUID. The integration fixture at tests/luthien_proxy/integration_tests/test_fragment_turns.py:57 inserts ids like "event-frag-01", so test_fragment_turns_pagination will hit this raise on page 2 → 400 (the integration suite isn't in the default pytest selection, so it won't fail unless run with -m integration).

The fix referenced in the PR description ("now binds cursor event ID as uuid.UUID type") is correct for Postgres asyncpg — which rejects str-binding into a UUID column — but shouldn't be applied unconditionally. Suggested shape:

if db_pool.is_sqlite:
    cursor_id_param = cursor_event_id  # bind as str
else:
    cursor_id_param = _UUID(cursor_event_id)  # raise ValueError → 400 only here

🟡 Medium

Dev HMAC sentinel only warns, doesn't fail. main.py:198-204 logs a warning when CURSOR_HMAC_KEY == "luthien-perf-cursor-key-dev" but lets the gateway start. The key is in source, so anyone reading the repo can forge cursors. Cursors are admin-gated, so the practical blast radius is limited to forging pagination positions an admin could already query — but consider failing-closed in production (e.g. require an explicit LUTHIEN_ALLOW_DEV_CURSOR_KEY=1 opt-in) so a deploy that bypasses auto_provision_defaults() doesn't silently ship with a known key.

Empty preview latches at first event. history/service.py:1354-1359 (and the existing _fetch_session_list_sqlite path it mirrors) caches (raw or "")[:100] under if sid not in previews — so if the first ordered transaction.request_recorded event yields _extract_preview_message → None (e.g. content is all <system-reminder> and strips to empty), no subsequent event for that session is tried, and the session forever shows a blank preview. Not new in this PR, but the new code copies the pattern; worth a follow-up.

Preview shape divergence. fetch_session_list returns preview_message: str | None; fetch_sessions_page returns preview: str (empty when missing) via (raw or "")[:100]. Templates handle both, but two endpoints returning the same logical field with different null-handling will eventually trip someone up.

🟢 Low / nits

  • MAX_RAW_EVENTS_PER_CALL = 50 caps per call_id, not total (conversation_live.js:170). On a 442-message session the dict can still hold ~22k entries. Better than unbounded; a session-level cap (or LRU on call_ids) would actually bound memory.
  • Dropped raw events are invisible in the UI. Once you hit 50, oldest entries are silently shifted out and the count chip still says (50). A (50+) or (showing 50 of N) would set expectations.
  • q parameter has no min-length guard. q=a triggers a leading-wildcard LIKE with one-char selectivity; combined with the changelog caveat, requiring len(q) >= 2 would be cheap insurance against accidental DoS-by-typing.
  • Jinja {% autoescape true %} blocks in fragments/*.html are redundant — the env at ui/routes.py:35-38 already enables autoescape via select_autoescape(["html"]). Harmless, just noise.
  • fragments/sessions.html builds the URL with {{ session.session_id }} directly into the path. Session IDs in production are UUID-ish so this is safe in practice, but urlencode-style escaping would be more defensive against ids containing /, ?, or #.
  • history_list.html always sends q= even when empty. The backend treats empty q as falsy so this is fine, but stripping it client-side keeps logs/URLs cleaner.

✅ Things that are well done

  • HMAC signing of cursors with a thoughtful inline justification for 8-byte truncation (admin-gated, integrity not confidentiality). Right call for the threat model.
  • cursor_where_clause returning a clean (ts, sid) < (...) composite predicate so tied timestamps don't silently drop sessions. The regression test at test_service_sqlite.py:930 is exactly the test you'd write after getting bitten by this once.
  • _fetch_session_list_pg/_sqlite keep the conversation_calls join out of the hot path when no user filter is requested — the inline comment makes the perf intent obvious.
  • Auto-provisioning of CURSOR_HMAC_KEY to ~/.luthien/cursor_hmac.key with mode 0600 — good first-boot ergonomics without sacrificing prod safety (when the dev sentinel slips through, the warning fires).
  • time_phase context manager keyed on a ContextVar is the right shape; the Server-Timing middleware's prefix gate keeps the hot /v1/messages path zero-cost.

Operational

dev-checks is currently FAILURE on this branch (per gh pr checks 752). Worth resolving before flipping to ready-for-review — the failure may already cover some of what's noted above.


🤖 Generated with Claude Code

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code Review — PR #752 (cursor pagination + lazy loading)

Solid PR overall: the cursor design is sound, the perf harness is a real asset, and test coverage is broad. A few issues stood out — one likely blocker, several worth addressing before merge.

🔴 Likely bug — pagination broken for SQLite

history/service.py:1121 in fetch_session_turns_page unconditionally does _UUID(cursor_event_id). This is the "Postgres UUID fix" called out in the description, but it isn't gated by backend:

  • Postgres: conversation_events.id is UUID
  • SQLite: conversation_events.id is TEXT and the production INSERT in observability/emitter.py:266 omits the column entirely (no default → row gets NULL id, since SQLite's TEXT PRIMARY KEY doesn't disallow NULL). Even where ids are populated (perf/seeding.py:189 uses f\"{call_id}-req\", test_fragment_turns.py:57 uses event-frag-0X), they're not UUID-shaped.

Page 2 of /ui/fragments/sessions/{id}/turns will throw ValueError → HTTP 400 on any SQLite deployment, including the dockerless dev mode that --local enables. The PR description does say "turns endpoint ships for future use; not yet wired into the conversation viewer" — but it's still publicly mounted and the integration test test_fragment_turns_pagination asserts 200 on page 2, which I'd expect to fail. Worth verifying that test actually runs in the gate (it's marked integration; if integration tests aren't required, this slipped).

Fix: only _UUID(...) when not db_pool.is_sqlite, or bind the string and add an explicit cast in the Postgres SQL.

🟡 Notable

escapeHtml in conversation_live.js:4 doesn't escape \" or '. It uses the div.textContent → innerHTML trick, which only escapes <, >, &. The escaped value is then dropped into many double-quoted attribute contexts (data-call-id=\"${escapeHtml(turn.call_id)}\", data-toggle-raw=\"${eventKey}\", data-tool-call-id=\"...\"). Today these are all server-generated UUIDs and policy names, so not exploitable — but it's a latent XSS that will fire the moment a future change puts LLM- or user-controlled text into an attribute. Recommend a real attribute-safe escape that also handles \" and '.

fetch_sessions_page aggregates the entire table on every page. The CTE WITH sessions_agg AS (... GROUP BY session_id) runs before the cursor filter (history/service.py:1229 SQLite / 1278 Postgres). Cursor pagination eliminates render and network cost (the headline win) but not query cost — every page still scans all of conversation_events. For the stated goal of "fixing the admin dashboard slowness" this is partial; a conversation_sessions summary table or a (session_id, last_ts) rollup would be the real fix. The changelog acknowledges the q= and filter=claude limitations but not this base case.

.env.example leaks the dev sentinel as a copyable line. Line 110: # CURSOR_HMAC_KEY=luthien-perf-cursor-key-dev. The startup warning catches it at boot, but anyone who uncomments without reading the comment ships the public key. ConfigFieldMeta already supports dynamic_default=True for exactly this case (used by PROXY_VERSION) — flip it on so the generator emits a blank value with explanation.

Double cursor-decode: route handlers (ui/routes.py:245, 285) decode for validation, then fetch_*_page decodes again. Not a bug; just unnecessary. Either skip the validation pass or pass the decoded tuple through.

decode_cursor has a robustness gap. data[\"sid\"] raises TypeError if the payload JSON decodes to a non-dict (e.g. an int or null). The except clause catches (json.JSONDecodeError, KeyError, ValueError)TypeError isn't in there. Hard to trigger in practice given the HMAC check, but a leaked dev key would convert 400s into 500s. One-line fix.

fetch_sessions_page(user_id=...) raises NotImplementedError. Per CLAUDE.md's "don't design for hypothetical future requirements" — either delete the param or make the partial impl explicit. Soft footgun: callers get a runtime error instead of a type error.

🟢 Minor

  • cursor.py:21: isinstance(key, str) branch is dead — settings type is str. Remove.
  • filter=claude (history/service.py:1224, 1276): substring match on payload LIKE '%claude-code%' will false-positive on any user message containing the substring. Documented as slow but not as imprecise.
  • Postgres cursor query (history/service.py:1266) binds cursor_ts.isoformat() as a string against a timestamptz column. Asyncpg will coerce, but binding the datetime directly avoids the cast and any index-use surprises.

🟢 Strengths worth calling out

  • HMAC-signed cursors with an honest threat-model comment defending the 64-bit signature truncation.
  • ServerTimingMiddleware gated by path prefix → zero overhead on /v1/messages.
  • auto_provision_defaults persists CURSOR_HMAC_KEY to a 0o600 file so single-node deploys get a stable key across restarts.
  • Memory cap (50 events/call_id, FIFO) and SSE-event debouncing in conversation_live.js directly attack the runtime bloat the changelog calls out.
  • Broad test coverage: cursor unit tests, fragment integration tests, SSE regression test, perf harness with snapshots.
  • Clean separation from the perf-harness/Server-Timing infra in feat(perf): performance test harness, Server-Timing middleware, and baseline evidence #753 follows the "one PR = one concern" guidance from CLAUDE.md.

🤖 Generated with Claude Code

- Gate _UUID(cursor_event_id) on not db_pool.is_sqlite; SQLite event ids
  are plain strings (not UUIDs), so page 2 of the turns endpoint was
  throwing ValueError → 400 on all SQLite deployments
- Bind cursor_ts as datetime directly in Postgres query instead of
  isoformat string to avoid implicit cast and index surprises
- Add TypeError to decode_cursor except clause so a non-dict JSON
  payload (e.g. int or null) raises ValueError(400) not TypeError(500)
- Remove dead isinstance(key, str) branch in cursor._get_hmac_key;
  settings type is str, the else branch was unreachable
- Fix escapeHtml to also escape double and single quotes so values are
  safe in HTML attribute contexts, not just text nodes
- Mark CURSOR_HMAC_KEY as dynamic_default=True so .env.example emits a
  blank value instead of the dev sentinel; regenerate .env.example
- Remove dead user_id param from fetch_sessions_page; it raised
  NotImplementedError unconditionally and was never passed by any caller
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code Review

Solid PR overall — cursor design with HMAC signing is clean, the threat model is documented inline, and the perf trade-offs are called out in the changelog with "known limitations." A few items worth addressing before merge.

🐛 Likely bug — Postgres timestamp parameter type mismatch

In fetch_sessions_page (src/luthien_proxy/history/service.py:1259), the Postgres branch binds the cursor timestamp as an ISO string:

query_args.append(cursor_ts.isoformat())

But the companion fetch_session_turns_page at service.py:1137 deliberately distinguishes:

cursor_ts if not db_pool.is_sqlite else cursor_ts.isoformat(),

last_ts is a timestamp column; asyncpg expects a datetime for timestamp parameters. Passing a str will either error (DataError/InvalidParameterValue) or — worse — fall back to text-vs-text lexicographic comparison, which silently misorders pages around DST transitions, '+00:00' vs 'Z' representations, or rows that differ only in microseconds. Integration tests at tests/luthien_proxy/integration_tests/test_fragment_sessions.py:38 are SQLite-only, so this path isn't covered. Suggest mirroring the turns-endpoint pattern (cursor_ts for PG, cursor_ts.isoformat() for SQLite) and adding a PG-backed pagination test.

🐛 Minor — stats.events drifts from rawEvents total

src/luthien_proxy/static/conversation_live.js:181 unconditionally does this.stats.events++ on every SSE event, but lines 171–174 cap each bucket at 50 with shift(). After buckets fill, the displayed events count will diverge from Object.values(this.rawEvents).reduce(...) at line 301. Either drop the increment and recompute from rawEvents, or skip the increment when the cap evicted an event.

🟡 Minor — double truncation in preview path

service.py:1352:

previews[sid] = (raw or "")[:100]

_extract_preview_message already truncates to 100 chars and appends "..." (lines 351–352), producing up to 103 chars. The outer [:100] slices off the "..." suffix exactly when the message was long enough to need it — the user gets a clean cut with no indicator that the text was clipped. Either drop the second slice or have _extract_preview_message accept a max-len arg so the truncation is computed once.

🟡 Multi-replica caveat is documented but under-emphasized

config_fields.py:191-197 notes that auto-provisioned per-replica CURSOR_HMAC_KEYs will reject each other's cursors. This is correct, but it isn't mentioned in the PR body or changelog. Anyone deploying behind an LB without sticky sessions will see intermittent 400s. Worth adding one bullet to changelog.d/perf-fix.md under "Known limitations" and/or surfacing in the startup warning when multiple replicas are detected.

🟢 Nice-to-have observations

  • Redundant cursor validation: ui/routes.py:243-248 and ui/routes.py:283-288 decode the cursor and discard the result purely for validation; fetch_session_turns_page / fetch_sessions_page decode it again. Functionally fine (defense in depth), but the early decode could be removed if the inner ValueError → 400 mapping is uniform.
  • Brittle script ordering in static/history_list.html:500-505: the inline click-delegation <script> runs after the body, so #sessions-list exists, but a later refactor that defers this script could break it silently. Consider attaching the listener inside the Alpine component's init() instead.
  • Cursor + composite filter pushdown: (last_ts, session_id) < (...) is applied after the sessions_agg CTE on a GROUP BY session_id aggregation. The optimizer can't use a btree on conversation_events.created_at to skip rows before aggregation, so for deep pages on large tables every fetch still scans the full event history. For "small deployments" this is fine and the changelog says so, but worth filing as the follow-up alongside the search/index work already mentioned.
  • payload::text ILIKE '%claude-code%' (service.py:1269) materializes the entire JSON to text for every event. If a sessions table has long payloads this dominates the query cost. A gin_trgm_ops index on (payload::text) or a structured client_type column would help, as the changelog acknowledges.

✅ Things that look good

  • Cursor module (utils/cursor.py) is tight: HMAC-signed, base64url, constant-time compare, threat model documented in the truncation comment.
  • Tampering / short-token / round-trip tests in test_cursor.py are exactly the cases I'd want.
  • time_phase("db") / time_phase("render") instrumentation on the fragment endpoints, plus Server-Timing middleware gating only on /api/history/, /api/debug/, /ui/fragments/ — keeps the hot /v1/messages path untouched.
  • Jinja {% autoescape true %} on both fragments + data-cursor round-tripped through HMAC = no reflection-injection surface.
  • User-isolation invariants on the existing fetch_session_list_* paths are preserved (SQL-injection comments + the separate user_call_filter_lookups block at service.py:660-665).

Suggested follow-ups (out of scope, but worth tickets)

  1. PG-backed integration test for fragment pagination — would have caught the timestamp-string bug.
  2. Test for HMAC-key rotation: confirm existing-cursor → 400, new request → fresh cursor works.
  3. UX for "cursor invalidated mid-scroll" — currently the user just sees a 400 in console; consider auto-recovering by re-fetching from page 1.

🤖 Generated with Claude Code

- Bind cursor_ts as datetime (not isoformat string) in Postgres branch
  of fetch_sessions_page; the turns endpoint already did this correctly,
  sessions page was inconsistent and risked lexicographic comparison
  instead of timestamp comparison
- Drop stats.events++ in handleSSEEvent; updateStats() already
  recomputes the count from rawEvents, so the increment drifted after
  the FIFO cap evicted events from a bucket
- Drop outer [:100] slice on preview text; _extract_preview_message
  already truncates to 100 chars and appends '...', the second slice
  was silently removing the ellipsis on long messages
- Add multi-replica CURSOR_HMAC_KEY caveat to changelog known
  limitations
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code Review — Cursor Pagination + Lazy Loading

Overall a clean, well-scoped change. Cursor design is solid: HMAC-signed, opaque, idempotent, with tamper-rejection tests. The N+1 avoidance pattern in fetch_sessions_page (batched preview/model lookups keyed on the page's session_ids) is the right shape. Good defense-in-depth — dev-key warning at startup, key auto-provisioned to ~/.luthien/cursor_hmac.key with mode 0600, all new fragment endpoints behind check_auth_or_redirect. Changelog is unusually candid about known limitations, which I appreciate.

A few things worth addressing — none are blockers but several are worth fixing before merge:

1. Cursors aren't scoped to the endpoint that issued them (medium)

A cursor produced by /ui/fragments/sessions encodes (last_ts, session_id). A cursor from /ui/fragments/sessions/{id}/turns encodes (last_ts, event_id). Both pass the same HMAC check, so the routes can't tell them apart.

  • On Postgres, a sessions cursor sent to turns hits _UUID(cursor_event_id) and raises (caught → 400). OK.
  • On SQLite (history/service.py:1120-1140), cursor_id_param is a raw string; the query becomes (created_at, id) > ($2, $3) with a session_id string in the id slot. No error, just wrong results.

Easy fix: add a "kind" tag to the cursor payload ("sessions" vs "turns") and verify on decode. Or expose two encode functions with derived sub-keys via HKDF.

2. filter=claude is a full-table payload scan (medium, acknowledged)

payload::text ILIKE '%claude-code%' inside an IN (SELECT DISTINCT session_id …) subquery (history/service.py:1268-1269) cannot use an index and scans every event row on every page load. The PR's stated goal is fixing admin dashboard slowness; this filter is a footgun that will reintroduce it on production-sized Postgres. Acknowledged in the changelog, but worth a follow-up Trello card to extract client_type into a typed column with a btree index.

3. conversation_live.html vendors Alpine AJAX + Intersect that it doesn't use yet

conversation_live.html:929-930 adds the two <script defer …> tags, but the PR description explicitly says lazy-loading the conversation viewer is deferred. ~50 KB extra JS per page load for no functionality. Move these <script> lines to the follow-up PR that actually wires the lazy loader.

4. loadInitial() lost its 404-specific message (conversation_live.js:98-120)

Previous code distinguished "Conversation not found" from generic errors. Now every non-redirect failure shows "Failed to load conversation. Please refresh." Lossy for users who arrived at a stale or deleted session URL. Cheap to restore:

if (resp.status === 404) throw new Error('Conversation not found');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);

5. rawEvents per-call cap doesn't cap call_ids

MAX_RAW_EVENTS_PER_CALL = 50 bounds events per call, but this.rawEvents is keyed on call_id with no eviction. A session with thousands of turns will keep growing. Probably fine for current sessions but worth an LRU cap if you expect long-running dashboards.

6. cursor_hmac.key write is not atomic (main.py:737-741)

If two processes start concurrently (uvicorn reload, parallel workers), one can read a partial file. Use:

with tempfile.NamedTemporaryFile(dir=data_dir, delete=False) as tmp:
    tmp.write(value.encode())
os.chmod(tmp.name, 0o600)
os.replace(tmp.name, key_path)

7. auto_provision_defaults() is __main__-only

Anyone calling create_app() directly (custom embedding, some test paths) lands on the public dev sentinel "luthien-perf-cursor-key-dev". The lifespan warning catches it at runtime, which is the right safety net. Worth noting in the create_app docstring so embedders know they need to set CURSOR_HMAC_KEY themselves.

8. cursor_where_clause() advertises named placeholders that no caller uses

The helper returns (ts_col, sid_col) < (:cursor_ts, :cursor_sid), but fetch_sessions_page immediately .replace(':cursor_ts', '?') to convert. The named-param API is misleading — used in exactly one place with string replacement. Either inline the fragment or have the helper return the backend-correct placeholders directly.

9. Double decode_cursor per request

Routes (ui/routes.py:244-247 and ui/routes.py:284-287) decode the cursor to validate, then fetch_sessions_page decodes it again. Cheap, but redundant. Pass (ts, key) through the service signature.

10. Cursor positions on MAX(created_at), which can shift

next_cursor is encoded from the aggregated last_ts. A new event on an older session bumps its last_ts and can either re-surface or skip that session across page boundaries. Acceptable for an activity dashboard, but worth a one-line comment explaining the "active-time" semantics so future readers don't think it's a bug.

Tests

Unit coverage on cursor.py is good (roundtrip, tamper, idempotent, microseconds, mixed key types). Integration tests cover happy path, bad cursor → 400, unauth → 303, pagination disjointness, and q-filter. Missing: a test that explicitly mixes endpoint cursors (issue #1 above) — and one that exercises the SQLite (created_at, id) > (?, ?) branch in fetch_session_turns_page with cursor_id_param as a string.

Nits

  • escapeHtml quote-fix at conversation_live.js:10 is correct and necessary for attribute-context usage. Good.
  • _escape_like correctly handles \, %, _. Good.
  • MAX_RAW_EVENTS_PER_CALL capitalization at module top is a nice signal for a tunable constant.
  • 64-bit HMAC truncation is appropriate for the documented threat model; the rationale comment is worth keeping.

PaoloC68 added 2 commits May 20, 2026 00:27
- Add "kind" field to cursor payload (sessions/turns); decode_cursor now
  requires the expected kind and raises ValueError on mismatch, preventing
  a sessions cursor from silently producing wrong results on the turns
  endpoint (SQLite path had no UUID check to catch it)
- Remove cursor_where_clause() helper; it returned named placeholders that
  the single caller immediately replaced with positional ones via string
  replacement — misleading API. Inline the fragment directly.
- Fix non-atomic cursor_hmac.key write: use NamedTemporaryFile + os.replace
  so concurrent startup (uvicorn reload, parallel workers) cannot read a
  partial file
- Remove unused Alpine AJAX + Intersect scripts from conversation_live.html;
  lazy-loading is deferred to a follow-up PR, no need to ship ~50 KB of JS
- Restore 404-specific error message in loadInitial(); generic "please
  refresh" was shown for stale/deleted session URLs
- Add comment explaining active-time cursor semantics in fetch_sessions_page
- Update test_cursor.py: add kind= to all encode/decode calls, replace the
  two cursor_where_clause tests with test_wrong_kind_rejected
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Code review notes

Overall a strong PR: cursor design is clean, SQL is parameterised, fragment templates use autoescape, and the test_fetch_sessions_page_no_duplicates_across_pages test catches exactly the class of CTE-filter bug that's easy to introduce here. The "known limitation" notes in changelog.d/perf-fix.md cover the most important caveats. A few things worth fixing or following up on:

Bugs / correctness

  1. CURSOR_HMAC_KEY is printed to stdout on first boot. auto_provision_defaults adds it to the provisioned dict (src/luthien_proxy/main.py:745-746), and main() then prints every entry with its value (main.py:839-841). Unlike ADMIN_API_KEY (regenerated each run, so operators need it printed), CURSOR_HMAC_KEY is persisted to ~/.luthien/cursor_hmac.key mode 0600 — printing the secret weakens that. Suggest either suppressing this key from the announcement, or printing <auto-generated, stored in ~/.luthien/cursor_hmac.key> instead of the literal value.

  2. stats.events counts under-report after FIFO eviction kicks in. conversation_live.js:300 sums Object.values(this.rawEvents).map(b => b.length), but each bucket is capped at 50 (line 173-175). On a busy session the displayed count appears to plateau even as events keep streaming. Either rename the stat ("Visible events") or keep a separate _totalReceived counter alongside the FIFO buffer.

  3. presentTurns cumulative-array invariant fails silently. conversation_live.js:275-281 slices messages.slice(prevRealMsgCount); if a turn ever has fewer cumulative messages than the previous turn the slice is empty and we render an empty turn body, with only a console.warn. If this is ever hit in production no operator will see it. Recommend falling back to messages (the full list) when messages.length < prevRealMsgCount, and surfacing it via an emitter rather than console.warn.

  4. turns.html sets data-event-id and data-last-event-id to the same value on every row. Either one is redundant, or data-last-event-id was meant for only the last row.

Performance

  1. Preview / model lookup queries scan all transaction.request_recorded events per session. In fetch_sessions_page, preview_rows and model_rows (history/service.py:1323-1344) fetch every request_recorded event for every session on the page, only to keep the first preview and dedupe models. For sessions with hundreds of turns this is wasteful — Postgres has DISTINCT ON (session_id) (used elsewhere in this file for _fetch_session_list_pg); SQLite can use MIN(created_at) correlated subqueries or a GROUP BY session_id. Tightening this should be a meaningful cursor-pagination win.

  2. Cursor is decoded twice on the hot path. routes.py:285-288 validates with decode_cursor, then fetch_sessions_page decodes again at line 1208 (and same in the turns path). Minor — pass the decoded (ts, sid) through.

  3. Conversation viewer re-fetches the full session JSON on every SSE refresh (conversation_live.js:196-204). The PR description acknowledges paginated turn loading is deferred to a follow-up — worth tracking, because for the 442-message session this was scoped to fix, the in-page refresh is still O(session size).

Operational

  1. Multi-replica auto-provisioned HMAC key is documented but not surfaced at startup. When RAILWAY_SERVICE_NAME (or any PaaS marker) is set and CURSOR_HMAC_KEY was auto-provisioned rather than env-supplied, consider an explicit logger.warning("CURSOR_HMAC_KEY auto-generated on this replica — set it in env for multi-replica deployments"). Right now operators only see the dev-sentinel warning (main.py:200-205), which doesn't fire on auto-generated keys.

Test coverage

  1. fetch_sessions_page Postgres branch is uncovered by unit tests. tests/luthien_proxy/unit_tests/history/test_service_sqlite.py only exercises the SQLite branch; the PG-specific placeholder arithmetic in the cursor/q/quick_filter assembly is non-trivial and would benefit from at least one Postgres unit/integration test.

  2. The "non-UUID cursor on Postgres" guard in fetch_session_turns_page (service.py:1123-1126) has no test. The outer routes.py decode_cursor check is tested, but a cursor whose payload decodes cleanly but contains a non-UUID sid (cross-kind forgery isn't possible thanks to the kind field, but a stale or hand-rolled cursor could trigger this) is not exercised.

Style / minor

  1. WHERE 1=1 in fetch_sessions_page works but reads as a stub — could be cleaner to assemble the WHERE conjuncts and only emit WHERE when at least one is present.
  2. _pendingLoad debounce in history_list.html silently drops intermediate search/filter changes if multiple fire while a request is in-flight. Final state is correct, just worth knowing if the UX team expects every keystroke to land.

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review: cursor pagination + lazy loading

Reviewed by Claude. Solid implementation overall — cursor design is sound, the security comments in utils/cursor.py and history/service.py are unusually good, and test coverage on fetch_sessions_page (especially the no-duplicates-across-pages case and the user-filter SQLi/leak tests) is strong. Main concern is a subtle SQLite correctness bug; rest are smaller.

Issues

🔴 SQLite cursor pagination is broken in production (timestamp-format mismatch).

fetch_session_turns_page and fetch_sessions_page both encode the cursor timestamp via cursor_ts.isoformat() (which always emits T as the separator) and then compare it lexicographically against created_at / last_ts. The SQLite migrations default created_at to datetime('now') — see migrations/sqlite/003_add_conversation_tables.sql:18 and :29 — which produces space-separated strings like 2025-01-15 10:00:00.

Lex order: ' ' (0x20) < 'T' (0x54), so for a row written by the default:

  • fetch_session_turns_page: WHERE (created_at, id) > ('2025-01-15T10:00:00', ...) skips every row at or after the cursor's timestamp → page 2 returns empty / under-paginates.
  • fetch_sessions_page: WHERE (last_ts, session_id) < ('2025-01-15T10:00:00', ...) matches the row that anchored page 1 → duplicates across pages / infinite scroll loops on the same set.

The integration tests (test_fragment_turns_pagination, test_fragment_sessions_pagination) don't catch this because their fixtures pre-write timestamps as ISO-T strings; the no-duplicates unit test (test_fetch_sessions_page_no_duplicates_across_pages) does the same. Real deployments use the datetime('now') default.

Fix options:

  • Pass cursor_ts.strftime('%Y-%m-%d %H:%M:%S') on the SQLite path so the comparison matches the storage format, or
  • Compare with datetime(created_at) > datetime(?) so SQLite normalizes both sides, or
  • Normalize on write (always store ISO-T) — broader change but removes the class of bug.

Whichever you pick, add a unit test that uses datetime('now') (or INSERT … DEFAULT VALUES) for the timestamp so the fixture matches production.

🟡 Fragment endpoints lack cache-control headers.

StaticCacheMiddleware (main.py:437-449) only sets headers on /api/*, /health, /ready, and /static/*. The new /ui/fragments/sessions and /ui/fragments/sessions/{id}/turns get default cacheability — i.e., browsers and any intermediate CDN are free to cache them. A stale cached fragment will replay a stale next_cursor, which after a CURSOR_HMAC_KEY rotation becomes a 400 and breaks scroll silently. Recommend extending the middleware to set Cache-Control: no-store on /ui/fragments/.

🟡 PR scope ≠ One PR = One Concern (per CLAUDE.md).

PR body says "Depends on PR #753 (perf harness + Server-Timing middleware)" but this PR also contains the perf harness itself: scripts/perf_*.py, scripts/run_perf.sh, src/luthien_proxy/perf/{db,seeding,timing_middleware}.py, tests/luthien_proxy/perf_tests/, plus all the perf-baseline changelog. ~3,000 of the 5,628 added lines are #753 material. If #753 is merging separately, rebase to drop the overlap; if not, retitle/split so reviewers know this is the harness + the feature.

🟡 Cursor is decoded twice per request.

In ui/routes.py:243-248 and :283-288, the route validates the cursor with decode_cursor(...) and then fetch_sessions_page / fetch_session_turns_page decodes it again. Two HMAC verifications + two JSON parses per request. Cheap individually, but more importantly it means the kind literal lives in two places and can drift. Suggest decoding once in the route and passing (ts, sid) into the service (or letting the service raise and translating to 400 in the route).

🟡 CURSOR_HMAC_KEY ephemeral-FS behavior is documented but not detected.

auto_provision_defaults() writes the key to ~/.luthien/cursor_hmac.key. On Railway / Render / Fly-machines without a persistent volume, every restart regenerates the key, silently 400-ing every cursor issued by the previous instance. The changelog calls this out, but at runtime the user just sees "infinite scroll stopped working." Consider logging at startup whether the key was freshly generated vs. read from disk, so operators see the signal in restart logs.

Smaller things

  • fetch_session_turns_page lacks a dedicated unit test. fetch_sessions_page has great coverage (test_fetch_sessions_page_no_duplicates_across_pages, user-filter tests). Turns endpoint only has the integration test, which (a) uses a misleading timestamp format and (b) doesn't exercise the Postgres UUID-binding branch. Worth adding a test_service_sqlite.py block mirroring the sessions one.
  • fragments/turns.html renders turn.created_at as a datetime. The service stores parse_db_ts(...) (a datetime object) under created_at, and the template emits data-created-at=\"{{ turn.created_at }}\" → Jinja calls str()2025-01-15 10:00:00+00:00. Harmless today but if anything downstream tries to parse it as ISO it will need the +00:00-vs-Z distinction. Either .isoformat() it in the service or document the format.
  • Conversation viewer is still full-fetch on every SSE refreshloadInitial() and refreshTurns() both pull /api/history/sessions/{id} (the whole session). Per the PR body this is intentional and deferred to a follow-up, but the PR title ("+ lazy loading for admin dashboard") oversells what shipped for the viewer; the lazy-loading really only applies to the history list. Worth softening the title or being explicit in the description that the turns endpoint ships unused.
  • HTTPException raised inside except ValueError as e in ui/routes.py:248 and :257 drops the exception chain — raise HTTPException(...) from e preserves debug context without changing the 400 response.
  • _get_event_summary payload contract is technically dict[str, Any] | str | None (asyncpg vs. aiosqlite vs. NULL), but the signature only takes dict[str, Any] | None. Works today because callers parse the JSON first, but the type hint is misleading.

What I liked

  • The SECURITY INVARIANT comments in _fetch_session_list_{pg,sqlite} are excellent and the tests back them up (SQLi + cross-user leak coverage).
  • HMAC truncation rationale is laid out in utils/cursor.py:32-35 — exactly the right level of detail for the threat model.
  • _TIMED_PREFIXES tuple-prefix gate in ServerTimingMiddleware plus the explicit /v1/messages absence test is a nice perf-hot-path guarantee.
  • Cursor kind field prevents session-cursor↔turns-cursor confusion, with the test to prove it.

- Fix SQLite cursor timestamp format mismatch: datetime('now') produces
  space-separated strings ('2025-01-15 10:00:00') but cursor_ts.isoformat()
  produces T-separated strings ('2025-01-15T10:00:00+00:00'). Since
  ' ' < 'T' lexicographically, page-2 cursor comparisons were wrong in
  production. Fix: wrap both sides in datetime() in the SQLite WHERE
  clause so SQLite normalizes the format before comparing.
- Add Cache-Control: no-store to /ui/fragments/ responses; fragment HTML
  embeds HMAC-signed cursors that become 400s after key rotation if a
  stale cached fragment is replayed
- Log whether CURSOR_HMAC_KEY was freshly generated or loaded from disk
  so operators can diagnose ephemeral-FS restarts (Railway/Render/Fly)
  that silently invalidate all in-flight cursors
- Preserve exception chain in ui/routes.py: raise HTTPException from e
  instead of bare raise, keeping debug context in tracebacks
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review by Claude (Opus 4.7)

Overall this is a well-executed PR with clear separation of concerns, thoughtful security framing, and good test coverage. The cursor-pagination design is sound, the changelog is honest about known limitations, and the SQL-injection invariants are clearly documented in code and verified by tests. A few small issues and one latent JS bug stood out — none are blockers.

Strengths

  • Cursor design (utils/cursor.py): HMAC-SHA256 with kind separation (sessions vs turns) and hmac.compare_digest for constant-time comparison. The 8-byte truncation is appropriate for the documented threat model (cursors are admin-auth-gated and only encode pagination position). Tamper and wrong-kind cases are tested.
  • SQL safety: All user input is parameter-bound. _escape_like() handles wildcards in `q`. The SECURITY INVARIANT comments in `history/service.py` and `test_user_id_filter_sql_injection_safe` show a deliberate posture.
  • Pagination correctness: `test_fetch_sessions_page_no_duplicates_across_pages` exercises the tricky case where a session's events span the cursor boundary — the CTE aggregation cannot be filtered by `created_at` without breaking page invariants. Nice catch in test design.
  • Auto-provisioning: `CURSOR_HMAC_KEY` uses `secrets.token_urlsafe(32)`, atomic `os.replace`, `0o600` perms — solid. Startup warning fires if the dev sentinel ever leaks into production.
  • Cache headers: `Cache-Control: no-store` on `/ui/fragments/` prevents stale-cursor 400s after key rotation. Good thinking.
  • Memory bound: `MAX_RAW_EVENTS_PER_CALL = 50` (FIFO) addresses the unbounded-growth concern from the prior implementation.
  • Tests: many new test files spanning unit (cursor, db, seeding, templates, timing), integration (fragments, server-timing, SSE regression), and perf-harness tiers. Coverage is thorough.

Issues

1. Latent bug in `conversation_live.js:485` — `this.rawEvents[callId]` uses the escaped form of `turn.call_id`:

```js
const callId = escapeHtml(turn.call_id); // line 416 — escaped
...
const events = this.rawEvents[callId] || []; // line 485 — lookup with escaped key
```

But `handleSSEEvent` populates `this.rawEvents` keyed by the raw `call_id` from the event payload. UUIDs have no escapeable characters so this is harmless in practice, but if a `call_id` ever contains `<`, `>`, `&`, `"`, or `'`, the event timeline silently shows nothing for that turn. Suggest splitting: keep a separate `escapedCallId` for HTML interpolation, and use `turn.call_id` for the dict lookup.

2. `q` route parameter has no `max_length` (`ui/routes.py:274`)

The service layer caps `q` to 128 chars via slicing (good defense-in-depth), but the route uses `Query(default=None)`. Adding `max_length=128` would surface a clean 422 to clients instead of silently truncating. The commit log mentions "enforce q max_length" but it only got applied at the service layer.

3. Cursor double-decode (`ui/routes.py:243-248, 283-288`)

Both the route handler and the service call `decode_cursor(...)`. The route validates so the DB isn't touched for malformed cursors — fair — but the two checks can drift over time. Consider letting the service raise and translating to `HTTPException` in one place.

4. Backend behavior inconsistency: `LIKE` vs `ILIKE` (`history/service.py:1213, 1262`)

SQLite branch uses `LIKE` (case-sensitive by default), Postgres uses `ILIKE` (case-insensitive). The same `q=Alpha` returns different results across backends. Worth either documenting in helptext or normalizing (e.g. `LOWER(session_id) LIKE LOWER(?)` for SQLite).

5. `payload_preview` materializes the full JSON before slicing (`history/service.py:1158-1167`)

```python
payload_str = json.dumps(payload_raw)
...
"payload_preview": payload_str[:200],
```

For multi-MB event payloads this serializes the whole payload just to take the first 200 chars. Consider DB-side `SUBSTR(payload, 1, 200)` or skipping the preview when the payload is large. Not urgent — the turns fragment endpoint isn't wired into the UI yet — but worth fixing before it ships.

6. `_jinja_env` is module-level singleton (`ui/routes.py:35-38`)

Templates are loaded from disk on first `get_template` call and cached for the process lifetime. In dev, template edits won't appear without a server restart. Very minor; if desired: `auto_reload=os.environ.get("DEV") == "1"`.

Observations (not action items)

  • Multi-replica HMAC key: explicitly called out in the changelog. Good. The auto-provisioning flow has a benign race if multiple processes start simultaneously (each generates a different key; only one wins the rename). The documented mitigation — set `CURSOR_HMAC_KEY` explicitly in env — is the right answer.
  • Cursor uses `MAX(created_at)` as `last_ts`: a session can re-surface or skip across page boundaries when new events arrive mid-pagination. The comment in `fetch_sessions_page` calls this out as intentional ("ordered by activity, not creation"). Acceptable for an admin UI.
  • `filter=claude` and `q` LIKE patterns: full scans, documented in the changelog as known limitations with a long-term fix path. Honest framing.
  • `refreshTurns()` re-fetches the entire session JSON on each debounce window: noted as deferred. For long sessions this can be heavy; the follow-up PR for paginated turn loading is the right path.
  • `fetch_session_list_pg` legacy offset path still runs `COUNT(DISTINCT session_id)` per request — pre-existing, not introduced here, but worth flagging that the cursor path is now the preferred way.

Test coverage

Comprehensive — unit, integration, and perf tiers all touched. The pagination boundary test (`test_fetch_sessions_page_no_duplicates_across_pages`) and the SQL injection test (`test_user_id_filter_sql_injection_safe`) are particularly good. Nice to see the JSON API endpoint preserved with explicit `test_existing_json_endpoint_unchanged` regression checks.


🤖 Generated with Claude Code

- Fix rawEvents key mismatch in conversation_live.js: callId is the
  HTML-escaped form of turn.call_id (for attribute contexts), but
  this.rawEvents is keyed by the raw call_id from SSE events. Add
  rawCallId = turn.call_id and use it for the dict lookup so event
  timelines work correctly if a call_id ever contains escapeable chars
- Add max_length=128 to q Query param in fragment_sessions route; the
  service layer already slices to 128 but the route was silently
  truncating instead of returning a clean 422
- Normalize SQLite q search to case-insensitive: LOWER(session_id) LIKE
  LOWER(?) to match Postgres ILIKE behavior across backends
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review

Solid, well-scoped PR. Strong security hygiene (HMAC-signed cursors, parameterized SQL everywhere, an explicit SQL-injection regression test, autoescaped Jinja templates), thoughtful comments on intentional trade-offs (the (last_ts, session_id) cursor docstring at history/service.py:1373-1376 is a great example), and a changelog that is honest about known limitations. The 50-event/call FIFO cap and the 0o600 atomic-write of cursor_hmac.key are both nicely done.

Bugs / correctness

  • fetch_session_turns_page Postgres UUID branch is untested (history/service.py:1118-1126). The PR description explicitly calls this fix out, but the only tests that exercise this function are the integration tests in tests/luthien_proxy/integration_tests/test_fragment_turns.py, which run SQLite-only. There's no unit test against a Postgres id column, and no test that an invalid-UUID cursor returns 400 cleanly on Postgres. Since this is the bug the PR is fixing, please add a regression test (or at minimum a unit test that asserts ValueError is raised when cursor_event_id is not a valid UUID).

  • fetch_session_turns_page raw cursor passthrough on SQLite (history/service.py:1121). On the SQLite branch, cursor_id_param is whatever string the (signed) cursor encoded. That's safe — but if a Postgres-issued cursor (UUID string) is ever decoded by a SQLite instance (e.g. someone redeploys onto a different backend with the same CURSOR_HMAC_KEY), the comparison id > '550e8400-…' will silently succeed against text-stored ids and return wrong pages. Not exploitable, just a sharp edge worth a brief comment.

  • Frontend loadInitial() still fetches the entire session (static/conversation_live.js:103-106). The PR title says "lazy loading for admin dashboard" and the changelog says it "Fixes the admin dashboard slowness reported by Sami," but the conversation viewer still loads the full session JSON on open and on every debounced refresh. For Sami's 442-message session that's exactly the page that's slow. The changelog and PR body do acknowledge "paginated lazy loading of turns is deferred to a follow-up PR" — consider tightening the PR title / summary so reviewers and Sami don't think the conversation-viewer slowness is fixed in this PR.

Code quality

  • Mixed placeholder styles in fetch_sessions_page (history/service.py:1207-1306). The SQLite branch builds queries with ? placeholders directly, while every other SQLite query in this file (including _fetch_session_list_sqlite right above it) uses asyncpg-style $N and lets db_sqlite._translate_params rewrite them. Both work, but the inconsistency makes the new code harder to scan and slightly risky for future maintainers — a query that mixes styles would silently behave wrong ($N would still translate, then the remaining ? would shift). Recommend converting to $N for consistency.

  • _Q_MAX_LEN = 128 is dead code (history/service.py:1181, 1205). FastAPI already enforces max_length=128 on the q query param at ui/routes.py:274 and returns 422 before the service ever sees a longer value. The truncation isn't wrong, but the service-side guard can never fire and the duplicated constant invites drift. Either drop the service-side truncation or drop the route-level max_length and rely on one source of truth.

  • quick_filter substring 'claude-code' is hardcoded across two SQL dialects (history/service.py:1225, 1277). The changelog calls out the perf cost, but not that this is a brittle string match — a future request-shape change (e.g., user-agent capitalization) silently breaks the filter. Worth a # substring depends on … comment pointing at where it's emitted in the recorded payload.

Test coverage

  • No tests for quick_filter='30days' or quick_filter='claude' — the filter clauses are mutually exclusive and one path being broken would not be caught.
  • No test that ServerTimingMiddleware actually emits the Server-Timing header for filtered paths (and that it's absent on /v1/messages). test_server_timing.py exists — please double-check it covers both the include and exclude cases.
  • No frontend test for the MAX_RAW_EVENTS_PER_CALL = 50 FIFO cap, which is the actual memory-leak fix. Acceptable given the cost of a JS test harness, but worth a one-line comment about how it was manually verified.

Minor / nits

  • static/history_list.html:431searchQuery is interpolated via URLSearchParams, which is correct, but worth a quick check that an empty q= doesn't cause the server to match every session (it doesn't, because the falsy check if q: in fetch_sessions_page short-circuits, but the route still forwards q='' to the service).
  • perf/seeding.py:215-234 — recreated indexes are duplicated from the migration files. If a new index is added to a migration, the perf harness will silently lack it and perf numbers will diverge from prod. Consider sourcing this list from the migration runner or at least a shared constant.
  • auto_provision_defaults in main.py:735-752 writes cursor_hmac.key atomically (good) but doesn't verify the existing file's permissions on the load path. If a user manually creates it with 0o644, the warning is silent.

Security

LGTM overall. Cursor HMAC is appropriate for the threat model and clearly documented. Auto-provisioned dev secret has a startup warning. SQL injection invariants are explicit and tested. Frontend uses Jinja autoescape on fragments and a defensive escapeHtml (with quote escaping) on the JS-rendered side.

- test_fetch_session_turns_page_first_page: basic turns pagination
- test_fetch_session_turns_page_second_page_space_timestamps: page-2
  cursor comparison with space-format timestamps (datetime('now') default)
  — the exact production format that was broken before the datetime()
  normalization fix
- test_fetch_sessions_page_quick_filter_30days: 30-day filter returns
  recent sessions and excludes old ones
- test_fetch_sessions_page_quick_filter_claude: claude filter matches
  sessions with 'claude-code' in payload and excludes others
- test_fetch_session_turns_page_postgres_non_uuid_cursor_raises: Postgres
  UUID branch raises ValueError for a non-UUID cursor event_id; this is
  the regression test for the named 'Postgres UUID fix' in the PR
- Add comment on SQLite cross-backend cursor sharp edge: a Postgres-issued
  cursor decoded by a SQLite instance silently returns wrong pages
@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

Review — cursor pagination + lazy loading

Read the cursor utility, history service additions, fragment routes, history_list.html, and the tests. Cursor design (HMAC-signed, kind-scoped, tiebreaker on session_id/event_id) and the SQL-injection invariants are well covered by prior reviews and the existing tests, so I'll skip those and focus on items I didn't see called out cleanly.

Worth fixing before merge

1. CURSOR_HMAC_KEY auto-provisioning has a multi-process startup race. In main.py:735-752, the check-then-write is not atomic:

if os.path.exists(key_path):
    with open(key_path) as f: value = f.read().strip()
else:
    value = secrets.token_urlsafe(32)
    ...os.replace(tmp_path, key_path)

If two processes start simultaneously without CURSOR_HMAC_KEY set and the file doesn't exist (fresh deploy, multi-worker uvicorn), both generate independent keys; whichever os.replace lands last wins on disk, but both processes hold their original in-memory keys. Each process then rejects cursors signed by the other → intermittent 400 Invalid cursor errors during pagination depending on which worker handles the next request.

Fix: after generating, re-read the file (someone else may have won the race) and use whatever's on disk. Or open(..., os.O_CREAT|os.O_EXCL|os.O_WRONLY) and fall back to reading the existing file on FileExistsError. The docstring already warns about multi-replica deployments — same caveat applies to multi-worker on a single host. Single-worker Docker is fine.

2. window.location = card.dataset.href in history_list.html:500-505 sets the full location from a dataset value templated as /conversation/live/{{ session.session_id }}. session_id is autoescaped in attribute context (no XSS), but the navigation performs URL parsing on the raw value, so a session_id containing ../ would resolve to a different path. If session_id can ever come from request-supplied headers/cookies (not just gateway-issued UUIDs), this is a soft phishing/path-traversal vector. Safer: build the URL with encodeURIComponent(card.dataset.sessionId) and a hard-coded prefix in JS, or render an <a href> and let the browser handle it.

Code quality

3. loadPage's pending-load drain pattern is duplicated between the $watch callback (lines 408-417) and applyQuickFilter (lines 484-494). Both do the same _pendingLoad = {...}; if (!this.loading) { drain } dance. One helper (_scheduleReload({clearList})) would dedupe it and make the intent obvious.

4. payload_preview is json.dumps(payload_raw)[:200] in service.py:1163-1172 — a hard slice that frequently lands mid-token, with no ellipsis to signal truncation. The fragment template renders it raw inside autoescape, so it's safe; it just often looks like broken JSON to the operator. Append (or "…") when len(payload_str) > 200. Minor UX.

5. Fragment vs legacy JSON drift on user_ids. fetch_session_list (legacy /api/history/sessions) returns user_ids per session; fetch_sessions_page (new /ui/fragments/sessions) does not. Both endpoints are admin-only, so it's not a leak, but if the fragment list is intended to replace the JSON list long-term, the data shape should converge — otherwise the next person wiring user attribution into the new UI re-discovers this gap. Either add user_ids to the fragment payload now (mirror _fetch_session_list_sqlite's separate-lookup pattern) or document explicitly in fetch_sessions_page's docstring that user attribution lives only on the legacy endpoint.

Tests

Solid: test_fetch_sessions_page_no_duplicates_across_pages, the SQL-injection regression test, the kind-scoping cursor tests, and the bad-cursor 400 paths are all there. Two small gaps worth adding:

  • Cursor kind inversion at the route layer. Unit tests exercise decode_cursor raising on wrong kind, but there's no integration test that submitting a sessions cursor to /ui/fragments/sessions/{id}/turns returns 400. Cheap test, prevents a future refactor from quietly dropping the check.
  • Pagination stability when last_ts mutates between requests. The comment at service.py:1378-1382 says re-surfacing/skipping under concurrent writes is intentional; an explicit test would prevent a future "fix" that breaks the contract.

Not concerns

  • HMAC 8-byte truncation: fine for admin-gated pagination integrity (already documented inline at cursor.py:32-36).
  • LIKE/ILIKE leading-wildcard scans for q and quick_filter='claude': called out in the changelog as known limitations for small deployments.
  • BaseHTTPMiddleware overhead on the /v1/messages hot path: a single path.startswith and return; non-negligible but out of scope for this PR.

Nothing here is a merge blocker except the CURSOR_HMAC_KEY race if you run multi-worker uvicorn on a fresh host. The rest are quality fixes.

@PaoloC68
PaoloC68 marked this pull request as ready for review May 21, 2026 20:03
PaoloC68 added a commit that referenced this pull request May 25, 2026
# Conflicts:
#	changelog.d/perf-baseline.md
#	scripts/perf_explain.py
#	scripts/perf_report.py
#	scripts/run_perf.sh
#	src/luthien_proxy/history/service.py
#	src/luthien_proxy/main.py
#	src/luthien_proxy/perf/db.py
#	src/luthien_proxy/perf/seeding.py
#	src/luthien_proxy/perf/timing_middleware.py
#	tests/luthien_proxy/e2e_tests/sqlite/test_activity_stream_regression.py
#	tests/luthien_proxy/integration_tests/test_server_timing.py
#	tests/luthien_proxy/perf_tests/AGENTS.md
#	tests/luthien_proxy/perf_tests/conftest.py
#	tests/luthien_proxy/perf_tests/test_api_contract.py
#	tests/luthien_proxy/perf_tests/test_page_load.py
#	tests/luthien_proxy/perf_tests/test_sse_memory.py
#	tests/luthien_proxy/perf_tests/test_throttled_network.py
#	tests/luthien_proxy/perf_tests/test_transcript_open.py
#	tests/luthien_proxy/unit_tests/perf/test_db.py
#	tests/luthien_proxy/unit_tests/perf/test_harness_helpers.py
#	tests/luthien_proxy/unit_tests/perf/test_seeding.py
#	tests/luthien_proxy/unit_tests/perf/test_timing_middleware.py
@scottwofford

Copy link
Copy Markdown
Member

Claude-generated merge-queue triage of all open Luthien PRs, requested by Scott (Jul 7, 2026). Advisory only; Scott has not yet acted on these recommendations.

Recommendation: close.

The two slow paths this targeted are being addressed by #795 (turn-paginated, bounded-memory session detail, which is the follow-up this PR explicitly deferred) together with the list-path fixes in the same queue. This branch is roughly 600 commits behind and conflicts with the history UI, which has since gained per-user filters, labels, search, and two XSS-hardening passes, so the frontend would need a near-total redo; the last review round also had open correctness findings in the pagination path. The cursor-pagination design remains a useful reference in the branch.

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