// 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"; import { getDesktopCsrfToken } from "./desktop-csrf.js"; import { apiUrl, platformFetch } from "./origin.js"; import { inTauri } from "./tauri-env.js"; 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 | 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 { if (queue.length === 0) return; const entries = queue.splice(0, queue.length); flushing = true; try { const headers: Record = { "content-type": "application/json" }; // /api/logs is behind requireAuth → assertCsrf on POST. On desktop the // cookie is unreadable (see desktop-csrf.ts) — without this branch every // desktop flush 403'd and was dropped here, silently, by design (found // 2026-09-04: no desktop client log had EVER reached app_logs). const csrf = inTauri() ? getDesktopCsrfToken() : readCookie(CSRF_COOKIE); if (csrf) headers[CSRF_HEADER] = csrf; await platformFetch(apiUrl(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). Browser * only — sendBeacon is a native browser API with no Tauri-HTTP-plugin equivalent, * so this drops silently in the desktop shell (unload is rare there; the regular * 4s-interval flush above covers the common case). */ 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(apiUrl(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); } }