diff --git a/foundations-sentry/Cargo.toml b/foundations-sentry/Cargo.toml index b5d8250..f673315 100644 --- a/foundations-sentry/Cargo.toml +++ b/foundations-sentry/Cargo.toml @@ -33,6 +33,7 @@ workspace = true foundations = { workspace = true, features = ["settings", "ratelimit", "metrics"] } governor = { workspace = true } sentry-core = "0.47.0" +sentry-panic = "0.47.0" [dev-dependencies] sentry-core = { version = "0.47.0", features = ["test"] } diff --git a/foundations-sentry/src/lib.rs b/foundations-sentry/src/lib.rs index d0301a2..fb95958 100644 --- a/foundations-sentry/src/lib.rs +++ b/foundations-sentry/src/lib.rs @@ -35,6 +35,7 @@ //! ``` pub mod metrics; +pub mod panic; mod hook; mod settings; diff --git a/foundations-sentry/src/panic.rs b/foundations-sentry/src/panic.rs new file mode 100644 index 0000000..dfe73b8 --- /dev/null +++ b/foundations-sentry/src/panic.rs @@ -0,0 +1,62 @@ +//! Sentry panic integration that records panic events without flushing immediately. + +use std::any::Any; +use std::panic::{self, PanicHookInfo}; +use std::sync::Once; + +use sentry_core::{ClientOptions, Integration}; + +/// A Sentry panic handler [`Integration`] that does not flush after each event. +/// +/// This emits the same events as [`sentry_panic::PanicIntegration`] by using +/// its public event construction API, but avoids flushing the events individually +/// to reduce time spent in the panic hook. +#[derive(Debug, Default)] +pub struct NoFlushPanicIntegration { + inner: sentry_panic::PanicIntegration, +} + +impl NoFlushPanicIntegration { + /// Creates a new no-flush panic integration. + pub fn new() -> Self { + Self::default() + } +} + +static INIT: Once = Once::new(); + +impl Integration for NoFlushPanicIntegration { + fn name(&self) -> &'static str { + self.inner.name() + } + + fn setup(&self, cfg: &mut ClientOptions) { + // `cfg.integrations` is copied before `setup` is called, so we + // can't remove an upstream integration ourselves. + let upstream_integration: Option<&sentry_panic::PanicIntegration> = cfg + .integrations + .iter() + .find_map(|i| ::downcast_ref(i)); + + if let Some(integ) = upstream_integration { + panic!( + "Found an upstream `sentry_panic::PanicIntegration` while installing `NoFlushPanicIntegration`: {integ:?}. This defeats the purpose of NoFlushPanicIntegration and will cause duplicate events." + ); + } + + INIT.call_once(|| { + let next = panic::take_hook(); + panic::set_hook(Box::new(move |info| { + panic_handler(info); + next(info) + })); + }); + } +} + +fn panic_handler(info: &PanicHookInfo) { + sentry_core::with_integration(|integration: &NoFlushPanicIntegration, hub| { + hub.capture_event(integration.inner.event_from_panic_info(info)); + // no `client.flush()`! + }); +} diff --git a/foundations-sentry/tests/no_flush_panic.rs b/foundations-sentry/tests/no_flush_panic.rs new file mode 100644 index 0000000..31a8ebf --- /dev/null +++ b/foundations-sentry/tests/no_flush_panic.rs @@ -0,0 +1,72 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use foundations_sentry::panic::NoFlushPanicIntegration; +use sentry_core::{ClientOptions, Envelope, Hub, Level, Transport}; + +const TEST_DSN: &str = "https://example@sentry.io/123"; + +struct CountingTransport { + envelopes: Mutex>, + flushes: AtomicU64, +} + +impl CountingTransport { + fn new() -> Arc { + Arc::new(Self { + envelopes: Default::default(), + flushes: Default::default(), + }) + } + + fn fetch_and_clear_envelopes(&self) -> Vec { + let mut guard = self.envelopes.lock().unwrap(); + std::mem::take(&mut *guard) + } + + fn flushes(&self) -> u64 { + self.flushes.load(Ordering::Relaxed) + } +} + +impl Transport for CountingTransport { + fn send_envelope(&self, envelope: Envelope) { + self.envelopes.lock().unwrap().push(envelope); + } + + fn flush(&self, _timeout: Duration) -> bool { + self.flushes.fetch_add(1, Ordering::Relaxed); + true + } +} + +#[test] +fn no_flush_panic_doesnt_flush() { + let transport = CountingTransport::new(); + let options = ClientOptions { + dsn: Some(TEST_DSN.parse().unwrap()), + transport: Some(Arc::new(Arc::clone(&transport))), + default_integrations: false, + integrations: vec![Arc::new(NoFlushPanicIntegration::default())], + ..Default::default() + }; + + let client = sentry_core::Client::with_options(options); + let hub = Hub::new(Some(Arc::new(client)), Default::default()); + + Hub::run(Arc::new(hub), || { + let _ = std::panic::catch_unwind(|| panic!("captured panic")); + }); + + let envelopes = transport.fetch_and_clear_envelopes(); + assert_eq!(envelopes.len(), 1); + let event = envelopes[0] + .event() + .expect("Transport should have received exactly 1 event"); + + assert_eq!(transport.flushes(), 0); + assert_eq!(event.level, Level::Fatal); + assert_eq!(event.exception[0].ty, "panic"); + assert_eq!(event.exception[0].value.as_deref(), Some("captured panic")); +}