Rust error monitoring with the sentry-rust SDK.
Rust surfaces errors through panics, through Result propagation that gets swallowed somewhere up the call chain, through async tasks that drop futures mid-execution, and through FFI boundaries where C code hands back a fault that Rust never expected. This guide covers how to wire up the sentry crate so all four paths reach your error tracker, how to attach the tracing layer for breadcrumbs and performance spans, how to produce symbolicated stack traces in release builds, and how to point the entire setup at urgentry with a single DSN change.
20 seconds. Add sentry to Cargo.toml. Call sentry::init with a ClientOptions struct at the top of main. Enable the sentry-panic integration to capture panics. Use sentry::integrations::anyhow::capture_anyhow at your service boundaries. Set the dsn field to your urgentry project DSN. Nothing else in your code changes.
60 seconds. Rust does not have a global exception handler. Panics unwind the current thread; in a Tokio runtime, a panic inside a spawned task produces a JoinError on the handle rather than crashing the process. The sentry panic hook fires inside the task before cancellation, so you do get the event, but you must also check JoinHandle::await for the is_panic() error to handle it in your orchestration code. For Result errors, nothing captures them automatically: call capture_anyhow or hub.capture_error at the boundary where you have decided the error is not recoverable. Swallowed errors never reach urgentry.
Release builds strip debug info by default. Every stack frame in urgentry will read as an unknown symbol unless you add debug = true to [profile.release] and upload the debug file with sentry-cli before deployment. Set this up before your first production release. Retroactively symbolicated frames require you to keep the binary artifact alongside every deployed version.
Where Rust errors come from
Rust has four distinct error surfaces that require different capture strategies.
Panics are the closest thing Rust has to uncaught exceptions. A panic unwinds the current thread unless the binary is compiled with panic = "abort", in which case it terminates the process immediately. Common panic sources: index out of bounds, unwrap on None, arithmetic overflow in debug mode, and explicit panic!() calls for programming-error assertions. The sentry panic integration intercepts the panic hook before the unwind begins and sends the event synchronously.
Result error propagation that gets swallowed is the silent failure mode unique to Rust's type system. An Err(e) value that gets converted to None with .ok(), logged and then dropped, or matched in a branch that returns a default value never reaches any error tracker. It is a legitimate error that looks like success to every monitoring system. You have to call a capture function explicitly at the boundary where you decide the error is terminal.
Async cancellation is specific to runtimes like Tokio. When a Future is dropped before it completes, execution stops at the next .await point without running any teardown code. A task that was mid-way through a database write, an HTTP request, or a file operation simply stops. The runtime does not report this as an error by default. It shows up as an Err(JoinError) on the join handle, or silently if the handle is dropped too.
FFI boundary crashes occur when C or C++ code called through Rust's extern "C" blocks causes a segfault, stack overflow, or other signal-based fault. These do not go through Rust's panic machinery. They arrive as process signals and require either Crashpad integration or OS-level crash reporting to capture. The sentry-rust SDK does not automatically handle these; minidump capture through a separate process is the correct solution.
Install
Add the crate with the integrations you need:
cargo add sentry --features default,anyhow,tracing,backtrace
Or add it directly in Cargo.toml:
[dependencies]
sentry = { version = "0.34", features = ["default", "anyhow", "tracing", "backtrace"] }
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
sentry-tracing = "0.34"
Initialize the SDK once at the top of main, before any async runtime starts:
use sentry::ClientOptions;
fn main() {
let _guard = sentry::init(ClientOptions {
dsn: "https://<key>@errors.example.com/<project_id>".parse().ok(),
release: sentry::release_name!(),
environment: Some(std::env::var("APP_ENV")
.unwrap_or_else(|_| "production".into())
.into()),
traces_sample_rate: 0.1,
..Default::default()
});
// start tokio runtime, run your program
tokio::runtime::Runtime::new()
.unwrap()
.block_on(run());
}
The returned _guard value must stay alive for the lifetime of the process. When it drops, the SDK flushes the event buffer and shuts down the transport. Assign it to a variable, never to _: a bare underscore drops the value immediately.
The dsn field accepts a DSN string in the format https://<public_key>@<host>/<project_id>. Generate one in your urgentry project settings. The SDK cannot distinguish a Sentry DSN from an urgentry DSN; the format and wire protocol are identical.
sentry::release_name!() reads CARGO_PKG_NAME and CARGO_PKG_VERSION at compile time and produces a release string in the form name@version, which urgentry uses to group release-level regressions.
Panic handler integration
The sentry-panic integration is enabled by default when you add sentry with its default features. It installs a custom panic hook via std::panic::set_hook that runs before the unwind begins.
When a panic fires, the hook captures:
- The panic message (the string passed to
panic!or the display of the unwrapped value) - The panic location (file, line, column) from
PanicInfo::location() - A backtrace if
RUST_BACKTRACE=1is set or if thebacktracefeature is enabled - The current scope: breadcrumbs, tags, and user context accumulated before the panic
The hook chain works as follows: when you call sentry::init, the SDK calls std::panic::take_hook to retrieve whatever hook was previously registered, stores it, and installs its own hook. When a panic fires, the SDK hook runs first (capturing the event), then calls the previous hook. This means your own panic hooks registered before sentry::init still run, and the default hook (which prints the backtrace to stderr) also runs.
If you register a custom hook after sentry::init, you take over from the SDK's hook. The SDK's hook stops running unless you explicitly chain it. The correct order: initialize the SDK first, then install any additional hooks.
Capturing errors from anyhow and eyre
The anyhow crate is the standard choice for error handling in Rust application code. The sentry crate's anyhow feature adds a capture_anyhow helper:
use anyhow::{Context, Result};
use sentry::integrations::anyhow::capture_anyhow;
async fn process_order(order_id: &str) -> Result<()> {
let order = db::fetch_order(order_id)
.await
.with_context(|| format!("failed to fetch order {order_id}"))?;
charge_payment(&order)
.await
.with_context(|| format!("payment failed for order {order_id}"))?;
Ok(())
}
async fn handle_request(order_id: &str) {
match process_order(order_id).await {
Ok(()) => {}
Err(err) => {
// Capture the full anyhow error chain
capture_anyhow(&err);
respond_with_error(500, "internal error");
}
}
}
The capture_anyhow function walks the anyhow error chain and builds a Sentry exception chain from it. Each layer added with .context() or .with_context() becomes a separate exception in the chain. In the urgentry UI, you see the full chain from the root cause to the outermost context, in the same way a Java exception chain or a Go error wrap chain appears. This is significantly more useful than a single flat error message.
For eyre, the pattern is the same. The eyre crate is API-compatible with anyhow and the SDK's anyhow feature works with both. Use sentry::integrations::anyhow::capture_anyhow with an eyre::Report value directly.
For the lower-level CaptureErrorExt trait available on the hub, you can also call hub.capture_error(&err) on any value that implements std::error::Error. This does not walk chains as thoroughly as capture_anyhow, but works for any standard error type without the anyhow dependency.
tracing-sentry layer
The tracing crate is the standard structured logging and instrumentation API in the Rust ecosystem. The sentry-tracing crate provides a tracing::Subscriber layer that bridges tracing events to Sentry breadcrumbs and tracing::Span instances to Sentry transactions.
Set up the subscriber during initialization, before the async runtime starts:
use tracing_subscriber::prelude::*;
fn main() {
let _guard = sentry::init(/* ... */);
tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer())
.with(sentry_tracing::layer())
.init();
// Now start the runtime
tokio::runtime::Runtime::new()
.unwrap()
.block_on(run());
}
The level mapping from tracing levels to Sentry breadcrumb levels:
TRACEandDEBUGmap to SentrydebugINFOmaps to SentryinfoWARNmaps to SentrywarningERRORmaps to Sentryerror
Events emitted with tracing::event! or the shorthand macros (info!, warn!, error!) become breadcrumbs on the current Sentry scope. They appear in the breadcrumb trail of the next captured event.
tracing::Span instances created with #[tracing::instrument] or tracing::span! become Sentry transactions. The span name becomes the transaction name. Nested spans become child spans within the transaction, producing a performance trace in urgentry's transaction view. The traces_sample_rate in ClientOptions controls what fraction of these transactions the SDK sends.
To discard health check or metrics routes from performance tracing, use a BeforeTransaction callback:
use sentry::{ClientOptions, TransactionContext};
let _guard = sentry::init(ClientOptions {
dsn: "...".parse().ok(),
traces_sample_rate: 0.1,
before_send_transaction: Some(std::sync::Arc::new(|mut tx| {
// Drop transactions for /health and /metrics
if tx.name.starts_with("/health") || tx.name.starts_with("/metrics") {
None
} else {
Some(tx)
}
})),
..Default::default()
});
Tokio and async cancellation
The interaction between Tokio's task model and Rust's error monitoring has three non-obvious behaviors worth understanding.
When you drop a JoinHandle without awaiting it, the spawned task continues running until it completes or until the runtime shuts down. The output is discarded. If the task panics, the panic hook fires and the SDK captures the event, but your orchestrating code never sees the error. The fix is to track join handles and await them at shutdown, or to use a task set like tokio::task::JoinSet that propagates errors.
When you await a JoinHandle and the task panicked, you get Err(JoinError) where join_error.is_panic() returns true. The sentry panic hook already captured this panic from inside the task, but you still need to handle the JoinError in the calling code:
let handle = tokio::spawn(async {
process_batch(batch).await
});
match handle.await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
// Task returned an Err: capture if it is not recoverable
capture_anyhow(&err);
}
Err(join_err) if join_err.is_panic() => {
// Task panicked: sentry already captured the event from the panic hook
// Log for observability, do not double-capture
tracing::error!("task panicked: {:?}", join_err);
}
Err(join_err) => {
// Task was cancelled (JoinHandle dropped or runtime shut down)
tracing::warn!("task cancelled: {:?}", join_err);
}
}
The third case is async cancellation without a panic: a future dropped at an .await point produces no error, no event, and no log unless you add instrumentation. Tasks with meaningful business impact (a payment mid-write, a queue message pulled but not acknowledged) deserve at minimum a tracing::warn! on cancellation, typically via a tokio::select! branch that matches a shutdown signal.
Native symbolication
A Rust release build compiled with default Cargo settings strips all debug information. Every stack frame in urgentry for a release-build event shows as <unknown>. This is correct behavior from the compiler's perspective: debug info increases binary size and slows link time. It is not useful for production error monitoring.
The fix is two steps. First, tell Cargo to include debug info in release builds:
[profile.release]
debug = true # full debug info; use debug = 1 for line tables only
debug = 1 (line tables only) adds file and line number information without the full variable inspection data. The resulting binary is larger but DWARF line tables are sufficient for urgentry to show file-and-line frames. debug = true (equivalent to debug = 2) adds full DWARF info including variable names and types, which is useful for debugger sessions but unnecessary for server-side symbolication.
Second, upload the debug file to urgentry after each build:
export SENTRY_URL=https://errors.example.com
export SENTRY_AUTH_TOKEN=your_urgentry_auth_token
export SENTRY_ORG=your_org_slug
# Build with debug info
cargo build --release
# Upload the ELF binary (Linux) or Mach-O binary (macOS)
sentry-cli debug-files upload \
--org "$SENTRY_ORG" \
--project your-project-slug \
target/release/your-binary
urgentry stores the debug file indexed by its build ID. When an event arrives with a stack frame address from a binary with a matching build ID, urgentry resolves the address to a file and line number from the uploaded DWARF data. The event UI shows the resolved frames.
Linux produces ELF binaries; macOS produces Mach-O. Both formats are supported. For cross-compiled binaries targeting a Linux server from a macOS dev machine, upload the binary for the target architecture. If you need to ship a size-optimized stripped binary, run objcopy --only-keep-debug to extract debug info to a .debug file before stripping, then upload the .debug file rather than the stripped binary.
For native crashes beyond what the panic hook covers (segfaults from FFI code, signals from the OS), Crashpad provides out-of-process minidump capture. Minidumps upload to urgentry's minidump endpoint. This is outside the scope of a standard Rust service but relevant for binaries with significant unsafe C interop.
Pointing all of this at urgentry
urgentry implements the same Sentry envelope ingest protocol that sentry-rust targets. The DSN format is identical. Every feature described in this guide works against urgentry without modification.
For the SDK, set the dsn field in ClientOptions:
let _guard = sentry::init(ClientOptions {
// Before: "https://key@o1234.ingest.sentry.io/5678"
// After:
dsn: std::env::var("SENTRY_DSN")
.ok()
.and_then(|s| s.parse().ok()),
release: sentry::release_name!(),
environment: Some("production".into()),
..Default::default()
});
For sentry-cli, set SENTRY_URL to your urgentry instance base URL before any sentry-cli command:
export SENTRY_URL=https://errors.example.com
sentry-cli debug-files upload --org my-org --project my-project target/release/my-binary
The urgentry compatibility matrix at /sentry-alternative/ lists all 218 Sentry REST API operations and their urgentry support status. The operations relevant to a Rust deployment are all covered: event ingestion, debug file upload, release creation, and the issue management API.
urgentry runs as a single Go binary. At 400 events per second it uses approximately 52 MB of resident memory on a 1 vCPU / 1 GB VPS. SQLite is the default storage backend; Postgres is an option for higher write volumes. The operational footprint is small enough to run on the same host as your Rust service during early deployment.
Performance tracing
Performance tracing in sentry-rust uses the same transaction and span model as other Sentry SDKs. The sentry-tracing layer translates tracing::Span instances into Sentry spans automatically. The traces_sample_rate in ClientOptions controls the sampling rate, from 0.0 (no transactions) to 1.0 (all transactions).
Instrument a function with #[tracing::instrument] to create a transaction per invocation:
#[tracing::instrument(skip(db), fields(order_id = %order_id))]
async fn process_order(db: &Db, order_id: &str) -> anyhow::Result<()> {
let order = db.fetch_order(order_id).await?;
charge(&order).await?;
fulfill(&order).await?;
Ok(())
}
The fields attribute adds key-value data to the span that appears as transaction data in urgentry. Nested calls to other instrumented functions create child spans. urgentry renders the full span tree as a waterfall trace.
For HTTP services built with axum, add tower-http's TraceLayer::new_for_http() to your router to generate a root transaction per request. Health check and readiness endpoints generate one transaction per probe. Use the before_send_transaction callback shown in the tracing-sentry section to discard those routes before they reach urgentry.
Three Rust gotchas
1. Panics in build.rs that do not reach runtime
Rust build scripts (build.rs) run at compile time in a separate process. A panic in build.rs terminates the build process, not your application. The sentry SDK is not initialized in the build script context. Build script panics appear as Cargo build errors in your CI output, not as events in urgentry.
The monitoring story for build script failures is your CI system, not your error tracker. If your build script calls external tools or makes network requests, add explicit error handling with anyhow::Context and let Cargo propagate the error message through the build output.
2. catch_unwind in FFI boundaries
When Rust code is called from C through an FFI boundary, a panic that unwinds across the FFI boundary is undefined behavior. The correct pattern for FFI-exported functions is to catch panics before they cross the boundary:
use std::panic;
#[no_mangle]
pub extern "C" fn my_exported_fn(input: *const u8) -> i32 {
let result = panic::catch_unwind(|| {
// actual logic here
process_input(input)
});
match result {
Ok(val) => val,
Err(err) => {
// Panic was caught before crossing the FFI boundary
// The sentry panic hook already fired inside catch_unwind
-1 // signal error to the C caller
}
}
}
The sentry panic hook fires inside catch_unwind before the panic is caught, so the event reaches urgentry. The C caller receives the error sentinel. Both sides of the boundary behave correctly.
3. unwind vs. abort panic strategy in Cargo profiles
Cargo profiles support two panic strategies: panic = "unwind" (the default) and panic = "abort". The choice has direct consequences for error monitoring.
With panic = "unwind", the sentry panic hook runs, the event is captured, and the unwind proceeds. The process may recover if something catches the panic with catch_unwind.
With panic = "abort", the panic hook still fires but the process terminates immediately after it returns. The SDK sends the event synchronously in this path, so events do reach urgentry. However, the binary compiled with panic = "abort" produces smaller binaries and links faster, and some crates require it (notably WASM targets). Minidump capture via Crashpad is a useful complement for abort-mode panics since the process state at time of abort is often more informative than the panic message alone.
Setting panic = "abort" in a library crate's Cargo.toml has no effect: only the final binary's profile controls the panic strategy. Library authors should not set the panic strategy in their crate's profile; that decision belongs to the application author.
FAQ
Does sentry-rust capture stack traces automatically?
Only for panics. The panic integration attaches a backtrace when it intercepts std::panic::set_hook. For Result-propagated errors captured via capture_anyhow or CaptureErrorExt, you get the error chain as the event message but no automatic stack unless the error itself was constructed with a backtrace (RUST_BACKTRACE=1 triggers this for anyhow). Symbolication of those frames requires debug info in your binary.
What happens to a panic inside a tokio::spawn task?
The spawned task is cancelled and the JoinHandle resolves to Err(JoinError) with is_panic() == true. The panic does not propagate to the calling task. The global panic hook fires inside the spawned task before the cancellation, so the sentry panic integration does capture it, but you must also check the JoinHandle return value to handle the error in your orchestrating code.
Can I use sentry-rust and the OTel OTLP SDK at the same time?
Yes. sentry-rust handles error grouping, issue tracking, and alerting. The OTel Rust SDK (opentelemetry crate) sends OTLP spans and logs. urgentry accepts both the Sentry envelope protocol and OTLP/HTTP in the same binary, so both data streams land in one place.
Does urgentry work with sentry-rust without code changes?
The only change is the DSN. Set the dsn field in ClientOptions to your urgentry project DSN. The SDK sends the same Sentry envelope it always sent. urgentry receives and stores it identically to sentry.io.
What Cargo profile setting is required for useful stack traces in release builds?
Add debug = true (or debug = 1 for line tables only) to the [profile.release] section of your Cargo.toml. Without it, rustc strips debug info and every release-build frame in urgentry shows as an unknown symbol. Upload the resulting debug file with sentry-cli debug-files upload before deployment so urgentry can symbolicate the frames.
Sources
- getsentry/sentry-rust — the official Rust SDK. Source for
ClientOptions, the panic integration, anyhow and tracing feature flags, and theCaptureErrorExttrait. - sentry-tracing on docs.rs — the
tracing::Layerimplementation that maps tracing events to breadcrumbs and tracing spans to Sentry transactions, including the level mapping table. - sentry-anyhow on docs.rs — the
capture_anyhowhelper and how it walks the anyhow error chain to produce a Sentry exception chain. - std::panic::set_hook documentation — the standard library reference for panic hooks, including the guarantee that the hook runs before unwinding begins.
- Cargo profiles reference — the canonical reference for
[profile.release]settings including thedebugandpanicfields and their valid values. - urgentry Sentry compatibility matrix — the full list of 218 Sentry REST API operations and their urgentry support status, including debug file upload and release management.
- FSL-1.1-Apache-2.0 license — the Functional Source License under which urgentry is distributed, converting to Apache 2.0 after two years.
- urgentry quickstart documentation — DSN creation, project setup, and the OTLP endpoint configuration for mixed Sentry/OTel deployments.
Ready to wire up your Rust service?
urgentry accepts the sentry-rust SDK without any code changes beyond the DSN. Panic capture, anyhow error chains, tracing spans, and debug file symbolication all work against urgentry the same way they work against sentry.io. Start receiving Rust events in under ten minutes.