rootcause 0.10.0

A flexible, ergonomic, and inspectable error reporting library for Rust
Documentation

rootcause

A flexible, ergonomic, and inspectable error reporting library for Rust.

Build Status Crates.io Documentation Discord License: MIT/Apache-2.0

Overview

rootcause helps you build rich, structured error reports that capture not just what went wrong, but the full context and history.

Here's a simple example (from examples/basic.rs) showing how errors build up context as they propagate through your call stack:

use rootcause::prelude::*;

fn read_file(path: &str) -> Result<String, Report> {
    // The ? operator automatically converts io::Error to Report
    let content = std::fs::read_to_string(path)?;
    Ok(content)
}

fn load_config(path: &str) -> Result<String, Report> {
    // Add context to explain what this file is for
    let content = read_file(path)
        .context("Failed to load application configuration")?;
    Ok(content)
}

fn load_config_with_debug_info(path: &str) -> Result<String, Report> {
    // Attach debugging information
    let content = load_config(path)
        .attach(format!("Config path: {path}"))
        .attach("Expected format: TOML")?;
    Ok(content)
}

fn startup(config_path: &str, environment: &str) -> Result<(), Report> {
    let _config = load_config_with_debug_info(config_path)
        .context("Application startup failed")
        .attach(format!("Environment: {environment}"))?;
    Ok(())
}

When startup() fails, you get a chain showing the full story:

 ● Application startup failed
 ├ examples/basic.rs:76:10
 ├ Environment: production
 │
 ● Failed to load application configuration
 ├ examples/basic.rs:47:35
 ├ Config path: /nonexistent/config.toml
 ├ Expected format: TOML
 │
 ● No such file or directory (os error 2)
 ╰ examples/basic.rs:34:19

Each layer adds context and debugging information, building a trail from the high-level operation down to the root cause.

Core Concepts

At a high level, rootcause helps you build a tree of error reports. Each node in the tree represents a step in the error's history - you start with a root error, then add context and attachments as it propagates up through your code.

Most error reports are linear chains (just like anyhow), but the tree structure lets you collect multiple related errors when needed.

Project Goals

  • Ergonomic: The ? operator should work with most error types, even ones not designed for this library
  • Multi-failure tracking: When operations fail multiple times (retry attempts, batch processing, parallel execution), all failures should be captured and preserved in a single report
  • Inspectable: The objects in a Report should not be glorified strings. Inspecting and interacting with them should be easy
  • Optionally typed: Users should be able to (optionally) specify the type of the context in the root node
  • Beautiful: The default formatting should look pleasant—and if it doesn't match your style, the hook system lets you customize it
  • Cloneable: It should be possible to clone a Report when you need to
  • Self-documenting: Reports should automatically capture information (like backtraces and locations) that might be useful in debugging
  • Customizable: It should be possible to customize what data gets collected, or how reports are formatted
  • Lightweight: Report has a pointer-sized representation, keeping Result<T, Report> small and fast

Why rootcause?

Collecting Multiple Errors

When operations fail multiple times (retries, batch processing, parallel tasks), rootcause lets you gather all the failures into a single tree structure with readable output (from examples/retry_with_collection.rs):

use rootcause::{prelude::*, report_collection::ReportCollection};

fn fetch_document_with_retry(url: &str, retry_count: usize) -> Result<Vec<u8>, Report> {
    let mut errors = ReportCollection::new();

    for attempt in 1..=retry_count {
        match fetch_document(url).attach_with(|| format!("Attempt #{attempt}")) {
            Ok(data) => return Ok(data),
            // Make error cloneable so we can store it (see "Supporting..." section below)
            Err(error) => errors.push(error.into_cloneable()),
        }
    }

    Err(errors.context(format!("Unable to fetch document {url}")))?
}

The output from the above function will be a tree with data associated to each node:

 ● Unable to fetch document http://example.com
 ├ examples/retry_with_collection.rs:59:16
 │
 ├─ ● HTTP error: 500 Internal server error
 │  ├ examples/retry_with_collection.rs:42:9
 │  ╰ Attempt #1
 │
 ╰─ ● HTTP error: 404 Not found
    ├ examples/retry_with_collection.rs:42:9
    ╰ Attempt #2

For more tree examples, see retry_with_collection.rs and batch_processing.rs.

Inspecting Error Trees

Errors aren't just formatted strings—they're structured objects you can traverse and analyze programmatically. This enables analytics, custom error handling, and automated debugging (from examples/inspecting_errors.rs):

use rootcause::prelude::*;

// Analyze retry failures to extract structured data
fn analyze_retry_failures(report: &Report) -> Vec<(u32, u16)> {
    let mut attempts = Vec::new();

    // Traverse all nodes in the error tree
    for node in report.iter_reports() {
        // Try to downcast the context to NetworkError
        if let Some(network_err) = node.downcast_current_context::<NetworkError>() {
            // Search through attachments for RetryMetadata
            for attachment in node.attachments().iter() {
                if let Some(retry_meta) = attachment.downcast_inner::<RetryMetadata>() {
                    attempts.push((retry_meta.attempt, network_err.status_code));
                }
            }
        }
    }

    attempts
}

