use std::error::Error as StdError;
use sentry::IntoDsn;
pub use sentry::integrations::tower::{NewSentryLayer, SentryHttpLayer};
pub use sentry::integrations::tracing::layer as tracing_layer;
pub use sentry::release_name;
#[derive(Clone, Debug)]
pub struct Config {
pub dsn: Option<String>,
pub environment: String,
pub traces_sample_rate: f32,
pub release: Option<String>,
pub service: Option<String>,
}
impl Config {
pub fn traces_sample_rate_for(environment: &str) -> f32 {
if environment == "production" { 0.1 } else { 1.0 }
}
}
pub fn init(config: &Config) -> Option<sentry::ClientInitGuard> {
let dsn = config.dsn.as_deref().into_dsn().ok().flatten()?;
let guard = sentry::init(sentry::ClientOptions {
dsn: Some(dsn),
release: config.release.clone().map(Into::into),
environment: Some(config.environment.clone().into()),
traces_sample_rate: config.traces_sample_rate,
..Default::default()
});
if let Some(service) = &config.service {
sentry::Hub::main().configure_scope(|scope| tag_service(scope, service));
}
Some(guard)
}
pub fn report(error: &dyn StdError) {
sentry::capture_error(error);
}
fn tag_service(scope: &mut sentry::Scope, service: &str) {
scope.set_tag("service", service);
}
#[cfg(test)]
mod tests {
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use super::*;
#[test]
fn sample_rate_policy() {
assert_eq!(Config::traces_sample_rate_for("production"), 0.1);
assert_eq!(Config::traces_sample_rate_for("development"), 1.0);
assert_eq!(Config::traces_sample_rate_for("staging"), 1.0);
assert_eq!(Config::traces_sample_rate_for(""), 1.0);
}
#[test]
fn init_is_noop_without_dsn() {
let config = Config {
dsn: None,
environment: "test".to_string(),
traces_sample_rate: 1.0,
release: None,
service: None,
};
assert!(init(&config).is_none());
}
#[test]
fn init_is_noop_with_an_unusable_dsn() {
for dsn in ["https://sentry.io/42", "not a dsn", "ftp://public@example.com/1", "", " "] {
let config = Config {
dsn: Some(dsn.to_string()),
environment: "test".to_string(),
traces_sample_rate: 1.0,
release: None,
service: None,
};
assert!(init(&config).is_none(), "an unusable DSN should disable reporting, got a guard for {dsn:?}");
}
}
#[test]
fn init_returns_a_guard_with_a_valid_dsn() {
let config = Config {
dsn: Some("https://abc@example.com/1".to_string()),
environment: "test".to_string(),
traces_sample_rate: 1.0,
release: None,
service: None,
};
let guard = init(&config);
assert!(guard.is_some(), "a syntactically valid DSN should yield a guard");
drop(guard);
}
#[test]
fn init_uses_the_configured_release() {
let config = Config {
dsn: Some("https://abc@example.com/1".to_string()),
environment: "test".to_string(),
traces_sample_rate: 1.0,
release: Some("site-backend@2.3.1".to_string()),
service: None,
};
let guard = init(&config).expect("valid DSN yields a guard");
assert_eq!(guard.options().release.as_deref(), Some("site-backend@2.3.1"));
drop(guard);
}
#[test]
fn init_never_pins_its_own_release() {
let config = Config {
dsn: Some("https://abc@example.com/1".to_string()),
environment: "test".to_string(),
traces_sample_rate: 1.0,
release: None,
service: None,
};
let guard = init(&config).expect("valid DSN yields a guard");
let own_release = concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION"));
assert_ne!(guard.options().release.as_deref(), Some(own_release), "the wrapper must not pin its own release");
if std::env::var("SENTRY_RELEASE").is_err() {
assert_eq!(guard.options().release, None, "no release without Config.release or SENTRY_RELEASE");
}
drop(guard);
}
#[test]
fn the_service_names_an_event() {
let mut scope = sentry::Scope::default();
tag_service(&mut scope, "piggybank-core");
let event = scope.apply_to_event(sentry::protocol::Event::default()).expect("tagging must not drop the event");
assert_eq!(event.tags.get("service").map(String::as_str), Some("piggybank-core"));
}
#[test]
fn the_service_names_a_transaction_too() {
let mut scope = sentry::Scope::default();
tag_service(&mut scope, "cabinet-backend");
let mut transaction = sentry::protocol::Transaction::default();
scope.apply_to_transaction(&mut transaction);
assert_eq!(transaction.tags.get("service").map(String::as_str), Some("cabinet-backend"));
}
#[test]
fn no_service_leaves_events_untagged() {
let scope = sentry::Scope::default();
let event = scope.apply_to_event(sentry::protocol::Event::default()).expect("an empty scope must not drop the event");
assert!(!event.tags.contains_key("service"), "an unnamed service should add no tag, got {:?}", event.tags);
}
struct CapturingTransport {
envelopes: Arc<Mutex<Vec<sentry::Envelope>>>,
}
impl sentry::Transport for CapturingTransport {
fn send_envelope(&self, envelope: sentry::Envelope) {
self.envelopes.lock().unwrap().push(envelope);
}
}
#[test]
fn report_captures_the_error_as_an_event() {
let captured: Arc<Mutex<Vec<sentry::Envelope>>> = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let options = sentry::ClientOptions {
dsn: Some("https://public@example.com/1".parse().unwrap()),
transport: Some(Arc::new(move |_: &sentry::ClientOptions| {
Arc::new(CapturingTransport { envelopes: sink.clone() }) as Arc<dyn sentry::Transport>
})),
..Default::default()
};
let hub = Arc::new(sentry::Hub::new(Some(Arc::new(options.into())), Arc::new(Default::default())));
sentry::Hub::run(hub.clone(), || {
let err = std::io::Error::other("disk on fire");
report(&err);
});
hub.client().unwrap().flush(Some(Duration::from_secs(1)));
let envelopes = captured.lock().unwrap();
assert_eq!(envelopes.len(), 1, "report should send exactly one envelope");
let event = envelopes[0].event().expect("the captured envelope should carry an event");
let exception = event.exception.values.first().expect("capture_error records an exception value");
assert!(
exception.value.as_deref().unwrap_or_default().contains("disk on fire"),
"the reported error message should reach Sentry, got {:?}",
exception.value
);
}
}