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, &timestamp, &::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}