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::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}