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,
}
})
}
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();
}
}
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);
}
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);
}
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);
}
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())
}
}
#[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
}};
}
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;
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);
}
#[test]
fn public_api_end_to_end() {
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; });
let err = std::io::Error::other("boom");
capture_error(&err, context! {"order_id" => 7});
wait_for(&received, 1);
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);
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);
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);
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);
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()))
);
}
}