Skip to main content

detritus/
panic_hook.rs

1use std::{
2    backtrace::Backtrace,
3    ffi::OsStr,
4    fs, io,
5    panic::PanicHookInfo,
6    path::{Path, PathBuf},
7    sync::Arc,
8};
9
10use chrono::Utc;
11use detritus_protocol::{AttachmentManifest, BuildInfo, CrashKind, CrashMetadata, SourceId};
12use flate2::{Compression, write::GzEncoder};
13use secrecy::SecretString;
14use serde::{Deserialize, Serialize};
15use serde_json::json;
16use url::Url;
17use uuid::Uuid;
18
19#[cfg(test)]
20mod tests;
21
22#[cfg(all(feature = "minidump", not(target_os = "android")))]
23static MINIDUMPER_HANDLES: std::sync::OnceLock<
24    parking_lot::Mutex<Vec<minidumper_child::ClientHandle>>,
25> = std::sync::OnceLock::new();
26
27/// Configuration for the process-wide panic hook.
28#[derive(Debug, Clone)]
29pub struct PanicHookConfig {
30    /// HTTP endpoint for crash upload. A base server URL is joined with `/v1/crashes`.
31    pub endpoint: Url,
32    /// Bearer token used by [`crate::ship_pending_crashes`].
33    pub token: SecretString,
34    /// Source identity stored in crash metadata.
35    pub source: SourceId,
36    /// Root directory containing `pending/` and `sent/` crash entries.
37    pub spool_dir: PathBuf,
38    /// Crash artifact strategy.
39    pub kind: PanicKind,
40    /// Build metadata stored in crash reports.
41    pub build: BuildInfo,
42    /// Extra JSON context stored in crash reports.
43    pub context: serde_json::Value,
44    /// Extra files copied into each crash entry as attachments.
45    pub context_files: Vec<PathBuf>,
46    /// Number of days to retain successfully sent entries locally.
47    pub sent_retention_days: u64,
48}
49
50/// Crash artifact strategy used by [`install_panic_hook`].
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub enum PanicKind {
53    /// Native minidump output where platform support is available.
54    ///
55    /// `minidumper-child` supports Linux, Windows, and macOS. Android runtime
56    /// minidumps require Crashpad or Breakpad integration, so Android builds
57    /// should use [`PanicKind::PanicTarball`].
58    Minidump,
59    /// Portable panic bundle containing `panic.txt`, `backtrace.txt`, and `env.json`.
60    PanicTarball,
61}
62
63impl PanicKind {
64    fn panic_crash_kind(self) -> CrashKind {
65        match self {
66            Self::Minidump => CrashKind::PanicTarball,
67            Self::PanicTarball => CrashKind::PanicTarball,
68        }
69    }
70}
71
72/// Errors returned while installing or running the panic hook setup.
73#[derive(Debug, thiserror::Error)]
74#[non_exhaustive]
75pub enum PanicHookError {
76    /// Hook spool directory could not be created.
77    #[error("failed to prepare panic spool directory: {0}")]
78    Io(#[from] io::Error),
79    /// Native minidump handler could not be started.
80    #[cfg(all(feature = "minidump", not(target_os = "android")))]
81    #[cfg_attr(docsrs, doc(cfg(feature = "minidump")))]
82    #[error("failed to start minidump child process: {0}")]
83    Minidump(#[from] minidumper_child::Error),
84}
85
86/// Installs a process-wide hook that synchronously spools panic artifacts.
87///
88/// The installed hook performs no network I/O. It writes a pending crash entry
89/// and then chains to the hook that was installed previously.
90///
91/// # Errors
92///
93/// Returns [`PanicHookError::Io`] if the spool directory cannot be prepared.
94/// With the `minidump` feature enabled, also returns the `Minidump` variant if
95/// the native minidump child process fails to start.
96pub fn install_panic_hook(config: PanicHookConfig) -> Result<(), PanicHookError> {
97    fs::create_dir_all(config.spool_dir.join("pending"))?;
98    fs::create_dir_all(config.spool_dir.join("sent"))?;
99    #[cfg(all(feature = "minidump", not(target_os = "android")))]
100    install_minidumper_if_requested(&config)?;
101    let previous = std::panic::take_hook();
102    let previous = Arc::new(previous);
103    std::panic::set_hook(Box::new(move |info| {
104        if let Err(error) = write_pending_crash(&config, info) {
105            // Panic-time: report on stderr, never via tracing. The hook runs while
106            // the process is unwinding and the user's subscriber may be the
107            // detritus Layer, so the async/tracing stack must not be entered here.
108            eprintln!("[observability] failed to write panic artifact: {error}");
109        }
110        previous(info);
111    }));
112    Ok(())
113}
114
115#[cfg(all(feature = "minidump", not(target_os = "android")))]
116fn install_minidumper_if_requested(config: &PanicHookConfig) -> Result<(), PanicHookError> {
117    if config.kind != PanicKind::Minidump {
118        return Ok(());
119    }
120    let config = config.clone();
121    let handle = minidumper_child::MinidumperChild::new()
122        .with_crashes_dir(config.spool_dir.join("minidumper"))
123        // The reporter re-executes the application; preserve the arguments that
124        // select its configuration so it reaches this installation point again.
125        // https://docs.rs/minidumper-child/0.5.0/minidumper_child/struct.MinidumperChild.html#method.on_process
126        .on_process(|process| {
127            process.args(std::env::args_os().skip(1));
128        })
129        .on_minidump(move |buffer, _path| {
130            if let Err(error) = write_pending_minidump(&config, &buffer) {
131                // Crash-callback context: stderr only, never tracing (as in set_hook).
132                eprintln!("[observability] failed to write minidump artifact: {error}");
133            }
134        })
135        .spawn()?;
136    MINIDUMPER_HANDLES
137        .get_or_init(|| parking_lot::Mutex::new(Vec::new()))
138        .lock()
139        .push(handle);
140    Ok(())
141}
142
143fn write_pending_crash(config: &PanicHookConfig, info: &PanicHookInfo<'_>) -> io::Result<()> {
144    let id = Uuid::new_v4();
145    let dir = config.spool_dir.join("pending").join(id.to_string());
146    fs::create_dir_all(&dir)?;
147    let panic_text = panic_text(info);
148    let dump = match config.kind {
149        PanicKind::PanicTarball => panic_tarball(&panic_text)?,
150        PanicKind::Minidump => panic_tarball(&panic_text)?,
151    };
152    let mut metadata = CrashMetadata::new(
153        config.source.clone(),
154        Utc::now(),
155        config.kind.panic_crash_kind(),
156        config.build.clone(),
157        config.context.clone(),
158    );
159    metadata.panic_text = Some(panic_text);
160    metadata.attachments.push(AttachmentManifest {
161        key: "sdk-config".to_owned(),
162        filename: Some("sdk-config.json".to_owned()),
163        content_type: "application/json".to_owned(),
164        len: serde_json::to_vec(&StoredUploadConfig::from_config(config))
165            .map(|bytes| bytes.len() as u64)
166            .unwrap_or(0),
167    });
168    let context_attachments = copy_context_files(config, &dir)?;
169    metadata.attachments.extend(context_attachments);
170
171    fs::write(
172        dir.join("metadata.json"),
173        serde_json::to_vec_pretty(&metadata)
174            .map_err(|error| io::Error::other(error.to_string()))?,
175    )?;
176    fs::write(dir.join("dump.bin"), dump)?;
177    fs::write(
178        dir.join("sdk-config.json"),
179        serde_json::to_vec_pretty(&StoredUploadConfig::from_config(config))
180            .map_err(|error| io::Error::other(error.to_string()))?,
181    )?;
182    Ok(())
183}
184
185#[cfg(all(feature = "minidump", not(target_os = "android")))]
186fn write_pending_minidump(config: &PanicHookConfig, dump: &[u8]) -> io::Result<()> {
187    let id = Uuid::new_v4();
188    let dir = config.spool_dir.join("pending").join(id.to_string());
189    fs::create_dir_all(&dir)?;
190    let metadata = CrashMetadata::new(
191        config.source.clone(),
192        Utc::now(),
193        CrashKind::Minidump,
194        config.build.clone(),
195        config.context.clone(),
196    );
197    let mut metadata = metadata;
198    let context_attachments = copy_context_files(config, &dir)?;
199    metadata.attachments.extend(context_attachments);
200    fs::write(
201        dir.join("metadata.json"),
202        serde_json::to_vec_pretty(&metadata)
203            .map_err(|error| io::Error::other(error.to_string()))?,
204    )?;
205    fs::write(dir.join("dump.bin"), dump)?;
206    fs::write(
207        dir.join("sdk-config.json"),
208        serde_json::to_vec_pretty(&StoredUploadConfig::from_config(config))
209            .map_err(|error| io::Error::other(error.to_string()))?,
210    )?;
211    Ok(())
212}
213
214fn copy_context_files(config: &PanicHookConfig, dir: &Path) -> io::Result<Vec<AttachmentManifest>> {
215    config
216        .context_files
217        .iter()
218        .enumerate()
219        .map(|(index, path)| copy_context_file(index, path, dir))
220        .collect()
221}
222
223fn copy_context_file(index: usize, path: &Path, dir: &Path) -> io::Result<AttachmentManifest> {
224    let filename = path
225        .file_name()
226        .and_then(OsStr::to_str)
227        .map(ToOwned::to_owned)
228        .unwrap_or_else(|| format!("context-{index}.json"));
229    let bytes = fs::read(path)?;
230    fs::write(dir.join(&filename), &bytes)?;
231    Ok(AttachmentManifest {
232        key: format!("context-{index}"),
233        filename: Some(filename),
234        content_type: "application/json".to_owned(),
235        len: bytes.len() as u64,
236    })
237}
238
239fn panic_text(info: &PanicHookInfo<'_>) -> String {
240    let payload = if let Some(value) = info.payload().downcast_ref::<&str>() {
241        (*value).to_owned()
242    } else if let Some(value) = info.payload().downcast_ref::<String>() {
243        value.clone()
244    } else {
245        "<non-string panic payload>".to_owned()
246    };
247    match info.location() {
248        Some(location) => format!(
249            "{payload}\n\nat {}:{}:{}",
250            location.file(),
251            location.line(),
252            location.column()
253        ),
254        None => payload,
255    }
256}
257
258fn panic_tarball(panic_text: &str) -> io::Result<Vec<u8>> {
259    let encoder = GzEncoder::new(Vec::new(), Compression::default());
260    let mut builder = tar::Builder::new(encoder);
261    append_bytes(&mut builder, "panic.txt", panic_text.as_bytes())?;
262    let backtrace = format!("{:?}", Backtrace::force_capture());
263    append_bytes(&mut builder, "backtrace.txt", backtrace.as_bytes())?;
264    let env = json!({
265        "args": std::env::args().collect::<Vec<_>>(),
266        "current_dir": std::env::current_dir().ok(),
267        "os": std::env::consts::OS,
268        "arch": std::env::consts::ARCH,
269    });
270    let env =
271        serde_json::to_vec_pretty(&env).map_err(|error| io::Error::other(error.to_string()))?;
272    append_bytes(&mut builder, "env.json", &env)?;
273    let encoder = builder.into_inner()?;
274    encoder.finish()
275}
276
277fn append_bytes<W: io::Write>(
278    builder: &mut tar::Builder<W>,
279    path: &str,
280    bytes: &[u8],
281) -> io::Result<()> {
282    let mut header = tar::Header::new_gnu();
283    header.set_path(path)?;
284    header.set_size(bytes.len() as u64);
285    header.set_mode(0o644);
286    header.set_cksum();
287    builder.append(&header, bytes)
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub(crate) struct StoredUploadConfig {
292    pub(crate) endpoint: String,
293    pub(crate) sent_retention_days: u64,
294}
295
296impl StoredUploadConfig {
297    fn from_config(config: &PanicHookConfig) -> Self {
298        Self {
299            endpoint: config.endpoint.to_string(),
300            sent_retention_days: config.sent_retention_days,
301        }
302    }
303}