forge-ops-tracker 0.1.1

Rust error reporting client for a private, self-hosted ForgeOps tracker instance.
Documentation
//! ForgeOps error tracking client for a private, self-hosted ForgeOps tracker instance:
//!
//! ```no_run
//! forge_ops_tracker::init(|c| {
//!     c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string());
//! });
//! ```
//!
//! See the README for what gets captured automatically vs. what needs an explicit
//! [`capture_error`] call. A from-scratch port of `gems/forge_ops_tracker` (the Rails client) --
//! see that gem's README for the shared design rationale behind the pieces this crate is built
//! from (Configuration, EventBuilder, DeliveryQueue, Reporter, Client).

mod client;
mod configuration;
mod delivery_queue;
mod event_builder;
mod pii_scrubber;
mod reporter;

pub use configuration::Configuration;
pub use event_builder::{Event, Frame};
pub use pii_scrubber::Value;

use std::collections::HashMap;
use std::sync::{Arc, OnceLock, RwLock};

use client::Client;
use delivery_queue::DeliveryQueue;
use reporter::Reporter;

struct State {
    configuration: Arc<RwLock<Configuration>>,
    reporter: Reporter,
}

static STATE: OnceLock<State> = OnceLock::new();

fn state() -> &'static State {
    STATE.get_or_init(|| {
        let configuration = Arc::new(RwLock::new(Configuration::new()));
        let client = Arc::new(Client::new(Arc::clone(&configuration)));
        let queue_size = configuration.read().unwrap().queue_size;
        let delivery_queue = DeliveryQueue::new(queue_size, client);
        let reporter = Reporter::new(Arc::clone(&configuration), delivery_queue);
        State {
            configuration,
            reporter,
        }
    })
}

/// Configures the client. Call once at startup, before your server starts accepting requests.
/// Pass a closure to set any [`Configuration`] field:
///
/// ```no_run
/// forge_ops_tracker::init(|c| {
///     c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string());
///     c.release = Some("a1b2c3d".to_string());
/// });
/// ```
///
/// Installs the global panic hook (see [`install_panic_hook`]) unless
/// `Configuration.install_panic_hook` is set to `false` inside the closure.
pub fn init(configure: impl FnOnce(&mut Configuration)) {
    let s = state();
    let install_hook = {
        let mut config = s.configuration.write().unwrap();
        configure(&mut config);
        config.install_panic_hook
    };
    if install_hook {
        install_panic_hook();
    }
}

/// Reports an error you've already handled. Call it right at the point you'd otherwise just log
/// it:
///
/// ```no_run
/// # use std::collections::HashMap;
/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
/// if let Err(err) = charge_card() {
///     forge_ops_tracker::capture_error(&err, HashMap::new());
/// }
/// ```
///
/// The backtrace is captured right here, at the call site -- unlike Python/Java/PHP, a plain Rust
/// `std::error::Error` carries no stack of its own, so `capture_error` has to be the one call that
/// knows where the trace starts. `exception_class` is inferred via [`std::any::type_name`], which
/// needs `E` to be a concrete, statically-known type -- for a `Box<dyn Error>` or other trait
/// object, where that isn't possible, use [`capture_error_with_class`] instead and supply the
/// class yourself.
pub fn capture_error<E: std::error::Error>(err: &E, context: HashMap<String, Value>) {
    capture_error_with_class(std::any::type_name::<E>(), err, context);
}

/// The same as [`capture_error`], but for a `&dyn std::error::Error` (a `Box<dyn Error>`, a trait
/// object) whose concrete type isn't known at the call site, so `exception_class` has to be
/// supplied explicitly rather than inferred.
pub fn capture_error_with_class(
    exception_class: &str,
    err: &dyn std::error::Error,
    context: HashMap<String, Value>,
) {
    let s = state();
    s.reporter
        .report_with_captured_backtrace(exception_class, &err.to_string(), context);
}

