sentry-minidump 0.49.3

Sentry integration for capturing native crashes as minidumps.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Captures native crashes as minidumps in a separate process and sends
//! them to Sentry as attachments.
//!
//! Add [`MinidumpIntegration`] to your [`ClientOptions`]. The integration
//! does all of its work inside `sentry::init`:
//!
//! - In the app process it spawns a crash reporter process and keeps the
//!   handle for the life of the client.
//! - In the crash reporter process it never returns. It builds its own
//!   client from the same options, runs the minidump server, and exits.
//!
//! ```rust
//! let _guard = sentry::init(
//!     sentry::ClientOptions::new()
//!         .dsn("https://your-dsn@sentry.io/0")
//!         .add_integration(
//!             sentry_minidump::MinidumpIntegration::new()
//!                 .crashes_dir("/var/lib/my-app/crashes"),
//!         ),
//! );
//! // Only the app process reaches here.
//! ```
//!
//! Code before `sentry::init` runs in both processes, because the crash
//! reporter re-executes the current binary. Build the integration and call
//! [`MinidumpIntegration::is_crash_reporter_process`] on it to skip work
//! that should run only in the app process.
//!
//! Initialise the minidump integration once per process. It runs a single
//! crash reporter for the whole process; there is no per-client isolation.
//! Only the first initialization that has a DSN starts the reporter; later
//! calls do nothing.
//!
//! # Scope sync
//!
//! Scope changes do not cross the process boundary on their own. Send them
//! to the crash reporter through the integration:
//!
//! ```rust
//! # let user = sentry::User::default();
//! sentry::with_integration(|minidump: &sentry_minidump::MinidumpIntegration, _| {
//!     minidump.set_user(Some(user.clone()));
//! });
//! ```
//!
//! # Platforms
//!
//! `sentry-minidump` builds on Linux, macOS and Windows only.

#![doc(html_favicon_url = "https://sentry-brand.storage.googleapis.com/favicon.ico")]
#![doc(html_logo_url = "https://sentry-brand.storage.googleapis.com/sentry-glyph-black.png")]
#![deny(unsafe_code)]

use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::{exit, Command};
use std::sync::{Arc, Once, OnceLock};
use std::time::Duration;

use minidumper_child::{ClientHandle, MinidumperChild};
use sentry_core::protocol::{Attachment, AttachmentType, Breadcrumb, Event, User, Value};
use sentry_core::{sentry_debug, Client, ClientOptions, Hub, Integration, Level, Scope};

/// The default environment variable that marks the crash reporter process.
const DEFAULT_SERVER_ENV_VAR: &str = "_SENTRY_CRASH_REPORTER_SERVER";

/// The default time to wait for the crash event to upload.
const DEFAULT_FLUSH_TIMEOUT: Duration = Duration::from_secs(5);

/// The default `argv[0]` of the crash reporter process, shown by `ps`.
const DEFAULT_PROCESS_NAME: &str = "Crash Reporter (Sentry Rust SDK)";

type OnProcess = dyn Fn(&mut Command) + Send + Sync + 'static;
type BeforeCapture = dyn Fn(&mut Scope, &Path) + Send + Sync + 'static;

/// An update to the crash reporter's scope, sent over the socket.
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone, PartialEq)]
enum ScopeUpdate {
    AddBreadcrumb(Breadcrumb),
    SetUser(Option<User>),
    SetExtra(String, Option<Value>),
    SetTag(String, Option<String>),
}

/// Captures native crashes as minidumps and sends them to Sentry.
///
/// Build it with [`MinidumpIntegration::new`] and the builder methods, then
/// pass it to [`ClientOptions::add_integration`]. See the
/// [crate docs](crate) for the process model.
///
/// The [`MinidumpIntegration`] can be initialized at most once per process. After the first
/// initialization attempt, attempts to initialize the [`MinidumpIntegration`] again will no-op
/// and log a warning regardless of whether the same or another instance was attempted to be
/// re-initialized.
pub struct MinidumpIntegration {
    crashes_dir: Option<PathBuf>,
    server_env_var: String,
    inherit_args: bool,
    process_name: Option<OsString>,
    on_process: Option<Arc<OnProcess>>,
    before_capture: Option<Arc<BeforeCapture>>,
    flush_timeout: Duration,
    client_connect_timeout: Option<Duration>,
    server_stale_timeout: Option<Duration>,
    handle: OnceLock<ClientHandle>,
}

