forge-ops-tracker 0.1.0

Rust error reporting client for a private, self-hosted ForgeOps tracker instance.
Documentation

forge-ops-tracker

Rust error reporting client for a private, self-hosted ForgeOps tracker instance. Requires Rust 1.81+. A from-scratch port of gems/forge_ops_tracker (the Rails client) -- see that gem's README for the shared design rationale; this document only covers what's Rust-specific.

Installation

Not yet published to crates.io -- point at this path directly (or a local checkout, once split into its own repo):

[dependencies]
forge-ops-tracker = { path = "/path/to/forge_ops/sdks/rust" }

Dependencies

Rust's standard library, unlike Python's, Go's, or Java's, 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 same file/line/method/in_app shape every other client in this repo already builds.
  • 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, the same "only depend on what the language truly can't do itself" line every other client in this repo draws (see the C++ SDK's own dependency on libcurl for the same reasoning, applied there to just HTTP).

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 -- the same builder-block shape the Java and .NET clients use for their own init/AddForgeOpsTracker call.

What gets reported automatically, and what doesn't

A panic on any thread needs no further wiring at all, once init() has run. Unlike Go, where only a defer Recover() in the exact same goroutine can see a panic, Rust's panic hook (std::panic::set_hook) is genuinely 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. This is a stronger "automatic capture" story than any other client in this repo has for its own language -- closer to Python's sys.excepthook, just extended to genuinely cover every thread, not only the main one.

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) -- the same "report, then don't change program behavior" rule the .NET middleware and Python excepthook wrapper both follow. 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});
    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})?;

A plain Rust std::error::Error carries no stack trace of its own (unlike Python's traceback or Java's Throwable, which travel with the exception), 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) 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. Unlike the Ruby gem and Python client, whose worker threads start lazily on first push specifically so a prefork server (Puma, Gunicorn) forking after the module has already loaded doesn't leave a dead thread in every forked child, this starts its worker eagerly (the same choice the .NET SDK makes): Rust programs essentially never fork themselves at the application level, so that hazard doesn't apply here.

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 works the same way the Ruby gem's Rails.root comparison and the Python client's os.getcwd() comparison do. 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.

PII scrubbing

Same behavior as every other client in this repo: 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 (password, api_key, ssn, and similar) -- 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.

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