/// Installs a global panic hook that reports any panic on any thread, then calls whatever hook
/// was previously installed (Rust's own default, which prints to stderr, unless something else
/// already replaced it) -- never changing panic behavior itself, the same "report, then don't
/// change program behavior" rule the .NET middleware and Python `excepthook` wrapper both follow.
///
/// Unlike Go, where only a `defer Recover()` in the same goroutine can see a panic, Rust's panic
/// hook is genuinely process-wide: it fires for a panic on *any* thread, including a web
/// framework's own worker threads, with no per-framework middleware needed at all. `init()` calls
/// this automatically unless `Configuration.install_panic_hook` is set to `false`; call it
/// directly only if you're managing configuration some other way.
///
/// Always chains onto whatever hook is *currently* installed via `take_hook()`, rather than
/// latching "already installed" after the first call -- deliberately, even though that means
/// calling this more than once wraps another reporting layer each time (a real panic would then
/// report once per accumulated layer). A one-shot latch was tried first and rejected: it makes
/// this call a silent no-op the moment anything else calls `std::panic::set_hook` after this one
/// runs (a host app installing its own hook after `init()`, say), discarding this crate's
/// reporting entirely with no error or warning. Duplicate reports from calling this redundantly
/// is a far more visible, far less damaging failure mode than reporting silently going dark, and
/// is easily avoided the same way `init()` already asks to be called: once, at startup.
pub fn install_panic_hook() {
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        report_panic(info);
        previous(info);
    }));
}

fn report_panic(info: &std::panic::PanicHookInfo) {
    let s = state();
    let (exception_class, message) = panic_message(info);

    let mut context = HashMap::new();
    if let Some(location) = info.location() {
        context.insert(
            "panic_location".to_string(),
            Value::String(format!(
                "{}:{}:{}",
                location.file(),
                location.line(),
                location.column()
            )),
        );
    }

    s.reporter
        .report_with_captured_backtrace(&exception_class, &message, context);
}

/// panic!() accepts any value, but the overwhelming majority of real panics carry either a `&str`
/// (`panic!("boom")`) or a `String` (`panic!("boom: {err}")`) payload -- these are the only two
/// downcast targets std's own default panic hook special-cases too. Anything else reports as a
/// generic "non-string panic payload" message, since there's no way to `Display` an arbitrary
/// `dyn Any` payload.
fn panic_message(info: &std::panic::PanicHookInfo) -> (String, String) {
    let payload = info.payload();
    if let Some(s) = payload.downcast_ref::<&str>() {
        ("panic".to_string(), s.to_string())
    } else if let Some(s) = payload.downcast_ref::<String>() {
        ("panic".to_string(), s.clone())
    } else {
        ("panic".to_string(), "non-string panic payload".to_string())
    }
}

/// Builds a `HashMap<String, Value>` from `key => value` pairs, the same literal-context ergonomics
/// every other client in this repo gets for free from its own language (a Python dict, a JS object
/// literal, a PHP array):
///
/// ```
/// let ctx = forge_ops_tracker::context!{"order_id" => 42, "customer" => "acme-inc"};
/// ```
#[macro_export]
macro_rules! context {
    ( $( $key:expr => $value:expr ),* $(,)? ) => {{
        #[allow(unused_mut)]
        let mut map = ::std::collections::HashMap::new();
        $( map.insert(::std::string::ToString::to_string($key), $crate::Value::from($value)); )*
        map
    }};
}

/// Extension trait for `Result`, so a fallible call can report its own error and still propagate
/// it in one step:
///
/// ```no_run
/// # use std::collections::HashMap;
/// use forge_ops_tracker::ResultReportExt;
/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
/// # fn run() -> Result<(), std::io::Error> {
/// charge_card().report_err(HashMap::new())?;
/// # Ok(())
/// # }
/// ```
pub trait ResultReportExt<T> {
    fn report_err(self, context: HashMap<String, Value>) -> Self;
}

impl<T, E: std::error::Error> ResultReportExt<T> for Result<T, E> {
    fn report_err(self, context: HashMap<String, Value>) -> Self {
        if let Err(ref err) = self {
            capture_error(err, context);
        }
        self
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::{Read, Write};
    use std::net::TcpListener;
    use std::sync::atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering};
    use std::time::Duration;

    // A minimal single-request-per-connection HTTP server, the same pattern client.rs's own tests
    // use, kept local to this module rather than shared: these tests specifically drive the
    // *public* init/capture_error/panic-hook API end to end, not the lower-level types directly.
    fn spawn_tracker_server() -> (std::net::SocketAddr, Arc<AtomicI32>) {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let addr = listener.local_addr().unwrap();
        let received = Arc::new(AtomicI32::new(0));
        let received_clone = Arc::clone(&received);

        std::thread::spawn(move || {
            for stream in listener.incoming().flatten() {
                let mut stream = stream;
                let mut buf = [0u8; 8192];
                let mut total = Vec::new();
                loop {
                    let n = stream.read(&mut buf).unwrap_or(0);
                    if n == 0 {
                        break;
                    }
                    total.extend_from_slice(&buf[..n]);
                    if total.windows(4).any(|w| w == b"\r\n\r\n") {
                        break;
                    }
                }
                received_clone.fetch_add(1, AtomicOrdering::SeqCst);
                let _ = stream.write_all(
                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
                );
            }
        });

        (addr, received)
    }

