feat(admin): router-style in-app error log view #322

Merged
nrupard merged 2 commits from feat/BUNYIP-327-admin-error-log-view into main 2026-07-02 16:51:24 +02:00
Owner

Why

Production auth/rate-limit problems on NC01 (unauthorized /users/me, refresh failed/expired, revoked tokens, rate limiting) were only visible via SSH log inspection, and the app surfaced nothing when e.g. a verification-email send was throttled. This adds an admin-gated, filterable, rotated error-log view so the team can diagnose recurring issues in-product.

Decisions (confirmed with the requester)

  • Storage: in-memory ring buffer, router-style and rotated (capacity 1000, oldest evicted). No migration, no persistence: entries are lost on api restart/redeploy, an accepted v1 trade-off matching the "routers-style" framing.
  • Capture: a tracing Layer in the existing subscriber registry captures every ERROR-level event automatically, so any tracing::error! app-wide lands in the view with its structured fields. No per-call-site plumbing.
  • Rate limit vs "errors only": rate-limit trips are emitted at ERROR with category = "rate_limit" + the affected client, so they surface in the errors-only view and stay attributable. Other warnings stay warn and are excluded by construction.

API (bunyip-api)

  • error_log module: ErrorLogBuffer (bounded, thread-safe Arc<Mutex<VecDeque>>, newest-first snapshot with optional category filter) + ErrorLogLayer (ERROR-only capture, splits message/category/route/client out of the fields, retains the rest).
  • Wired into init_tracing; buffer registered as app data.
  • GET /v1/admin/logs (AdminUser-gated): newest-first entries, optional ?category=, with matched/buffered/capacity for rotation reporting.
  • Rate-limit ERROR emission at the shared check_rate_limit chokepoint (login/registration/magic-link/password-reset) and the two email-resend limits in bunyip-domain. Client IP is whatever the caller passes today; the external-IP companion issue refines attribution.

Web (bunyip-web)

  • /admin/logs page mirroring the audit-log view: category filter (All / Rate limit), per-entry message, Error + category badges, the target • route • client attribution line, and any extra fields. Sidebar nav entry added.
  • error_logs() api client + AdminErrorLog / ErrorLogsResponse types.

Acceptance criteria

  • Admins can view a filterable, rotated log view of error-level entries -> /admin/logs.
  • Rate-limit events appear with the affected client identifiable -> category="rate_limit" + client field, rendered on the attribution line.
  • Warnings excluded from the default view -> the buffer captures ERROR only.
  • Tests cover an error captured + rendered and a warning not -> see below.

Tests

  • error_log unit tests: ERROR captured / WARN not; category filter; ring rotates oldest at capacity; extra fields retained.
  • web render test: an entry renders message, category, client, route, extra fields, tagged as an error.

Verification

just check-container green (fmt + clippy -D warnings + workspace lib tests, incl. the 4 new error_log tests). cargo test -p bunyip-web --bins green (73 passed, incl. the render test).

Not in scope

External client-IP attribution (companion issue) and log persistence across restarts (in-memory by design for v1).

Fixes BUNYIP-327.

## Why Production auth/rate-limit problems on NC01 (unauthorized `/users/me`, refresh failed/expired, revoked tokens, rate limiting) were only visible via SSH log inspection, and the app surfaced nothing when e.g. a verification-email send was throttled. This adds an admin-gated, filterable, rotated error-log view so the team can diagnose recurring issues in-product. ## Decisions (confirmed with the requester) - **Storage:** in-memory ring buffer, router-style and rotated (capacity 1000, oldest evicted). No migration, no persistence: entries are lost on api restart/redeploy, an accepted v1 trade-off matching the "routers-style" framing. - **Capture:** a `tracing` Layer in the existing subscriber registry captures every ERROR-level event automatically, so any `tracing::error!` app-wide lands in the view with its structured fields. No per-call-site plumbing. - **Rate limit vs "errors only":** rate-limit trips are emitted at ERROR with `category = "rate_limit"` + the affected `client`, so they surface in the errors-only view and stay attributable. Other warnings stay `warn` and are excluded by construction. ## API (bunyip-api) - `error_log` module: `ErrorLogBuffer` (bounded, thread-safe `Arc<Mutex<VecDeque>>`, newest-first snapshot with optional category filter) + `ErrorLogLayer` (ERROR-only capture, splits `message`/`category`/`route`/`client` out of the fields, retains the rest). - Wired into `init_tracing`; buffer registered as app data. - `GET /v1/admin/logs` (`AdminUser`-gated): newest-first entries, optional `?category=`, with `matched`/`buffered`/`capacity` for rotation reporting. - Rate-limit ERROR emission at the shared `check_rate_limit` chokepoint (login/registration/magic-link/password-reset) and the two email-resend limits in `bunyip-domain`. Client IP is whatever the caller passes today; the external-IP companion issue refines attribution. ## Web (bunyip-web) - `/admin/logs` page mirroring the audit-log view: category filter (All / Rate limit), per-entry message, Error + category badges, the `target • route • client` attribution line, and any extra fields. Sidebar nav entry added. - `error_logs()` api client + `AdminErrorLog` / `ErrorLogsResponse` types. ## Acceptance criteria - Admins can view a filterable, rotated log view of error-level entries -> `/admin/logs`. - Rate-limit events appear with the affected client identifiable -> `category="rate_limit"` + `client` field, rendered on the attribution line. - Warnings excluded from the default view -> the buffer captures ERROR only. - Tests cover an error captured + rendered and a warning not -> see below. ## Tests - `error_log` unit tests: ERROR captured / WARN not; category filter; ring rotates oldest at capacity; extra fields retained. - web render test: an entry renders message, category, client, route, extra fields, tagged as an error. ## Verification `just check-container` green (fmt + clippy `-D warnings` + workspace lib tests, incl. the 4 new `error_log` tests). `cargo test -p bunyip-web --bins` green (73 passed, incl. the render test). ## Not in scope External client-IP attribution (companion issue) and log persistence across restarts (in-memory by design for v1). Fixes BUNYIP-327.
feat(admin): router-style in-app error log view (BUNYIP-327)
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 34s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
411abe6af1
Production auth and rate-limit problems on NC01 were only visible over SSH; the app surfaced nothing when something like a verification-email send hit a rate limit. This adds an admin-gated, filterable, rotated error-log view so the team can diagnose recurring issues in-product.

