whereat

Crates.iowhereat
lib.rswhereat
version0.1.3
created_at2026-01-18 04:52:42.229788+00
updated_at2026-01-18 10:46:48.737536+00
descriptionLightweight error location tracking with small sizeof and no_std support
homepage
repositoryhttps://github.com/lilith/whereat
max_upload_size
id2051785
size506,665
Lilith River (lilith)

documentation

README

whereat

CI Crates.io Documentation codecov License

Production error tracing without debuginfo, panic, or overhead.

After a decade of distributing server binaries, I'm finally extracting this approach into its own crate!

In production, you need to immediately know where the bug is at() — without panic!, debuginfo, or overhead. Just replace ? with .at()? in your call tree to get beautiful build-time & async-friendly stacktraces with GitHub links.

Error: UserNotFound
   at src/db.rs:142:9
      ╰─ user_id = 42
   at src/api.rs:89:5
      ╰─ in handle_request
   at myapp @ https://github.com/you/myapp/blob/a1b2c3d/src/main.rs#L23

Compatible with plain enums, errors, structs, thiserror, anyhow, or any type with Debug. No changes to your error types required!

Performance

                                 Error creation time (lower is better)

Ok path (no error)      █ <1ns            ← ZERO overhead on success
plain enum error        █ <1ns
whereat (1 frame)       ███ 18ns          ← file:line:col captured
whereat (3 frames)      ███ 19ns
whereat (10 frames)     ██████████ 67ns

With RUST_BACKTRACE=1:
anyhow                  █████████████████████████████████████████████████ 2,500ns
backtrace crate         ████████████████████████████████████████████████████████████████████████████████████████████████████ 6,300ns
panic + catch_unwind    ██████████████████████████ 1,300ns

Fair comparison (same 10-frame depth, 10k iterations):

whereat .at()           █ 1.2ms           ← 100x faster than backtrace
panic + catch_unwind    ██████████████████████ 27ms
backtrace crate         ████████████████████████████████████████████████████████████████████████████████████████████████████ 119ms

anyhow/panic only capture backtraces when RUST_BACKTRACE=1. whereat always captures location.

Linux x86_64 (WSL2), 2026-01-18. See cargo bench --bench overhead and cargo bench --bench nested_loops "fair_10fr".

Quick Start

// In lib.rs or main.rs - required for at!() and at_crate!() macros
whereat::define_at_crate_info!();

use whereat::{At, ResultAtExt, at};

#[derive(Debug)]
enum MyError {
    NotFound,
    InvalidInput(String),
}

fn find_user(id: u64) -> Result<String, At<MyError>> {
    if id == 0 {
        return Err(at!(MyError::InvalidInput("id cannot be zero".into())));
    }
    Err(at!(MyError::NotFound))
}

fn process(id: u64) -> Result<String, At<MyError>> {
    find_user(id).at_str("looking up user")?;  // Adds context
    Ok("done".into())
}

For workspace crates: whereat::define_at_crate_info!(path = "crates/mylib/");

API Overview

Starting a trace:

Function Works on Crate info Use when
at!(err) Any type ✅ GitHub links Default choice with define_at_crate_info!()
at(err) Any type ❌ None Simple usage, no links needed
err.start_at() Error types ❌ None Chaining on error values

Extending a trace (on Result<T, At<E>>):

Method Effect
.at() New frame at caller's location
.at_str("msg") Add context to last frame (no new location)
.map_err_at(|e| ...) Convert error type, preserve trace

Key: .at() creates a NEW frame. .at_str() adds to the LAST frame. See Adding Context for full list.

Best Practices

DO: Keep your hot loops zero-alloc

  • You do NOT need At<> inside hot loops. Defer tracing until you exit.
  • .at_skipped_frames() adds a [...] marker to indicate frames were skipped.

DO: Use at_crate!() at crate boundaries

  • When consuming errors from other crates, this ensures backtraces show myapp @ src/lib.rs:42 instead of confusing paths.

