Skip to main content

forge_ops_tracker/
lib.rs

1//! ForgeOps error tracking client for ForgeOps:
2//!
3//! ```no_run
4//! forge_ops_tracker::init(|c| {
5//!     c.dsn = Some("https://<api_key>@getforgeops.net/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 breadcrumb_buffer;
15mod client;
16mod configuration;
17mod delivery_queue;
18mod event_builder;
19mod histogram_bucketer;
20mod metric_buffer;
21mod performance_flusher;
22mod pii_scrubber;
23mod reporter;
24mod span_buffer;
25mod span_queue;
26mod sql_statement;
27#[cfg(test)]
28mod test_support;
29
30pub use breadcrumb_buffer::Breadcrumb;
31pub use configuration::Configuration;
32pub use event_builder::{Event, Frame};
33pub use pii_scrubber::Value;
34pub use sql_statement::SqlObjects;
35
36use std::cell::RefCell;
37use std::collections::HashMap;
38use std::sync::{Arc, OnceLock, RwLock};
39
40use client::Client;
41use delivery_queue::DeliveryQueue;
42use metric_buffer::{Kind, MetricBuffer};
43use performance_flusher::PerformanceFlusher;
44use reporter::Reporter;
45use span_queue::SpanQueue;
46
47struct State {
48    configuration: Arc<RwLock<Configuration>>,
49    reporter: Reporter,
50    performance_flusher: Arc<PerformanceFlusher>,
51    span_queue: SpanQueue,
52    metrics: Arc<MetricBuffer>,
53    infrastructure_metrics: Arc<MetricBuffer>,
54}
55
56static STATE: OnceLock<State> = OnceLock::new();
57
58thread_local! {
59    // The user set via `set_user`, if any. A plain thread-local, not a process-wide global: the
60    // right choice for the thread-per-request model this crate's own synchronous, non-async
61    // design naturally pairs with (see Cargo.toml's own comment on why `ureq`, not an async HTTP
62    // client, was chosen), the same reasoning `gems/forge_ops_tracker` documents for its own
63    // `Thread.current` use. **Does not propagate across an `.await` in an async runtime**: unlike
64    // Ruby's green/native threads, a single OS thread in an async executor (tokio, async-std)
65    // interleaves multiple unrelated tasks, so a value set on one task can leak into, or simply
66    // never reach, another. A host app built on an async runtime should pass `user` explicitly to
67    // `capture_error`/`capture_error_with_class` instead of relying on `set_user`, the same way
68    // `sdks/node` needs `AsyncLocalStorage` rather than a bare thread-local for the identical
69    // reason.
70    static CURRENT_USER: RefCell<Option<HashMap<String, Value>>> = const { RefCell::new(None) };
71}
72
73fn current_user() -> Option<HashMap<String, Value>> {
74    CURRENT_USER.with(|u| u.borrow().clone())
75}
76
77fn state() -> &'static State {
78    STATE.get_or_init(|| {
79        let configuration = Arc::new(RwLock::new(Configuration::new()));
80        let client = Arc::new(Client::new(Arc::clone(&configuration)));
81        let queue_size = configuration.read().unwrap().queue_size;
82        let delivery_queue = DeliveryQueue::new(queue_size, Arc::clone(&client));
83        let span_queue = SpanQueue::new(queue_size, Arc::clone(&client));
84        let metrics = MetricBuffer::new(
85            Kind::Custom,
86            Arc::clone(&configuration),
87            Arc::clone(&client),
88        );
89        let infrastructure_metrics = MetricBuffer::new(
90            Kind::Infrastructure,
91            Arc::clone(&configuration),
92            Arc::clone(&client),
93        );
94        let reporter = Reporter::new(Arc::clone(&configuration), delivery_queue);
95        let performance_flusher = PerformanceFlusher::new(Arc::clone(&configuration), client);
96        State {
97            configuration,
98            reporter,
99            performance_flusher,
100            span_queue,
101            metrics,
102            infrastructure_metrics,
103        }
104    })
105}
106
107/// Configures the client. Call once at startup, before your server starts accepting requests.
108/// Pass a closure to set any [`Configuration`] field:
109///
110/// ```no_run
111/// forge_ops_tracker::init(|c| {
112///     c.dsn = Some("https://<api_key>@getforgeops.net/api/v1/events".to_string());
113///     c.release = Some("a1b2c3d".to_string());
114/// });
115/// ```
116///
117/// Installs the global panic hook (see [`install_panic_hook`]) unless
118/// `Configuration.install_panic_hook` is set to `false` inside the closure.
119pub fn init(configure: impl FnOnce(&mut Configuration)) {
120    let s = state();
121    let install_hook = {
122        let mut config = s.configuration.write().unwrap();
123        configure(&mut config);
124        config.install_panic_hook
125    };
126    if install_hook {
127        install_panic_hook();
128    }
129}
130
131/// Reports an error you've already handled. Call it right at the point you'd otherwise just log
132/// it:
133///
134/// ```no_run
135/// # use std::collections::HashMap;
136/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
137/// if let Err(err) = charge_card() {
138///     forge_ops_tracker::capture_error(&err, HashMap::new(), None);
139/// }
140/// ```
141///
142/// The backtrace is captured right here, at the call site: unlike Python/Java/PHP, a plain Rust
143/// `std::error::Error` carries no stack of its own, so `capture_error` has to be the one call that
144/// knows where the trace starts. `exception_class` is inferred via [`std::any::type_name`], which
145/// needs `E` to be a concrete, statically-known type: for a `Box<dyn Error>` or other trait
146/// object, where that isn't possible, use [`capture_error_with_class`] instead and supply the
147/// class yourself.
148///
149/// `user` defaults to whatever [`set_user`] last established on this thread, if anything (`None`
150/// here means "use that", not "no user"); pass `Some(..)` to override it for this one report.
151pub fn capture_error<E: std::error::Error>(
152    err: &E,
153    context: HashMap<String, Value>,
154    user: Option<HashMap<String, Value>>,
155) {
156    capture_error_with_class(std::any::type_name::<E>(), err, context, user);
157}
158
159/// The same as [`capture_error`], but for a `&dyn std::error::Error` (a `Box<dyn Error>`, a trait
160/// object) whose concrete type isn't known at the call site, so `exception_class` has to be
161/// supplied explicitly rather than inferred.
162pub fn capture_error_with_class(
163    exception_class: &str,
164    err: &dyn std::error::Error,
165    context: HashMap<String, Value>,
166    user: Option<HashMap<String, Value>>,
167) {
168    let s = state();
169    s.reporter.report_with_captured_backtrace(
170        exception_class,
171        &err.to_string(),
172        context,
173        user.or_else(current_user),
174        breadcrumb_buffer::current_breadcrumbs(),
175    );
176}
177
178/// The same as [`capture_error`], for an error caused by a database call: pass the SQL that ran.
179/// A Rust error carries no statement of its own and no Rust database crate puts one on its error
180/// types, so the code that ran the query has to hand it over.
181///
182/// With `Configuration::capture_sql_objects` on (the default), the names of the stored procedure,
183/// table and view the statement touched are sent, so an issue says where to start looking. With
184/// `Configuration::capture_sql_statement` on too (off by default), the statement itself is sent as
185/// well, with every string and number replaced by `?` first. The raw statement never leaves this
186/// process either way.
187///
188/// ```ignore
189/// if let Err(err) = sqlx::query(QUERY).bind(id).execute(&pool).await {
190///     forge_ops_tracker::capture_error_with_sql(&err, QUERY, HashMap::new(), None);
191/// }
192/// ```
193pub fn capture_error_with_sql<E: std::error::Error>(
194    err: &E,
195    statement: &str,
196    context: HashMap<String, Value>,
197    user: Option<HashMap<String, Value>>,
198) {
199    capture_error_with_class_and_sql(std::any::type_name::<E>(), err, statement, context, user);
200}
201
202/// The same as [`capture_error_with_sql`], but for a `&dyn std::error::Error` whose concrete type
203/// isn't known at the call site, so `exception_class` is supplied explicitly.
204pub fn capture_error_with_class_and_sql(
205    exception_class: &str,
206    err: &dyn std::error::Error,
207    statement: &str,
208    context: HashMap<String, Value>,
209    user: Option<HashMap<String, Value>>,
210) {
211    let s = state();
212    s.reporter.report_with_captured_backtrace_and_sql(
213        exception_class,
214        &err.to_string(),
215        context,
216        user.or_else(current_user),
217        breadcrumb_buffer::current_breadcrumbs(),
218        statement,
219    );
220}
221
222/// Manually attaches an affected user to whatever gets reported from here on, *on this thread*
223/// (an explicit [`capture_error`]/[`capture_error_with_class`] call with no `user` argument, or a
224/// panic the installed hook catches): there's no way to automatically detect "the current user"
225/// the way a server-side web framework with its own session/auth middleware can, so call this
226/// yourself, e.g. right after sign-in. `id`/`email`/`username` are all independently optional;
227/// call with an empty map to clear whatever was set, e.g. on sign-out. See this crate's own
228/// `CURRENT_USER` thread-local (in the source) for why this is thread-local, and the real caveat
229/// that comes with that choice under an async runtime.
230pub fn set_user(user: HashMap<String, Value>) {
231    let user = if user.is_empty() { None } else { Some(user) };
232    CURRENT_USER.with(|u| *u.borrow_mut() = user);
233}
234
235/// Records one entry into the current thread's breadcrumb trail: a query, an outbound call, or
236/// anything worth remembering right up to the moment something actually goes wrong. `category`
237/// defaults to `"custom"` and `level` to `"info"` when passed an empty string. A no-op, not an
238/// error, when `Configuration.track_breadcrumbs` is `false`.
239///
240/// This crate has no web framework integration of its own (unlike `sdks/go`'s net/http/Gin
241/// middleware), so there's no automatic breadcrumb source and no middleware to start a fresh trail
242/// per request on its own: call [`clear_breadcrumbs`] yourself at the start of each request, the
243/// same place you'd already be calling [`set_user`] from, or entries from an earlier request
244/// handled on a reused thread will bleed into this one's own report.
245pub fn add_breadcrumb(message: &str, category: &str, level: &str, data: HashMap<String, Value>) {
246    let config = state().configuration.read().unwrap();
247    breadcrumb_buffer::add_breadcrumb(
248        config.track_breadcrumbs,
249        config.max_breadcrumbs,
250        message,
251        category,
252        level,
253        data,
254    );
255}
256
257/// Clears the current thread's breadcrumb trail. See [`add_breadcrumb`]'s own doc for why calling
258/// this yourself, at the start of each request, is this crate's responsibility to ask of you
259/// rather than something it can do on its own.
260pub fn clear_breadcrumbs() {
261    breadcrumb_buffer::clear_breadcrumbs();
262}
263
264/// Records one timed call's duration, in milliseconds, under `transaction_name`: tallied
265/// in-process (count, total, max) and flushed periodically as one small aggregate report, for the
266/// Performance page's per-transaction table, not one network call per call. A no-op when
267/// `Configuration.track_performance` is `false` or reporting isn't enabled for this environment.
268///
269/// This crate has no web framework integration of its own (unlike `sdks/go`'s net/http/Gin
270/// middleware), so nothing is timed automatically: wrap whatever you want on the Performance page
271/// yourself, e.g. a request handler, with [`time_transaction`], or call this directly with a
272/// duration you measured. Keep `transaction_name` low-cardinality (`"GET /users/:id"`, not
273/// `"GET /users/42"`): every distinct name is its own row.
274pub fn record_performance(transaction_name: &str, duration_ms: f64) {
275    state()
276        .performance_flusher
277        .record(transaction_name, duration_ms);
278}
279
280/// Runs `f`, records how long it took under `transaction_name` (see [`record_performance`]), and
281/// returns whatever `f` returned. Recorded even if `f` panics: the duration up to the panic is
282/// still a real duration, and a handler that panics is exactly one worth seeing on the
283/// Performance page.
284///
285/// ```no_run
286/// # fn handle_request() -> u32 { 200 }
287/// let status = forge_ops_tracker::time_transaction("GET /users/:id", || handle_request());
288/// ```
289pub fn time_transaction<T>(transaction_name: &str, f: impl FnOnce() -> T) -> T {
290    struct Timing<'a> {
291        name: &'a str,
292        started_at: std::time::Instant,
293    }
294    impl Drop for Timing<'_> {
295        fn drop(&mut self) {
296            record_performance(self.name, self.started_at.elapsed().as_secs_f64() * 1000.0);
297        }
298    }
299
300    let _timing = Timing {
301        name: transaction_name,
302        started_at: std::time::Instant::now(),
303    };
304    f()
305}
306
307/// Delivers whatever has been tallied so far right now, instead of waiting for the next
308/// `performance_flush_interval` tick. The background flush thread is a daemon: it does not run on
309/// a normal process exit the way the Ruby gem's `at_exit` hook does (Rust has no equivalent), so a
310/// short-lived program, or one about to shut down, should call this itself to avoid losing the
311/// last partial window.
312pub fn flush_performance() {
313    state().performance_flusher.flush();
314}
315
316/// Records a named business metric (a signup, a payment, anything you want to name), buffered and
317/// flushed periodically as one batch rather than one network call per capture. Pass `1.0` for a bare
318/// counter-style call ("a signup happened") or a real magnitude ("a $49 payment"); it may be negative
319/// (a refund). A no-op when the client isn't enabled (no DSN, or this environment isn't in
320/// `enabled_environments`), and a NaN or infinite value is dropped.
321///
322/// Rust has no exit hook to flush from, so a short-lived program should call [`flush_metrics`]
323/// before it returns from `main`.
324///
325/// ```no_run
326/// forge_ops_tracker::capture_metric("signup", 1.0);
327/// forge_ops_tracker::capture_metric("payment", 49.0);
328/// ```
329pub fn capture_metric(name: &str, value: f64) {
330    let s = state();
331    let (enabled, environment, release) = {
332        let config = s.configuration.read().unwrap();
333        (
334            config.is_enabled(),
335            config.environment.clone(),
336            config.release.clone(),
337        )
338    };
339    if !enabled {
340        return;
341    }
342    let release = release
343        .as_deref()
344        .map(pii_scrubber::json_string)
345        .unwrap_or_else(|| "null".to_string());
346    s.metrics.record(
347        value,
348        &format!(
349            "\"metric_name\":{},\"value\":{value},\"environment\":{},\"release\":{release}",
350            pii_scrubber::json_string(name),
351            pii_scrubber::json_string(&environment),
352        ),
353    );
354}
355
356/// Records one infrastructure reading (CPU, memory, disk, anything else a program of yours reads)
357/// from one of your own hosts. `hostname` defaults to `Configuration.server_name` when `None`, so a
358/// script running on the box it reports about needs no argument. Same buffered-batch delivery and
359/// no-op-when-disabled contract as [`capture_metric`].
360///
361/// ```no_run
362/// forge_ops_tracker::capture_infrastructure_metric("cpu", 0.42, None);
363/// forge_ops_tracker::capture_infrastructure_metric("disk", 0.81, Some("db-1"));
364/// forge_ops_tracker::flush_metrics();
365/// ```
366pub fn capture_infrastructure_metric(name: &str, value: f64, hostname: Option<&str>) {
367    let s = state();
368    let (enabled, server_name) = {
369        let config = s.configuration.read().unwrap();
370        (config.is_enabled(), config.server_name.clone())
371    };
372    if !enabled {
373        return;
374    }
375    let hostname = hostname
376        .map(str::to_string)
377        .or(server_name)
378        .unwrap_or_default();
379    s.infrastructure_metrics.record(
380        value,
381        &format!(
382            "\"metric_name\":{},\"value\":{value},\"hostname\":{}",
383            pii_scrubber::json_string(name),
384            pii_scrubber::json_string(&hostname),
385        ),
386    );
387}
388
389/// Delivers every buffered metric and infrastructure reading right now, instead of waiting for the
390/// next flush interval. The background flush thread is a daemon and Rust has no exit hook, so call
391/// this before a short-lived program ends.
392pub fn flush_metrics() {
393    let s = state();
394    s.metrics.flush();
395    s.infrastructure_metrics.flush();
396}
397
398/// Runs `f` as a trace named `root_name` (for example `"GET /checkout"` or `"job:reindex"`): every
399/// [`span`] and [`record_span`] inside it, on this thread, nests beneath this root. When `f` took
400/// at least `Configuration.trace_capture_threshold` (1 second by default) the whole trace is sent
401/// to ForgeOps, so fast calls cost nothing on the wire. Sent even if `f` panics. Called inside an
402/// already-open trace it just records a span instead.
403///
404/// This crate has no web framework integration, so nothing starts a trace automatically: wrap
405/// whatever you want traced, typically a request handler or a background job.
406///
407/// ```no_run
408/// # fn handle_request() -> u32 { 200 }
409/// let status = forge_ops_tracker::trace("GET /checkout", || handle_request());
410/// ```
411pub fn trace<T>(root_name: &str, f: impl FnOnce() -> T) -> T {
412    let (owns_trace, threshold) = {
413        let config = state().configuration.read().unwrap();
414        (span_buffer::begin(&config), config.trace_capture_threshold)
415    };
416    if !owns_trace {
417        return span(root_name, "service", HashMap::new(), f);
418    }
419
420    struct Root<'a> {
421        name: &'a str,
422        started_at: std::time::SystemTime,
423        timer: std::time::Instant,
424        threshold: std::time::Duration,
425    }
426    impl Drop for Root<'_> {
427        fn drop(&mut self) {
428            let duration_ms = self.timer.elapsed().as_secs_f64() * 1000.0;
429            if let Some(body) = span_buffer::end(
430                self.threshold,
431                self.name,
432                "controller",
433                self.started_at,
434                duration_ms,
435            ) {
436                state().span_queue.push(body);
437            }
438        }
439    }
440
441    let _root = Root {
442        name: root_name,
443        started_at: std::time::SystemTime::now(),
444        timer: std::time::Instant::now(),
445        threshold,
446    };
447    f()
448}
449
450/// Times `f` as a child span of whatever span is open on this thread (or of the trace's root),
451/// returning what `f` returned. Outside a [`trace`] it just runs `f`. Recorded even if `f` panics.
452/// `kind` is one of `"controller"`, `"service"`, `"database"`, `"redis"`, `"http"`, `"job"`,
453/// `"other"` (anything else is sent as `"other"`).
454///
455/// ```no_run
456/// # use std::collections::HashMap;
457/// # fn charge(id: u32) -> u32 { id }
458/// let charged = forge_ops_tracker::span("charge card", "service", HashMap::new(), || charge(7));
459/// ```
460pub fn span<T>(name: &str, kind: &str, data: HashMap<String, Value>, f: impl FnOnce() -> T) -> T {
461    let Some((id, parent)) = span_buffer::open_span() else {
462        return f();
463    };
464
465    struct Open<'a> {
466        id: String,
467        parent: String,
468        name: &'a str,
469        kind: &'a str,
470        data: HashMap<String, Value>,
471        started_at: std::time::SystemTime,
472        timer: std::time::Instant,
473    }
474    impl Drop for Open<'_> {
475        fn drop(&mut self) {
476            span_buffer::close_span(
477                std::mem::take(&mut self.id),
478                std::mem::take(&mut self.parent),
479                self.name,
480                self.kind,
481                self.started_at,
482                self.timer.elapsed().as_secs_f64() * 1000.0,
483                std::mem::take(&mut self.data),
484            );
485        }
486    }
487
488    let _open = Open {
489        id,
490        parent,
491        name,
492        kind,
493        data,
494        started_at: std::time::SystemTime::now(),
495        timer: std::time::Instant::now(),
496    };
497    f()
498}
499
500/// Records a span you timed yourself under the current one; a no-op outside a [`trace`].
501pub fn record_span(
502    name: &str,
503    kind: &str,
504    started_at: std::time::SystemTime,
505    duration_ms: f64,
506    data: HashMap<String, Value>,
507) {
508    span_buffer::record_leaf(name, kind, started_at, duration_ms, data);
509}
510
511/// Installs a global panic hook that reports any panic on any thread, then calls whatever hook
512/// was previously installed (Rust's own default, which prints to stderr, unless something else
513/// already replaced it): never changing panic behavior itself, the same "report, then don't
514/// change program behavior" rule the .NET middleware and Python `excepthook` wrapper both follow.
515///
516/// Unlike Go, where only a `defer Recover()` in the same goroutine can see a panic, Rust's panic
517/// hook is genuinely process-wide: it fires for a panic on *any* thread, including a web
518/// framework's own worker threads, with no per-framework middleware needed at all. `init()` calls
519/// this automatically unless `Configuration.install_panic_hook` is set to `false`; call it
520/// directly only if you're managing configuration some other way.
521///
522/// Always chains onto whatever hook is *currently* installed via `take_hook()`, rather than
523/// latching "already installed" after the first call: deliberately, even though that means
524/// calling this more than once wraps another reporting layer each time (a real panic would then
525/// report once per accumulated layer). A one-shot latch was tried first and rejected: it makes
526/// this call a silent no-op the moment anything else calls `std::panic::set_hook` after this one
527/// runs (a host app installing its own hook after `init()`, say), discarding this crate's
528/// reporting entirely with no error or warning. Duplicate reports from calling this redundantly
529/// is a far more visible, far less damaging failure mode than reporting silently going dark, and
530/// is easily avoided the same way `init()` already asks to be called: once, at startup.
531pub fn install_panic_hook() {
532    let previous = std::panic::take_hook();
533    std::panic::set_hook(Box::new(move |info| {
534        report_panic(info);
535        previous(info);
536    }));
537}
538
539fn report_panic(info: &std::panic::PanicHookInfo) {
540    let s = state();
541    let (exception_class, message) = panic_message(info);
542
543    let mut context = HashMap::new();
544    if let Some(location) = info.location() {
545        context.insert(
546            "panic_location".to_string(),
547            Value::String(format!(
548                "{}:{}:{}",
549                location.file(),
550                location.line(),
551                location.column()
552            )),
553        );
554    }
555
556    s.reporter.report_with_captured_backtrace(
557        &exception_class,
558        &message,
559        context,
560        current_user(),
561        breadcrumb_buffer::current_breadcrumbs(),
562    );
563}
564
565/// panic!() accepts any value, but the overwhelming majority of real panics carry either a `&str`
566/// (`panic!("boom")`) or a `String` (`panic!("boom: {err}")`) payload: these are the only two
567/// downcast targets std's own default panic hook special-cases too. Anything else reports as a
568/// generic "non-string panic payload" message, since there's no way to `Display` an arbitrary
569/// `dyn Any` payload.
570fn panic_message(info: &std::panic::PanicHookInfo) -> (String, String) {
571    let payload = info.payload();
572    if let Some(s) = payload.downcast_ref::<&str>() {
573        ("panic".to_string(), s.to_string())
574    } else if let Some(s) = payload.downcast_ref::<String>() {
575        ("panic".to_string(), s.clone())
576    } else {
577        ("panic".to_string(), "non-string panic payload".to_string())
578    }
579}
580
581/// Builds a `HashMap<String, Value>` from `key => value` pairs, the same literal-context ergonomics
582/// every other client in this repo gets for free from its own language (a Python dict, a JS object
583/// literal, a PHP array):
584///
585/// ```
586/// let ctx = forge_ops_tracker::context!{"order_id" => 42, "customer" => "acme-inc"};
587/// ```
588#[macro_export]
589macro_rules! context {
590    ( $( $key:expr => $value:expr ),* $(,)? ) => {{
591        #[allow(unused_mut)]
592        let mut map = ::std::collections::HashMap::new();
593        $( map.insert(::std::string::ToString::to_string($key), $crate::Value::from($value)); )*
594        map
595    }};
596}
597
598/// Extension trait for `Result`, so a fallible call can report its own error and still propagate
599/// it in one step:
600///
601/// ```no_run
602/// # use std::collections::HashMap;
603/// use forge_ops_tracker::ResultReportExt;
604/// # fn charge_card() -> Result<(), std::io::Error> { Ok(()) }
605/// # fn run() -> Result<(), std::io::Error> {
606/// charge_card().report_err(HashMap::new(), None)?;
607/// # Ok(())
608/// # }
609/// ```
610pub trait ResultReportExt<T> {
611    fn report_err(
612        self,
613        context: HashMap<String, Value>,
614        user: Option<HashMap<String, Value>>,
615    ) -> Self;
616}
617
618impl<T, E: std::error::Error> ResultReportExt<T> for Result<T, E> {
619    fn report_err(
620        self,
621        context: HashMap<String, Value>,
622        user: Option<HashMap<String, Value>>,
623    ) -> Self {
624        if let Err(ref err) = self {
625            capture_error(err, context, user);
626        }
627        self
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634    use std::io::{Read, Write};
635    use std::net::TcpListener;
636    use std::sync::atomic::{AtomicBool, AtomicI32, Ordering as AtomicOrdering};
637    use std::sync::Mutex;
638    use std::time::Duration;
639
640    // A minimal single-request-per-connection HTTP server, the same pattern client.rs's own tests
641    // use, kept local to this module rather than shared: these tests specifically drive the
642    // *public* init/capture_error/panic-hook API end to end, not the lower-level types directly.
643    fn spawn_tracker_server() -> (std::net::SocketAddr, Arc<AtomicI32>) {
644        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
645        let addr = listener.local_addr().unwrap();
646        let received = Arc::new(AtomicI32::new(0));
647        let received_clone = Arc::clone(&received);
648
649        std::thread::spawn(move || {
650            for stream in listener.incoming().flatten() {
651                let mut stream = stream;
652                crate::test_support::read_full_request(&mut stream);
653                received_clone.fetch_add(1, AtomicOrdering::SeqCst);
654                let _ = stream.write_all(
655                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
656                );
657            }
658        });
659
660        (addr, received)
661    }
662
663    // Same shape as spawn_tracker_server, but also reads and records each request's full body
664    // (headers *and* the Content-Length-declared body after them, not just the header block): the
665    // set_user/explicit-user-override scenarios below need to inspect the delivered JSON itself,
666    // not merely count deliveries.
667    fn spawn_tracker_server_capturing_body(
668    ) -> (std::net::SocketAddr, Arc<AtomicI32>, Arc<Mutex<String>>) {
669        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
670        let addr = listener.local_addr().unwrap();
671        let received = Arc::new(AtomicI32::new(0));
672        let received_clone = Arc::clone(&received);
673        let last_body = Arc::new(Mutex::new(String::new()));
674        let last_body_clone = Arc::clone(&last_body);
675
676        std::thread::spawn(move || {
677            for stream in listener.incoming().flatten() {
678                let mut stream = stream;
679                let mut buf = [0u8; 8192];
680                let mut total = Vec::new();
681                let mut header_end = None;
682                loop {
683                    let n = stream.read(&mut buf).unwrap_or(0);
684                    if n == 0 {
685                        break;
686                    }
687                    total.extend_from_slice(&buf[..n]);
688                    if let Some(pos) = total.windows(4).position(|w| w == b"\r\n\r\n") {
689                        header_end = Some(pos + 4);
690                        let header_text = String::from_utf8_lossy(&total[..pos]).into_owned();
691                        let content_length: usize = header_text
692                            .lines()
693                            .find(|l| l.to_lowercase().starts_with("content-length:"))
694                            .and_then(|l| l.split(':').nth(1))
695                            .and_then(|v| v.trim().parse().ok())
696                            .unwrap_or(0);
697                        while total.len() < pos + 4 + content_length {
698                            let n = stream.read(&mut buf).unwrap_or(0);
699                            if n == 0 {
700                                break;
701                            }
702                            total.extend_from_slice(&buf[..n]);
703                        }
704                        break;
705                    }
706                }
707                if let Some(header_end) = header_end {
708                    let body = String::from_utf8_lossy(&total[header_end..]).into_owned();
709                    *last_body_clone.lock().unwrap() = body;
710                }
711                received_clone.fetch_add(1, AtomicOrdering::SeqCst);
712                let _ = stream.write_all(
713                    b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}",
714                );
715            }
716        });
717
718        (addr, received, last_body)
719    }
720
721    fn wait_for(received: &AtomicI32, count: i32) {
722        let deadline = std::time::Instant::now() + Duration::from_secs(2);
723        while received.load(AtomicOrdering::SeqCst) < count && std::time::Instant::now() < deadline
724        {
725            std::thread::sleep(Duration::from_millis(5));
726        }
727        assert_eq!(received.load(AtomicOrdering::SeqCst), count);
728    }
729
730    // The public API sits behind one process-wide OnceLock (see `state()` above), so every test
731    // touching it has to run against that same singleton: unlike this crate's other modules,
732    // which build fresh, independent instances per test. Rather than fight Rust's default
733    // parallel test execution (or add a dev-dependency purely to serialize a handful of tests),
734    // every scenario that touches the public API lives in this one #[test] function and runs
735    // sequentially. Configuration itself is re-read from its RwLock on every delivery attempt
736    // (see Client::deliver), so repeatedly calling `init()` to point at a fresh DSN between
737    // scenarios below works correctly even though the underlying Reporter/DeliveryQueue/Client
738    // are only ever constructed once.
739    #[test]
740    fn public_api_end_to_end() {
741        // init + capture_error delivers through the full stack
742        let (addr, received) = spawn_tracker_server();
743        init(|c| {
744            c.dsn = Some(format!("http://key@{addr}/events"));
745            c.environment = "production".to_string();
746            c.timeout = Duration::from_secs(2);
747            c.install_panic_hook = false; // installed explicitly, in the last scenario below instead
748        });
749
750        let err = std::io::Error::other("boom");
751        capture_error(&err, context! {"order_id" => 7}, None);
752        wait_for(&received, 1);
753
754        // capture_error_with_class works for a trait-object error
755        let (addr, received) = spawn_tracker_server();
756        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
757        let boxed: Box<dyn std::error::Error> = Box::new(std::io::Error::other("boxed boom"));
758        capture_error_with_class("std::io::Error", boxed.as_ref(), HashMap::new(), None);
759        wait_for(&received, 1);
760
761        // ResultReportExt reports on Err and passes the Result through unchanged
762        let (addr, received) = spawn_tracker_server();
763        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
764        let result: Result<(), std::io::Error> = Err(std::io::Error::other("reported via ext"));
765        let passed_through = result.report_err(HashMap::new(), None);
766        assert!(passed_through.is_err());
767        wait_for(&received, 1);
768
769        let ok: Result<i32, std::io::Error> = Ok(42);
770        assert_eq!(ok.report_err(HashMap::new(), None).unwrap(), 42);
771
772        // set_user attaches the user to a later capture_error call with no explicit user, on this
773        // thread
774        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
775        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
776        set_user(context! {"id" => 42, "email" => "alice@example.com"});
777        capture_error(&std::io::Error::other("boom"), HashMap::new(), None);
778        wait_for(&received, 1);
779        assert!(last_body.lock().unwrap().contains("alice@example.com"));
780
781        // an explicit user argument overrides whatever set_user last set
782        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
783        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
784        set_user(context! {"id" => 42});
785        capture_error(
786            &std::io::Error::other("boom"),
787            HashMap::new(),
788            Some(context! {"id" => 99}),
789        );
790        wait_for(&received, 1);
791        assert!(last_body.lock().unwrap().contains("\"id\":99"));
792        set_user(HashMap::new()); // clear, so it doesn't leak into whatever test runs on this thread next
793
794        // add_breadcrumb accumulates on this thread and capture_error attaches the trail
795        // automatically; clear_breadcrumbs empties it again
796        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
797        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
798        add_breadcrumb("opened checkout", "custom", "info", HashMap::new());
799        add_breadcrumb(
800            "charged card",
801            "custom",
802            "info",
803            context! {"order_id" => 42},
804        );
805        capture_error(&std::io::Error::other("boom"), HashMap::new(), None);
806        wait_for(&received, 1);
807        {
808            let body = last_body.lock().unwrap();
809            assert!(body.contains("opened checkout"));
810            assert!(body.contains("charged card"));
811        }
812        clear_breadcrumbs();
813
814        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
815        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
816        capture_error(&std::io::Error::other("boom"), HashMap::new(), None);
817        wait_for(&received, 1);
818        assert!(!last_body.lock().unwrap().contains("\"breadcrumbs\""));
819
820        // record_performance tallies in-process and flush_performance delivers one aggregate batch
821        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
822        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
823        record_performance("GET /users/:id", 10.0);
824        record_performance("GET /users/:id", 30.0);
825        flush_performance();
826        wait_for(&received, 1);
827        {
828            let body = last_body.lock().unwrap();
829            assert!(body.starts_with("{\"samples\":["), "body = {body}");
830            assert!(body.contains("\"transaction_name\":\"GET /users/:id\""));
831            assert!(body.contains("\"request_count\":2"));
832        }
833
834        // time_transaction returns the closure's own value and records how long it took, even if
835        // the closure panics
836        let (addr, received, last_body) = spawn_tracker_server_capturing_body();
837        init(|c| c.dsn = Some(format!("http://key@{addr}/events")));
838        let value = time_transaction("timed", || 42);
839        assert_eq!(value, 42);
840        let panicked = std::panic::catch_unwind(|| {
841            time_transaction("timed panics", || -> u32 {
842                panic!("inside a timed transaction")
843            })
844        });
845        assert!(panicked.is_err());
846        flush_performance();
847        wait_for(&received, 1);
848        {
849            let body = last_body.lock().unwrap();
850            assert!(
851                body.contains("\"transaction_name\":\"timed\""),
852                "body = {body}"
853            );
854            assert!(
855                body.contains("\"transaction_name\":\"timed panics\""),
856                "body = {body}"
857            );
858        }
859
860        // track_performance = false records and delivers nothing
861        let (addr, received, _last_body) = spawn_tracker_server_capturing_body();
862        init(|c| {
863            c.dsn = Some(format!("http://key@{addr}/events"));
864            c.track_performance = false;
865        });
866        record_performance("never recorded", 10.0);
867        flush_performance();
868        std::thread::sleep(Duration::from_millis(150));
869        assert_eq!(received.load(AtomicOrdering::SeqCst), 0);
870        init(|c| c.track_performance = true);
871
872        // init's automatic panic hook reports a panic, then still lets it unwind unchanged
873        let (addr, received) = spawn_tracker_server();
874        let previous_hook_ran = Arc::new(AtomicBool::new(false));
875        let previous_hook_ran_clone = Arc::clone(&previous_hook_ran);
876        // Installed *before* init() specifically to prove install_panic_hook chains onto whatever
877        // hook already exists (via take_hook()) rather than replacing it outright.
878        std::panic::set_hook(Box::new(move |_| {
879            previous_hook_ran_clone.store(true, AtomicOrdering::SeqCst);
880        }));
881        init(|c| {
882            c.dsn = Some(format!("http://key@{addr}/events"));
883            c.install_panic_hook = true;
884        });
885
886        let result = std::panic::catch_unwind(|| {
887            panic!("test panic");
888        });
889        assert!(result.is_err());
890        assert!(
891            previous_hook_ran.load(AtomicOrdering::SeqCst),
892            "the previously-installed hook should still have run"
893        );
894        wait_for(&received, 1);
895
896        // Restore a silent hook so later tests in this binary don't print this test's own
897        // intentional panic to stderr.
898        std::panic::set_hook(Box::new(|_| {}));
899    }
900
901    #[test]
902    fn span_just_runs_the_closure_outside_a_trace() {
903        assert_eq!(span("free", "service", HashMap::new(), || 7), 7);
904        // record_span outside a trace is a no-op, not a panic.
905        record_span(
906            "free",
907            "database",
908            std::time::SystemTime::now(),
909            1.0,
910            HashMap::new(),
911        );
912    }
913
914    #[test]
915    fn a_panic_inside_trace_or_span_propagates_and_leaves_no_trace_behind() {
916        // Only the thread-local matters here, so this holds whether or not another test has
917        // already enabled reporting on the shared global configuration.
918        let result = std::panic::catch_unwind(|| {
919            trace("GET /boom", || {
920                span("bad", "service", HashMap::new(), || panic!("boom"));
921            })
922        });
923        assert!(result.is_err());
924        assert!(!span_buffer::is_active());
925    }
926
927    #[test]
928    fn trace_returns_the_closures_value_and_a_nested_trace_is_a_span() {
929        let value = trace("outer", || trace("inner", || 5));
930        assert_eq!(value, 5);
931        assert!(!span_buffer::is_active());
932    }
933
934    #[test]
935    fn context_macro_builds_expected_map() {
936        let ctx = context! {"order_id" => 42, "customer" => "acme-inc"};
937        assert_eq!(ctx.get("order_id"), Some(&Value::Number(42.0)));
938        assert_eq!(
939            ctx.get("customer"),
940            Some(&Value::String("acme-inc".to_string()))
941        );
942    }
943}