crashlog/lib.rs
1//! # Crashlog: Panic handling for humans
2//!
3//! Inspired by [human-panic](https://lib.rs/crates/human-panic), but with the following
4//! goals/improvements:
5//! - Fewer dependencies
6//! - Uses [`std::backtrace`] for backtraces instead of a third-party crate.
7//! - Writes logs in a plain-text format; no need for [`serde`][serde].
8//! - Simplifies color support so third-party libraries aren't needed.
9//! - Customizable message (WIP)
10//! - Includes timestamps in logs
11//!
12//! [serde]: https://crates.io/crates/serde
13//!
14//! # Example
15//!
16//! When a program using Crashlog panics, it prints a message like this:
17//! ```text
18//! $ westwood
19//!
20//! thread 'main' panicked at src/main.rs:100:5:
21//! explicit panic
22//! note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
23//!
24//! ---
25//!
26//! Uh oh! Westwood crashed.
27//!
28//! A crash log was saved at the following path:
29//! /var/folders/sr/kr0r9zfn6wj5pfw35xl47wlm0000gn/T/aaa750e1c7ca7487.txt
30//!
31//! To help us figure out why this happened, please report this crash.
32//! Either open a new issue on GitHub [1] or send an email to the author(s) [2].
33//! Attach the file listed above or copy and paste its contents into the report.
34//!
35//! [1]: https://github.com/kdkasad/westwood/issues/new
36//! [2]: Kian Kasad <kian@kasad.com>
37//!
38//! For your privacy, we don't automatically collect any information, so we rely on
39//! users to submit crash reports to help us find issues. Thank you!
40//! ```
41//!
42//! As mentioned in the message, a crash log file is produced, which looks like this:
43//! ```text
44//! Package: Westwood
45//! Binary: westwood
46//! Version: 0.0.0
47//!
48//! Architecture: arm64
49//! Operating system: Mac OS 15.4.1 [64-bit]
50//! Timestamp: 2025-05-12 22:10:11.191447 UTC
51//!
52//! Message: explicit panic
53//! Source location: src/main.rs:100
54//!
55//! 0: std::backtrace::Backtrace::create
56//! 1: crashlog::setup::{{closure}}
57//! 2: std::panicking::rust_panic_with_hook
58//! 3: std::panicking::begin_panic_handler::{{closure}}
59//! 4: std::sys::backtrace::__rust_end_short_backtrace
60//! 5: _rust_begin_unwind
61//! 6: core::panicking::panic_fmt
62//! 7: core::panicking::panic_explicit
63//! 8: westwood::main::panic_cold_explicit
64//! 9: westwood::main
65//! 10: std::sys::backtrace::__rust_begin_short_backtrace
66//! 11: std::rt::lang_start::{{closure}}
67//! 12: std::rt::lang_start_internal
68//! 13: _main
69//! ```
70//!
71//! # Usage
72//!
73//! Simply call [`crashlog::setup!()`][crate::setup!] to register the panic handler.
74//!
75//! ```ignore
76//! crashlog::setup!(ProgramMetadata { /* ... */ }, false);
77//! ```
78//!
79//! You can use the [`cargo_metadata!()`] helper macro to automatically extract the metadata from
80//! your `Cargo.toml` file.
81//!
82//! ```compile_fail
83//! // This example doesn't compile because tests/examples don't have the proper metadata
84//! // set by Cargo.
85//! use crashlog::cargo_metadata;
86//! crashlog::setup!(cargo_metadata!().capitalized(), false);
87//! ```
88//!
89//! You can also provide a default placeholder in case some metadata entries are missing, instead
90//! of that causing a compilation error.
91//!
92//! ```
93//! # use crashlog::cargo_metadata;
94//! crashlog::setup!(cargo_metadata!(default = "(unknown)"), true);
95//! ```
96//!
97//! Finally, you can provide your own panic message to be printed to the user. See [`setup!()`] for
98//! information on how to do so.
99//!
100//! ```
101//! # use crashlog::cargo_metadata;
102//! crashlog::setup!(cargo_metadata!(default = "(unknown)"), false, "\
103//! {package} crashed. Please go to {repository}/issues/new
104//! and paste the contents of {log_path}.
105//! ");
106//! ```
107//!
108//! # Implementation notes
109//!
110//! ## When Crashlog fails
111//!
112//! Creating the crash log file can fail. If it does, the original panic hook is called,
113//! regardless of the value of the `replace` argument to [`setup!()`].
114//!
115//! ## Backtrace formatting
116//!
117//! The backtrace is handled by [`std::backtrace`], and looks different in debug mode vs. release
118//! mode. The backtrace in the example log above is produced by a program compiled in release mode,
119//! as that resembles production crashes.
120//!
121//! Run `cargo run --example backtrace` with and without the `-r` flag in this project's repository
122//! to see the difference.
123
124use std::{
125 backtrace::Backtrace,
126 borrow::Cow,
127 fs::File,
128 io::{BufWriter, Write},
129 panic::PanicHookInfo,
130 path::PathBuf,
131};
132
133use chrono::{DateTime, Utc};
134
135/// Attempts to generate a crash log and write it to a file.
136/// The file is placed in a temporary directory as given by [`std::env::temp_dir()`].
137/// If creating or writing to the file fails, `None` is returned, otherwise `Some` is returned with
138/// the path of the log file.
139///
140/// This is an internal function, and should not be called by users of Crashlog.
141#[doc(hidden)]
142pub fn try_generate_report(
143 metadata: &ProgramMetadata,
144 info: &PanicHookInfo,
145 timestamp: &DateTime<Utc>,
146 backtrace: &Backtrace,
147) -> Option<PathBuf> {
148 // Construct filename
149 let mut path = std::env::temp_dir();
150 path.push(format!("{:08x}.txt", getrandom::u64().ok()?));
151
152 // Open file and create buffer
153 let file = File::create(&path).ok()?;
154 let mut w = BufWriter::new(file);
155
156 // Write build information
157 let os = os_info::get();
158 writeln!(w, "Package: {}", metadata.package).ok()?;
159 writeln!(w, "Binary: {}", metadata.binary).ok()?;
160 writeln!(w, "Version: {}", metadata.version).ok()?;
161
162 writeln!(w).ok()?;
163
164 // Write system information
165 writeln!(w, "Architecture: {}", os.architecture().unwrap_or("(unknown)")).ok()?;
166 writeln!(w, "Operating system: {os}").ok()?;
167 writeln!(w, "Timestamp: {timestamp}").ok()?;
168
169 writeln!(w).ok()?;
170
171 // Write panic cause & location
172 let payload_str =
173 match (info.payload().downcast_ref::<&str>(), info.payload().downcast_ref::<String>()) {
174 (None, None) => "Unknown",
175 (Some(str), None) => *str,
176 (None, Some(string)) => string.as_str(),
177 (Some(_), Some(_)) => unreachable!(),
178 };
179 writeln!(w, "Message: {payload_str}").ok()?;
180 if let Some(loc) = info.location() {
181 writeln!(w, "Source location: {}:{}", loc.file(), loc.line()).ok()?;
182 } else {
183 writeln!(w, "Source location: (unknown)").ok()?;
184 }
185
186 writeln!(w).ok()?;
187
188 // Write backtrace
189 write!(w, "{}", backtrace).ok()?;
190
191 w.flush().ok()?;
192 Some(path)
193}
194
195/// Wrapper function for macro hygiene
196#[doc(hidden)]
197pub fn get_timestamp() -> DateTime<Utc> {
198 chrono::Utc::now()
199}
200
201/// Registers Crashlog's panic handler.
202///
203/// The first argument is a `metadata` structure which provides information about the program which
204/// will be included in the crash log file and in the message printed to the user.
205///
206/// If the second argument, `replace`, is `false`, Crashlog's panic handler will be appended to the
207/// current (or if none is set, the default) panic handler. If `true`, the current panic handler
208/// will be replaced.
209///
210/// The optional third argument allows you to specify a custom message to be printed to the user.
211/// This argument must be a string literal. It should use the regular [`std::fmt`] syntax for
212/// interpolating values. The fields of the `metadata` structure are all available as
213/// [named arguments][1], as well as `log_path`, which represents the path of the crash log file.
214/// For example, `"{package} crash log saved at {log_path}"`.
215/// If this argument is not given, [`DEFAULT_USER_MESSAGE_TEMPLATE`] is used.
216///
217/// [1]: std::fmt#named-parameters
218///
219/// With `replace` set to `false`:
220/// ```text
221/// $ westwood
222///
223/// thread 'main' panicked at src/main.rs:100:5:
224/// explicit panic
225/// note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
226///
227///
228/// ---
229///
230/// Uh oh! Westwood crashed.
231///
232/// A crash log was saved at the following path:
233/// /var/folders/sr/kr0r9zfn6wj5pfw35xl47wlm0000gn/T/20ea72fca069a0b7.txt
234///
235/// To help us figure out why this happened, please report this crash.
236/// Either open a new issue on GitHub [1] or send an email to the author(s) [2].
237/// Attach the file listed above or copy and paste its contents into the report.
238///
239/// [1]: https://github.com/kdkasad/westwood/issues/new
240/// [2]: Kian Kasad <kian@kasad.com>
241///
242/// For your privacy, we don't automatically collect any information, so we rely on
243/// users to submit crash reports to help us find issues. Thank you!
244/// ```
245///
246/// With `replace` set to `true`:
247/// ```text
248/// $ westwood
249/// Uh oh! Westwood crashed.
250///
251/// A crash log was saved at the following path:
252/// /var/folders/sr/kr0r9zfn6wj5pfw35xl47wlm0000gn/T/20ea72fca069a0b7.txt
253///
254/// To help us figure out why this happened, please report this crash.
255/// Either open a new issue on GitHub [1] or send an email to the author(s) [2].
256/// Attach the file listed above or copy and paste its contents into the report.
257///
258/// [1]: https://github.com/kdkasad/westwood/issues/new
259/// [2]: Kian Kasad <kian@kasad.com>
260///
261/// For your privacy, we don't automatically collect any information, so we rely on
262/// users to submit crash reports to help us find issues. Thank you!
263/// ```
264#[macro_export]
265macro_rules! setup {
266 ($metadata:expr, $replace:expr) => {
267 $crate::setup!(
268 $metadata,
269 $replace,
270 // WARNING: If changing the message below, also change DEFAULT_USER_MESSAGE_TEMPLATE
271 "\
272Uh oh! {package} crashed.
273
274A crash log was saved at the following path:
275{log_path}
276
277To help us figure out why this happened, please report this crash.
278Either open a new issue on GitHub [1] or send an email to the author(s) [2].
279Attach the file listed above or copy and paste its contents into the report.
280
281[1]: {repository}/issues/new
282[2]: {authors}
283
284For your privacy, we don't automatically collect any information, so we rely on
285users to submit crash reports to help us find issues. Thank you!"
286 )
287 };
288
289 ($metadata:expr, $replace:expr, $template:literal) => {{
290 let metadata = $metadata;
291 let replace = $replace;
292 let old_hook = std::panic::take_hook();
293 let new_hook = ::std::boxed::Box::new(move |info: &::std::panic::PanicHookInfo| {
294 // Get timestamp before running old hook
295 let timestamp = $crate::get_timestamp();
296
297 if !replace {
298 old_hook(info);
299 }
300
301 if let Some(log_path) =
302 $crate::try_generate_report(&metadata, info, ×tamp, &::std::backtrace::Backtrace::force_capture())
303 {
304 if <::std::io::Stderr as ::std::io::IsTerminal>::is_terminal(&::std::io::stderr()) {
305 eprint!("\x1b[31m");
306 }
307 if !replace {
308 eprintln!("\n---\n");
309 }
310 eprintln!(
311 // Use all format specifiers with widths of 0 so they don't actually get
312 // produced. This is to silence the unused argument error.
313 concat!("{package:.0}{binary:.0}{version:.0}{repository:.0}{authors:.0}{log_path:.0}", $template),
314 package = metadata.package,
315 binary = metadata.binary,
316 version = metadata.version,
317 repository = metadata.repository,
318 authors = metadata.authors,
319 log_path = log_path.display(),
320 );
321 if <::std::io::Stderr as ::std::io::IsTerminal>::is_terminal(&::std::io::stderr()) {
322 eprint!("\x1b[m");
323 }
324 } else if !replace {
325 // If creating the crash log failed, and we didn't already run the default hook,
326 // run it now.
327 old_hook(info);
328 }
329 });
330 ::std::panic::set_hook(new_hook);
331 }};
332}
333
334/// Default user message template
335pub const DEFAULT_USER_MESSAGE_TEMPLATE: &str = "\
336Uh oh! {package} crashed.
337
338A crash log was saved at the following path:
339{log_path}
340
341To help us figure out why this happened, please report this crash.
342Either open a new issue on GitHub [1] or send an email to the author(s) [2].
343Attach the file listed above or copy and paste its contents into the report.
344
345[1]: {repository}/issues/new
346[2]: {authors}
347
348For your privacy, we don't automatically collect any information, so we rely on
349users to submit crash reports to help us find issues. Thank you!";
350
351/// Metadata about the program to be printed in the crash report.
352///
353/// Typically sourced from `Cargo.toml` using the `CARGO_PKG_*` environment variables.
354/// Use [`cargo_metadata!()`] to create a `ProgramMetadata` filled with values from `Cargo.toml`.
355#[derive(Debug, Clone)]
356pub struct ProgramMetadata {
357 pub package: Cow<'static, str>,
358 pub binary: Cow<'static, str>,
359 pub version: Cow<'static, str>,
360 pub repository: Cow<'static, str>,
361 pub authors: Cow<'static, str>,
362}
363
364impl ProgramMetadata {
365 /// Capitalizes the first letter of the package name.
366 ///
367 /// # Example
368 ///
369 /// ```
370 /// use crashlog::cargo_metadata;
371 /// crashlog::setup!(cargo_metadata!(default = "").capitalized(), false);
372 /// ```
373 pub fn capitalized(self) -> Self {
374 let mut new = self;
375 let mut chars = new.package.chars();
376 new.package = chars
377 .next()
378 .iter()
379 .flat_map(|first_letter| first_letter.to_uppercase())
380 .chain(chars)
381 .collect::<String>()
382 .into();
383 new
384 }
385}
386
387/// Macro to generate a [`ProgramMetadata`] structure using information from Cargo.
388///
389/// The metadata is retrieved from [environment variables set by Cargo][1]. If any of the
390/// expected environment variables are not set, compilation will fail. To avoid this, you can
391/// provide a default placeholder by providing `default = "..."` as arguments to the macro. The
392/// string literal can contain any value.
393///
394/// [1]: https://doc.rust-lang.org/stable/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-crates
395///
396/// # Examples
397///
398/// ```compile_fail
399/// # use crashlog::cargo_metadata;
400/// // This will fail to compile because examples don't have CARGO_BIN_NAME set.
401/// let m = cargo_metadata!();
402/// ```
403///
404/// ```
405/// # use crashlog::cargo_metadata;
406/// let m = cargo_metadata!(default = "");
407/// assert_eq!(m.package, "crashlog");
408/// assert_eq!(m.binary, "");
409/// ```
410#[macro_export]
411macro_rules! cargo_metadata {
412 () => {
413 $crate::ProgramMetadata {
414 package: ::std::borrow::Cow::Borrowed(env!("CARGO_PKG_NAME")),
415 binary: ::std::borrow::Cow::Borrowed(env!("CARGO_BIN_NAME")),
416 version: ::std::borrow::Cow::Borrowed(env!("CARGO_PKG_VERSION")),
417 repository: ::std::borrow::Cow::Borrowed(env!("CARGO_PKG_REPOSITORY")),
418 authors: $crate::cow_replace(env!("CARGO_PKG_AUTHORS"), ":", ", "),
419 }
420 };
421
422 (default = $placeholder:literal) => {
423 $crate::ProgramMetadata {
424 package: ::std::borrow::Cow::Borrowed(
425 option_env!("CARGO_PKG_NAME").unwrap_or($placeholder),
426 ),
427 binary: ::std::borrow::Cow::Borrowed(
428 option_env!("CARGO_BIN_NAME").unwrap_or($placeholder),
429 ),
430 version: ::std::borrow::Cow::Borrowed(
431 option_env!("CARGO_PKG_VERSION").unwrap_or($placeholder),
432 ),
433 repository: ::std::borrow::Cow::Borrowed(
434 option_env!("CARGO_PKG_REPOSITORY").unwrap_or($placeholder),
435 ),
436 authors: option_env!("CARGO_PKG_AUTHORS")
437 .map_or(::std::borrow::Cow::Borrowed($placeholder), |s| {
438 $crate::cow_replace(s, ":", ", ")
439 }),
440 }
441 };
442}
443
444/// Like [`str::replace()`], but only clones the string if a replacement is needed.
445///
446/// This is an internal helper function and is not part of Crashlog's API.
447/// You should not use this function.
448#[doc(hidden)]
449pub fn cow_replace<'a>(s: &'a str, from: &str, to: &str) -> Cow<'a, str> {
450 if s.contains(from) {
451 Cow::Owned(s.replace(from, to))
452 } else {
453 Cow::Borrowed(s)
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn capitalize_package_name() {
463 let metadata = ProgramMetadata {
464 package: "crashlog".into(),
465 binary: "".into(),
466 version: "".into(),
467 repository: "".into(),
468 authors: "".into(),
469 };
470 let new = metadata.capitalized();
471 assert_eq!("Crashlog", new.package);
472 let mut empty = new;
473 empty.package = "".into();
474 let new = empty.capitalized();
475 assert_eq!("", new.package);
476 }
477
478 #[test]
479 fn metadata_placeholder() {
480 // For unit tests, Cargo does not set `CARGO_BIN_NAME`, so we expect the placeholder.
481 let metadata = cargo_metadata!(default = "place");
482 assert_eq!(metadata.binary, "place");
483 }
484
485 #[test]
486 fn test_cow_replace() {
487 // When found, string should be copied and replaced
488 let s = "abc:def";
489 assert!(matches!(cow_replace(s, ":", ","), Cow::Owned(val) if val == "abc,def"));
490
491 // When not found, string should not be copied
492 let s = "abc:def";
493 assert!(matches!(cow_replace(s, "+", " "), Cow::Borrowed(val) if val == s));
494 }
495}