    fn wait_for(received: &AtomicI32, count: i32) {
        let deadline = std::time::Instant::now() + Duration::from_secs(2);
        while received.load(AtomicOrdering::SeqCst) < count && std::time::Instant::now() < deadline
        {
            std::thread::sleep(Duration::from_millis(5));
        }
        assert_eq!(received.load(AtomicOrdering::SeqCst), count);
    }

    // The public API sits behind one process-wide OnceLock (see `state()` above), so every test
    // touching it has to run against that same singleton -- unlike this crate's other modules,
    // which build fresh, independent instances per test. Rather than fight Rust's default
    // parallel test execution (or add a dev-dependency purely to serialize a handful of tests),
    // every scenario that touches the public API lives in this one #[test] function and runs
    // sequentially. Configuration itself is re-read from its RwLock on every delivery attempt
    // (see Client::deliver), so repeatedly calling `init()` to point at a fresh DSN between
    // scenarios below works correctly even though the underlying Reporter/DeliveryQueue/Client
    // are only ever constructed once.
    #[test]
    fn public_api_end_to_end() {
        // -- init + capture_error delivers through the full stack --
        let (addr, received) = spawn_tracker_server();
        init(|c| {
            c.dsn = Some(format!("http://key@{addr}/events"));
            c.environment = "production".to_string();
            c.timeout = Duration::from_secs(2);
            c.install_panic_hook = false; // installed explicitly, in the last scenario below instead
        });

        let err = std::io::Error::other("boom");
        capture_error(&err, context! {"order_id" => 7});
        wait_for(&received, 1);

        // -- capture_error_with_class works for a trait-object error --
        let (addr, received) = spawn_tracker_server();
        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
        let boxed: Box<dyn std::error::Error> = Box::new(std::io::Error::other("boxed boom"));
        capture_error_with_class("std::io::Error", boxed.as_ref(), HashMap::new());
        wait_for(&received, 1);

        // -- ResultReportExt reports on Err and passes the Result through unchanged --
        let (addr, received) = spawn_tracker_server();
        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
        let result: Result<(), std::io::Error> = Err(std::io::Error::other("reported via ext"));
        let passed_through = result.report_err(HashMap::new());
        assert!(passed_through.is_err());
        wait_for(&received, 1);

        let ok: Result<i32, std::io::Error> = Ok(42);
        assert_eq!(ok.report_err(HashMap::new()).unwrap(), 42);

        // -- init's automatic panic hook reports a panic, then still lets it unwind unchanged --
        let (addr, received) = spawn_tracker_server();
        let previous_hook_ran = Arc::new(AtomicBool::new(false));
        let previous_hook_ran_clone = Arc::clone(&previous_hook_ran);
        // Installed *before* init() specifically to prove install_panic_hook chains onto whatever
        // hook already exists (via take_hook()) rather than replacing it outright.
        std::panic::set_hook(Box::new(move |_| {
            previous_hook_ran_clone.store(true, AtomicOrdering::SeqCst);
        }));
        init(|c| {
            c.dsn = Some(format!("http://key@{addr}/events"));
            c.install_panic_hook = true;
        });

        let result = std::panic::catch_unwind(|| {
            panic!("test panic");
        });
        assert!(result.is_err());
        assert!(
            previous_hook_ran.load(AtomicOrdering::SeqCst),
            "the previously-installed hook should still have run"
        );
        wait_for(&received, 1);

        // Restore a silent hook so later tests in this binary don't print this test's own
        // intentional panic to stderr.
        std::panic::set_hook(Box::new(|_| {}));
    }

    #[test]
    fn context_macro_builds_expected_map() {
        let ctx = context! {"order_id" => 42, "customer" => "acme-inc"};
        assert_eq!(ctx.get("order_id"), Some(&Value::Number(42.0)));
        assert_eq!(
            ctx.get("customer"),
            Some(&Value::String("acme-inc".to_string()))
        );
    }
}