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