bfb6ab0b36
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
51 lines
1.9 KiB
TypeScript
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>
|
|
);
|
|
}
|
|
}
|