forge-ops-tracker 0.1.1

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+. 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
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:

```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
forge_ops_tracker::init(|c| {
    c.scrub_pii = false;
});
```

## Running the tests

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