impl std::fmt::Debug for MinidumpIntegration {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("MinidumpIntegration")
            .field("crashes_dir", &self.crashes_dir)
            .field("server_env_var", &self.server_env_var)
            .field("inherit_args", &self.inherit_args)
            .field("process_name", &self.process_name)
            .field("flush_timeout", &self.flush_timeout)
            .field("client_connect_timeout", &self.client_connect_timeout)
            .field("server_stale_timeout", &self.server_stale_timeout)
            .finish_non_exhaustive()
    }
}

impl Default for MinidumpIntegration {
    fn default() -> Self {
        Self {
            crashes_dir: None,
            server_env_var: DEFAULT_SERVER_ENV_VAR.to_owned(),
            inherit_args: true,
            process_name: Some(OsString::from(DEFAULT_PROCESS_NAME)),
            on_process: None,
            before_capture: None,
            flush_timeout: DEFAULT_FLUSH_TIMEOUT,
            client_connect_timeout: None,
            server_stale_timeout: None,
            handle: OnceLock::new(),
        }
    }
}

impl MinidumpIntegration {
    /// Creates a new minidump integration with default settings.
    pub fn new() -> Self {
        Self::default()
    }

    /// Returns `true` if the current process is the crash reporter process.
    ///
    /// This respects [`server_env_var`](Self::server_env_var) and does not
    /// need a Sentry client, so it can run before logging or Sentry are set
    /// up. Build the integration, then call this on it to skip app-only
    /// work in the crash reporter process.
    pub fn is_crash_reporter_process(&self) -> bool {
        std::env::var_os(&self.server_env_var).is_some()
    }

    /// Sets the directory where minidumps are written before upload.
    ///
    /// Defaults to `Crashes` inside the system temp directory.
    #[must_use]
    pub fn crashes_dir<P>(mut self, dir: P) -> Self
    where
        P: Into<PathBuf>,
    {
        self.crashes_dir = Some(dir.into());
        self
    }

    /// Sets the environment variable that marks the crash reporter process.
    ///
    /// Defaults to `_SENTRY_CRASH_REPORTER_SERVER`. Change it when the
    /// default could clash with another crash reporter in the same process
    /// tree.
    #[must_use]
    pub fn server_env_var<S>(mut self, name: S) -> Self
    where
        S: Into<String>,
    {
        self.server_env_var = name.into();
        self
    }

    /// Passes the arguments of the current process to the crash reporter
    /// process.
    ///
    /// Useful if the app reads its config from command-line arguments.
    /// Defaults to `true`.
    #[must_use]
    pub fn inherit_args(mut self, inherit: bool) -> Self {
        self.inherit_args = inherit;
        self
    }

    /// Sets the process name of the crash reporter as shown by `ps`.
    ///
    /// This sets `argv[0]` on unix. It has no effect on other platforms.
    /// Defaults to `Crash Reporter (Sentry Rust SDK)`.
    #[must_use]
    pub fn process_name<S>(mut self, name: S) -> Self
    where
        S: Into<OsString>,
    {
        self.process_name = Some(name.into());
        self
    }

    /// Modifies the [`Command`] used to spawn the crash reporter process.
    ///
    /// Runs after [`inherit_args`](Self::inherit_args) and
    /// [`process_name`](Self::process_name) are applied. The marker
    /// environment variable is set after this callback, so `env_clear`
    /// does not break process detection.
    #[must_use]
    pub fn on_process<F>(mut self, f: F) -> Self
    where
        F: Fn(&mut Command) + Send + Sync + 'static,
    {
        self.on_process = Some(Arc::new(f));
        self
    }