DO: Feel free to add ergonomic aliases

  • type MyError = At<MyInternalError> works perfectly.

Design Philosophy

You define your own error types. whereat doesn't impose any structure on your errors — use enums, structs, or whatever suits your domain. whereat just wraps them in At<E> to add location+context+crate tracking.

Which Approach?

Situation Use
You have an existing struct/enum you don't want to modify Wrap with At<YourError>
You want traces embedded inside your error type Implement AtTraceable trait

Wrapper approach (most common): Return Result<T, At<YourError>> from functions. The trace lives outside your error type.

Embedded approach: Implement AtTraceable on your error type and store an AtTrace (or Box<AtTrace>) field inside it. Return Result<T, YourError> directly. See ADVANCED.md for details.

This means you can:

  • Use thiserror for ergonomic Display/From impls, or anyhow
  • Use any enum or struct that implements Debug
  • Define type aliases like type MyError = At<BaseError>
  • Access your error via .error() or deref
  • Support nesting with core::error::Error::source()

Features

  • Small sizeof: At<E> is only sizeof(E) + 8 bytes (one pointer for boxed trace)
  • Zero allocation on Ok path: No heap allocation until an error occurs
  • Ergonomic API: .at() on Results, .start_at() on errors, .map_err_at() for trace-preserving conversions
  • Context options: .at_str(), .at_string(), .at_fn(), .at_named(), .at_data(), .at_debug(), .at_error()
  • Cross-crate tracing: at!() and at_crate!() macros capture crate info for GitHub/GitLab/Gitea/Bitbucket links
  • Equality/Hashing: PartialEq, Eq, Hash compare only the error, not the trace
  • no_std compatible: Works with just core + alloc

Adding Context

Add a new location frame:

result.at()?                    // New frame with just file:line:col
result.at_fn(|| {})?            // New frame + captures function name
result.at_named("validation")?  // New frame + custom label

Add context to the last frame (no new location):

result.at_str("loading config")?            // Static string (zero-cost)
result.at_string(|| format!("id={}", id))?  // Dynamic string (lazy)
result.at_data(|| path_context)?            // Typed via Display (lazy)
result.at_debug(|| request_info)?           // Typed via Debug (lazy)
result.at_error(io_err)?                    // Attach a source error

If the trace is empty, context methods create a frame first. Example:

// One frame with two contexts attached
let e = at!(MyError).at_str("a").at_str("b");
assert_eq!(e.frame_count(), 1);

// Two frames: at!() creates first, .at() creates second
let e = at!(MyError).at().at_str("on second frame");
assert_eq!(e.frame_count(), 2);

Cross-Crate Tracing

When consuming errors from other crates, use at_crate!() to mark the boundary:

whereat::define_at_crate_info!();

fn call_external() -> Result<(), At<ExternalError>> {
    at_crate!(external_crate::do_thing())?;  // Wraps Result, marks boundary
    Ok(())
}

The at_crate!() macro takes a Result and desugars to:

result.at_crate(crate::at_crate_info())  // Adds your crate's info as boundary marker

This ensures traces show myapp @ src/lib.rs:42 instead of confusing paths from dependencies.

Hot Loops

Don't trace inside hot loops. Defer until you exit:

fn process_batch(items: &[Item]) -> Result<(), MyError> {
    for item in items {
        process_one(item)?;  // Plain Result here, no At<>
    }
    Ok(())
}

fn caller() -> Result<(), At<MyError>> {
    process_batch(&items)
        .map_err(|e| at!(e).at_skipped_frames())?;  // Wrap on exit, mark skipped
    Ok(())
}

Advanced Usage

See ADVANCED.md for:

  • Embedded traces with AtTraceable trait
  • Custom storage options (inline vs boxed)
  • Complex workspace layouts
  • Link format customization (GitLab, Gitea, Bitbucket)
  • Inline storage features for reduced allocations

License

MIT OR Apache-2.0

Commit count: 122

cargo fmt