use std::sync::OnceLock;
use crate::config::Config;
#[derive(Debug, Clone, Copy)]
pub struct SentryEnabled {
pub on: bool,
pub decided_by: SentryDecidedBy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SentryDecidedBy {
DisabledEnv,
ConfigFile,
NoBakedDsn,
Default,
}
pub fn baked_dsn() -> &'static str {
static DSN: OnceLock<String> = OnceLock::new();
DSN.get_or_init(|| {
std::env::var("OPENLATCH_PROVIDER_SENTRY_DSN")
.unwrap_or_else(|_| env!("OPENLATCH_PROVIDER_SENTRY_DSN").to_string())
})
}
pub fn resolve(cfg: &Config) -> SentryEnabled {
if std::env::var("SENTRY_DISABLED").is_ok_and(|v| !v.is_empty() && v != "0") {
return SentryEnabled {
on: false,
decided_by: SentryDecidedBy::DisabledEnv,
};
}
if !cfg.crashreport.enabled {
return SentryEnabled {
on: false,
decided_by: SentryDecidedBy::ConfigFile,
};
}
if baked_dsn().is_empty() {
return SentryEnabled {
on: false,
decided_by: SentryDecidedBy::NoBakedDsn,
};
}
SentryEnabled {
on: true,
decided_by: SentryDecidedBy::Default,
}
}
pub fn init_if_enabled(cfg: &Config) -> Option<sentry::ClientInitGuard> {
let resolved = resolve(cfg);
if !resolved.on {
return None;
}
let dsn = baked_dsn().to_string();
let release = format!("openlatch-provider@{}", env!("CARGO_PKG_VERSION"));
let guard = sentry::init((
dsn,
sentry::ClientOptions {
release: Some(release.into()),
sample_rate: 1.0,
send_default_pii: false,
attach_stacktrace: true,
before_send: Some(std::sync::Arc::new(|event| Some(scrub(event)))),
..Default::default()
},
));
Some(guard)
}
fn scrub(mut event: sentry::protocol::Event<'static>) -> sentry::protocol::Event<'static> {
if let Some(req) = event.request.as_mut() {
req.cookies = None;
req.data = None;
let keys: Vec<String> = req.headers.keys().cloned().collect();
for k in keys {
let lower = k.to_ascii_lowercase();
if lower == "authorization"
|| lower == "cookie"
|| lower == "x-api-key"
|| lower.contains("token")
|| lower.contains("secret")
{
req.headers.insert(k, "<redacted>".into());
}
}
req.env.clear();
}
event.server_name = None;
event.user = None;
event.extra.clear();
event.tags.clear();
for crumb in event.breadcrumbs.iter_mut() {
if let Some(msg) = crumb.message.as_ref() {
let lower = msg.to_ascii_lowercase();
if lower.contains("token")
|| lower.contains("secret")
|| lower.contains("password")
|| lower.contains("authorization")
{
crumb.message = Some("<redacted>".into());
crumb.data.clear();
}
}
}
event
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_env_wins() {
std::env::set_var("SENTRY_DISABLED", "1");
let cfg = Config::default();
let r = resolve(&cfg);
assert!(!r.on);
assert_eq!(r.decided_by, SentryDecidedBy::DisabledEnv);
std::env::remove_var("SENTRY_DISABLED");
}
#[test]
fn config_opt_out_disables() {
std::env::remove_var("SENTRY_DISABLED");
let mut cfg = Config::default();
cfg.crashreport.enabled = false;
let r = resolve(&cfg);
assert!(!r.on);
assert_eq!(r.decided_by, SentryDecidedBy::ConfigFile);
}
#[test]
fn empty_dsn_disables() {
std::env::remove_var("SENTRY_DISABLED");
let cfg = Config::default();
let r = resolve(&cfg);
if baked_dsn().is_empty() {
assert!(!r.on);
assert_eq!(r.decided_by, SentryDecidedBy::NoBakedDsn);
}
}
#[test]
fn scrub_removes_authorization_header() {
use sentry::protocol::{Event, Request};
let mut req = Request::default();
req.headers
.insert("Authorization".into(), "Bearer secret_token".into());
let e = Event {
request: Some(req),
..Default::default()
};
let scrubbed = scrub(e);
assert_eq!(
scrubbed.request.unwrap().headers["Authorization"],
"<redacted>"
);
}
}