Skip to main content

forge_ops_tracker/
lib.rs

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