SDK & Framework Guides

Error Tracking in Rust Axum

Wire up error monitoring in Rust Axum with Sentry and LightTrace. Capture panics, timeouts, and distributed traces with full context for fast debugging.

Rust is known for compile-time safety, but runtime errors still happen — panics in async tasks, database connection timeouts, malformed requests that slip through validation. The challenge is that Rust Axum, like most web frameworks in the Rust ecosystem, has weak built-in observability. Panics crash the worker without a trace, middleware failures go unseen, and by the time a user reports a 500 error, you've already lost context. Rust Axum error monitoring requires deliberate instrumentation: you have to wire up error capturing, span tracking, and context propagation manually. The payoff is worth it — a single integration point gives you production visibility into every panic, timeout, and failed request, grouped and searchable in a dashboard.

This guide shows you how to integrate the Sentry SDK with Axum (pointed at LightTrace) so every error lands with a full stack trace, breadcrumbs of preceding requests, and enough context to reproduce the bug in minutes. The same patterns apply to other Rust async frameworks, but Axum's middleware system makes the setup clean and reusable. Whether you're running a microservice or a monolith, the techniques here follow the same principles as error tracking in other frameworks — but Rust's async runtime means you need to be explicit about where errors hide. The pattern is similar to Kotlin Ktor error tracking: wire middleware, capture context, add tracing — but Rust forces you to be more explicit about error handling due to its type system.

Install Sentry and set up initialization

Start by adding the Sentry crate for Rust to your Cargo.toml:

cargo add sentry --features logging,backtrace
cargo add tokio --features full
cargo add axum
cargo add tracing
cargo add tracing-subscriber

The logging feature hooks into Rust's standard logging infrastructure, and backtrace ensures panics capture stack traces. Now initialize Sentry as early as possible in your main function — before any async work starts:

// src/main.rs
use sentry::{init, ClientOptions, Client};
use axum::Router;
use std::sync::Arc;

#[tokio::main]
async fn main() {
    // Initialize Sentry first
    let _guard = sentry::init((
        "https://<key>@light-trace.robomiri.com/1",
        sentry::ClientOptions {
            environment: Some(std::env::var("ENVIRONMENT").unwrap_or_else(|_| "development".to_string()).into()),
            release: Some("my-app@1.0.0".into()),
            ..Default::default()
        },
    ));

    // Set up logging to tie into Sentry
    tracing_subscriber::fmt()
        .with_target(false)
        .init();

    // Build your Axum app here
    let app = Router::new()
        .route("/", axum::routing::get(|| async { "Hello" }));

    let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}

The _guard keeps the Sentry client alive for the lifetime of your application. Without it, the client shuts down and you lose error reports.

LightTrace speaks the Sentry protocol, so you use the official Sentry Rust crate and change only the DSN — no custom integrations needed. Every error sent to Sentry automatically goes to LightTrace with the same SDK.

Add error handling middleware

Axum's middleware system lets you wrap all requests in a centralized error handler. This catches panics and allows you to attach request context before sending them to LightTrace:

use axum::{
    http::{Request, StatusCode},
    middleware::Next,
    response::{IntoResponse, Response},
};
use sentry::integrations::tracing::EventBuilder;
use std::panic::AssertUnwindSafe;

async fn error_handler<B>(
    request: Request<B>,
    next: Next,
) -> Result<Response, (StatusCode, String)> {
    let method = request.method().clone();
    let uri = request.uri().clone();

    // Add breadcrumb for this request
    sentry::add_breadcrumb(sentry::Breadcrumb {
        category: Some("request".into()),
        message: Some(format!("{} {}", method, uri)),
        level: sentry::Level::Info,
        ..Default::default()
    });

    // Catch panics and timeouts
    match tokio::time::timeout(
        std::time::Duration::from_secs(30),
        AssertUnwindSafe(next.run(request)),
    )
    .await
    {
        Ok(response) => Ok(response),
        Err(_) => {
            sentry::capture_message(
                &format!("Request timeout: {} {}", method, uri),
                sentry::Level::Error,
            );
            Err((StatusCode::GATEWAY_TIMEOUT, "Request timed out".to_string()))
        }
    }
}

Wire it into your router:

use axum::middleware;

let app = Router::new()
    .route("/", axum::routing::get(|| async { "Hello" }))
    .layer(middleware::from_fn(error_handler));

Now every request that times out or panics is automatically reported to LightTrace with context about which endpoint was called. The timeout duration is configurable — adjust it based on your slowest expected endpoint. For most services, 30 seconds is reasonable; for batch processors or long-running uploads, extend it to 60 or 120 seconds.

The AssertUnwindSafe wrapper tells Rust's runtime system that we're OK catching panics from the next handler. Without it, you lose visibility into handler crashes. This is the primary way production errors escape notice in Rust services — panics in tokio tasks aren't automatically logged or reported. Wrapping them in Sentry's capture means you get a dashboard alert within seconds.

