The “Context-Less” Error Problem

It’s 2 AM. Your pager goes off. A microservice is failing in production, and the logs are flooded with a generic, unhelpful error: sql: no rows in result set or perhaps a vague unexpected EOF.

You know what happened, but you have absolutely no idea where or why. Was it the payment gateway? The user profile fetch? Which user? Which transaction ID? You spend three grueling hours digging through distributed traces, cross-referencing timestamps across different services, just because the error didn’t carry enough context.

Error handling in Go is famous for its simplicity the ubiquitous if err != nil pattern is beautifully explicit. But in large scale distributed systems, simplicity without context is a liability. When an error is just a string, it loses all the rich, structured data that was available at the moment it occurred.

Current Limitations: Why Reinvent the Wheel?

You might be thinking, “Why didn’t you just use fmt.Errorf("failed to fetch user %s: %w", userID, err)?”

String formatting works, but only up to a point. Once you start interpolating multiple variables into a string, parsing that error string later in a centralized logging system correctly becomes a nightmare. Modern observability relies on structured logs (JSON-compatible metadata), not regex-parsing massive text blobs.

Other existing solutions and libraries often felt too “heavy” or weren’t structured enough for my needs. Some modified the standard error interface in ways that broke backward compatibility or added significant memory allocation overhead. I needed a solution that was lean, preserved the identity of the original error (so errors.Is and errors.As would still work), and organically integrated with Go’s context.Context to carry structured data seamlessly.

That’s why I built Crumbs.

Design Principles: The Architecture of Crumbs

When designing Crumbs, I focused on a few core principles that matter most in high-performance Go applications.

1. Contextual Wrapping

Instead of just concatenating strings, Crumbs attaches structured “breadcrumbs” (key-value pairs) directly to your errors and context. This data is preserved as structured metadata rather than being immediately flattened into a string, allowing your logger to extract it as proper JSON fields later.

2. Zero-Allocation Mindset

Performance is non-negotiable. The hot path for creating and wrapping errors with Crumbs is designed with a zero-allocation mindset for common operations. By minimizing heap allocations, Crumbs ensures that adding context doesn’t become a bottleneck in highly concurrent systems.

3. Native Integration

Crumbs is designed to play nicely with modern Go observability. Because the structured data is carried along the error chain, you can pull it out at the top level and feed it directly into standard logging libraries like log/slog, Zap, or Zerolog without losing fidelity.

Show, Don’t Just Tell

Let’s look at how this plays out in a real world scenario, like a database repository function or a middleware handler, where multiple layers of context are added.

The Bad Example (String Formatting):

func (r *Repo) FetchUser(ctx context.Context, userID string) (*User, error) {
    user, err := r.db.QueryContext(ctx, query, userID)
    if err != nil {
        // We lose the ability to log `userID` as a structured JSON field
        // unless we parse the string later.
        return nil, fmt.Errorf("fetching user %s: %w", userID, err)
    }
    return user, nil
}

The Good Example (Using Crumbs):

import (
    "context"
    "github.com/sri-shubham/crumbs"
)

func ProcessOrder(ctx context.Context, orderID string) error {
    // Add some initial context from the request
    ctx = crumbs.AddCrumb(ctx, 
        "orderID", orderID,
        "timestamp", time.Now(),
    )
    
    order, err := fetchOrder(ctx, orderID)
    if err != nil {
        // Wrap the error with specific context about the failure
        // The original error identity is preserved, and the structured
        // crumbs are attached!
        return crumbs.Wrap(ctx, err, "failed to process order",
            "status", "failed",
            "step", "fetch",
        )
    }
    
    return nil
}

When you handle this error at the top level and pass it to a structured logger (like log/slog), all those key-value pairs (orderID, status, step) are automatically expanded into searchable JSON fields.

Trade-offs and Considerations

Nothing is perfect in engineering, and Crumbs is no exception. It’s important to understand the trade-offs.

  • Stack Traces aren’t free: Capturing stack traces is expensive in Go. Crumbs has them disabled by default to maintain blazing fast performance. If you turn them on crumbs.ConfigureStackTraces(true, 32), be prepared for the memory and CPU overhead. Use them judiciously, perhaps only on critical panics or complex deep-call errors.
  • Compatibility: The good news is that Crumbs preserves backward compatibility. Under the hood, a Crumbs error implements the Unwrap interface, meaning errors.Is and errors.As work exactly as you’d expect.
  • When NOT to use it: If your application is a simple CLI tool where logs are just printed to standard out and read by a human, Crumbs might be overkill. Standard fmt.Errorf is perfectly fine there. Crumbs shines in distributed systems, APIs, and microservices where logs go to Datadog, ELK, or Grafana Loki.

Benchmarks

I ran extensive benchmarks to ensure Crumbs stays lean. Here’s how it compares:

Library / FunctionTime / OpAllocations / OpBytes / Op
errors.New (Standard)13.65 ns/op1 allocs/op16 B/op
crumbs.NewError (Basic)24.74 ns/op1 allocs/op80 B/op
crumbs.NewError (with Crumbs)55.36 ns/op2 allocs/op176 B/op
crumbs.NewError (with Stack Trace)1318 ns/op4 allocs/op784 B/op

Note: Benchmarks run on macOS (darwin/arm64) using an Apple M1.

The basic overhead for creating an error with Crumbs without a stack trace is minimal (around 25ns vs. 14ns for a standard error, maintaining a very low allocation profile). You only pay the cost both in time and memory for the features you explicitly use.

Conclusion & Roadmap

We are just getting started with structured error observability in Go. For the road ahead, I’m exploring:

  • First-class integrations with OpenTelemetry.
  • AI-assisted error summarization built directly into observability pipelines based on extracted crumbs.
  • Refining the allocation profile even further for extreme high-throughput use cases.

If you’ve ever felt the pain of an unhelpful sql: no rows in result set in the middle of the night, I invite you to give it a spin.

Check out the Crumbs repository on GitHub. I’d genuinely love to hear how you handle error context in your teams, and whether Crumbs is a good fit for your architecture!