Diagnostics
What nd-diag records in every app without code, and how to read an incident bundle after a panic, crash, or hang.
Every nd app runs nd-diag from its first frame. You don't need to enable anything. Logs roll to disk, and a flight recorder keeps the last inputs and frames in memory. A panic, a native crash, or a main-thread hang writes an incident bundle that you can read with nd diagnose.

Captured on macOS 27.0, 2026-10-22.

Captured on NixOS 26.11 (Zokor), Hyprland, 2026-10-22.

Captured on Windows 11 26H2, 2026-10-22.
Where files go
Files go under the app data directory (ND_APP_DATA_DIR overrides it):
logs/<app>.log: rolls at 10 MB. nd-diag keeps 7 gzipped files and deletes them after 7 days.diagnostics/<utc timestamp>-<kind>/: one directory per incident.kindispanic,crash,hang, orreport.
ND_LOG takes a tracing EnvFilter. The default is info.
What a bundle holds
| File | Content |
|---|---|
panic.txt, hang.json, crash.json | The trigger: the message and backtrace, the blocked thread and time, or the crash signal. |
minidump.dmp | Every thread's stack, for crashes and hangs, when the crash helper runs. |
log.ndjson | The last 5000 log events. |
tree.json | The semantic tree of every window (the same schema as the automation getTree) and, on macOS, the native view and layer hierarchy. |
input.ndjson | The last 200 inputs. Release builds replace typed text with bullets; ND_DIAG_KEEP_TEXT=1 keeps it. |
frames/ | Per-frame layout diffs of the last 120 frames on macOS; the last presented frame per window on the GPUI backends. |
profile.json | Frame timing histograms against the display interval. |
meta.json | App, version, git sha, OS, and backend. |
macOS doesn't record pixels in production. An AppKit window has no drawable that the app owns, and renderInContext misses glass, vibrancy, and web content. The layout diffs and the native hierarchy take the place of pixels.
The hello test below panics the example on purpose and asserts on the bundle that it leaves.
let found = bundles(data.path(), "panic");
assert_eq!(found.len(), 1, "one panic bundle: {found:?}");
let dir = &found[0];
let panic = fs::read_to_string(dir.join("panic.txt")).expect("panic.txt");
assert!(panic.contains("ND_DEBUG_PANIC: forced panic"), "{panic}");
assert!(panic.contains("panicked at"), "{panic}");
assert!(panic.contains("after_first_frame"), "backtrace names the trigger:\n{panic}");
assert_snapshot(dir, "panic");
let tree = json(&dir.join("tree.json"));
let native = &tree["native"]["windows"][0];
// A scaffold makes its split view controller the window's content, with nd's root view
// over it, so the hierarchy holds nd's views below the content view.
assert!(native["content"].to_string().contains("NdRootView"), "native hierarchy: {native}");
let inputs = lines(&dir.join("input.ndjson"));
let kinds: Vec<&str> = inputs.iter().filter_map(|i| i["kind"].as_str()).collect();
assert_eq!(kinds, ["mouse_down", "mouse_up", "key_up"]);
assert_eq!(inputs[2]["text"], "n", "debug builds keep typed text");
assert!(inputs[0]["position"].is_array());
let log = lines(&dir.join("log.ndjson"));
assert!(
log.iter().any(|l| l["level"] == "ERROR" && l["message"].as_str().is_some_and(|m| m.starts_with("panic:")))
);
assert_minidump(dir);
From examples/hello/tests/incidents.rs, line 119.
Panics, crashes and hangs
A panic hook writes the -panic bundle, then the process exits with code 101. A helper process (crash-handler plus minidumper) catches native crashes. While something changes, the app refreshes a snapshot of the tree, inputs, and log once a second. After a crash, the helper writes the -crash bundle from that snapshot and the minidump. Set ND_DIAG_CRASH_HANDLER=0 to run without the helper.
A watchdog thread keeps one ping queued on the main thread. When the ping stays unanswered for 5 s (ND_DIAG_HANG_MS changes the threshold), the watchdog writes the -hang bundle with the main thread's stack. A hang doesn't end the process: the app keeps running, and a log line says when the main thread answers again. The planned wait-or-relaunch window after 10 s isn't built yet.
For tests, ND_DEBUG_PANIC=1, ND_DEBUG_CRASH=1, and ND_DEBUG_HANG_MS=<ms> trigger each one after the first frame in the AppKit host. incidents.rs uses them on macOS.
Reading a bundle
nd diagnose --last # summary of the newest bundle
nd diagnose --list # bundles, newest first
nd diagnose <id> --json # machine-readable
The summary has what an agent needs before it reproduces a bug: the trigger and top frames, the semantic tree, the last inputs, the slowest frames, and the log tail. --app <name> picks the app when the current directory doesn't identify it. The test below runs it over a real panic bundle.
let text = output(&args(false), &dir).expect("diagnose");
assert!(text.contains("panic on thread"), "{text}");
assert!(text.contains("ND_DEBUG_PANIC: forced panic"), "{text}");
assert!(text.contains("after_first_frame"), "top frames name the trigger:\n{text}");
assert!(text.contains("button \"Add one\""), "semantic tree:\n{text}");
assert!(text.contains("key_up") && text.contains("\"n\""), "inputs:\n{text}");
assert!(text.contains("frames,") && text.contains("log tail"), "{text}");
assert!(text.contains("minidump:"), "{text}");
From examples/hello/tests/incidents.rs, line 162.
Report an issue from the app
let bundle: Option<std::path::PathBuf> = nd::report_issue("user report");
This writes a -report bundle and returns its directory. It returns None when diagnostics aren't running, which is always the case on the web. Wire it to a Help menu item that reveals the directory. For reveal_path, see system.
Retention
nd-diag keeps the newest 20 bundles, none older than 14 days, and 200 MB in total. Cleanup runs on a background thread after the first frame.
The web
On the web, nd-diag installs only the panic hook, which writes to the browser console. A browser tab has no files, threads, or helper process, so there are no bundles.
Tracy
The tracy feature of nd-diag adds a tracing layer for the Tracy profiler (on demand, localhost only). Without the feature, none of it is compiled in. For the frame budgets, see performance.