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