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
+48
View File
@@ -26,6 +26,7 @@ export const RESOURCES = [
"session", // active sessions, lookup
"event", // the signed ledger feed + void
"report", // events feed, occupancy, future reports
"log", // application/diagnostic logs (app_logs) — view + retention
] as const;
export type Resource = (typeof RESOURCES)[number];
@@ -51,6 +52,7 @@ export const PERMISSIONS: readonly Permission[] = [
"session:read",
"event:read", "event:void",
"report:read",
"log:read",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -260,6 +262,52 @@ export function reasonPayload(
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
/**
* Application/diagnostic logs — a THIRD unsigned, prunable stream (app_logs), distinct
* from the signed ledger and from device telemetry. Backend warn+ and frontend errors
* land here so a booth problem is queryable in one place. See
* wiki/concepts/app-logs.md, decisions/event-streams-split.md.
*/
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
export type LogSource = "frontend" | "backend";
/** A persisted log record (the read shape returned by GET /api/logs). */
export interface AppLogRecord {
readonly id: string;
readonly level: LogLevel;
readonly source: LogSource;
readonly message: string;
readonly context: Record<string, unknown> | null;
readonly httpStatus: number | null;
readonly path: string | null;
readonly stack: string | null;
readonly userId: string | null;
readonly userAgent: string | null;
readonly createdAt: string;
}
/** One log entry POSTed by the frontend to /api/logs (server stamps id/userId/time). */
export interface ClientLogInput {
readonly level: LogLevel;
readonly message: string;
readonly context?: Record<string, unknown> | null;
readonly httpStatus?: number | null;
readonly path?: string | null;
readonly stack?: string | null;
/** Client-side capture time (ISO). The server records its own receive time too. */
readonly at?: string;
}
/** The numeric ordering of levels (pino-compatible), for threshold comparisons. */
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
trace: 10,
debug: 20,
info: 30,
warn: 40,
error: 50,
fatal: 60,
};
/**
* The composable rate card stored in a tariff_version.structure.
*