# 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`](../../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):
```toml
[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`](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 same
file/line/method/in_app shape every other client in this repo already builds.
- **[`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`, 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:
```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 -- 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:
```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 (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.
[`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. 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:
```rust
});
```
## Running the tests
```bash
cd sdks/rust
cargo test
cargo clippy --all-targets -- -D warnings
cargo fmt -- --check
```