ipc: log_frontend bridge + debug bootstrap forwards js errors to rust
- SHA
17f83dca321adc157de2e66025277213b69f1818- Parents
-
89b9e84 - Tree
86c1170
17f83dc
17f83dca321adc157de2e66025277213b69f181889b9e84
86c1170| Status | File | + | - |
|---|---|---|---|
| M |
src-tauri/src/commands.rs
|
40 | 0 |
| A |
src/lib/debug.ts
|
86 | 0 |
| M |
src/lib/ipc/client.ts
|
24 | 0 |
| M |
src/main.tsx
|
5 | 0 |
src-tauri/src/commands.rsmodified@@ -601,6 +601,46 @@ pub fn shutdown_active_ptys(state: &AppState) { | ||
| 601 | 601 | } |
| 602 | 602 | } |
| 603 | 603 | |
| 604 | +// ============================================================================ | |
| 605 | +// Frontend log bridge | |
| 606 | +// ============================================================================ | |
| 607 | + | |
| 608 | +/// Write a structured log entry from the webview into Rust tracing. | |
| 609 | +/// Wired up from `src/lib/debug.ts` to capture `window.onerror`, | |
| 610 | +/// `unhandledrejection`, and `console.error` — anything that would | |
| 611 | +/// otherwise silently crash the React tree without leaving a trace | |
| 612 | +/// on disk. | |
| 613 | +/// | |
| 614 | +/// `level` is one of `"error"`, `"warn"`, `"info"`, `"debug"`; any | |
| 615 | +/// other value is treated as `"info"`. `source` is a free-form tag | |
| 616 | +/// (`"window.onerror"`, `"react"`, `"console.error"`, etc.) and | |
| 617 | +/// `message` is the payload. The optional `stack` argument carries | |
| 618 | +/// a JS stack trace when available. | |
| 619 | +#[tauri::command] | |
| 620 | +pub fn log_frontend( | |
| 621 | + level: String, | |
| 622 | + source: String, | |
| 623 | + message: String, | |
| 624 | + stack: Option<String>, | |
| 625 | +) -> IpcResult<()> { | |
| 626 | + let stack_fmt = stack.as_deref().unwrap_or(""); | |
| 627 | + match level.as_str() { | |
| 628 | + "error" => { | |
| 629 | + tracing::error!(target: "claudex::frontend", source = %source, stack = %stack_fmt, "{message}"); | |
| 630 | + } | |
| 631 | + "warn" => { | |
| 632 | + tracing::warn!(target: "claudex::frontend", source = %source, stack = %stack_fmt, "{message}"); | |
| 633 | + } | |
| 634 | + "debug" => { | |
| 635 | + tracing::debug!(target: "claudex::frontend", source = %source, stack = %stack_fmt, "{message}"); | |
| 636 | + } | |
| 637 | + _ => { | |
| 638 | + tracing::info!(target: "claudex::frontend", source = %source, stack = %stack_fmt, "{message}"); | |
| 639 | + } | |
| 640 | + } | |
| 641 | + Ok(()) | |
| 642 | +} | |
| 643 | + | |
| 604 | 644 | /// All session summaries for a single project (by **encoded source |
| 605 | 645 | /// dir**, not the merged project id), newest first. |
| 606 | 646 | /// |
src/lib/debug.tsadded@@ -0,0 +1,86 @@ | ||
| 1 | +// Frontend error bridge. Installs global handlers that forward JS | |
| 2 | +// errors into Rust tracing (which writes them to | |
| 3 | +// `~/Library/Logs/claudex/claudex.log.<date>`) so a silent React | |
| 4 | +// crash doesn't leave us flying blind. | |
| 5 | +// | |
| 6 | +// What gets captured: | |
| 7 | +// * `window.onerror` — uncaught exceptions from synchronous code | |
| 8 | +// * `window.addEventListener("unhandledrejection")` — rejected | |
| 9 | +// promises with no `.catch()` | |
| 10 | +// * `console.error` / `console.warn` — anything React's error | |
| 11 | +// boundary subsystem funnels through the console, plus our own | |
| 12 | +// explicit `console.error` calls | |
| 13 | +// | |
| 14 | +// Install once from the app entrypoint, before React mounts. | |
| 15 | + | |
| 16 | +import { logFrontend, type LogLevel } from "@/lib/ipc/client"; | |
| 17 | + | |
| 18 | +let installed = false; | |
| 19 | + | |
| 20 | +export function installDebugBridge(): void { | |
| 21 | + if (installed) return; | |
| 22 | + installed = true; | |
| 23 | + | |
| 24 | + // Raw sync errors — message + stack end up on disk. | |
| 25 | + window.addEventListener("error", (ev) => { | |
| 26 | + const err = ev.error as unknown; | |
| 27 | + const message = formatMessage(ev.message, err); | |
| 28 | + const stack = extractStack(err); | |
| 29 | + void logFrontend("error", "window.onerror", message, stack); | |
| 30 | + }); | |
| 31 | + | |
| 32 | + // Unhandled promise rejections. | |
| 33 | + window.addEventListener("unhandledrejection", (ev) => { | |
| 34 | + const reason = ev.reason as unknown; | |
| 35 | + const message = formatMessage("unhandledrejection", reason); | |
| 36 | + const stack = extractStack(reason); | |
| 37 | + void logFrontend("error", "unhandledrejection", message, stack); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + // Shadow console.error / console.warn so anything that goes to | |
| 41 | + // devtools also shows up in the log file. We keep the original | |
| 42 | + // so the devtools output is unchanged. | |
| 43 | + wrapConsole("error"); | |
| 44 | + wrapConsole("warn"); | |
| 45 | +} | |
| 46 | + | |
| 47 | +function wrapConsole(level: "error" | "warn") { | |
| 48 | + const original = console[level].bind(console); | |
| 49 | + const mapped: LogLevel = level; | |
| 50 | + console[level] = (...args: unknown[]) => { | |
| 51 | + original(...args); | |
| 52 | + try { | |
| 53 | + const message = args.map(stringify).join(" "); | |
| 54 | + const stack = args.map(extractStack).find((s): s is string => !!s); | |
| 55 | + void logFrontend(mapped, `console.${level}`, message, stack); | |
| 56 | + } catch { | |
| 57 | + // Never let logging fail noisily. | |
| 58 | + } | |
| 59 | + }; | |
| 60 | +} | |
| 61 | + | |
| 62 | +function formatMessage(fallback: string, err: unknown): string { | |
| 63 | + if (err instanceof Error) return err.message || fallback; | |
| 64 | + if (typeof err === "string") return err; | |
| 65 | + if (err == null) return fallback; | |
| 66 | + try { | |
| 67 | + return JSON.stringify(err); | |
| 68 | + } catch { | |
| 69 | + return fallback; | |
| 70 | + } | |
| 71 | +} | |
| 72 | + | |
| 73 | +function extractStack(err: unknown): string | undefined { | |
| 74 | + if (err instanceof Error && typeof err.stack === "string") return err.stack; | |
| 75 | + return undefined; | |
| 76 | +} | |
| 77 | + | |
| 78 | +function stringify(v: unknown): string { | |
| 79 | + if (v instanceof Error) return `${v.name}: ${v.message}`; | |
| 80 | + if (typeof v === "string") return v; | |
| 81 | + try { | |
| 82 | + return JSON.stringify(v); | |
| 83 | + } catch { | |
| 84 | + return String(v); | |
| 85 | + } | |
| 86 | +} | |
src/lib/ipc/client.tsmodified@@ -240,3 +240,27 @@ export async function onPtyExit( | ||
| 240 | 240 | ): Promise<UnlistenFn> { |
| 241 | 241 | return listen<PtyExitEvent>("pty:exit", (e) => cb(e.payload)); |
| 242 | 242 | } |
| 243 | + | |
| 244 | +// ============================================================================ | |
| 245 | +// Frontend log bridge | |
| 246 | +// ============================================================================ | |
| 247 | + | |
| 248 | +export type LogLevel = "error" | "warn" | "info" | "debug"; | |
| 249 | + | |
| 250 | +/** Forward a structured log entry into Rust tracing, which writes | |
| 251 | + * it to the daily-rotated file at | |
| 252 | + * `~/Library/Logs/claudex/claudex.log.<date>`. Used by the global | |
| 253 | + * error handlers in `src/lib/debug.ts` so a silent React crash | |
| 254 | + * still leaves a trace on disk. */ | |
| 255 | +export async function logFrontend( | |
| 256 | + level: LogLevel, | |
| 257 | + source: string, | |
| 258 | + message: string, | |
| 259 | + stack?: string, | |
| 260 | +): Promise<void> { | |
| 261 | + try { | |
| 262 | + await invoke("log_frontend", { level, source, message, stack }); | |
| 263 | + } catch { | |
| 264 | + // Swallow — logging must never raise its own errors. | |
| 265 | + } | |
| 266 | +} | |
src/main.tsxmodified@@ -1,8 +1,13 @@ | ||
| 1 | 1 | import React from "react"; |
| 2 | 2 | import ReactDOM from "react-dom/client"; |
| 3 | 3 | import App from "./App"; |
| 4 | +import { installDebugBridge } from "./lib/debug"; | |
| 4 | 5 | import "./index.css"; |
| 5 | 6 | |
| 7 | +// Install the frontend→Rust log bridge before React mounts so any | |
| 8 | +// crash during initial render still lands in ~/Library/Logs/claudex. | |
| 9 | +installDebugBridge(); | |
| 10 | + | |
| 6 | 11 | ReactDOM.createRoot(document.getElementById("root")!).render( |
| 7 | 12 | <React.StrictMode> |
| 8 | 13 | <App /> |