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#[derive(Debug, Clone)]
29pub struct PanicHookConfig {
30 pub endpoint: Url,
32 pub token: SecretString,
34 pub source: SourceId,
36 pub spool_dir: PathBuf,
38 pub kind: PanicKind,
40 pub build: BuildInfo,
42 pub context: serde_json::Value,
44 pub context_files: Vec<PathBuf>,
46 pub sent_retention_days: u64,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52pub enum PanicKind {
53 Minidump,
59 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#[derive(Debug, thiserror::Error)]
74#[non_exhaustive]
75pub enum PanicHookError {
76 #[error("failed to prepare panic spool directory: {0}")]
78 Io(#[from] io::Error),
79 #[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
86pub 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 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 .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 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}