Skip to main content

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}