foundations-sentry 1.2.0

Foundations companion crate with Sentry integrations
Documentation
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;

use foundations_sentry::backtrace::UnresolvedStacktraceIntegration;
use foundations_sentry::panic::NoFlushPanicIntegration;
use sentry_core::protocol::Event;
use sentry_core::{ClientOptions, Envelope, Hub, Level, Transport};

const TEST_DSN: &str = "https://example@sentry.io/123";

struct CountingTransport {
    envelopes: Mutex<Vec<Envelope>>,
    flushes: AtomicU64,
}

impl CountingTransport {
    fn new() -> Arc<Self> {
        Arc::new(Self {
            envelopes: Default::default(),
            flushes: Default::default(),
        })
    }

    fn fetch_and_clear_envelopes(&self) -> Vec<Envelope> {
        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
    }
}

fn capture_panic(
    integration: NoFlushPanicIntegration,
    panic: impl FnOnce() + std::panic::UnwindSafe,
) -> Event<'static> {
    let transport = CountingTransport::new();
    let options = ClientOptions {
        dsn: Some(TEST_DSN.parse().unwrap()),
        transport: Some(Arc::new(Arc::clone(&transport))),
        attach_stacktrace: true,
        default_integrations: false,
        integrations: vec![
            Arc::new(UnresolvedStacktraceIntegration),
            Arc::new(integration),
        ],
        ..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), || {
        assert!(std::panic::catch_unwind(panic).is_err());
    });

    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.len(), 1);
    assert_eq!(event.exception[0].ty, "panic");
    let mechanism = event.exception[0].mechanism.as_ref().unwrap();
    assert_eq!(mechanism.ty, "panic");
    assert_eq!(mechanism.handled, Some(false));
    assert!(event.threads.is_empty());
    event.clone()
}

fn assert_unresolved(event: &Event<'_>) {
    let stacktrace = event.exception[0].stacktrace.as_ref().unwrap();
    assert!(!stacktrace.frames.is_empty());
    for frame in &stacktrace.frames {
        assert!(frame.instruction_addr.is_some());
        assert_eq!(frame.function.as_deref(), Some("<unknown>"));
        assert!(frame.symbol.is_none());
        assert!(frame.filename.is_none());
        assert!(frame.abs_path.is_none());
        assert!(frame.lineno.is_none());
        assert!(frame.colno.is_none());
    }
}

#[test]
fn no_flush_panic_doesnt_flush() {
    let event = capture_panic(NoFlushPanicIntegration::default(), || {
        panic!("captured panic")
    });
    assert_eq!(event.exception[0].value.as_deref(), Some("captured panic"));
    let stacktrace = event.exception[0].stacktrace.as_ref().unwrap();
    assert!(stacktrace.frames.iter().any(|frame| {
        frame
            .function
            .as_deref()
            .is_some_and(|function| function != "<unknown>")
    }));
}

#[test]
fn unresolved_panic_doesnt_resolve_or_flush() {
    let event = capture_panic(
        NoFlushPanicIntegration::new().with_unresolved_stacktraces(),
        || panic!("captured panic"),
    );
    assert_eq!(event.exception[0].value.as_deref(), Some("captured panic"));
    assert_unresolved(&event);
}

#[test]
fn unresolved_panic_preserves_string_payload() {
    let event = capture_panic(
        NoFlushPanicIntegration::new().with_unresolved_stacktraces(),
        || std::panic::panic_any(String::from("owned panic")),
    );
    assert_eq!(event.exception[0].value.as_deref(), Some("owned panic"));
    assert_unresolved(&event);
}

#[test]
fn unresolved_panic_preserves_unknown_payload() {
    let event = capture_panic(
        NoFlushPanicIntegration::new().with_unresolved_stacktraces(),
        || std::panic::panic_any(42_u32),
    );
    assert_eq!(event.exception[0].value.as_deref(), Some("Box<Any>"));
    assert_unresolved(&event);
}