feat(logs): app log store — backend pino DB sink + frontend error collection

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
This commit is contained in:
2026-06-19 12:54:22 +02:00
parent 0074e82a2a
commit bfb6ab0b36
20 changed files with 1064 additions and 9 deletions
+20
View File
@@ -0,0 +1,20 @@
CREATE TABLE `app_logs` (
`id` text PRIMARY KEY NOT NULL,
`level` text NOT NULL,
`source` text NOT NULL,
`message` text NOT NULL,
`context` text,
`http_status` integer,
`path` text,
`stack` text,
`user_id` text,
`user_agent` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `app_logs_created_at_idx` ON `app_logs` (`created_at`);--> statement-breakpoint
CREATE INDEX `app_logs_level_idx` ON `app_logs` (`level`);--> statement-breakpoint
-- Grant the new log:read permission to the built-in admin role (enforcement is
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','log:read');
+7
View File
@@ -64,6 +64,13 @@
"when": 1781885100000,
"tag": "0008_user_profile_theme",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1781885200000,
"tag": "0009_app_logs",
"breakpoints": true
}
]
}