Files
parking_solution/apps/web/src/lib/ErrorBoundary.tsx
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

51 lines
1.9 KiB
TypeScript

import { Component, type ErrorInfo, type ReactNode } from "react";
import { logClient } from "./logger.js";
// Top-level React error boundary: catches a render/lifecycle crash anywhere in the
// tree, reports it to the backend log store (app_logs), and shows a minimal recovery
// screen instead of a white page. A booth must never be left staring at a blank
// screen with no trace of why. See wiki/concepts/app-logs.md.
interface State {
hasError: boolean;
message?: string;
}
export class ErrorBoundary extends Component<{ children: ReactNode }, State> {
override state: State = { hasError: false };
static getDerivedStateFromError(err: Error): State {
return { hasError: true, message: err.message };
}
override componentDidCatch(err: Error, info: ErrorInfo): void {
logClient({
level: "fatal",
message: err.message || "React render error",
stack: err.stack,
path: typeof location !== "undefined" ? location.pathname : undefined,
context: { kind: "react_error_boundary", componentStack: info.componentStack },
});
}
override render(): ReactNode {
if (!this.state.hasError) return this.props.children;
// Intentionally un-i18n'd + dependency-free: the app tree just crashed, so we can't
// assume providers (i18n/router/query) are healthy.
return (
<div style={{ padding: "2rem", fontFamily: "monospace", color: "#e5e5e5", background: "#0a0a0a", minHeight: "100vh" }}>
<h1 style={{ color: "#ef4444" }}>Something went wrong</h1>
<p>The screen crashed and has been reported. Try reloading.</p>
{this.state.message && <pre style={{ color: "#a3a3a3" }}>{this.state.message}</pre>}
<button
type="button"
onClick={() => location.reload()}
style={{ marginTop: "1rem", padding: "0.5rem 1rem", cursor: "pointer" }}
>
Reload
</button>
</div>
);
}
}