NativeDesktop

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.

Example: Hello. The smallest app: a toolbar button, a count and window controls placed by coordinates. Source in examples/hello.
Runs Hello in this page as WebAssembly.
Hello on macOS

Captured on macOS 27.0, 2026-10-22.

Hello on Linux

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

Hello on Windows

Captured on Windows 11 26H2, 2026-10-22.

Where files go

Files go under the app data directory (ND_APP_DATA_DIR overrides it):

ND_LOG takes a tracing EnvFilter. The default is info.

What a bundle holds

FileContent
panic.txt, hang.json, crash.jsonThe trigger: the message and backtrace, the blocked thread and time, or the crash signal.
minidump.dmpEvery thread's stack, for crashes and hangs, when the crash helper runs.
log.ndjsonThe last 5000 log events.
tree.jsonThe semantic tree of every window (the same schema as the automation getTree) and, on macOS, the native view and layer hierarchy.
input.ndjsonThe 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.jsonFrame timing histograms against the display interval.
meta.jsonApp, 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.