# forge-ops-tracker
Rust error reporting client for a private, self-hosted [ForgeOps](../../) tracker 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
Not yet published to crates.io -- point at this path directly (or a local checkout, once split
into its own repo):
```toml
[dependencies]
forge-ops-tracker = { path = "/path/to/forge_ops/sdks/rust" }
```
### 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`](https://crates.io/crates/ureq)** -- delivers events over HTTPS. `std` has no HTTP
client at all.
- **[`backtrace`](https://crates.io/crates/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`](https://crates.io/crates/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:
```rust
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:
```rust
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:
```rust
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, 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.
[`std::any::type_name`]: https://doc.rust-lang.org/std/any/fn.type_name.html
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.
## `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`.
## 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 (`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:
```rust
});
```
## Running the tests
```bash
cd sdks/rust
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt -- --check
```