Skip to main content

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}