Useful Rust errors without native backtraces
Updated
An error like failed to parse CSV: missing column "email" tells you what failed. It does not tell you which service called the parser or which background job was running.
While investigating runtime stalls in a Rust service, we disabled library backtraces to avoid their capture and resolution costs. We still needed to know how an error reached the reporter. For a contact import, that path might be:
ImportContactsJob
→ contact_service::import_contacts
→ csv_import::read_contacts_csv
→ failed to parse CSV: missing column "email"
We can record that path directly: keep the error’s cause and append a source location each time it crosses an annotated application boundary. An upload ID still belongs in the event’s context; the locations identify the code path.
Record the caller
Rust’s Location::caller() provides a source file, line, and column. On a function marked #[track_caller], it reports the caller’s location. It does not need to walk the native stack.
Here is a small implementation using anyhow for causes and thiserror for the error wrapper:
use std::panic::Location;
#[derive(Debug, thiserror::Error)]
#[error("{source:#}")]
pub struct AppError {
#[source]
source: anyhow::Error,
locations: Vec<&'static Location<'static>>,
}
impl From<anyhow::Error> for AppError {
fn from(source: anyhow::Error) -> Self {
Self {
source,
locations: Vec::new(),
}
}
}
pub trait ResultExt<T> {
#[track_caller]
fn ctx(self) -> Result<T, AppError>;
}
impl<T, E> ResultExt<T> for Result<T, E>
where
AppError: From<E>,
{
#[track_caller]
fn ctx(self) -> Result<T, AppError> {
let location = Location::caller();
self.map_err(|error| {
let mut error = AppError::from(error);
error.locations.push(location);
error
})
}
}
Capture the location before entering the closure so it points to the call to ctx().
This example accepts anyhow::Error or an existing AppError. Add conversions for other error types as needed. When wrapping errors, preserve both the cause and recorded locations.
Use it at application boundaries
At each boundary, call the synchronous helper on the completed result. With illustrative application functions:
async fn import_contacts(upload_id: u64) -> Result<(), AppError> {
let contacts = read_contacts_csv(upload_id).await.ctx()?;
save_contacts(contacts).await.ctx()?;
Ok(())
}
async fn run_job(upload_id: u64) -> Result<(), AppError> {
import_contacts(upload_id).await.ctx()?;
Ok(())
}
If read_contacts_csv fails because the email column is missing, the service and job append their locations to the error. save_contacts never runs.
At the reporting boundary, print the cause and reverse the collected locations to read from job to CSV reader. With an annotation inside the reader too, an illustrative report looks like this:
failed to parse CSV: missing column "email"
Application propagation sites:
src/jobs/import_contacts.rs:24:37
src/services/contacts.rs:47:53
src/csv_import.rs:18:29
Know what it records
An ordinary ? adds no location; only annotated boundaries appear, so this is not a complete stack trace. Error debug formatting can still include a stored backtrace, so format the causes and recorded locations explicitly. For routine application failures, this short trail is often enough to show which operation failed and how it reached the reporter.