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):
[]
= { = "/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.stdhas no HTTP client at all.backtrace-- structured per-frame file/line/function data.std::backtrace::Backtracecaptures 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.stdhas 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:
init;
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 = charge_card
Or, more concisely, via the ResultReportExt extension trait, which reports on Err and passes
the Result through unchanged:
use ResultReportExt;
charge_card.report_err?;
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:
init;
Running the tests