forge-ops-tracker 0.3.0

Rust error reporting client for a ForgeOps instance.
Documentation

forge-ops-tracker

Rust error reporting client for a ForgeOps instance. Requires Rust 1.81+. It captures panics on any thread and explicitly reported errors, builds a backtrace, scrubs likely PII, and delivers events to ForgeOps over HTTP without blocking whatever raised them.

Installation

[dependencies]
forge-ops-tracker = "0.2.0"

Dependencies

Rust's standard library has no HTTP client, no structured per-frame backtrace access on stable, and no regular expression engine. This crate depends on exactly the three widely-used crates that fill those three specific gaps, each doing something std genuinely can't:

  • ureq: delivers events over HTTPS. std has no HTTP client at all.
  • backtrace: structured per-frame file/line/function data. std::backtrace::Backtrace captures a trace on stable Rust, but only exposes it as a formatted string (no public per-frame accessors) so it can't produce the structured file/line/method/in_app shape this client needs to build an event payload.
  • regex: the PII scrubber's pattern matching. std has no regular expression engine.

Everything else (the event payload's own JSON encoding, a DSN parser, timestamp formatting) is hand-rolled rather than reaching for serde_json/url/chrono: this crate only depends on something outside std when the language truly can't do it itself.

Configuration

Set a DSN (from a project's settings page in ForgeOps), either via the FORGE_OPS_DSN environment variable or explicitly:

forge_ops_tracker::init(|c| {
    c.dsn = Some("https://<api_key>@your-forgeops-host/api/v1/events".to_string()); // or leave unset to read FORGE_OPS_DSN
    c.release = Some("...".to_string());
    c.environment = "production".to_string();
});

Call init once at startup, before your server starts accepting requests. Pass a closure to set any Configuration field, so every option is available through the one call without a long list of positional arguments or a separate setter for each field.

What gets reported automatically, and what doesn't

A panic on any thread needs no further wiring at all, once init() has run. Rust's panic hook (std::panic::set_hook) is process-wide: it fires for a panic on any thread, including a web framework's own worker threads (Actix, Axum/Tokio, a plain std::thread), with no per-framework middleware needed at all.

init() installs this hook automatically unless Configuration.install_panic_hook is set to false. It never swallows the panic: after reporting, it calls whatever hook was previously installed (Rust's own default, which prints to stderr, unless something else already replaced it), so reporting a panic never changes what your program actually does afterward. Call install_panic_hook() directly only if you're managing configuration some other way than init().

Rust doesn't have exceptions, so an error your own code already caught (a Result::Err) is a second, separate case: report it explicitly, right at the point you'd otherwise just log it:

if let Err(err) = charge_card(&order) {
    forge_ops_tracker::capture_error(&err, forge_ops_tracker::context!{"order_id" => order.id}, None);
    return Err(err);
}

Or, more concisely, via the ResultReportExt extension trait, which reports on Err and passes the Result through unchanged:

use forge_ops_tracker::ResultReportExt;

charge_card(&order).report_err(forge_ops_tracker::context!{"order_id" => order.id}, None)?;

A plain Rust std::error::Error carries no stack trace of its own, so capture_error captures the backtrace at its own call site. Call it as close to the point you learned about the error as you reasonably can, for the most useful trace.

exception_class is inferred via std::any::type_name, which needs a concrete, statically-known error type: for a Box<dyn Error> or other trait object, where that isn't possible, use capture_error_with_class(class, err, context, user) instead and supply the class yourself.

Delivery happens on a background thread with a bounded channel and a short per-request HTTP timeout (Configuration.timeout, 2s default). Every failure mode: network errors, timeouts, a full queue, a malformed DSN: is caught and dropped rather than propagated, so a broken or unreachable tracker can never take down the host app. The worker thread starts eagerly, at init() time, rather than waiting for the first push: Rust programs essentially never fork themselves at the application level after startup, so there's no risk of an eagerly-started thread being left dead in a forked child, and starting it up front means it's ready before the first event needs to be delivered.

Identifying users

forge_ops_tracker::capture_error(&err, forge_ops_tracker::context!{}, Some(forge_ops_tracker::context!{"id" => user.id, "email" => user.email}));

Or set_user to attach it to every subsequently reported error on this thread (an explicit capture_error/capture_error_with_class call, or a panic the installed hook catches) until changed or cleared, rather than passing it to every call by hand, e.g. right after authenticating a request:

forge_ops_tracker::set_user(forge_ops_tracker::context!{"id" => user.id, "email" => user.email});
// once the request is done, or on sign-out:
forge_ops_tracker::set_user(std::collections::HashMap::new());

There's no way to automatically detect "the current user" the way a server-side web framework with its own session/auth middleware can, so this is always manual. set_user is a plain thread-local, not a process-wide global: the right choice for the thread-per-request model this crate's own synchronous, non-async design naturally pairs with (see "Dependencies" below for why ureq, not an async HTTP client, was chosen). It does not propagate across an .await in an async runtime: unlike a native OS thread, a single thread in an async executor (Tokio, async-std) interleaves multiple unrelated tasks, so a value set on one task can leak into, or never reach, another. A host app built on an async runtime should pass user explicitly to capture_error/capture_error_with_class on every call instead of relying on set_user, the same reason sdks/node needs AsyncLocalStorage rather than a bare thread-local. id/email/ username keys are all independently optional. Shows up on an issue's own detail page, and as its own affected-users count alongside the regular event count.

in_app backtrace frames

A Rust binary built with debug info embeds the real build-time source paths, so file-path matching against Configuration.app_root is a straightforward prefix comparison against those embedded paths. Defaults to the current working directory; set it explicitly if that doesn't match your binary's actual build layout. Third-party crate source under Cargo's registry cache and the Rust toolchain's own std/core source are never marked in_app, regardless of app_root.

Source context

By default, each in-app backtrace frame (never a third-party crate) is captured along with the 5 lines of source on either side of the culprit line, read straight off disk at capture time, so an issue's detail page can show the actual code that broke, not just a file:line:method reference. This never applies to a frame outside your own app's code, and it fails silently (no context, not a panic) for any file that can't be read for whatever reason, e.g. built in a container image the running host doesn't have access to.

This is a real, deliberate exception to "off by default is safer": literal source code is being transmitted, not just a reference to it, and the real protection here isn't this field. Every project on ForgeOps has its own setting (on by default, off durably and immediately once an org owner turns it off, regardless of what any individual app's own local Configuration is still set to) that governs whether the server will ever actually store what a client sends. Set capture_source_context to false if you'd rather this crate never even attempt the disk read in the first place:

forge_ops_tracker::init(|c| {
    c.capture_source_context = false;
});

PII scrubbing

By default, the message, backtrace, and any context you attach are scanned for likely personal data (email addresses, formatted SSNs/credit cards, known API key/token formats, and anything under a suspiciously-named key like password, api_key, or ssn) and redacted before the payload ever leaves this process. ForgeOps itself scrubs again on arrival regardless, so this is a second, earlier layer, not the only one. The user attached via capture_error's third argument or set_user above is a deliberate exception: it's never scrubbed, since redacting it would defeat the whole point of identifying users in the first place.

To disable it:

forge_ops_tracker::init(|c| {
    c.scrub_pii = false;
});

Running the tests

cd sdks/rust
cargo test
cargo clippy --all-targets: -D warnings
cargo fmt: --check