This lets you extract retry statistics, categorize errors, or build custom monitoring—not just display them. See inspecting_errors.rs for complete examples.

Supporting Advanced Use Cases

When you need to use the same error in multiple places—like sending to a logging backend and displaying to a user, potentially on different threads—you can make errors cloneable:

use rootcause::prelude::*;

// Make the error cloneable so we can use it multiple times
let error = fetch_data().unwrap_err().into_cloneable();

// Send to background logging service
let log_error = error.clone();
tokio::spawn(async move {
    log_to_backend(log_error).await;
});

// Also display to user
display_error(error);

For the niche case where you're working with !Send errors from other libraries or need to attach thread-local data:

// Include thread-local data in error reports
let report: Report<_, _, Local> = report!(MyError)
    .attach(Rc::new(expensive_data));

Most code uses the defaults, but these type parameters are available when you need them. See Report Type Parameters for details.

Type-Safe Error Handling

Libraries often need to preserve specific error types so callers can handle errors programmatically. Use Report<YourError> to enable pattern matching without runtime type checks (simplified from examples/typed_reports.rs):

use rootcause::prelude::*;

// Library function returns typed error
fn query_database(id: u32) -> Result<Data, Report<DatabaseError>> {
    // ... can fail with specific error types
}

// Caller can pattern match to handle errors intelligently
fn process_with_retry(id: u32) -> Result<Data, Report> {
    match query_database(id) {
        Ok(data) => Ok(data),
        Err(report) => {
            // Pattern match on the typed context
            match report.current_context() {
                DatabaseError::ConnectionLost | DatabaseError::QueryTimeout => {
                    // Retry transient errors
                    query_database(id).map_err(|e| e.into_dyn_any())
                }
                DatabaseError::ConstraintViolation { .. } => {
                    // Don't retry permanent errors
                    Err(report.into_dyn_any())
                }
            }
        }
    }
}

See typed_reports.rs for a complete example with retry logic.

Quick Start

Add this to your Cargo.toml:

[dependencies]
rootcause = "0.10.0"

Use Report as your error type:

use rootcause::prelude::*;

fn your_function() -> Result<(), Report> {
    // Your existing code with ? already works
    std::fs::read_to_string("/path/to/file")?;
    Ok(())
}

That's it! The ? operator automatically converts any error type to Report.

Ready to learn more? See examples/basic.rs for a hands-on tutorial covering .context(), .attach(), and composing error chains.

Next Steps

Features

  • std (default): Enable standard library support
  • backtrace: Automatic backtrace capture on error creation

Coming from other libraries?

Feature anyhow error-stack rootcause
Error structure Linear chain Opaque attachment model Explicit tree
Type safety No Required Optional
Adding context .context() .change_context() .context()
Structured attachments No Yes (.attach_printable()) Yes (.attach())
Tree navigation Linear (.chain()) Limited iterators Full tree access
Cloneable errors No No Yes (Report<_, Cloneable>)
Thread-local errors No No Yes (Report<_, _, Local>)
Location tracking Single backtrace ? Multiple backtraces supported
Customization hooks No Formatting only Creation + formatting

See the retry example in "Why rootcause?" for how the tree structure enables collecting multiple related errors with ReportCollection.

Advanced Features

Once you're comfortable with the basics, rootcause offers powerful features for complex scenarios. See the examples directory for patterns including:

Architecture

The library consists of two main crates:

  • rootcause: The main user-facing API. Contains safe abstractions and some unsafe code for type parameter handling.
  • rootcause-internals: Low-level implementation details. Contains the majority of unsafe code, isolated from user-facing APIs.

This separation ensures that most unsafe operations are contained in a single, auditable crate. The public API in rootcause uses these primitives to provide safe, ergonomic error handling.

Stability and Roadmap

Current status: Pre-1.0 (v0.10.0)

rootcause follows semantic versioning. As a 0.x library, breaking changes may occur in minor version bumps (0.x → 0.x+1). We're actively refining the API based on real-world usage and focused on reaching 1.0.

When to adopt:

  • Now: If you value the inspection capabilities and can tolerate occasional breaking changes during 0.x
  • Wait for 1.0: If you need long-term API stability guarantees

We're committed to reaching 1.0, but we want to get the API right first.

Minimum Supported Rust Version (MSRV)

Our current Minimum Supported Rust Version is 1.89.0. When adding features, we will follow these guidelines:

  • Our goal is to support at least five minor Rust versions. This gives you a 6 month window to upgrade your compiler.
  • Any change to the MSRV will be accompanied with a minor version bump.

Acknowledgements

This library was inspired by and draws ideas from several existing error handling libraries in the Rust ecosystem, including anyhow, thiserror, and error-stack.

License