Capture request and user context

Raw errors lack context. Add user information, request headers, and other relevant data so you can understand exactly what was happening when the error occurred. This is the same principle as capturing context in frontend frameworks — user ID, request path, session info — but on the server side you have direct access to the request object:

use axum::{
    extract::{ConnectInfo, Request},
    http::HeaderMap,
};
use std::net::SocketAddr;

async fn set_user_context(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    headers: HeaderMap,
    mut request: Request,
    next: Next,
) -> Response {
    // Extract user ID from a custom header (your auth mechanism may differ)
    if let Some(user_id) = headers.get("x-user-id").and_then(|v| v.to_str().ok()) {
        sentry::set_user(Some(sentry::User {
            id: Some(user_id.to_string()),
            ip_address: Some(addr.ip().to_string()),
            ..Default::default()
        }));
    }

    next.run(request).await
}

Add this middleware before your main app:

let app = Router::new()
    .route("/", axum::routing::get(|| async { "Hello" }))
    .layer(middleware::from_fn(set_user_context))
    .layer(middleware::from_fn(error_handler))
    .into_make_service_with_connect_info::<SocketAddr>();

Now when an error occurs, the LightTrace dashboard shows which user hit it and from which IP.

Add distributed tracing with spans

For more detailed tracking of slow requests or complex operations, use Sentry's span API alongside tracing. This is where distributed tracing becomes powerful — you can see the exact milliseconds spent in each operation and catch performance regressions before users do:

use tracing::{info, error, span, Level};

async fn checkout_handler(
    user_id: String,
    payload: Json<CheckoutRequest>,
) -> Result<Json<CheckoutResponse>, StatusCode> {
    let span = span!(Level::INFO, "checkout", user_id = %user_id);
    let _enter = span.enter();

    info!("Starting checkout for user");

    // Database query with its own span
    let order = {
        let db_span = span!(Level::INFO, "db_query");
        let _db_enter = db_span.enter();

        match query_orders(&user_id).await {
            Ok(order) => {
                info!("Order found");
                order
            }
            Err(e) => {
                error!("Database error: {:?}", e);
                sentry::capture_exception(Box::new(e));
                return Err(StatusCode::INTERNAL_SERVER_ERROR);
            }
        }
    };

    // Process payment with its own span
    let payment = {
        let payment_span = span!(Level::INFO, "process_payment");
        let _payment_enter = payment_span.enter();

        match process_payment(&order).await {
            Ok(p) => {
                info!("Payment processed");
                p
            }
            Err(e) => {
                error!("Payment failed: {:?}", e);
                sentry::capture_exception(Box::new(e));
                return Err(StatusCode::PAYMENT_REQUIRED);
            }
        }
    };

    Ok(Json(CheckoutResponse {
        order_id: order.id,
        confirmation: payment.confirmation_id,
    }))
}

In your Sentry initialization, add the tracing integration:

let _guard = sentry::init((
    "https://<key>@light-trace.robomiri.com/1",
    sentry::ClientOptions {
        environment: Some("production".into()),
        release: Some("my-app@1.0.0".into()),
        integrations: vec![
            Box::new(sentry::integrations::tracing::TracingIntegration::new()),
        ],
        traces_sample_rate: 1.0,
        ..Default::default()
    },
));

tracing_subscriber::fmt()
    .with_target(false)
    .init();

Now every checkout request creates a trace that shows how long the database query took, how long payment processing took, and where any error occurred in the chain. LightTrace displays this as a span waterfall so you can spot bottlenecks. If the database query suddenly went from 50ms to 2 seconds, the trace makes it obvious — and if a payment timeout is the culprit, you've pinpointed the exact service and can escalate to the payments team with hard numbers.

Span sampling matters for high-traffic apps. A traces_sample_rate of 1.0 means 100% of requests generate spans — great for learning, but expensive at scale. Drop it to 0.1 in production to sample 10% of requests and reduce costs while still catching slow endpoints.

Handle panics gracefully

Rust's panic hook lets you send panic info to Sentry before the worker dies. This is crucial because tokio tasks swallow panics by default — they log to stderr but don't propagate them to your monitoring system:

use std::panic;

fn init_panic_hook() {
    let default_hook = panic::take_hook();
    panic::set_hook(Box::new(move |panic_info| {
        let msg = panic_info.to_string();
        sentry::capture_message(&msg, sentry::Level::Fatal);
        default_hook(panic_info);
    }));
}

#[tokio::main]
async fn main() {
    init_panic_hook();
    let _guard = sentry::init((...));
    // rest of your app
}

This ensures unwind-safe panics are captured before process death. Combined with middleware that catches panics in async tasks, you get complete visibility into what killed your server. The default hook still runs (so stderr logging continues), but now LightTrace also gets a fatal-level error. Without this hook, panics in your async runtime become silent log lines — nobody notices until prod traffic drops.

