Skip to main content

forge_ops_tracker/
lib.rs

1//! ForgeOps error tracking client for a private, self-hosted ForgeOps tracker instance:
2//!
3//! ```no_run
4//! forge_ops_tracker::init(|c| {
5//!     c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string());
6//! });
7//! ```
8//!
9//! See the README for what gets captured automatically vs. what needs an explicit
10//! [`capture_error`] call. A from-scratch port of `gems/forge_ops_tracker` (the Rails client) --
11//! see that gem's README for the shared design rationale behind the pieces this crate is built
12//! from (Configuration, EventBuilder, DeliveryQueue, Reporter, Client).
13
14mod client;
15mod configuration;
16mod delivery_queue;
17mod event_builder;
18mod pii_scrubber;
19mod reporter;
20
21pub use configuration::Configuration;
22pub use event_builder::{Event, Frame};
23pub use pii_scrubber::Value;
24
25use std::collections::HashMap;
26use std::sync::{Arc, OnceLock, RwLock};
27
28use client::Client;
29use delivery_queue::DeliveryQueue;
30use reporter::Reporter;
31
32struct State {
33    configuration: Arc<RwLock<Configuration>>,
34    reporter: Reporter,
35}
36
37static STATE: OnceLock<State> = OnceLock::new();
38
39fn state() -> &'static State {
40    STATE.get_or_init(|| {
41        let configuration = Arc::new(RwLock::new(Configuration::new()));
42        let client = Arc::new(Client::new(Arc::clone(&configuration)));
43        let queue_size = configuration.read().unwrap().queue_size;
44        let delivery_queue = DeliveryQueue::new(queue_size, client);
45        let reporter = Reporter::new(Arc::clone(&configuration), delivery_queue);
46        State {
47            configuration,
48            reporter,
49        }
50    })
51}
52
53/// Configures the client. Call once at startup, before your server starts accepting requests.
54/// Pass a closure to set any [`Configuration`] field:
55///
56/// ```no_run
57/// forge_ops_tracker::init(|c| {
58///     c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string());
59///     c.release = Some("a1b2c3d".to_string());
60/// });
61/// ```
62///
63/// Installs the global panic hook (see [`install_panic_hook`]) unless
64/// `Configuration.install_panic_hook` is set to `false` inside the closure.
65pub fn init(configure: impl FnOnce(&mut Configuration)) {
66    let s = state();
67    let install_hook = {
68        let mut config = s.configuration.write().unwrap();
69        configure(&mut config);
70        config.install_panic_hook
71    };
72    if install_hook {
73        install_panic_hook();
74    }
75}
76
77/// Reports an error you've already handled. Call it right at the point you'd otherwise just log
78/// it:
79///
80/// ```no_run
81/// # use std::collections::HashMap;
82/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
83/// if let Err(err) = charge_card() {
84///     forge_ops_tracker::capture_error(&err, HashMap::new());
85/// }
86/// ```
87///
88/// The backtrace is captured right here, at the call site -- unlike Python/Java/PHP, a plain Rust
89/// `std::error::Error` carries no stack of its own, so `capture_error` has to be the one call that
90/// knows where the trace starts. `exception_class` is inferred via [`std::any::type_name`], which
91/// needs `E` to be a concrete, statically-known type -- for a `Box<dyn Error>` or other trait
92/// object, where that isn't possible, use [`capture_error_with_class`] instead and supply the
93/// class yourself.
94pub fn capture_error<E: std::error::Error>(err: &E, context: HashMap<String, Value>) {
95    capture_error_with_class(std::any::type_name::<E>(), err, context);
96}
97
98/// The same as [`capture_error`], but for a `&dyn std::error::Error` (a `Box<dyn Error>`, a trait
99/// object) whose concrete type isn't known at the call site, so `exception_class` has to be
100/// supplied explicitly rather than inferred.
101pub fn capture_error_with_class(
102    exception_class: &str,
103    err: &dyn std::error::Error,
104    context: HashMap<String, Value>,
105) {
106    let s = state();
107    s.reporter
108        .report_with_captured_backtrace(exception_class, &err.to_string(), context);
109}
110
111/// Installs a global panic hook that reports any panic on any thread, then calls whatever hook
112/// was previously installed (Rust's own default, which prints to stderr, unless something else
113/// already replaced it) -- never changing panic behavior itself, the same "report, then don't
114/// change program behavior" rule the .NET middleware and Python `excepthook` wrapper both follow.
115///
116/// Unlike Go, where only a `defer Recover()` in the same goroutine can see a panic, Rust's panic
117/// hook is genuinely process-wide: it fires for a panic on *any* thread, including a web
118/// framework's own worker threads, with no per-framework middleware needed at all. `init()` calls
119/// this automatically unless `Configuration.install_panic_hook` is set to `false`; call it
120/// directly only if you're managing configuration some other way.
121///
122/// Always chains onto whatever hook is *currently* installed via `take_hook()`, rather than
123/// latching "already installed" after the first call -- deliberately, even though that means
124/// calling this more than once wraps another reporting layer each time (a real panic would then
125/// report once per accumulated layer). A one-shot latch was tried first and rejected: it makes
126/// this call a silent no-op the moment anything else calls `std::panic::set_hook` after this one
127/// runs (a host app installing its own hook after `init()`, say), discarding this crate's
128/// reporting entirely with no error or warning. Duplicate reports from calling this redundantly
129/// is a far more visible, far less damaging failure mode than reporting silently going dark, and
130/// is easily avoided the same way `init()` already asks to be called: once, at startup.
131pub fn install_panic_hook() {
132    let previous = std::panic::take_hook();
133    std::panic::set_hook(Box::new(move |info| {
134        report_panic(info);
135        previous(info);
136    }));
137}
138
139fn report_panic(info: &std::panic::PanicHookInfo) {
140    let s = state();
141    let (exception_class, message) = panic_message(info);
142
143    let mut context = HashMap::new();
144    if let Some(location) = info.location() {
145        context.insert(
146            "panic_location".to_string(),
147            Value::String(format!(
148                "{}:{}:{}",
149                location.file(),
150                location.line(),
151                location.column()
152            )),
153        );
154    }
155
156    s.reporter
157        .report_with_captured_backtrace(&exception_class, &message, context);
158}
159
160/// panic!() accepts any value, but the overwhelming majority of real panics carry either a `&str`
161/// (`panic!("boom")`) or a `String` (`panic!("boom: {err}")`) payload -- these are the only two
162/// downcast targets std's own default panic hook special-cases too. Anything else reports as a
163/// generic "non-string panic payload" message, since there's no way to `Display` an arbitrary
164/// `dyn Any` payload.
165fn panic_message(info: &std::panic::PanicHookInfo) -> (String, String) {
166    let payload = info.payload();
167    if let Some(s) = payload.downcast_ref::<&str>() {
168        ("panic".to_string(), s.to_string())
169    } else if let Some(s) = payload.downcast_ref::<String>() {
170        ("panic".to_string(), s.clone())
171    } else {
172        ("panic".to_string(), "non-string panic payload".to_string())
173    }
174}
175
176/// Builds a `HashMap<String, Value>` from `key => value` pairs, the same literal-context ergonomics
177/// every other client in this repo gets for free from its own language (a Python dict, a JS object
178/// literal, a PHP array):
179///
180/// ```
181/// let ctx = forge_ops_tracker::context!{"order_id" => 42, "customer" => "acme-inc"};
182/// ```
183#[macro_export]
184macro_rules! context {
185    ( $( $key:expr => $value:expr ),* $(,)? ) => {{
186        #[allow(unused_mut)]
187        let mut map = ::std::collections::HashMap::new();
188        $( map.insert(::std::string::ToString::to_string($key), $crate::Value::from($value)); )*
189        map
190    }};
191}
192
193/// Extension trait for `Result`, so a fallible call can report its own error and still propagate
194/// it in one step:
195///
196/// ```no_run
197/// # use std::collections::HashMap;
198/// use forge_ops_tracker::ResultReportExt;
199/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
200/// # fn run() -> Result<(), std::io::Error> {
201/// charge_card().report_err(HashMap::new())?;
202/// # Ok(())
203/// # }
204/// ```
205pub trait ResultReportExt<T> {
206    fn report_err(self, context: HashMap<String, Value>) -> Self;
207}
208
209impl<T, E: std::error::Error> ResultReportExt<T> for Result<T, E> {
210    fn report_err(self, context: HashMap<String, Value>) -> Self {
211        if let Err(ref err) = self {
212            capture_error(err, context);
213        }
214        self
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use std::io::{Read, Write};
222    use std::net::TcpListener;
223    use std::sync::atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering};
224    use std::time::Duration;
225
226    // A minimal single-request-per-connection HTTP server, the same pattern client.rs's own tests
227    // use, kept local to this module rather than shared: these tests specifically drive the
228    // *public* init/capture_error/panic-hook API end to end, not the lower-level types directly.
229    fn spawn_tracker_server() -> (std::net::SocketAddr, Arc<AtomicI32>) {
230        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
231        let addr = listener.local_addr().unwrap();
232        let received = Arc::new(AtomicI32::new(0));
233        let received_clone = Arc::clone(&received);
234
235        std::thread::spawn(move || {
236            for stream in listener.incoming().flatten() {
237                let mut stream = stream;
238                let mut buf = [0u8; 8192];
239                let mut total = Vec::new();
240                loop {
241                    let n = stream.read(&mut buf).unwrap_or(0);
242                    if n == 0 {
243                        break;
244                    }
245                    total.extend_from_slice(&buf[..n]);
246                    if total.windows(4).any(|w| w == b"\r\n\r\n") {
247                        break;
248                    }
249                }
250                received_clone.fetch_add(1, AtomicOrdering::SeqCst);
251                let _ = stream.write_all(
252                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
253                );
254            }
255        });
256
257        (addr, received)
258    }
259
260    fn wait_for(received: &AtomicI32, count: i32) {
261        let deadline = std::time::Instant::now() + Duration::from_secs(2);
262        while received.load(AtomicOrdering::SeqCst) < count && std::time::Instant::now() < deadline
263        {
264            std::thread::sleep(Duration::from_millis(5));
265        }
266        assert_eq!(received.load(AtomicOrdering::SeqCst), count);
267    }
268
269    // The public API sits behind one process-wide OnceLock (see `state()` above), so every test
270    // touching it has to run against that same singleton -- unlike this crate's other modules,
271    // which build fresh, independent instances per test. Rather than fight Rust's default
272    // parallel test execution (or add a dev-dependency purely to serialize a handful of tests),
273    // every scenario that touches the public API lives in this one #[test] function and runs
274    // sequentially. Configuration itself is re-read from its RwLock on every delivery attempt
275    // (see Client::deliver), so repeatedly calling `init()` to point at a fresh DSN between
276    // scenarios below works correctly even though the underlying Reporter/DeliveryQueue/Client
277    // are only ever constructed once.
278    #[test]
279    fn public_api_end_to_end() {
280        // -- init + capture_error delivers through the full stack --
281        let (addr, received) = spawn_tracker_server();
282        init(|c| {
283            c.dsn = Some(format!("http://key@{addr}/events"));
284            c.environment = "production".to_string();
285            c.timeout = Duration::from_secs(2);
286            c.install_panic_hook = false; // installed explicitly, in the last scenario below instead
287        });
288
289        let err = std::io::Error::other("boom");
290        capture_error(&err, context! {"order_id" => 7});
291        wait_for(&received, 1);
292
293        // -- capture_error_with_class works for a trait-object error --
294        let (addr, received) = spawn_tracker_server();
295        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
296        let boxed: Box<dyn std::error::Error> = Box::new(std::io::Error::other("boxed boom"));
297        capture_error_with_class("std::io::Error", boxed.as_ref(), HashMap::new());
298        wait_for(&received, 1);
299
300        // -- ResultReportExt reports on Err and passes the Result through unchanged --
301        let (addr, received) = spawn_tracker_server();
302        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
303        let result: Result<(), std::io::Error> = Err(std::io::Error::other("reported via ext"));
304        let passed_through = result.report_err(HashMap::new());
305        assert!(passed_through.is_err());
306        wait_for(&received, 1);
307
308        let ok: Result<i32, std::io::Error> = Ok(42);
309        assert_eq!(ok.report_err(HashMap::new()).unwrap(), 42);
310
311        // -- init's automatic panic hook reports a panic, then still lets it unwind unchanged --
312        let (addr, received) = spawn_tracker_server();
313        let previous_hook_ran = Arc::new(AtomicBool::new(false));
314        let previous_hook_ran_clone = Arc::clone(&previous_hook_ran);
315        // Installed *before* init() specifically to prove install_panic_hook chains onto whatever
316        // hook already exists (via take_hook()) rather than replacing it outright.
317        std::panic::set_hook(Box::new(move |_| {
318            previous_hook_ran_clone.store(true, AtomicOrdering::SeqCst);
319        }));
320        init(|c| {
321            c.dsn = Some(format!("http://key@{addr}/events"));
322            c.install_panic_hook = true;
323        });
324
325        let result = std::panic::catch_unwind(|| {
326            panic!("test panic");
327        });
328        assert!(result.is_err());
329        assert!(
330            previous_hook_ran.load(AtomicOrdering::SeqCst),
331            "the previously-installed hook should still have run"
332        );
333        wait_for(&received, 1);
334
335        // Restore a silent hook so later tests in this binary don't print this test's own
336        // intentional panic to stderr.
337        std::panic::set_hook(Box::new(|_| {}));
338    }
339
340    #[test]
341    fn context_macro_builds_expected_map() {
342        let ctx = context! {"order_id" => 42, "customer" => "acme-inc"};
343        assert_eq!(ctx.get("order_id"), Some(&Value::Number(42.0)));
344        assert_eq!(
345            ctx.get("customer"),
346            Some(&Value::String("acme-inc".to_string()))
347        );
348    }
349}