forge_ops_tracker/lib.rs
1//! ForgeOps error tracking client for a ForgeOps 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::cell::RefCell;
26use std::collections::HashMap;
27use std::sync::{Arc, OnceLock, RwLock};
28
29use client::Client;
30use delivery_queue::DeliveryQueue;
31use reporter::Reporter;
32
33struct State {
34 configuration: Arc<RwLock<Configuration>>,
35 reporter: Reporter,
36}
37
38static STATE: OnceLock<State> = OnceLock::new();
39
40thread_local! {
41 // The user set via `set_user`, if any. A plain thread-local, not a process-wide global: the
42 // right choice for the thread-per-request model this crate's own synchronous, non-async
43 // design naturally pairs with (see Cargo.toml's own comment on why `ureq`, not an async HTTP
44 // client, was chosen), the same reasoning `gems/forge_ops_tracker` documents for its own
45 // `Thread.current` use. **Does not propagate across an `.await` in an async runtime**: unlike
46 // Ruby's green/native threads, a single OS thread in an async executor (tokio, async-std)
47 // interleaves multiple unrelated tasks, so a value set on one task can leak into, or simply
48 // never reach, another. A host app built on an async runtime should pass `user` explicitly to
49 // `capture_error`/`capture_error_with_class` instead of relying on `set_user`, the same way
50 // `sdks/node` needs `AsyncLocalStorage` rather than a bare thread-local for the identical
51 // reason.
52 static CURRENT_USER: RefCell<Option<HashMap<String, Value>>> = const { RefCell::new(None) };
53}
54
55fn current_user() -> Option<HashMap<String, Value>> {
56 CURRENT_USER.with(|u| u.borrow().clone())
57}
58
59fn state() -> &'static State {
60 STATE.get_or_init(|| {
61 let configuration = Arc::new(RwLock::new(Configuration::new()));
62 let client = Arc::new(Client::new(Arc::clone(&configuration)));
63 let queue_size = configuration.read().unwrap().queue_size;
64 let delivery_queue = DeliveryQueue::new(queue_size, client);
65 let reporter = Reporter::new(Arc::clone(&configuration), delivery_queue);
66 State {
67 configuration,
68 reporter,
69 }
70 })
71}
72
73/// Configures the client. Call once at startup, before your server starts accepting requests.
74/// Pass a closure to set any [`Configuration`] field:
75///
76/// ```no_run
77/// forge_ops_tracker::init(|c| {
78/// c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string());
79/// c.release = Some("a1b2c3d".to_string());
80/// });
81/// ```
82///
83/// Installs the global panic hook (see [`install_panic_hook`]) unless
84/// `Configuration.install_panic_hook` is set to `false` inside the closure.
85pub fn init(configure: impl FnOnce(&mut Configuration)) {
86 let s = state();
87 let install_hook = {
88 let mut config = s.configuration.write().unwrap();
89 configure(&mut config);
90 config.install_panic_hook
91 };
92 if install_hook {
93 install_panic_hook();
94 }
95}
96
97/// Reports an error you've already handled. Call it right at the point you'd otherwise just log
98/// it:
99///
100/// ```no_run
101/// # use std::collections::HashMap;
102/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
103/// if let Err(err) = charge_card() {
104/// forge_ops_tracker::capture_error(&err, HashMap::new(), None);
105/// }
106/// ```
107///
108/// The backtrace is captured right here, at the call site: unlike Python/Java/PHP, a plain Rust
109/// `std::error::Error` carries no stack of its own, so `capture_error` has to be the one call that
110/// knows where the trace starts. `exception_class` is inferred via [`std::any::type_name`], which
111/// needs `E` to be a concrete, statically-known type: for a `Box<dyn Error>` or other trait
112/// object, where that isn't possible, use [`capture_error_with_class`] instead and supply the
113/// class yourself.
114///
115/// `user` defaults to whatever [`set_user`] last established on this thread, if anything (`None`
116/// here means "use that", not "no user"); pass `Some(..)` to override it for this one report.
117pub fn capture_error<E: std::error::Error>(
118 err: &E,
119 context: HashMap<String, Value>,
120 user: Option<HashMap<String, Value>>,
121) {
122 capture_error_with_class(std::any::type_name::<E>(), err, context, user);
123}
124
125/// The same as [`capture_error`], but for a `&dyn std::error::Error` (a `Box<dyn Error>`, a trait
126/// object) whose concrete type isn't known at the call site, so `exception_class` has to be
127/// supplied explicitly rather than inferred.
128pub fn capture_error_with_class(
129 exception_class: &str,
130 err: &dyn std::error::Error,
131 context: HashMap<String, Value>,
132 user: Option<HashMap<String, Value>>,
133) {
134 let s = state();
135 s.reporter.report_with_captured_backtrace(
136 exception_class,
137 &err.to_string(),
138 context,
139 user.or_else(current_user),
140 );
141}
142
143/// Manually attaches an affected user to whatever gets reported from here on, *on this thread*
144/// (an explicit [`capture_error`]/[`capture_error_with_class`] call with no `user` argument, or a
145/// panic the installed hook catches): there's no way to automatically detect "the current user"
146/// the way a server-side web framework with its own session/auth middleware can, so call this
147/// yourself, e.g. right after sign-in. `id`/`email`/`username` are all independently optional;
148/// call with an empty map to clear whatever was set, e.g. on sign-out. See this crate's own
149/// `CURRENT_USER` thread-local (in the source) for why this is thread-local, and the real caveat
150/// that comes with that choice under an async runtime.
151pub fn set_user(user: HashMap<String, Value>) {
152 let user = if user.is_empty() { None } else { Some(user) };
153 CURRENT_USER.with(|u| *u.borrow_mut() = user);
154}
155
156/// Installs a global panic hook that reports any panic on any thread, then calls whatever hook
157/// was previously installed (Rust's own default, which prints to stderr, unless something else
158/// already replaced it): never changing panic behavior itself, the same "report, then don't
159/// change program behavior" rule the .NET middleware and Python `excepthook` wrapper both follow.
160///
161/// Unlike Go, where only a `defer Recover()` in the same goroutine can see a panic, Rust's panic
162/// hook is genuinely process-wide: it fires for a panic on *any* thread, including a web
163/// framework's own worker threads, with no per-framework middleware needed at all. `init()` calls
164/// this automatically unless `Configuration.install_panic_hook` is set to `false`; call it
165/// directly only if you're managing configuration some other way.
166///
167/// Always chains onto whatever hook is *currently* installed via `take_hook()`, rather than
168/// latching "already installed" after the first call: deliberately, even though that means
169/// calling this more than once wraps another reporting layer each time (a real panic would then
170/// report once per accumulated layer). A one-shot latch was tried first and rejected: it makes
171/// this call a silent no-op the moment anything else calls `std::panic::set_hook` after this one
172/// runs (a host app installing its own hook after `init()`, say), discarding this crate's
173/// reporting entirely with no error or warning. Duplicate reports from calling this redundantly
174/// is a far more visible, far less damaging failure mode than reporting silently going dark, and
175/// is easily avoided the same way `init()` already asks to be called: once, at startup.
176pub fn install_panic_hook() {
177 let previous = std::panic::take_hook();
178 std::panic::set_hook(Box::new(move |info| {
179 report_panic(info);
180 previous(info);
181 }));
182}
183
184fn report_panic(info: &std::panic::PanicHookInfo) {
185 let s = state();
186 let (exception_class, message) = panic_message(info);
187
188 let mut context = HashMap::new();
189 if let Some(location) = info.location() {
190 context.insert(
191 "panic_location".to_string(),
192 Value::String(format!(
193 "{}:{}:{}",
194 location.file(),
195 location.line(),
196 location.column()
197 )),
198 );
199 }
200
201 s.reporter
202 .report_with_captured_backtrace(&exception_class, &message, context, current_user());
203}
204
205/// panic!() accepts any value, but the overwhelming majority of real panics carry either a `&str`
206/// (`panic!("boom")`) or a `String` (`panic!("boom: {err}")`) payload: these are the only two
207/// downcast targets std's own default panic hook special-cases too. Anything else reports as a
208/// generic "non-string panic payload" message, since there's no way to `Display` an arbitrary
209/// `dyn Any` payload.
210fn panic_message(info: &std::panic::PanicHookInfo) -> (String, String) {
211 let payload = info.payload();
212 if let Some(s) = payload.downcast_ref::<&str>() {
213 ("panic".to_string(), s.to_string())
214 } else if let Some(s) = payload.downcast_ref::<String>() {
215 ("panic".to_string(), s.clone())
216 } else {
217 ("panic".to_string(), "non-string panic payload".to_string())
218 }
219}
220
221/// Builds a `HashMap<String, Value>` from `key => value` pairs, the same literal-context ergonomics
222/// every other client in this repo gets for free from its own language (a Python dict, a JS object
223/// literal, a PHP array):
224///
225/// ```
226/// let ctx = forge_ops_tracker::context!{"order_id" => 42, "customer" => "acme-inc"};
227/// ```
228#[macro_export]
229macro_rules! context {
230 ( $( $key:expr => $value:expr ),* $(,)? ) => {{
231 #[allow(unused_mut)]
232 let mut map = ::std::collections::HashMap::new();
233 $( map.insert(::std::string::ToString::to_string($key), $crate::Value::from($value)); )*
234 map
235 }};
236}
237
238/// Extension trait for `Result`, so a fallible call can report its own error and still propagate
239/// it in one step:
240///
241/// ```no_run
242/// # use std::collections::HashMap;
243/// use forge_ops_tracker::ResultReportExt;
244/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
245/// # fn run() -> Result<(), std::io::Error> {
246/// charge_card().report_err(HashMap::new(), None)?;
247/// # Ok(())
248/// # }
249/// ```
250pub trait ResultReportExt<T> {
251 fn report_err(
252 self,
253 context: HashMap<String, Value>,
254 user: Option<HashMap<String, Value>>,
255 ) -> Self;
256}
257
258impl<T, E: std::error::Error> ResultReportExt<T> for Result<T, E> {
259 fn report_err(
260 self,
261 context: HashMap<String, Value>,
262 user: Option<HashMap<String, Value>>,
263 ) -> Self {
264 if let Err(ref err) = self {
265 capture_error(err, context, user);
266 }
267 self
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use std::io::{Read, Write};
275 use std::net::TcpListener;
276 use std::sync::atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering};
277 use std::sync::Mutex;
278 use std::time::Duration;
279
280 // A minimal single-request-per-connection HTTP server, the same pattern client.rs's own tests
281 // use, kept local to this module rather than shared: these tests specifically drive the
282 // *public* init/capture_error/panic-hook API end to end, not the lower-level types directly.
283 fn spawn_tracker_server() -> (std::net::SocketAddr, Arc<AtomicI32>) {
284 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
285 let addr = listener.local_addr().unwrap();
286 let received = Arc::new(AtomicI32::new(0));
287 let received_clone = Arc::clone(&received);
288
289 std::thread::spawn(move || {
290 for stream in listener.incoming().flatten() {
291 let mut stream = stream;
292 let mut buf = [0u8; 8192];
293 let mut total = Vec::new();
294 loop {
295 let n = stream.read(&mut buf).unwrap_or(0);
296 if n == 0 {
297 break;
298 }
299 total.extend_from_slice(&buf[..n]);
300 if total.windows(4).any(|w| w == b"\r\n\r\n") {
301 break;
302 }
303 }
304 received_clone.fetch_add(1, AtomicOrdering::SeqCst);
305 let _ = stream.write_all(
306 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
307 );
308 }
309 });
310
311 (addr, received)
312 }
313
314 // Same shape as spawn_tracker_server, but also reads and records each request's full body
315 // (headers *and* the Content-Length-declared body after them, not just the header block): the
316 // set_user/explicit-user-override scenarios below need to inspect the delivered JSON itself,
317 // not merely count deliveries.
318 fn spawn_tracker_server_capturing_body(
319 ) -> (std::net::SocketAddr, Arc<AtomicI32>, Arc<Mutex<String>>) {
320 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
321 let addr = listener.local_addr().unwrap();
322 let received = Arc::new(AtomicI32::new(0));
323 let received_clone = Arc::clone(&received);
324 let last_body = Arc::new(Mutex::new(String::new()));
325 let last_body_clone = Arc::clone(&last_body);
326
327 std::thread::spawn(move || {
328 for stream in listener.incoming().flatten() {
329 let mut stream = stream;
330 let mut buf = [0u8; 8192];
331 let mut total = Vec::new();
332 let mut header_end = None;
333 loop {
334 let n = stream.read(&mut buf).unwrap_or(0);
335 if n == 0 {
336 break;
337 }
338 total.extend_from_slice(&buf[..n]);
339 if let Some(pos) = total.windows(4).position(|w| w == b"\r\n\r\n") {
340 header_end = Some(pos + 4);
341 let header_text = String::from_utf8_lossy(&total[..pos]).into_owned();
342 let content_length: usize = header_text
343 .lines()
344 .find(|l| l.to_lowercase().starts_with("content-length:"))
345 .and_then(|l| l.split(':').nth(1))
346 .and_then(|v| v.trim().parse().ok())
347 .unwrap_or(0);
348 while total.len() < pos + 4 + content_length {
349 let n = stream.read(&mut buf).unwrap_or(0);
350 if n == 0 {
351 break;
352 }
353 total.extend_from_slice(&buf[..n]);
354 }
355 break;
356 }
357 }
358 if let Some(header_end) = header_end {
359 let body = String::from_utf8_lossy(&total[header_end..]).into_owned();
360 *last_body_clone.lock().unwrap() = body;
361 }
362 received_clone.fetch_add(1, AtomicOrdering::SeqCst);
363 let _ = stream.write_all(
364 b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
365 );
366 }
367 });
368
369 (addr, received, last_body)
370 }
371
372 fn wait_for(received: &AtomicI32, count: i32) {
373 let deadline = std::time::Instant::now() + Duration::from_secs(2);
374 while received.load(AtomicOrdering::SeqCst) < count && std::time::Instant::now() < deadline
375 {
376 std::thread::sleep(Duration::from_millis(5));
377 }
378 assert_eq!(received.load(AtomicOrdering::SeqCst), count);
379 }
380
381 // The public API sits behind one process-wide OnceLock (see `state()` above), so every test
382 // touching it has to run against that same singleton: unlike this crate's other modules,
383 // which build fresh, independent instances per test. Rather than fight Rust's default
384 // parallel test execution (or add a dev-dependency purely to serialize a handful of tests),
385 // every scenario that touches the public API lives in this one #[test] function and runs
386 // sequentially. Configuration itself is re-read from its RwLock on every delivery attempt
387 // (see Client::deliver), so repeatedly calling `init()` to point at a fresh DSN between
388 // scenarios below works correctly even though the underlying Reporter/DeliveryQueue/Client
389 // are only ever constructed once.
390 #[test]
391 fn public_api_end_to_end() {
392 // init + capture_error delivers through the full stack
393 let (addr, received) = spawn_tracker_server();
394 init(|c| {
395 c.dsn = Some(format!("http://key@{addr}/events"));
396 c.environment = "production".to_string();
397 c.timeout = Duration::from_secs(2);
398 c.install_panic_hook = false; // installed explicitly, in the last scenario below instead
399 });
400
401 let err = std::io::Error::other("boom");
402 capture_error(&err, context! {"order_id" => 7}, None);
403 wait_for(&received, 1);
404
405 // capture_error_with_class works for a trait-object error
406 let (addr, received) = spawn_tracker_server();
407 init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
408 let boxed: Box<dyn std::error::Error> = Box::new(std::io::Error::other("boxed boom"));
409 capture_error_with_class("std::io::Error", boxed.as_ref(), HashMap::new(), None);
410 wait_for(&received, 1);
411
412 // ResultReportExt reports on Err and passes the Result through unchanged
413 let (addr, received) = spawn_tracker_server();
414 init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
415 let result: Result<(), std::io::Error> = Err(std::io::Error::other("reported via ext"));
416 let passed_through = result.report_err(HashMap::new(), None);
417 assert!(passed_through.is_err());
418 wait_for(&received, 1);
419
420 let ok: Result<i32, std::io::Error> = Ok(42);
421 assert_eq!(ok.report_err(HashMap::new(), None).unwrap(), 42);
422
423 // set_user attaches the user to a later capture_error call with no explicit user, on this
424 // thread
425 let (addr, received, last_body) = spawn_tracker_server_capturing_body();
426 init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
427 set_user(context! {"id" => 42, "email" => "alice@example.com"});
428 capture_error(&std::io::Error::other("boom"), HashMap::new(), None);
429 wait_for(&received, 1);
430 assert!(last_body.lock().unwrap().contains("alice@example.com"));
431
432 // an explicit user argument overrides whatever set_user last set
433 let (addr, received, last_body) = spawn_tracker_server_capturing_body();
434 init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
435 set_user(context! {"id" => 42});
436 capture_error(
437 &std::io::Error::other("boom"),
438 HashMap::new(),
439 Some(context! {"id" => 99}),
440 );
441 wait_for(&received, 1);
442 assert!(last_body.lock().unwrap().contains("\"id\":99"));
443 set_user(HashMap::new()); // clear, so it doesn't leak into whatever test runs on this thread next
444
445 // init's automatic panic hook reports a panic, then still lets it unwind unchanged
446 let (addr, received) = spawn_tracker_server();
447 let previous_hook_ran = Arc::new(AtomicBool::new(false));
448 let previous_hook_ran_clone = Arc::clone(&previous_hook_ran);
449 // Installed *before* init() specifically to prove install_panic_hook chains onto whatever
450 // hook already exists (via take_hook()) rather than replacing it outright.
451 std::panic::set_hook(Box::new(move |_| {
452 previous_hook_ran_clone.store(true, AtomicOrdering::SeqCst);
453 }));
454 init(|c| {
455 c.dsn = Some(format!("http://key@{addr}/events"));
456 c.install_panic_hook = true;
457 });
458
459 let result = std::panic::catch_unwind(|| {
460 panic!("test panic");
461 });
462 assert!(result.is_err());
463 assert!(
464 previous_hook_ran.load(AtomicOrdering::SeqCst),
465 "the previously-installed hook should still have run"
466 );
467 wait_for(&received, 1);
468
469 // Restore a silent hook so later tests in this binary don't print this test's own
470 // intentional panic to stderr.
471 std::panic::set_hook(Box::new(|_| {}));
472 }
473
474 #[test]
475 fn context_macro_builds_expected_map() {
476 let ctx = context! {"order_id" => 42, "customer" => "acme-inc"};
477 assert_eq!(ctx.get("order_id"), Some(&Value::Number(42.0)));
478 assert_eq!(
479 ctx.get("customer"),
480 Some(&Value::String("acme-inc".to_string()))
481 );
482 }
483}