You'll also want to wrap handlers that might panic. Use the catch_unwind pattern sparingly, since it's expensive:

use std::panic::catch_unwind;

// Only for truly critical paths; most panics should be prevented via type safety
if let Ok(result) = catch_unwind(std::panic::AssertUnwindSafe(|| dangerous_operation())) {
    // handle result
} else {
    sentry::capture_message("Caught panic in critical operation", sentry::Level::Fatal);
}

Most of the time, Rust's type system prevents panics. But the few that do happen — integer overflows in debug mode, out-of-bounds indexing, failed unwraps — need explicit reporting.

Monitor background tasks and async failures

Axum applications often spawn background tasks — job processing, background syncs, scheduled work. These live outside the request/response cycle, so errors in them need explicit instrumentation. Unlike web requests, background tasks have no user waiting for a response, so failures are easy to miss. This is the same visibility problem that Python job queue error monitoring addresses — make sure your background workers report failures as loudly as web handlers:

use tokio::task;

async fn spawn_background_task() {
    task::spawn(async {
        let span = span!(Level::INFO, "background_job");
        let _enter = span.enter();

        match do_background_work().await {
            Ok(result) => {
                info!("Job completed");
            }
            Err(e) => {
                error!("Background job failed: {:?}", e);
                sentry::capture_exception(Box::new(e));
            }
        }
    });
}

Every spawned task should have its own error handling and Sentry reporting. If a task dies silently, your monitoring never knows — but with explicit error capture, each failure lands in your LightTrace issues dashboard. This is especially important for scheduled work (e.g., daily cleanup jobs) or fire-and-forget operations like sending emails or triggering webhooks.

For long-running workers or job queues, consider wrapping them in retry logic with exponential backoff:

async fn spawn_with_retries(max_retries: u32) {
    let mut retries = 0;
    loop {
        match do_background_work().await {
            Ok(_) => {
                info!("Task succeeded");
                break;
            }
            Err(e) if retries < max_retries => {
                retries += 1;
                let backoff = std::time::Duration::from_secs(2_u64.pow(retries));
                sentry::add_breadcrumb(sentry::Breadcrumb {
                    category: Some("retry".into()),
                    message: Some(format!("Retry {}/{} after {:?}", retries, max_retries, backoff)),
                    level: sentry::Level::Warning,
                    ..Default::default()
                });
                tokio::time::sleep(backoff).await;
            }
            Err(e) => {
                error!("Task failed after {} retries: {:?}", retries, e);
                sentry::capture_exception(Box::new(e));
                break;
            }
        }
    }
}

Now a failed email send or webhook delivery doesn't silently rot in logs — it appears in LightTrace with a breadcrumb trail showing each retry attempt.

Test your setup

Before deploying, verify that errors reach LightTrace. Add a test endpoint:

async fn test_error_handler() -> Result<String, StatusCode> {
    sentry::capture_message("Test error from Axum", sentry::Level::Error);
    Err(StatusCode::INTERNAL_SERVER_ERROR)
}

let app = Router::new()
    .route("/test-error", axum::routing::get(test_error_handler))
    .route("/", axum::routing::get(|| async { "Hello" }))
    .layer(middleware::from_fn(error_handler));

Hit GET /test-error and check that the error appears in your LightTrace dashboard within a few seconds.

Always test error capturing before going live. Enable SENTRY_LOG=debug to see what Sentry is doing under the hood. If errors aren't arriving, it's usually a DSN mismatch or network isolation blocking the HTTP POST to your LightTrace host.

Production checklist

Before shipping Rust Axum to production, make sure you have:

  • Error handling middleware that catches panics and timeouts.
  • User context set so you can link errors to users.
  • Breadcrumbs for at least request start, database queries, and third-party API calls.
  • Span instrumentation for slow operations or complex logic.
  • Panic hook in place to capture unwind-safe panics.
  • Background task error handling for any tokio::spawn or long-running tasks.
  • Sample rate tuned for your traffic volume — 1.0 for dev/staging, 0.1–0.5 for production.

Once you've added error tracking to your app with Sentry pointed at LightTrace, each of these pieces integrates automatically. This approach mirrors Next.js error tracking in structure — error boundaries and global handlers at the framework level, with context layered in — but Rust's strict type system means fewer surprise panics if your code compiles. See how to debug production errors for strategies once errors start arriving.

Start tracking errors in minutes

Set up Rust Axum error monitoring with Sentry and LightTrace — get readable stack traces and distributed tracing for every panic, timeout, and failed request, free for up to 5,000 events a month.

Rust gives you compile-time safety, but the real-world bugs live in production. With Sentry connected to LightTrace, panics and errors stop being silent failures and become actionable issues with full context. Your next Axum crash becomes a 5-minute fix instead of a user report.

Fix your next production error faster

Point any Sentry SDK at LightTrace — free up to 5,000 events/month.