Skip to content

feat: persistent rotating logs + live Logs UI#64

Merged
roziscoding merged 6 commits into
mainfrom
feat/log-storage-ui
Jul 4, 2026
Merged

feat: persistent rotating logs + live Logs UI#64
roziscoding merged 6 commits into
mainfrom
feat/log-storage-ui

Conversation

@roziscoding

@roziscoding roziscoding commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Adds durable log storage the UI can read, per the design we settled on: an in-process rotating NDJSON file (no external logrotate, no worker threads) plus a management API the Nuxt UI consumes for backfill + a live SSE tail. Pitchfork/supervisor is intentionally not part of this — deferred to a later change.

Backend

logger.ts now fans everything through one in-process pino multistream:

pino ─ multistream ─┬─ console (stdout in prod; pino-pretty as an in-process stream in dev)
                    ├─ otel bridge (when tracing on, unchanged)
                    ├─ RotatingFileSink → /config/logs/jack.ndjson   (persistence + retention)
                    └─ LogHub → live SSE fan-out
  • RotatingFileSink — a ~90-line native sink (node:fs, synchronous). Rotates before the overflowing write so every file stays within maxBytes; renames jack.ndjson → .1, shifts .N up, prunes past maxFiles. No dependency (your call), stable active-file path.
  • LogHub — doubles as a multistream destination: parses each line and pushes it to live SSE subscribers, and serves backfill() (last N lines) from the persisted file. Live delivery taps the log pipe directly, so it's oblivious to rotation — no file-tailing/reopen dance.
  • Persistence is free: logs default to dirname(APP_CONFIG_PATH)/logs = /config/logs, the same mounted volume as the sqlite DB. No Dockerfile/compose change. New env (all defaulted): LOG_TO_FILE, LOG_DIR, LOG_MAX_FILE_BYTES (10 MiB), LOG_MAX_FILES (5).
  • Management API (key-guarded): GET /logs?lines=N&level= (backfill) and GET /logs/stream (SSE live tail).

Redaction already runs in pino's log formatter, so the file/hub receive pre-scrubbed lines.

Note: the module dir is modules/logging/ (not logs/) because .gitignore has a bare logs rule that would have swallowed the source.

UI

New Logs page (+ nav entry): backfills the last 500 lines, then live-tails via EventSource through the existing BFF proxy. Level filter (reloads backfill + reconnects), pause/resume, clear, click-to-expand row detail, stick-to-bottom auto-scroll.

Verification

  • Backend: full suite 411 pass / 0 fail; typecheck + eslint clean. New unit tests cover rotation/pruning, hub pub-sub + backfill (level filter, malformed lines), and the router (backfill route + a real SSE stream test). Plus an end-to-end smoke run: the real production logger writes the file, GET /logs?level=warn returns exactly the warn+error lines, no-key → 401, /logs/streamtext/event-stream.
  • UI: nuxt typecheck clean, eslint clean, and a full production nuxt build succeeds (SFC compiles/bundles).
  • Not yet done: no visual/browser screenshot — there's no local management mock in this checkout to run the UI against, and SSE-through-the-BFF is best eyeballed against the real API. Worth a quick look when you're back; the proxy already returns res.body as a stream for every GET, so the live tail should pass through unchanged.

Requires the gitignored generated schema types (bun run generate in packages/schemas) for typecheck — unchanged from current CI expectations.

Greptile Summary

This PR adds persistent log storage and a live Logs page. The main changes are:

  • Rotating NDJSON log files for backend logs.
  • Management API endpoints for log backfill and live SSE streaming.
  • Nuxt Logs UI with filtering, pause/resume, clearing, and row details.
  • Follow-up fixes for file-sink startup failure, rotated backfill, level filtering, and stream reload races.

Confidence Score: 5/5

This looks safe to merge.

  • No blocking issues found in the changed code.

Reviews (8): Last reviewed commit: "fix(ui): ignore log stream callbacks fro..." | Re-trigger Greptile

Comment thread apps/backend/src/modules/logging/log-store.ts Outdated
Comment thread apps/backend/src/modules/logging/log-hub.ts Outdated
Comment thread apps/ui/app/pages/logs.vue
Comment thread apps/backend/src/modules/logging/log-hub.ts Outdated
Comment thread apps/ui/app/pages/logs.vue Outdated
Comment thread apps/ui/app/pages/logs.vue Outdated
Add a native, in-process pino multistream setup: logs fan out to console, a
size-rotating NDJSON file (persisted next to the sqlite DB under the config
volume), a live SSE hub, and the existing OTel bridge — all without a worker
thread (which thread-stream transports don't survive under Bun) or external
logrotate.

The management API gains GET /logs?lines=N&level= (backfill from the persisted
file) and GET /logs/stream (SSE live tail via the hub, which taps the same log
pipe so it's oblivious to rotation). New env: LOG_TO_FILE, LOG_DIR,
LOG_MAX_FILE_BYTES, LOG_MAX_FILES.
New /logs page: backfills the last 500 lines from GET /logs, then live-tails via
an EventSource against /logs/stream through the BFF proxy. Level filter (reloads
backfill + reconnects the stream), pause/resume, clear, click-to-expand row
detail, and stick-to-bottom auto-scroll. Adds a Logs nav entry.
…ters

- log-store: constructing the rotating file sink no longer throws at import
  time. A non-writable log dir now warns to stderr and disables file logging
  instead of crashing the process before startup.
- backfill: read across rotated files (.1, .2, …) until `lines` is satisfied,
  so recent history isn't dropped right after a rotation.
- level filter: fail closed in both backfill and the SSE stream — a record
  without a numeric level is excluded under a level floor, not let through.
- ui: close the existing EventSource before reloading on a filter change, so
  the old stream can't append previous-filter lines during the backfill.
A fast level change could start two reload()s; if the older backfill resolved
last it would overwrite rows with stale, previous-filter data. Each reload now
takes a sequence ticket and only the newest may replace rows, clear pending, or
reconnect the stream.
@roziscoding roziscoding force-pushed the feat/log-storage-ui branch 2 times, most recently from 0fd864a to 5034483 Compare July 4, 2026 09:20
Comment thread apps/ui/app/pages/logs.vue
… stream

Bun's HTTP server withholds the response header block until the first byte of
the body is written. The live-logs SSE stream stays idle until the first log
event (or the 15s keep-alive), so its `text/event-stream` headers were never
flushed and a reverse proxy in front (Traefik) hung indefinitely waiting for
them — the browser's EventSource and direct clients both saw a dead connection.

Open the stream with a `:` comment (ignored by EventSource) so the first body
byte — and thus the headers — go out immediately, and add `X-Accel-Buffering:
no` for nginx-family proxies. Verified against a raw socket: without the comment
the header block never terminates; with it, headers flush at t=0.
A fast level-filter change closes the old EventSource and opens a new one, but a
`message`/`open`/`error` callback already dispatched from the old stream could
still run after the switch and append a previous-filter record into the freshly
loaded rows (or flip `connected`). Capture the created EventSource and gate each
callback on it still being the active `source`, so only the current stream can
mutate state. Complements the reload ticket that already guards backfill.
@roziscoding roziscoding force-pushed the feat/log-storage-ui branch from 5034483 to 797dbac Compare July 4, 2026 09:49
@roziscoding roziscoding merged commit 7dd6e58 into main Jul 4, 2026
10 checks passed
@roziscoding roziscoding deleted the feat/log-storage-ui branch July 4, 2026 09:56
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.

1 participant