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
+28 -4
View File
@@ -17,6 +17,8 @@ import { CredentialCapture } from "./credential-capture.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { DeviceMonitor } from "./device-monitor.js";
import { buildSigner, buildVerifier } from "./signer.js";
import { LogService, pinoDbStream } from "./log-service.js";
import { logRoutes } from "./routes/logs.js";
import { authRoutes } from "./routes/auth.js";
import { userRoutes } from "./routes/users.js";
import { roleRoutes } from "./routes/roles.js";
@@ -43,12 +45,20 @@ export interface BuildOptions {
}
export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInstance> {
const app = Fastify({
logger: { level: process.env.LOG_LEVEL ?? "info" },
});
// DB first — the logger's DB sink needs it before Fastify is constructed.
const db = opts.db ?? createDb();
// Application-log store: a pino stream tees warn+ lines into app_logs (and still
// writes them to stdout), so backend warnings/errors are queryable from the booth
// alongside frontend errors. See log-service.ts + wiki/concepts/app-logs.md.
const logService = new LogService(db);
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL ?? "info",
stream: pinoDbStream(logService, process.stdout),
},
});
// Wire the RBAC permission resolver to this DB (route guards resolve a user's
// role → permission set through it). See auth.ts.
initAuth(db);
@@ -188,6 +198,20 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
await siteRoutes(app, db);
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
await logRoutes(app, logService);
// Periodic retention prune (age + row cap) so the log table stays bounded on the
// offline appliance. Runs hourly; unref'd so it never holds the process open.
const pruneTimer = setInterval(() => {
const n = logService.prune();
if (n > 0) app.log.debug(`pruned ${n} app_log rows`);
}, 60 * 60 * 1000);
pruneTimer.unref();
logService.prune(); // once at startup
app.addHook("onClose", async () => clearInterval(pruneTimer));
const unsubscribeInput = deviceEvents.onInput((e) => {
// Record every input edge as unsigned telemetry, keyed to the device that fired
// (provenance). No lane — the pool-of-spaces model has none. The entry flow