sentry_minidump/lib.rs
1//! Captures native crashes as minidumps in a separate process and sends
2//! them to Sentry as attachments.
3//!
4//! Add [`MinidumpIntegration`] to your [`ClientOptions`]. The integration
5//! does all of its work inside `sentry::init`:
6//!
7//! - In the app process it spawns a crash reporter process and keeps the
8//! handle for the life of the client.
9//! - In the crash reporter process it never returns. It builds its own
10//! client from the same options, runs the minidump server, and exits.
11//!
12//! ```rust
13//! let _guard = sentry::init(
14//! sentry::ClientOptions::new()
15//! .dsn("https://your-dsn@sentry.io/0")
16//! .add_integration(
17//! sentry_minidump::MinidumpIntegration::new()
18//! .crashes_dir("/var/lib/my-app/crashes"),
19//! ),
20//! );
21//! // Only the app process reaches here.
22//! ```
23//!
24//! Code before `sentry::init` runs in both processes, because the crash
25//! reporter re-executes the current binary. Build the integration and call
26//! [`MinidumpIntegration::is_crash_reporter_process`] on it to skip work
27//! that should run only in the app process.
28//!
29//! Initialise the minidump integration once per process. It runs a single
30//! crash reporter for the whole process; there is no per-client isolation.
31//! Only the first initialization that has a DSN starts the reporter; later
32//! calls do nothing.
33//!
34//! # Scope sync
35//!
36//! Scope changes do not cross the process boundary on their own. Send them
37//! to the crash reporter through the integration:
38//!
39//! ```rust
40//! # let user = sentry::User::default();
41//! sentry::with_integration(|minidump: &sentry_minidump::MinidumpIntegration, _| {
42//! minidump.set_user(Some(user.clone()));
43//! });
44//! ```
45//!
46//! # Platforms
47//!
48//! `sentry-minidump` builds on Linux, macOS and Windows only.
49
50#![doc(html_favicon_url = "https://sentry-brand.storage.googleapis.com/favicon.ico")]
51#![doc(html_logo_url = "https://sentry-brand.storage.googleapis.com/sentry-glyph-black.png")]
52#![deny(unsafe_code)]
53
54use std::ffi::OsString;
55use std::path::{Path, PathBuf};
56use std::process::{exit, Command};
57use std::sync::{Arc, Once, OnceLock};
58use std::time::Duration;
59
60use minidumper_child::{ClientHandle, MinidumperChild};
61use sentry_core::protocol::{Attachment, AttachmentType, Breadcrumb, Event, User, Value};
62use sentry_core::{sentry_debug, Client, ClientOptions, Hub, Integration, Level, Scope};
63
64/// The default environment variable that marks the crash reporter process.
65const DEFAULT_SERVER_ENV_VAR: &str = "_SENTRY_CRASH_REPORTER_SERVER";
66
67/// The default time to wait for the crash event to upload.
68const DEFAULT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);
69
70/// The default `argv[0]` of the crash reporter process, shown by `ps`.
71const DEFAULT_PROCESS_NAME: &str = "Crash Reporter (Sentry Rust SDK)";
72
73type OnProcess = dyn Fn(&mut Command) + Send + Sync + 'static;
74type BeforeCapture = dyn Fn(&mut Scope, &Path) + Send + Sync + 'static;
75
76/// An update to the crash reporter's scope, sent over the socket.
77#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
78enum ScopeUpdate {
79 AddBreadcrumb(Breadcrumb),
80 SetUser(Option<User>),
81 SetExtra(String, Option<Value>),
82 SetTag(String, Option<String>),
83}
84
85/// Captures native crashes as minidumps and sends them to Sentry.
86///
87/// Build it with [`MinidumpIntegration::new`] and the builder methods, then
88/// pass it to [`ClientOptions::add_integration`]. See the
89/// [crate docs](crate) for the process model.
90///
91/// The [`MinidumpIntegration`] can be initialized at most once per process. After the first
92/// initialization attempt, attempts to initialize the [`MinidumpIntegration`] again will no-op
93/// and log a warning regardless of whether the same or another instance was attempted to be
94/// re-initialized.
95pub struct MinidumpIntegration {
96 crashes_dir: Option<PathBuf>,
97 server_env_var: String,
98 inherit_args: bool,
99 process_name: Option<OsString>,
100 on_process: Option<Arc<OnProcess>>,
101 before_capture: Option<Arc<BeforeCapture>>,
102 flush_timeout: Duration,
103 client_connect_timeout: Option<Duration>,
104 server_stale_timeout: Option<Duration>,
105 handle: OnceLock<ClientHandle>,
106}
107
108impl std::fmt::Debug for MinidumpIntegration {
109 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
110 f.debug_struct("MinidumpIntegration")
111 .field("crashes_dir", &self.crashes_dir)
112 .field("server_env_var", &self.server_env_var)
113 .field("inherit_args", &self.inherit_args)
114 .field("process_name", &self.process_name)
115 .field("flush_timeout", &self.flush_timeout)
116 .field("client_connect_timeout", &self.client_connect_timeout)
117 .field("server_stale_timeout", &self.server_stale_timeout)
118 .finish_non_exhaustive()
119 }
120}
121
122impl Default for MinidumpIntegration {
123 fn default() -> Self {
124 Self {
125 crashes_dir: None,
126 server_env_var: DEFAULT_SERVER_ENV_VAR.to_owned(),
127 inherit_args: true,
128 process_name: Some(OsString::from(DEFAULT_PROCESS_NAME)),
129 on_process: None,
130 before_capture: None,
131 flush_timeout: DEFAULT_FLUSH_TIMEOUT,
132 client_connect_timeout: None,
133 server_stale_timeout: None,
134 handle: OnceLock::new(),
135 }
136 }
137}
138
139impl MinidumpIntegration {
140 /// Creates a new minidump integration with default settings.
141 pub fn new() -> Self {
142 Self::default()
143 }
144
145 /// Returns `true` if the current process is the crash reporter process.
146 ///
147 /// This respects [`server_env_var`](Self::server_env_var) and does not
148 /// need a Sentry client, so it can run before logging or Sentry are set
149 /// up. Build the integration, then call this on it to skip app-only
150 /// work in the crash reporter process.
151 pub fn is_crash_reporter_process(&self) -> bool {
152 std::env::var_os(&self.server_env_var).is_some()
153 }
154
155 /// Sets the directory where minidumps are written before upload.
156 ///
157 /// Defaults to `Crashes` inside the system temp directory.
158 #[must_use]
159 pub fn crashes_dir<P>(mut self, dir: P) -> Self
160 where
161 P: Into<PathBuf>,
162 {
163 self.crashes_dir = Some(dir.into());
164 self
165 }
166
167 /// Sets the environment variable that marks the crash reporter process.
168 ///
169 /// Defaults to `_SENTRY_CRASH_REPORTER_SERVER`. Change it when the
170 /// default could clash with another crash reporter in the same process
171 /// tree.
172 #[must_use]
173 pub fn server_env_var<S>(mut self, name: S) -> Self
174 where
175 S: Into<String>,
176 {
177 self.server_env_var = name.into();
178 self
179 }
180
181 /// Passes the arguments of the current process to the crash reporter
182 /// process.
183 ///
184 /// Useful if the app reads its config from command-line arguments.
185 /// Defaults to `true`.
186 #[must_use]
187 pub fn inherit_args(mut self, inherit: bool) -> Self {
188 self.inherit_args = inherit;
189 self
190 }
191
192 /// Sets the process name of the crash reporter as shown by `ps`.
193 ///
194 /// This sets `argv[0]` on unix. It has no effect on other platforms.
195 /// Defaults to `Crash Reporter (Sentry Rust SDK)`.
196 #[must_use]
197 pub fn process_name<S>(mut self, name: S) -> Self
198 where
199 S: Into<OsString>,
200 {
201 self.process_name = Some(name.into());
202 self
203 }
204
205 /// Modifies the [`Command`] used to spawn the crash reporter process.
206 ///
207 /// Runs after [`inherit_args`](Self::inherit_args) and
208 /// [`process_name`](Self::process_name) are applied. The marker
209 /// environment variable is set after this callback, so `env_clear`
210 /// does not break process detection.
211 #[must_use]
212 pub fn on_process<F>(mut self, f: F) -> Self
213 where
214 F: Fn(&mut Command) + Send + Sync + 'static,
215 {
216 self.on_process = Some(Arc::new(f));
217 self
218 }
219
220 /// Runs in the crash reporter process before the crash event is sent.
221 ///
222 /// The scope already holds the minidump attachment. Use this to add
223 /// tags or to log the path of the minidump.
224 #[must_use]
225 pub fn before_capture<F>(mut self, f: F) -> Self
226 where
227 F: Fn(&mut Scope, &Path) + Send + Sync + 'static,
228 {
229 self.before_capture = Some(Arc::new(f));
230 self
231 }
232
233 /// Sets how long the crash reporter waits for the upload to finish.
234 ///
235 /// Defaults to 5 seconds.
236 #[must_use]
237 pub fn flush_timeout(mut self, timeout: Duration) -> Self {
238 self.flush_timeout = timeout;
239 self
240 }
241
242 /// Sets how long the app waits for the crash reporter to accept a
243 /// connection.
244 #[must_use]
245 pub fn client_connect_timeout(mut self, timeout: Duration) -> Self {
246 self.client_connect_timeout = Some(timeout);
247 self
248 }
249
250 /// Sets how long the crash reporter waits without a ping from the app
251 /// before it exits.
252 #[must_use]
253 pub fn server_stale_timeout(mut self, timeout: Duration) -> Self {
254 self.server_stale_timeout = Some(timeout);
255 self
256 }
257
258 /// Sends a scope update to the crash reporter process.
259 ///
260 /// Does nothing when there is no crash reporter, because this is the
261 /// reporter process, the DSN was unset, or the spawn failed.
262 fn send(&self, update: &ScopeUpdate) {
263 if let Some(handle) = self.handle.get() {
264 if let Ok(buffer) = serde_json::to_vec(update) {
265 handle.send_message(0, buffer).ok();
266 }
267 }
268 }
269
270 /// Adds a breadcrumb to the crash reporter's scope.
271 pub fn add_breadcrumb(&self, breadcrumb: Breadcrumb) {
272 self.send(&ScopeUpdate::AddBreadcrumb(breadcrumb));
273 }
274
275 /// Sets the user on the crash reporter's scope.
276 pub fn set_user(&self, user: Option<User>) {
277 self.send(&ScopeUpdate::SetUser(user));
278 }
279
280 /// Sets or removes an extra value on the crash reporter's scope.
281 pub fn set_extra(&self, key: String, value: Option<Value>) {
282 self.send(&ScopeUpdate::SetExtra(key, value));
283 }
284
285 /// Sets or removes a tag on the crash reporter's scope.
286 pub fn set_tag(&self, key: String, value: Option<String>) {
287 self.send(&ScopeUpdate::SetTag(key, value));
288 }
289
290 /// Builds the child from the stored settings.
291 fn build_child(&self) -> MinidumperChild {
292 let mut child = MinidumperChild::new().with_server_env_var(self.server_env_var.clone());
293
294 if let Some(dir) = &self.crashes_dir {
295 child = child.with_crashes_dir(dir.clone());
296 }
297 if let Some(timeout) = self.client_connect_timeout {
298 child = child.with_client_connect_timeout(timeout);
299 }
300 if let Some(timeout) = self.server_stale_timeout {
301 child = child.with_server_stale_timeout(timeout);
302 }
303
304 let inherit_args = self.inherit_args;
305 let process_name = self.process_name.clone();
306 let on_process = self.on_process.clone();
307 child = child.on_process(move |command| {
308 if inherit_args {
309 command.args(std::env::args_os().skip(1));
310 }
311
312 #[cfg(unix)]
313 if let Some(name) = process_name {
314 use std::os::unix::process::CommandExt;
315 command.arg0(name);
316 }
317 #[cfg(not(unix))]
318 let _ = process_name;
319
320 if let Some(on_process) = &on_process {
321 on_process(command);
322 }
323 });
324
325 child = child.on_message(|_kind, buffer| {
326 if let Ok(update) = serde_json::from_slice::<ScopeUpdate>(&buffer) {
327 let hub = Hub::current();
328 match update {
329 ScopeUpdate::AddBreadcrumb(b) => hub.add_breadcrumb(b),
330 ScopeUpdate::SetUser(u) => hub.configure_scope(|scope| scope.set_user(u)),
331 ScopeUpdate::SetExtra(k, v) => hub.configure_scope(|scope| match v {
332 Some(v) => scope.set_extra(&k, v),
333 None => scope.remove_extra(&k),
334 }),
335 ScopeUpdate::SetTag(k, v) => hub.configure_scope(|scope| match v {
336 Some(v) => scope.set_tag(&k, &v),
337 None => scope.remove_tag(&k),
338 }),
339 }
340 }
341 });
342
343 let flush_timeout = self.flush_timeout;
344 let before_capture = self.before_capture.clone();
345 child.on_minidump(move |buffer, path| {
346 // The client lives on the current hub, bound in the reporter
347 // process before the server starts. Resolve it at crash time
348 // rather than capturing it here.
349 let hub = Hub::current();
350 hub.with_scope(
351 |scope| {
352 // This event came from the app process, not the crash
353 // reporter, so drop the reporter marker.
354 scope.remove_extra("event.process");
355
356 let filename = path
357 .file_name()
358 .map(|s| s.to_string_lossy().into_owned())
359 .unwrap_or_else(|| "minidump.dmp".to_string());
360
361 scope.add_attachment(Attachment {
362 buffer,
363 filename,
364 ty: Some(AttachmentType::Minidump),
365 ..Default::default()
366 });
367
368 if let Some(before_capture) = &before_capture {
369 before_capture(scope, path);
370 }
371 },
372 || {
373 hub.capture_event(Event {
374 level: Level::Fatal,
375 ..Default::default()
376 });
377 },
378 );
379
380 // The server exits after this closure returns, so flush now.
381 if let Some(client) = hub.client() {
382 client.flush(Some(flush_timeout));
383 }
384 })
385 }
386}
387
388/// Runs the crash reporter process and never returns.
389///
390/// Builds a second client from the app's options, binds it to the current
391/// hub, then runs the minidump server. `spawn` exits the process with 0 in
392/// server mode. Any error means the reporter could not start, so capture it
393/// and exit with 1 rather than fall through into app code.
394fn run_crash_reporter(
395 child: MinidumperChild,
396 options: &ClientOptions,
397 flush_timeout: Duration,
398) -> ! {
399 let mut reporter_options = options.clone();
400 // Drop this integration from the second client so its setup does not
401 // recurse into the reporter path.
402 reporter_options.integrations.retain(|i| {
403 // `as_ref` first, so `as_any` dispatches through the
404 // `dyn Integration` vtable. Calling `as_any` on the `Arc`
405 // directly would downcast the wrapper and never match.
406 i.as_ref()
407 .as_any()
408 .downcast_ref::<MinidumpIntegration>()
409 .is_none()
410 });
411
412 let client = Client::with_options(reporter_options);
413 let hub = Hub::current();
414 hub.bind_client(Some(Arc::new(client)));
415
416 // Mark Rust events so it is obvious when they come from the crash
417 // reporter rather than the app.
418 hub.configure_scope(|scope| {
419 scope.set_extra("event.process", Value::String("crash-reporter".to_string()));
420 });
421
422 match child.spawn() {
423 // `spawn` exits the process itself in server mode, so this arm is
424 // only a safety net.
425 Ok(_handle) => exit(0),
426 Err(err) => {
427 hub.capture_error(&err);
428 if let Some(client) = hub.client() {
429 client.flush(Some(flush_timeout));
430 }
431 exit(1);
432 }
433 }
434}
435
436impl Integration for MinidumpIntegration {
437 fn name(&self) -> &'static str {
438 "minidump"
439 }
440
441 fn setup(&self, options: &mut ClientOptions) {
442 /// Synchronization primitive to enforce that we can only attempt to initialize the
443 /// minidump integration once per process.
444 static SETUP: Once = Once::new();
445
446 // Without a DSN there is nothing to report, so do no work in any
447 // process: do not build or spawn the crash reporter.
448 if options.dsn.is_none() {
449 return;
450 }
451
452 // Boolean value to indicate whether the closure passed to call_once was executed on
453 // this invocation.
454 let mut setup_run = false;
455
456 SETUP.call_once(|| {
457 // Crash reporter process: run the minidump server; never returns.
458 if self.is_crash_reporter_process() {
459 run_crash_reporter(self.build_child(), options, self.flush_timeout);
460 }
461
462 setup_run = true;
463
464 // App process. Start the reporter once per process. A second init of
465 // the same instance finds the handle set and does nothing.
466 if self.handle.get().is_some() {
467 return;
468 }
469 match self.build_child().spawn() {
470 Ok(handle) => {
471 let _ = self.handle.set(handle);
472 }
473 Err(err) => {
474 sentry_debug!("could not start crash reporter: {err}");
475 }
476 }
477 });
478
479 if !setup_run {
480 sentry_debug!(
481 "Warning: MinidumpIntegration was initialized more than once! \
482 The minidump integration can be initialized at most once per process."
483 )
484 }
485 }
486}