    /// Runs in the crash reporter process before the crash event is sent.
    ///
    /// The scope already holds the minidump attachment. Use this to add
    /// tags or to log the path of the minidump.
    #[must_use]
    pub fn before_capture<F>(mut self, f: F) -> Self
    where
        F: Fn(&mut Scope, &Path) + Send + Sync + 'static,
    {
        self.before_capture = Some(Arc::new(f));
        self
    }

    /// Sets how long the crash reporter waits for the upload to finish.
    ///
    /// Defaults to 5 seconds.
    #[must_use]
    pub fn flush_timeout(mut self, timeout: Duration) -> Self {
        self.flush_timeout = timeout;
        self
    }

    /// Sets how long the app waits for the crash reporter to accept a
    /// connection.
    #[must_use]
    pub fn client_connect_timeout(mut self, timeout: Duration) -> Self {
        self.client_connect_timeout = Some(timeout);
        self
    }

    /// Sets how long the crash reporter waits without a ping from the app
    /// before it exits.
    #[must_use]
    pub fn server_stale_timeout(mut self, timeout: Duration) -> Self {
        self.server_stale_timeout = Some(timeout);
        self
    }

    /// Sends a scope update to the crash reporter process.
    ///
    /// Does nothing when there is no crash reporter, because this is the
    /// reporter process, the DSN was unset, or the spawn failed.
    fn send(&self, update: &ScopeUpdate) {
        if let Some(handle) = self.handle.get() {
            if let Ok(buffer) = serde_json::to_vec(update) {
                handle.send_message(0, buffer).ok();
            }
        }
    }

    /// Adds a breadcrumb to the crash reporter's scope.
    pub fn add_breadcrumb(&self, breadcrumb: Breadcrumb) {
        self.send(&ScopeUpdate::AddBreadcrumb(breadcrumb));
    }

    /// Sets the user on the crash reporter's scope.
    pub fn set_user(&self, user: Option<User>) {
        self.send(&ScopeUpdate::SetUser(user));
    }

    /// Sets or removes an extra value on the crash reporter's scope.
    pub fn set_extra(&self, key: String, value: Option<Value>) {
        self.send(&ScopeUpdate::SetExtra(key, value));
    }

    /// Sets or removes a tag on the crash reporter's scope.
    pub fn set_tag(&self, key: String, value: Option<String>) {
        self.send(&ScopeUpdate::SetTag(key, value));
    }

    /// Builds the child from the stored settings.
    fn build_child(&self) -> MinidumperChild {
        let mut child = MinidumperChild::new().with_server_env_var(self.server_env_var.clone());

        if let Some(dir) = &self.crashes_dir {
            child = child.with_crashes_dir(dir.clone());
        }
        if let Some(timeout) = self.client_connect_timeout {
            child = child.with_client_connect_timeout(timeout);
        }
        if let Some(timeout) = self.server_stale_timeout {
            child = child.with_server_stale_timeout(timeout);
        }

        let inherit_args = self.inherit_args;
        let process_name = self.process_name.clone();
        let on_process = self.on_process.clone();
        child = child.on_process(move |command| {
            if inherit_args {
                command.args(std::env::args_os().skip(1));
            }

            #[cfg(unix)]
            if let Some(name) = process_name {
                use std::os::unix::process::CommandExt;
                command.arg0(name);
            }
            #[cfg(not(unix))]
            let _ = process_name;

            if let Some(on_process) = &on_process {
                on_process(command);
            }
        });

        child = child.on_message(|_kind, buffer| {
            if let Ok(update) = serde_json::from_slice::<ScopeUpdate>(&buffer) {
                let hub = Hub::current();
                match update {
                    ScopeUpdate::AddBreadcrumb(b) => hub.add_breadcrumb(b),
                    ScopeUpdate::SetUser(u) => hub.configure_scope(|scope| scope.set_user(u)),
                    ScopeUpdate::SetExtra(k, v) => hub.configure_scope(|scope| match v {
                        Some(v) => scope.set_extra(&k, v),
                        None => scope.remove_extra(&k),
                    }),
                    ScopeUpdate::SetTag(k, v) => hub.configure_scope(|scope| match v {
                        Some(v) => scope.set_tag(&k, &v),
                        None => scope.remove_tag(&k),
                    }),
                }
            }
        });

        let flush_timeout = self.flush_timeout;
        let before_capture = self.before_capture.clone();
        child.on_minidump(move |buffer, path| {
            // The client lives on the current hub, bound in the reporter
            // process before the server starts. Resolve it at crash time
            // rather than capturing it here.
            let hub = Hub::current();
            hub.with_scope(
                |scope| {
                    // This event came from the app process, not the crash
                    // reporter, so drop the reporter marker.
                    scope.remove_extra("event.process");

                    let filename = path
                        .file_name()
                        .map(|s| s.to_string_lossy().into_owned())
                        .unwrap_or_else(|| "minidump.dmp".to_string());

                    scope.add_attachment(Attachment {
                        buffer,
                        filename,
                        ty: Some(AttachmentType::Minidump),
                        ..Default::default()
                    });

                    if let Some(before_capture) = &before_capture {
                        before_capture(scope, path);
                    }
                },
                || {
                    hub.capture_event(Event {
                        level: Level::Fatal,
                        ..Default::default()
                    });
                },
            );

            // The server exits after this closure returns, so flush now.
            if let Some(client) = hub.client() {
                client.flush(Some(flush_timeout));
            }
        })
    }
}

