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. There is nothing to enable: logs roll to disk, a flight recorder keeps the last inputs and frames in memory, and a panic, native crash or main-thread hang writes an incident bundle 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. It also runs live in the docs app.
Hello on macOS

Captured on macOS 27.0, 2026-10-14.

Where files go

Under the app data dir (ND_APP_DATA_DIR overrides it):

ND_LOG takes a tracing EnvFilter (default info).

What a bundle holds

FileContent
panic.txt, hang.json, crash.jsonThe trigger: 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. Typed text is replaced by bullets in release builds; 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, backend.

macOS does not record pixels in production: an AppKit window has no drawable the app owns, and renderInContext misses glass, vibrancy and web content. The layout diffs and the native hierarchy stand in for them.

The hello test below panics the example on purpose and asserts the bundle 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 native crash is caught by a helper process (crash-handler plus minidumper): the app refreshes a snapshot of the tree, inputs and log once a second while something changes, and after a crash the helper writes the -crash bundle from that snapshot plus 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 it stays unanswered for 5 s (ND_DIAG_HANG_MS changes the threshold) the -hang bundle is written with the main thread's stack. The app keeps running; a hang does not end the process, and a log line says when the main thread answers again. The planned wait-or-relaunch window after 10 s is not built.

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 is what an agent needs before reproducing a bug: the trigger and top frames, the semantic tree, the last inputs, slowest frames and the log tail. --app <name> picks the app when the current directory does not say. 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 are not running, which is always the case on the web. Wire it to a Help menu item that reveals the directory; system covers reveal_path.

Retention

Newest 20 bundles, none older than 14 days, 200 MB in total. Cleanup runs on a background thread after the first frame.

The web

Only the panic hook is installed, and it writes to the browser console. Files, threads and a helper process do not exist in a tab, so there are no bundles.

Tracy

The tracy feature of nd-diag adds a tracing layer for the Tracy profiler (on demand, localhost only). Nothing is compiled in without it. See performance for the frame budgets.