bfb6ab0b36
Add a third data stream (app_logs), distinct from the signed ledger and device telemetry, for operational/diagnostic logs — an offline appliance has no Sentry to ship to, so the host is the log store. Backend: a pino stream tees warn/error/fatal into app_logs (info/debug stay stdout-only) with no call-site change; the DB is built before Fastify so the logger has its sink. Frontend (lib/logger.ts): ships failed API requests (minus 401 churn), window.onerror, unhandledrejection, and a top-level React ErrorBoundary; console warn/error forwarded only at debug/trace. Batched/throttled POST, sendBeacon on pagehide, loop-safe (never logs the /api/logs call), best-effort everywhere. POST /api/logs (any signed-in user, CSRF, tolerant) + GET /api/logs gated by a new log:read permission (new `log` RBAC resource; admin holds it). Retention: pruned by age + row cap, hourly + at startup. UI: a Logs screen under /setup (filter level/source/since, expand to context+stack), sq+en. Migration 0009_app_logs. Verified end-to-end via app.inject: login -> POST 204 -> GET 200 with the record; backend warn/error persisted, info dropped; non-admin GET 403 / POST 204. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
102 lines
6.0 KiB
Markdown
102 lines
6.0 KiB
Markdown
---
|
|
type: concept
|
|
tags: [parking, observability, diagnostics, logging, frontend, backend]
|
|
sources: []
|
|
updated: 2026-06-19
|
|
status: open
|
|
---
|
|
|
|
# Application logs (diagnostics) — the third stream
|
|
|
|
A **third data stream**, deliberately distinct from the two in [[event-streams-split]]:
|
|
|
|
| Stream | Table | Signed? | Purpose |
|
|
| --- | --- | --- | --- |
|
|
| Business ledger | `ledger_events` | ✅ ATECC608 | money/accountability ([[append-only-event-chain]]) |
|
|
| Device telemetry | `device_events` | ❌ | hardware chatter ([[device-events]]) |
|
|
| **App logs** | **`app_logs`** | ❌ | **operational/diagnostic logs** (this page) |
|
|
|
|
App logs answer *"why did the booth misbehave?"* — a question neither of the other streams should
|
|
absorb (logs are neither business facts nor hardware telemetry). On an **offline appliance** there's
|
|
no Sentry/Datadog to ship to, so the host **is** the log store: backend warnings/errors AND frontend
|
|
errors land in one queryable table, viewable at the booth. Built 2026-06-19.
|
|
|
|
## What's captured
|
|
|
|
- **Backend `warn` / `error` / `fatal`** — a pino stream tees these into `app_logs` (and still writes
|
|
them to stdout, unchanged). `info`/`debug`/`trace` stay **stdout-only** — they'd bloat the DB. So
|
|
every `app.log.warn/error(...)` already in the codebase is now persisted with **no call-site
|
|
change**.
|
|
- **Frontend errors** (always): every **failed API request** (`apiFetch`'s non-OK path → method,
|
|
path, status, server error body — except `401`, which is normal pre-login churn), every **uncaught
|
|
error** (`window.onerror`), every **unhandled promise rejection**, and a top-level **React
|
|
ErrorBoundary** (a render crash is reported as `fatal` instead of a white screen).
|
|
- **`console.warn` / `console.error`** — only forwarded when the **client log level is `debug`/`trace`**
|
|
(off by default; they're noisy with third-party chatter). The high-signal sources above are always
|
|
on. Toggle via `VITE_LOG_LEVEL` / `setClientLogLevel()`.
|
|
|
|
## Shape
|
|
|
|
`app_logs`: `level` (pino names), `source` (`frontend`|`backend`), `message`, `context` (one JSON
|
|
column — the failed request, error name, component stack, anything), plus pulled-out `httpStatus` /
|
|
`path` for cheap filtering, `stack`, `userId`, `userAgent`, `createdAt`. Indexed on `created_at` +
|
|
`level`. Shared types: `AppLogRecord` / `ClientLogInput` / `LogLevel` in `@parking/shared`.
|
|
|
|
## The API + the access split
|
|
|
|
- **`POST /api/logs`** — the frontend ships errors here. **Any signed-in user** may write (it's their
|
|
own browser's diagnostics) — `requireAuth`, not a permission. CSRF still applies (it's a mutation).
|
|
Accepts one entry or a `{ entries: [...] }` batch (capped at 50). **Deliberately never 4xx's on a
|
|
malformed entry** — a client erroring *while reporting an error* must not get a second error.
|
|
- **`GET /api/logs`** — read the store (level/source/since filters), gated by the **new `log:read`
|
|
permission** (a new `log` resource in the dynamic [[local-jwt-auth|RBAC]] grid). Admin holds it;
|
|
it's grantable to a diagnostic role. *Verified: a cashier without `log:read` gets 403 on GET but
|
|
204 on POST — the intended asymmetry.*
|
|
|
|
## Reliability invariants (a logger must never make things worse)
|
|
|
|
- **No infinite loop.** The frontend collector never logs the `/api/logs` request itself, and flushes
|
|
via **raw `fetch`/`sendBeacon`**, not `apiFetch` (so a flush failure can't recurse into a new log).
|
|
The backend `LogService` has a **reentrancy guard** — persisting a log can't emit a persisted log.
|
|
- **Best-effort, never fatal.** Every write is wrapped; a DB/logging failure is swallowed (it can't be
|
|
logged — that's the recursion we guard). Diagnostics must never break the path they observe.
|
|
- **Bounded.** Frontend queue capped (drops oldest); message/stack/context clamped per row;
|
|
ingest batch capped.
|
|
|
|
## Retention (offline appliance ⇒ must be bounded)
|
|
|
|
Pruned by **age AND a row cap** (a burst could blow past an age-only window): delete older than
|
|
`LOG_RETENTION_DAYS` (default 30) **and** keep only the newest `LOG_RETENTION_MAX_ROWS` (default
|
|
50 000). Runs **hourly** (unref'd timer) + once at startup. Both env-configurable. Same "prunable,
|
|
not precious" durability class as `device_events` — the opposite of the append-only ledger.
|
|
|
|
## The booth viewer
|
|
|
|
A **Logs screen** under Setup (`/setup/logs`, gated by `log:read`, sq+en) — filter by
|
|
level/source/since, newest first, each row expands to the structured `context` + stack. Read-only
|
|
(logs are evidence, never edited). Polls every 15 s (no WS — diagnostics aren't latency-critical).
|
|
Sits alongside the other admin tabs in [[booth-console]].
|
|
|
|
## As-built (2026-06-19)
|
|
|
|
- `packages/db`: `app_logs` table + migration `0009_app_logs.sql` (+ journal idx 9; seeds admin
|
|
`log:read`). Applied to the live `apps/server/parking.sqlite`.
|
|
- `@parking/shared`: `log` resource + `log:read` permission; `LogLevel`/`LogSource`/`AppLogRecord`/
|
|
`ClientLogInput`/`LOG_LEVEL_ORDER`.
|
|
- `apps/server`: `log-service.ts` (`LogService` + `pinoDbStream`), `routes/logs.ts`, wired in
|
|
`server.ts` (DB built before Fastify so the pino stream has the sink; prune timer).
|
|
- `apps/web`: `lib/logger.ts` (collector + global handlers), `lib/ErrorBoundary.tsx`, `apiFetch` hook,
|
|
`LogsViewer.tsx` + route/nav, i18n.
|
|
|
|
## Open
|
|
|
|
- **No automated test** (the standing harness gap) — though the ingest/read/gate path was verified by
|
|
in-process `app.inject` smoke (login → POST 204 → GET 200 with the record; non-admin 403/204 split).
|
|
- **Server API error strings stay English** — unchanged here; this is about *persisting* logs, not
|
|
localizing them. The localized-ledger-reason pattern ([[i18n]]) is the template if log *display*
|
|
ever needs translation (currently the message is whatever the thrower wrote).
|
|
- **Surfacing critical logs live** — a `fatal`/`error` count badge on the booth footer over the
|
|
existing `/api/ws` could flag problems without opening the viewer. Deferred.
|
|
- **Correlation id** — no request-id threads a frontend failed-request log to its backend log yet;
|
|
add a `x-request-id` echo if cross-stream correlation is wanted.
|