Files
parking_solution/apps/web/src/lib/logger.ts
T
julian bfb6ab0b36 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
2026-06-19 12:54:22 +02:00

199 lines
7.1 KiB
TypeScript

// Frontend error/log collector. Ships failed requests, uncaught errors, and rejected
// promises to the backend (POST /api/logs → app_logs), so a booth problem is
// diagnosable from the host instead of needing the operator's devtools. See
// wiki/concepts/app-logs.md.
//
// Design notes:
// - BATCHED + THROTTLED: entries queue and flush on a short timer (and on page hide
// via sendBeacon), so a burst of errors is one request, not hundreds.
// - LOOP-SAFE: a failure of the /api/logs request itself is NEVER re-logged (that
// would be an infinite error → log → error spiral). We also never recurse through
// apiFetch — the flush uses raw fetch/sendBeacon.
// - LEVEL-GATED noise: console.warn/error are only forwarded when the client log
// level is debug/trace (off by default) — they're noisy (3rd-party chatter). The
// high-signal sources (failed requests, uncaught errors) are always captured.
import { LOG_LEVEL_ORDER, type ClientLogInput, type LogLevel } from "@parking/shared";
const ENDPOINT = "/api/logs";
const FLUSH_MS = 4000;
const MAX_QUEUE = 100; // drop oldest beyond this (bounded memory on a long-lived booth)
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
/** The client capture threshold. Entries below this level are dropped before queueing.
* Default `info`: failed requests (error) + uncaught errors (error) always pass;
* console.warn/error forwarding is wired separately and only ON at debug/trace. */
let clientLevel: LogLevel = (import.meta.env.VITE_LOG_LEVEL as LogLevel) || "info";
export function setClientLogLevel(level: LogLevel): void {
clientLevel = level;
}
export function getClientLogLevel(): LogLevel {
return clientLevel;
}
/** Are console.warn/error forwarded? Only when the client level is debug or trace. */
function consoleForwardEnabled(): boolean {
return LOG_LEVEL_ORDER[clientLevel] <= LOG_LEVEL_ORDER.debug;
}
const queue: ClientLogInput[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
/** Set true only while flushing, so the flush's own network activity is never logged. */
let flushing = false;
function readCookie(name: string): string | null {
const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return m ? decodeURIComponent(m[1]!) : null;
}
function scheduleFlush(): void {
if (timer != null) return;
timer = setTimeout(() => {
timer = null;
void flush();
}, FLUSH_MS);
}
/** Enqueue an entry. Drops it if below the client level or if it concerns the log
* endpoint itself (loop guard). */
export function logClient(entry: ClientLogInput): void {
if (LOG_LEVEL_ORDER[entry.level] < LOG_LEVEL_ORDER[clientLevel]) return;
if (flushing) return; // don't log anything produced by the flush itself
if (entry.path && entry.path.startsWith(ENDPOINT)) return; // never log the log call
queue.push({ ...entry, at: entry.at ?? new Date().toISOString() });
if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE);
scheduleFlush();
}
/** POST the queued entries. Raw fetch (not apiFetch) so a failure can't recurse. A
* failed flush silently re-queues nothing — diagnostics are best-effort, never fatal. */
async function flush(): Promise<void> {
if (queue.length === 0) return;
const entries = queue.splice(0, queue.length);
flushing = true;
try {
const headers: Record<string, string> = { "content-type": "application/json" };
const csrf = readCookie(CSRF_COOKIE);
if (csrf) headers[CSRF_HEADER] = csrf;
await fetch(ENDPOINT, {
method: "POST",
headers,
credentials: "include",
body: JSON.stringify({ entries }),
keepalive: true,
});
} catch {
// Drop on failure — we must not re-log (loop) nor grow unbounded.
} finally {
flushing = false;
}
}
/** Best-effort synchronous flush on page hide (sendBeacon survives unload). */
function flushBeacon(): void {
if (queue.length === 0) return;
const entries = queue.splice(0, queue.length);
try {
const blob = new Blob([JSON.stringify({ entries })], { type: "application/json" });
// sendBeacon can't set the CSRF header; the server accepts the ingest for any
// signed-in session (cookie sent automatically). If CSRF later guards it strictly,
// this path degrades to "lost on unload" — acceptable for diagnostics.
navigator.sendBeacon(ENDPOINT, blob);
} catch {
/* ignore */
}
}
/** Record a FAILED API request (called from apiFetch's error path). Always high-signal. */
export function logFailedRequest(info: {
path: string;
method: string;
status: number;
error?: string;
requestId?: string;
}): void {
logClient({
level: "error",
message: `${info.method} ${info.path} → ${info.status}${info.error ? `: ${info.error}` : ""}`,
httpStatus: info.status,
path: info.path,
context: { kind: "request_failed", method: info.method, requestId: info.requestId },
});
}
let installed = false;
/** Wire global handlers once, at app startup. Idempotent. */
export function installClientLogging(): void {
if (installed || typeof window === "undefined") return;
installed = true;
// Uncaught runtime errors.
window.addEventListener("error", (e: ErrorEvent) => {
logClient({
level: "error",
message: e.message || "uncaught error",
stack: e.error?.stack,
path: location.pathname,
context: {
kind: "window_error",
filename: e.filename,
line: e.lineno,
col: e.colno,
},
});
});
// Unhandled promise rejections.
window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => {
const reason = e.reason;
const message =
reason instanceof Error ? reason.message : typeof reason === "string" ? reason : "unhandled rejection";
logClient({
level: "error",
message,
stack: reason instanceof Error ? reason.stack : undefined,
path: location.pathname,
context: { kind: "unhandled_rejection" },
});
});
// console.warn / console.error → only forwarded at debug/trace (noisy otherwise).
const origWarn = console.warn.bind(console);
const origError = console.error.bind(console);
console.warn = (...args: unknown[]) => {
origWarn(...args);
if (consoleForwardEnabled()) {
logClient({ level: "warn", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
}
};
console.error = (...args: unknown[]) => {
origError(...args);
if (consoleForwardEnabled()) {
logClient({ level: "error", message: stringifyArgs(args), path: location.pathname, context: { kind: "console" } });
}
};
// Flush on tab hide / unload.
window.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") flushBeacon();
});
window.addEventListener("pagehide", flushBeacon);
}
function stringifyArgs(args: unknown[]): string {
return args
.map((a) => (a instanceof Error ? a.message : typeof a === "string" ? a : safeStringify(a)))
.join(" ")
.slice(0, 2000);
}
function safeStringify(v: unknown): string {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
}