API (bunyip-api):
- New error_log module: a bounded, thread-safe in-memory ring buffer (ErrorLogBuffer, capacity 1000, oldest evicted) plus ErrorLogLayer, a tracing Layer that copies every ERROR-level event into it with structured fields (message + category/route/client split out, remaining fields retained). Warnings are excluded by construction. In-memory and rotated by design (router-style): no migration, entries lost on restart, an accepted v1 trade-off.
- The layer is wired into the existing subscriber registry in init_tracing; the buffer is registered as app data.
- New admin endpoint GET /v1/admin/logs (AdminUser-gated) returns newest-first entries, optionally filtered by category, with buffer occupancy for rotation reporting.
- Rate-limit trips now emit at ERROR with category="rate_limit" and the affected client so they surface in the view and stay attributable: the shared check_rate_limit chokepoint (login/registration/magic-link/password-reset) and the two email-resend limits in bunyip-domain. (The client IP is whatever the caller passes today; the external-IP companion issue refines attribution.)

Web (bunyip-web):
- New /admin/logs page mirroring the audit-log view: category filter (All / Rate limit), per-entry message, error + category badges, the target/route/client attribution line, and any extra fields. Sidebar nav entry added.
- api client error_logs() + AdminErrorLog / ErrorLogsResponse types.

Tests:
- error_log unit tests: an ERROR event is captured and a WARN is not; category filter selects matching entries; the ring rotates the oldest out at capacity; extra fields are retained.
- web render test: an error entry renders its message, category, client, route and extra fields and is tagged as an error.

Verified: just check-container green (fmt + clippy -D warnings + workspace lib tests, incl. the new error_log tests); cargo test -p bunyip-web --bins green (incl. the render test).

#BUNYIP-327
fix(api): log a rate-limit trip once per window, not per over-limit request
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 42s
Check / fmt + clippy + build + tests (pull_request) Successful in 17m29s
Create release / Create release from merged PR (pull_request) Has been skipped
ec30ea0332
The BUNYIP-327 rate-limit ERROR emission fired on every request past the cap. Under a credential-stuffing burst that floods the shared error-log ring with `rate_limit` entries in seconds and evicts the auth-diagnostic errors the log view exists to surface (the category filter cannot recover already-evicted entries).

`check_and_increment` returns a monotonic post-increment count that resets each window, so `count == max_requests + 1` uniquely marks the first over-limit request in a window (atomic RETURNING makes it unique even under concurrent requests). Gate the emission on that via a pure, unit-tested `should_log_rate_limit_trip`. The 429 is still returned on every request; only the log line is suppressed. Each new window logs its first trip again.

Scope: this covers `check_rate_limit` (login/registration/magic-link/password-reset), the realistic high-volume vector. The two email-resend limits in `bunyip-domain` are left as-is: they are authenticated and bounded (3/hour) and their counter reflects created tokens, not attempts, so a rejected resend does not increment it and count-based first-trip detection does not apply.

#BUNYIP-327
nrupard scheduled this pull request to auto merge when all checks succeed 2026-07-02 16:47:08 +02:00
nrupard deleted branch feat/BUNYIP-327-admin-error-log-view 2026-07-02 16:51:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!322
No description provided.