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
+30 -2
View File
@@ -5,6 +5,9 @@
// CSRF cookie back in the X-CSRF-Token header (double-submit). See
// wiki/entities/local-jwt-auth.md.
import { logFailedRequest } from "./lib/logger.js";
import type { AppLogRecord } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
@@ -27,7 +30,14 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
const res = await fetch(path, { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
throw new ApiError(msg.error ?? `${path}: ${res.status}`, res.status);
const error = msg.error ?? `${path}: ${res.status}`;
// Ship the failed request to the backend log store (best-effort, loop-safe — the
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
// so we don't report them as errors. See lib/logger.ts.
if (res.status !== 401) {
logFailedRequest({ path, method, status: res.status, error });
}
throw new ApiError(error, res.status);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
@@ -160,6 +170,23 @@ export function deleteRole(id: string): Promise<{ ok: boolean }> {
return apiFetch(`/api/roles/${id}`, { method: "DELETE" });
}
// --- Application logs (app_logs) ------------------------------------------
/** Read recent diagnostic logs (gated server-side by log:read). */
export function fetchLogs(params: {
limit?: number;
level?: string;
source?: string;
since?: string;
} = {}): Promise<{ logs: AppLogRecord[] }> {
const q = new URLSearchParams();
if (params.limit) q.set("limit", String(params.limit));
if (params.level) q.set("level", params.level);
if (params.source) q.set("source", params.source);
if (params.since) q.set("since", params.since);
const qs = q.toString();
return apiFetch(`/api/logs${qs ? `?${qs}` : ""}`);
}
// --- Device setup ---------------------------------------------------------
export interface ConfigField {
@@ -632,7 +659,8 @@ export function fetchDeviceStatus(): Promise<{ devices: DeviceStatus[] }> {
/** A persisted ledger row. Re-exported from shared so UI code has one source of
* truth for the event shape (the same type the WS pushes). */
export type { LedgerEvent } from "@parking/shared";
export type { LedgerEvent, LogLevel, LogSource } from "@parking/shared";
export type { AppLogRecord };
/** Recent ledger events, newest first (default 100, max 1000). Used for the
* booth feed's initial load; live updates then arrive over the WS. `since` (ISO)