openlatch-client 0.1.6

The open-source security layer for AI agents — client forwarder
//! Crash reporting subsystem — forwards Rust panics to Sentry.
//!
//! Scope is deliberately narrow: panics only, scrubbed by `src/core/privacy/`
//! before transport, never active in `openlatch-hook`. See
//! `.brainstorms/2026-04-13-sentry-integration.md` for the full design
//! (especially Decisions 1–14) and `.claude/rules/telemetry.md` for the
//! consent contract.
//!
//! This module is feature-gated behind `crash-report`. When the feature is
//! absent the entire directory is skipped by `src/core/mod.rs` and the
//! `sentry` dep is dropped from the graph.

pub mod config;
pub mod consent;
pub mod scrub;

use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::time::Duration;

use sentry::{ClientInitGuard, ClientOptions};

use crate::privacy::PrivacyFilter;

use self::consent::{resolve, ConsentState};

/// Baked DSN from `build.rs`. Empty string when unset at build time (e.g.
/// `cargo install openlatch-client` on a crates.io build with no DSN) —
/// runtime short-circuits to a no-op guard in that case.
const BAKED_DSN: &str = env!("OPENLATCH_SENTRY_DSN");

/// Baked release SHA from `build.rs`. Used as `release = "openlatch-client@{sha}"`.
const BAKED_RELEASE_SHA: &str = env!("OPENLATCH_RELEASE_SHA");

/// A private filter used exclusively for scrubbing outgoing Sentry events.
///
/// Kept separate from the global `privacy::FILTER` (which may not yet be
/// initialized when `sentry::init` runs — `init_crash_report` fires as the
/// second statement of `main`, before daemon config load).
static SCRUB_FILTER: OnceLock<PrivacyFilter> = OnceLock::new();

fn scrub_filter() -> &'static PrivacyFilter {
    SCRUB_FILTER.get_or_init(|| PrivacyFilter::new(&[]))
}

/// Resolve the DSN from runtime env first, falling back to the baked value.
///
/// Runtime env wins so CI workflows and tests can point at a mock endpoint
/// without rebuilding. Empty string in both places → `None`.
fn resolve_dsn() -> Option<String> {
    std::env::var("OPENLATCH_SENTRY_DSN")
        .ok()
        .filter(|s| !s.is_empty())
        .or_else(|| {
            if BAKED_DSN.is_empty() {
                None
            } else {
                Some(BAKED_DSN.to_string())
            }
        })
}

/// Initialize Sentry for the current process.
///
/// Returns `Some(guard)` when crash reporting is active — the caller must hold
/// the guard for the lifetime of the program (its `Drop` impl flushes pending
/// events with a 2s deadline). Returns `None` when disabled by any consent
/// rule; subsequent `enrich_*` and `flush` calls are all no-ops in that state.
///
/// Must run **before** any tokio runtime is built and before clap parses
/// arguments — otherwise panics during runtime construction or arg parsing
/// are not captured.
pub fn init(openlatch_dir: &Path) -> Option<ClientInitGuard> {
    let dsn = resolve_dsn();
    let config_path = openlatch_dir.join("config.toml");
    let decision = resolve(&config_path, dsn.is_some());
    if decision.state != ConsentState::Enabled {
        return None;
    }
    // SAFETY: parse can still fail on a malformed DSN. Treat that as "disabled"
    // — we never unwrap and we never panic on startup for a misconfigured DSN.
    let parsed_dsn = dsn.as_deref().and_then(|s| s.parse().ok());
    parsed_dsn.as_ref()?;

    let release = format!("openlatch-client@{BAKED_RELEASE_SHA}");
    let environment = if cfg!(debug_assertions) {
        "development"
    } else {
        "production"
    };

    let options = ClientOptions {
        dsn: parsed_dsn,
        release: Some(release.into()),
        environment: Some(environment.into()),
        send_default_pii: false,
        traces_sample_rate: 0.0,
        // before_send: scrub every outgoing event through the privacy filter.
        before_send: Some(Arc::new(|event| scrub::scrub_event(event, scrub_filter()))),
        attach_stacktrace: true,
        ..Default::default()
    };

    Some(sentry::init(options))
}

/// Tag the current Hub scope as a CLI invocation.
///
/// No-op when crash reporting was never initialized (Hub has no client).
pub fn enrich_cli_scope(command: &str) {
    sentry::configure_scope(|scope| {
        scope.set_tag("process_type", "cli");
        scope.set_tag("command", command);
    });
}

/// Tag the current Hub scope as a detached daemon invocation.
///
/// Called as the first statement of `run_daemon_foreground` so any panic
/// during daemon bootstrap carries the correct `process_type` tag.
pub fn enrich_daemon_scope(port: u16, pid: u32) {
    sentry::configure_scope(|scope| {
        scope.set_tag("process_type", "daemon");
        scope.set_extra("port", u64::from(port).into());
        scope.set_extra("pid", u64::from(pid).into());
    });
}

/// Best-effort synchronous flush of pending events.
///
/// Used from the ctrlc handler (before `std::process::exit(130)`, which skips
/// `Drop` impls) and after the daemon server loop returns. No-op when crash
/// reporting is disabled.
pub fn flush(timeout: Duration) {
    if let Some(client) = sentry::Hub::current().client() {
        let _ = client.flush(Some(timeout));
    }
}

/// Compile-time probe used by `openlatch doctor` to report whether this build
/// includes Sentry at all. Always `true` when the `crash-report` feature is
/// on (since this module only compiles under that gate).
pub const fn build_includes_crash_report() -> bool {
    true
}

/// Convenience for callers that want the consent decision without initializing.
/// Used by `openlatch doctor` to report the effective state.
pub fn current_state(openlatch_dir: &Path) -> consent::Resolved {
    let dsn = resolve_dsn();
    resolve(&openlatch_dir.join("config.toml"), dsn.is_some())
}