forge-ops-tracker 0.1.0

Rust error reporting client for a private, self-hosted ForgeOps tracker instance.
Documentation
// Ties Configuration, EventBuilder, and DeliveryQueue together into the one thing callers actually
// need: report an error. Mirrors gems/forge_ops_tracker's ErrorSubscriber#report -- never panics
// back into the caller. An error reporter that can itself crash the host app while reporting an
// error is the worst possible failure mode, so every path here is guarded.

use std::collections::HashMap;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::sync::{Arc, RwLock};

use crate::configuration::Configuration;
use crate::delivery_queue::DeliveryQueue;
use crate::event_builder::{capture_backtrace, Event, EventBuilder, Frame};
use crate::pii_scrubber::Value;

pub struct Reporter {
    configuration: Arc<RwLock<Configuration>>,
    delivery_queue: DeliveryQueue,
}

impl Reporter {
    pub fn new(configuration: Arc<RwLock<Configuration>>, delivery_queue: DeliveryQueue) -> Self {
        Reporter {
            configuration,
            delivery_queue,
        }
    }

    pub fn report(
        &self,
        exception_class: &str,
        message: &str,
        backtrace: Vec<Frame>,
        context: HashMap<String, Value>,
    ) {
        // Deliberately broad: this must never panic back into the host app.
        let _ = catch_unwind(AssertUnwindSafe(|| {
            let config = self.configuration.read().unwrap();
            if !config.is_enabled() {
                return;
            }
            let event: Event =
                EventBuilder::new(&config).build(exception_class, message, backtrace, context);
            drop(config);
            self.delivery_queue.push(event);
        }));
    }

    /// Convenience for the common case: capture the backtrace right here (at Reporter::report's
    /// own call site) rather than requiring every caller to call capture_backtrace itself first.
    pub fn report_with_captured_backtrace(
        &self,
        exception_class: &str,
        message: &str,
        context: HashMap<String, Value>,
    ) {
        let backtrace = { capture_backtrace(&self.configuration.read().unwrap()) };
        self.report(exception_class, message, backtrace, context);
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::client::Client;
    use std::net::TcpListener;
    use std::sync::atomic::{AtomicI32, Ordering};
    use std::time::Duration;

    fn serving_reporter(status_ok: bool, environment: &str) -> (Reporter, Arc<AtomicI32>) {
        use std::io::{Read, Write};

        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, Ordering::SeqCst);
                let status_line = if status_ok {
                    "HTTP/1.1 200 OK"
                } else {
                    "HTTP/1.1 500 Internal Server Error"
                };
                let _ = stream.write_all(
                    format!("{status_line}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}")
                        .as_bytes(),
                );
            }
        });

        let mut config = Configuration::new();
        config.dsn = Some(format!("http://key@{addr}/events"));
        config.environment = environment.to_string();
        config.timeout = Duration::from_secs(2);
        let configuration = Arc::new(RwLock::new(config));
        let client = Arc::new(Client::new(Arc::clone(&configuration)));
        let delivery_queue = DeliveryQueue::new(10, client);

        (Reporter::new(configuration, delivery_queue), received)
    }

    #[test]
    fn report_skips_when_disabled() {
        let (reporter, received) = serving_reporter(true, "development");

        reporter.report_with_captured_backtrace("Error", "boom", HashMap::new());
        std::thread::sleep(Duration::from_millis(100));

        assert_eq!(received.load(Ordering::SeqCst), 0);
    }

    #[test]
    fn report_delivers_when_enabled() {
        let (reporter, received) = serving_reporter(true, "production");

        reporter.report_with_captured_backtrace("Error", "boom", HashMap::new());

        let deadline = std::time::Instant::now() + Duration::from_secs(2);
        while received.load(Ordering::SeqCst) < 1 && std::time::Instant::now() < deadline {
            std::thread::sleep(Duration::from_millis(5));
        }
        assert_eq!(received.load(Ordering::SeqCst), 1);
    }

    #[test]
    fn report_never_panics_even_when_delivery_fails() {
        let (reporter, _received) = serving_reporter(false, "production");
        // Must not panic -- an error reporter that can crash the host app while reporting is the
        // worst possible failure mode.
        reporter.report_with_captured_backtrace("Error", "boom", HashMap::new());
    }
}