openlatch-provider 0.2.1

Self-service onboarding CLI + runtime daemon for OpenLatch Editors and Providers
//! Sentry crash reporter.
//!
//! Sentry is **default-on** per `.claude/rules/telemetry.md` (separate
//! subsystem from PostHog usage analytics). Opt-out via either:
//!   - `SENTRY_DISABLED=1` env var
//!   - `~/.openlatch/provider/config.toml [crashreport] enabled = false`
//!
//! When the baked DSN (`OPENLATCH_PROVIDER_SENTRY_DSN`) is empty (the
//! `cargo install` build path) Sentry is also disabled.
//!
//! `before_send` runs a scrubber that drops fields likely to leak:
//!   - `Authorization` headers, request bodies, env-var values
//!   - Stack-locals containing the substring `token` / `secret` / `key`
//!   - File-path contents (we only allow file *paths*, not `read_to_string`
//!     captures)
//!
//! The actual `sentry::init` call returns a guard. Hold it until process
//! exit so the panic-handler hook stays installed.

use std::sync::OnceLock;

use crate::config::Config;

/// Resolved Sentry configuration.
#[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,
    }
}

/// Initialise Sentry. Returns a guard that MUST be held for the lifetime of
/// the process — dropping it removes the panic hook. `None` when Sentry is
/// disabled (env opt-out, config opt-out, missing baked DSN).
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()),
            // Default sample rate. Spike-detection knobs land in P3.
            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)
}

/// `before_send` scrubber. Drops obvious credential leaks before they leave
/// the process. Conservative — when in doubt the field is removed, not
/// rewritten.
fn scrub(mut event: sentry::protocol::Event<'static>) -> sentry::protocol::Event<'static> {
    // Strip request bodies / cookies / Authorization-shaped headers.
    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());
            }
        }
        // Env vars never leave the box.
        req.env.clear();
    }

    // Drop server-name / user / extra context entirely.
    event.server_name = None;
    event.user = None;
    event.extra.clear();
    event.tags.clear();

    // Conservative breadcrumb scrub.
    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);
        // The baked DSN env at compile-time is "" (empty default in build.rs)
        // unless a key was provided; running tests under cargo never sets one,
        // so we assert the no-DSN fallback fires.
        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>"
        );
    }
}