/// Runs the crash reporter process and never returns.
///
/// Builds a second client from the app's options, binds it to the current
/// hub, then runs the minidump server. `spawn` exits the process with 0 in
/// server mode. Any error means the reporter could not start, so capture it
/// and exit with 1 rather than fall through into app code.
fn run_crash_reporter(
    child: MinidumperChild,
    options: &ClientOptions,
    flush_timeout: Duration,
) -> ! {
    let mut reporter_options = options.clone();
    // Drop this integration from the second client so its setup does not
    // recurse into the reporter path.
    reporter_options.integrations.retain(|i| {
        // `as_ref` first, so `as_any` dispatches through the
        // `dyn Integration` vtable. Calling `as_any` on the `Arc`
        // directly would downcast the wrapper and never match.
        i.as_ref()
            .as_any()
            .downcast_ref::<MinidumpIntegration>()
            .is_none()
    });

    let client = Client::with_options(reporter_options);
    let hub = Hub::current();
    hub.bind_client(Some(Arc::new(client)));

    // Mark Rust events so it is obvious when they come from the crash
    // reporter rather than the app.
    hub.configure_scope(|scope| {
        scope.set_extra("event.process", Value::String("crash-reporter".to_string()));
    });

    match child.spawn() {
        // `spawn` exits the process itself in server mode, so this arm is
        // only a safety net.
        Ok(_handle) => exit(0),
        Err(err) => {
            hub.capture_error(&err);
            if let Some(client) = hub.client() {
                client.flush(Some(flush_timeout));
            }
            exit(1);
        }
    }
}

impl Integration for MinidumpIntegration {
    fn name(&self) -> &'static str {
        "minidump"
    }

    fn setup(&self, options: &mut ClientOptions) {
        /// Synchronization primitive to enforce that we can only attempt to initialize the
        /// minidump integration once per process.
        static SETUP: Once = Once::new();

        // Without a DSN there is nothing to report, so do no work in any
        // process: do not build or spawn the crash reporter.
        if options.dsn.is_none() {
            return;
        }

        // Boolean value to indicate whether the closure passed to call_once was executed on
        // this invocation.
        let mut setup_run = false;

        SETUP.call_once(|| {
            // Crash reporter process: run the minidump server; never returns.
            if self.is_crash_reporter_process() {
                run_crash_reporter(self.build_child(), options, self.flush_timeout);
            }

            setup_run = true;

            // App process. Start the reporter once per process. A second init of
            // the same instance finds the handle set and does nothing.
            if self.handle.get().is_some() {
                return;
            }
            match self.build_child().spawn() {
                Ok(handle) => {
                    let _ = self.handle.set(handle);
                }
                Err(err) => {
                    sentry_debug!("could not start crash reporter: {err}");
                }
            }
        });

        if !setup_run {
            sentry_debug!(
                "Warning: MinidumpIntegration was initialized more than once! \
                The minidump integration can be initialized at most once per process."
            )
        }
    }
}