tenseleyflow/claudex / 17f83dc

Browse files

ipc: log_frontend bridge + debug bootstrap forwards js errors to rust

Authored by espadonne
SHA
17f83dca321adc157de2e66025277213b69f1818
Parents
89b9e84
Tree
86c1170

4 changed files

StatusFile+-
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) {
601601
     }
602602
 }
603603
 
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
+
604644
 /// All session summaries for a single project (by **encoded source
605645
 /// dir**, not the merged project id), newest first.
606646
 ///
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(
240240
 ): Promise<UnlistenFn> {
241241
   return listen<PtyExitEvent>("pty:exit", (e) => cb(e.payload));
242242
 }
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 @@
11
 import React from "react";
22
 import ReactDOM from "react-dom/client";
33
 import App from "./App";
4
+import { installDebugBridge } from "./lib/debug";
45
 import "./index.css";
56
 
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
+
611
 ReactDOM.createRoot(document.getElementById("root")!).render(
712
   <React.StrictMode>
813
     <App />