# `ev_lib::error_monitoring` — cookbook
End-to-end Rust recipes for every surface of the `error_monitoring` feature. For
the API summary and the parity table, see [`README.md`](./README.md). The TS
mirror is [`@evinvest/error-monitoring`](../../../ts/error-monitoring).
- [The two engines](#the-two-engines)
- [DSN and environment config](#dsn-and-environment-config)
- [Full Axum wiring](#full-axum-wiring)
- [Reporting a 5xx error](#reporting-a-5xx-error)
- [Dioxus / wasm wiring](#dioxus--wasm-wiring)
- [The shared wire core](#the-shared-wire-core)
- [Testing](#testing)
- [Gotchas](#gotchas)
## The two engines
- **Native** (`cfg(not(target_arch = "wasm32"))`) — the `sentry` crate.
[`Config`], [`init`] (returns the guard), [`tracing_layer`], the tower
[`NewSentryLayer`]/[`SentryHttpLayer`], and [`report`].
- **wasm** (`cfg(target_arch = "wasm32")`) — pure Rust. [`init`] (stores the DSN
+ installs a panic hook) and [`report_error`], which build a Sentry
[`envelope`] and POST it via `reqwest` fetch.
- **Shared** — the [`wire`] module ([`parse_dsn`], [`ingest_url`],
[`auth_header`], [`envelope`]) is pure, I/O-free, and unit-tested on native.
The `sentry` crate is native-only and never linked into the browser bundle.
## DSN and environment config
Read both from the environment in the consuming app — the library never reads it
for you. A Sentry DSN's public key is publishable (same trust model as the JS
SDK), so a browser bundle may carry the DSN.
| `SENTRY_DSN` | native | DSN, or unset/empty/malformed → Sentry disabled (no-op). |
| `APP_ENV` | native | Environment tag (`production`, `staging`, …). |
| `SENTRY_RELEASE` | native | Release fallback the SDK reads when `Config.release` is `None`. |
| `OTEL_SERVICE_NAME` | native | Not read here — the name to mirror into `Config.service` so an issue, a trace and a log line agree. |
On native, the sampling policy mirrors the site: `Config::traces_sample_rate_for`
returns `0.1` in production and `1.0` everywhere else.
## Full Axum wiring
Mirrors the site backend. Four steps, all in the composition root.
**1. Init before the runtime, guard held in `main`.** Sentry must be initialised
before the async runtime starts, so do not use `#[tokio::main]` — build the
runtime by hand and keep the guard alive for the whole process:
```rust
use ev_lib::error_monitoring::{Config, init, release_name, tracing_layer};
fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
let env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string());
let config = Config {
dsn: std::env::var("SENTRY_DSN").ok(),
traces_sample_rate: Config::traces_sample_rate_for(&env),
environment: env,
// expands in *this* crate, so events are attributed to your app's release
release: release_name!().map(|r| r.into_owned()),
// a project is a DSN, so siblings sharing one need this to be told apart
service: Some("site-backend".to_string()),
};
// Dropping this guard flushes queued events — bind it for the life of main.
let _sentry_guard = init(&config);
init_tracing();
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(run(config))
}
```
**2. Add the tracing layer** so `error!`/`warn!` events become Sentry
breadcrumbs and events:
```rust
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt, prelude::*};
use ev_lib::error_monitoring::tracing_layer;
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer())
.with(tracing_layer())
.init();
}
```
**3. Apply the two tower layers on the router, in this exact order.** Use a
`ServiceBuilder` so the order matches the docs — binding the Sentry layers
directly on the `Router` in the wrong order can leak memory:
```rust
use axum::{Router, body::Body, http::Request};
use tower::ServiceBuilder;
use ev_lib::error_monitoring::{NewSentryLayer, SentryHttpLayer};
fn build(state: AppState) -> Router {
Router::new()
.nest("/api/v1", routes())
.layer(
ServiceBuilder::new()
.layer(NewSentryLayer::<Request<Body>>::new_from_top())
.layer(SentryHttpLayer::new().enable_transaction()),
)
.with_state(state)
}
```
`NewSentryLayer::new_from_top()` opens a fresh Sentry hub per request;
`SentryHttpLayer::new().enable_transaction()` records each request as a
transaction. The type parameter is the request type your stack uses
(`Request<Body>`, or `axum::extract::Request`).
## Reporting a 5xx error
Call [`report`] **only for genuinely unexpected failures** — 5xx territory.
Expected domain errors (not-found, validation, conflict) are client mistakes and
must not be reported:
```rust
use ev_lib::error_monitoring::report;
async fn handler(/* … */) -> Result<Json<Reply>, StatusCode> {
match do_work().await {
Ok(reply) => Ok(Json(reply)),
Err(error) => {
report(&error); // &dyn std::error::Error
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
```
`report` no-ops when Sentry was never initialised, so the same path is safe in
development.
## Dioxus / wasm wiring
Two calls. Init **once on first render**, not at module load: Dioxus installs its
own panic hook at startup, so installing ours from a `use_hook` keeps ours
outermost and forwards to the previous hook:
```rust
use dioxus::prelude::*;
use ev_lib::error_monitoring::{init, report_error};
#[component]
fn App() -> Element {
use_hook(|| {
init(
option_env!("SENTRY_DSN"), // None/empty → disabled
option_env!("APP_ENV").unwrap_or("development"),
);
});
rsx! { Body {} }
}
```
After `init`, every panic is reported automatically (the hook calls
`report_error(info.to_string())` and then the previous hook). You can also
report manually from a fallible spot:
```rust
#[component]
fn PaymentWidget() -> Element {
let mut mount = use_signal(|| Ok::<(), String>(()));
use_effect(move || {
if let Err(message) = mount() {
report_error(&message);
}
});
rsx! { /* … */ }
}
```
`report_error` parses the stored DSN, builds the envelope, and POSTs it
fire-and-forget. It no-ops when no DSN was configured or the DSN is malformed.
## The shared wire core
The envelope and DSN logic is pure, so you can reason about (and test) the exact
bytes sent to Sentry without a network — this is what the browser transport
POSTs:
```rust
use ev_lib::error_monitoring::{auth_header, envelope, ingest_url, parse_dsn};
let dsn = parse_dsn("https://pub@o9.ingest.sentry.io/4500").unwrap();
assert_eq!(ingest_url(&dsn), "https://o9.ingest.sentry.io/api/4500/envelope/");
assert!(auth_header(&dsn).contains("sentry_key=pub"));
let body = envelope("production", "0123456789abcdef0123456789abcdef", "boom");
assert_eq!(body.split('\n').count(), 3); // envelope header / item header / event
```
## Testing
- **The wire core is pure** — assert DSN parsing, the ingest URL, the auth
header, and the three-line envelope directly (see the tests in `wire.rs`). No
network, runs on native.
- **Native `init` no-ops without a usable DSN** (absent, empty or malformed) and
the sample-rate policy is a pure function:
```rust
# use ev_lib::error_monitoring::Config;
assert_eq!(Config::traces_sample_rate_for("production"), 0.1);
assert_eq!(Config::traces_sample_rate_for("staging"), 1.0);
```
- The browser transport is `wasm32`-only; exercise the message path through the
shared `wire` helpers on native rather than spinning up a headless browser.
## Gotchas
- **Hold the native guard.** `init` returns `Option<ClientInitGuard>`; the guard
flushes on drop. `let _guard = init(&config);` in `main` is correct;
`let _ = init(&config);` drops it immediately and loses buffered events.
- **Init Sentry before the tokio runtime.** Don't use `#[tokio::main]` — build
the runtime by hand after `init`.
- **Tower layer order matters.** `NewSentryLayer` first (top), then
`SentryHttpLayer`, via a `ServiceBuilder`.
- **wasm init goes in a `use_hook`, not module init.** Calling it earlier fights
Dioxus's own panic hook.
- **wasm panic delivery is best-effort.** `panic = abort` may tear the task down
before the fire-and-forget POST lands — don't rely on every panic reaching
Sentry.
- **No source maps in wasm.** The browser transport sends a message-level event;
stack frames are not symbolicated. Use native reporting for backend errors
that need full fidelity.
- **`report` is for 5xx only.** Reporting expected domain errors floods Sentry
with noise.