← Back to list

Building a Panic Recovery Middleware That Actually Tells You What Happened

There’s a specific kind of dread that hits when you’re on-call and your monitoring dashboard shows a spike in 500s — but Sentry is clean…

Erwin Hermanto · 2026-07-05 02:11 · 1 claps · 6.9 min read paywalled
#golang #backend #observability #sentry #software-development
Open on Medium ↗
Wiki topics: 🌐 · Web Development 🎬 · Film & Television

Building a Panic Recovery Middleware That Actually Tells You What Happened

There’s a specific kind of dread that hits when you’re on-call and your monitoring dashboard shows a spike in 500s — but Sentry is clean. No errors. No breadcrumbs. Just silence. Then you dig into the logs and find a single line: panic: runtime error: invalid memory address or nil pointer dereference. That's it. No stack trace. No request ID. No hint of which endpoint triggered it.

I’ve been there more than once. And it usually means someone (sometimes me) wrote a recovery middleware that catches the panic, logs “recovered from panic”, and moves on. Technically correct. Practically useless.

This article is about doing it properly — capturing the full stack trace, attaching request context, and shipping it to Sentry without accidentally leaking auth tokens or internal headers in the payload.

Why the Default recover() Is Not Enough

Go’s built-in recover() gives you back the value passed to panic(). That's it. If you don't capture the stack trace at the moment of the panic, you lose it forever. The goroutine stack unwinds and that information is gone.

Here’s what a naive recovery middleware looks like:

func NaivePanicMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                log.Printf("recovered from panic: %v", err)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

This catches the panic and keeps your server alive — which is good. But you’re flying blind. You don’t know:

  • Which goroutine panicked
  • What the call stack looked like
  • Which request triggered it
  • What the user was doing at the time

Let’s fix that.

Capturing the Stack Trace

Go provides runtime/debug.Stack() which returns the current goroutine's stack trace as a byte slice. The key is that you have to call it inside the deferred function, right after recover(), before anything else happens.

import (
    "fmt"
    "net/http"
    "runtime/debug"
)

func recoverWithStack() (panicVal interface{}, stack []byte) {
    panicVal = recover()
    if panicVal != nil {
        stack = debug.Stack()
    }
    return
}

But there’s a catch: you can’t call a function that calls recover() and have it work at two levels deep. recover() only works when called directly from a deferred function. So we need to be careful about abstraction here.

This pattern works:

func PanicMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                stack := debug.Stack()
                handlePanic(w, r, err, stack)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

func handlePanic(w http.ResponseWriter, r *http.Request, err interface{}, stack []byte) {
    // We do the real work here
    fmt.Printf("panic: %v\n\n%s\n", err, stack)
    http.Error(w, "something went wrong", http.StatusInternalServerError)
}

Separating handlePanic from the deferred closure keeps things readable without breaking the recover() semantics.

Adding Request Context

A stack trace alone doesn’t tell you which endpoint or which user triggered the panic. You need to attach context. At minimum, I want:

  • Request method and URL path
  • Request ID (from header or generated)
  • User ID if available (from auth middleware)
  • A sanitized version of the headers — without Authorization, Cookie, or anything sensitive
type PanicContext struct {
    RequestID  string
    Method     string
    Path       string
    UserID     string
    Headers    map[string]string
    PanicValue interface{}
    StackTrace string
}

var sensitiveHeaders = map[string]bool{
    "authorization": true,
    "cookie":        true,
    "x-api-key":     true,
    "x-auth-token":  true,
}
func extractContext(r *http.Request, err interface{}, stack []byte) PanicContext {
    headers := make(map[string]string)
    for key, vals := range r.Header {
        lower := strings.ToLower(key)
        if sensitiveHeaders[lower] {
            headers[key] = "[REDACTED]"
            continue
        }
        headers[key] = strings.Join(vals, ", ")
    }
    requestID := r.Header.Get("X-Request-ID")
    if requestID == "" {
        requestID = uuid.New().String() // or however you generate IDs
    }
    // Pull user ID from context - assumes auth middleware set it
    userID := ""
    if uid, ok := r.Context().Value(userIDKey{}).(string); ok {
        userID = uid
    }
    return PanicContext{
        RequestID:  requestID,
        Method:     r.Method,
        Path:       r.URL.Path,
        UserID:     userID,
        Headers:    headers,
        PanicValue: err,
        StackTrace: string(stack),
    }
}

The sensitiveHeaders map is the secret filtering mechanism. Everything in that list gets replaced with [REDACTED] before it goes anywhere — logs, Sentry, wherever.

The Stack Trace Flow

Here’s what the data flow looks like from the moment a panic fires to the moment it lands in Sentry:

Request comes in
      │
      ▼
┌─────────────────────┐
│  PanicMiddleware    │  ◄── deferred func wraps the handler
│  defer func()       │
└────────┬────────────┘
         │
         │  panic() fires somewhere in handler chain
         │
         ▼
┌─────────────────────┐
│  recover()          │  ◄── catches panic value
│  debug.Stack()      │  ◄── captures stack trace IMMEDIATELY
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  extractContext()   │  ◄── attaches request metadata
│  sanitizeHeaders()  │  ◄── strips sensitive values
└────────┬────────────┘
         │
         ▼
┌─────────────────────┐
│  structured log     │  ◄── goes to your log aggregator
│  Sentry capture     │  ◄── goes to Sentry with full context
└────────┬────────────┘
         │
         ▼
   500 response to client

Sending to Sentry Without Leaking Secrets

The Sentry Go SDK (github.com/getsentry/sentry-go) gives you a lot of flexibility in how you attach context. The trick is using sentry.WithScope to attach data for a single event without polluting the global scope.

First, initialize Sentry somewhere in your main.go:

import "github.com/getsentry/sentry-go"

func main() {
    err := sentry.Init(sentry.ClientOptions{
        Dsn:         os.Getenv("SENTRY_DSN"),
        Environment: os.Getenv("APP_ENV"),
        Release:     os.Getenv("APP_VERSION"),
        // BeforeSend gives us one last chance to scrub anything
        BeforeSend: func(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
            return scrubSentryEvent(event)
        },
    })
    if err != nil {
        log.Fatalf("sentry init failed: %v", err)
    }
    defer sentry.Flush(2 * time.Second)

    // ... rest of your setup
}

Now the middleware sends a properly scoped event:

func sendToSentry(ctx context.Context, pc PanicContext) {
    hub := sentry.CurrentHub().Clone()
    hub.ConfigureScope(func(scope *sentry.Scope) {
        scope.SetTag("request_id", pc.RequestID)
        scope.SetTag("http.method", pc.Method)
        scope.SetTag("http.path", pc.Path)
    if pc.UserID != "" {
            scope.SetUser(sentry.User{ID: pc.UserID})
        }
        // Attach sanitized headers as extra context
        scope.SetExtras(map[string]interface{}{
            "headers": pc.Headers, // already sanitized
        })
    })
    // Convert panic value to an actual error
    var panicErr error
    switch v := pc.PanicValue.(type) {
    case error:
        panicErr = v
    case string:
        panicErr = fmt.Errorf("panic: %s", v)
    default:
        panicErr = fmt.Errorf("panic: %v", v)
    }
    hub.CaptureException(panicErr)
}

One thing worth noting: sentry.CurrentHub().Clone() is important for concurrent requests. Without cloning, you'd be mutating the global hub's scope across goroutines, and you'd end up with mixed-up request IDs in your Sentry events. Cloning gives each request its own isolated scope.

The BeforeSend Safety Net

Even with header sanitization in place, I like to add a BeforeSend hook as a last line of defense. Sometimes panic values themselves contain sensitive data — think a database error that includes a connection string, or a decoded JWT payload.

func scrubSentryEvent(event *sentry.Event) *sentry.Event {
    sensitivePatterns := []*regexp.Regexp{
        regexp.MustCompile(`(?i)(password|passwd|secret|token|key)\s*[:=]\s*\S+`),
        regexp.MustCompile(`Bearer\s+[A-Za-z0-9\-._~+/]+=*`),
        regexp.MustCompile(`postgres://[^\s]+`),
        regexp.MustCompile(`mongodb(\+srv)?://[^\s]+`),
    }
    scrub := func(s string) string {
        for _, re := range sensitivePatterns {
            s = re.ReplaceAllString(s, "[REDACTED]")
        }
        return s
    }
    // Scrub exception messages
    for i, ex := range event.Exception {
        event.Exception[i].Value = scrub(ex.Value)
    }
    // Scrub extra context values
    for key, val := range event.Extra {
        if str, ok := val.(string); ok {
            event.Extra[key] = scrub(str)
        }
    }
    return event
}

This regex-based scrubbing is a blunt instrument, but it catches the obvious stuff: database URLs, bearer tokens, and key=value pairs that smell like credentials.

Putting It All Together

Here’s the complete middleware, assembled:

package middleware

import (
    "context"
    "fmt"
    "net/http"
    "runtime/debug"
    "strings"
    "github.com/getsentry/sentry-go"
    "go.uber.org/zap"
)
type userIDKey struct{}
var sensitiveHeaders = map[string]bool{
    "authorization": true,
    "cookie":        true,
    "x-api-key":     true,
    "x-auth-token":  true,
    "x-csrf-token":  true,
}
func PanicRecovery(logger *zap.Logger) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                if err := recover(); err != nil {
                    stack := debug.Stack()
                    pc := buildPanicContext(r, err, stack)
                    logger.Error("panic recovered",
                        zap.String("request_id", pc.RequestID),
                        zap.String("method", pc.Method),
                        zap.String("path", pc.Path),
                        zap.String("user_id", pc.UserID),
                        zap.Any("panic_value", pc.PanicValue),
                        zap.String("stack_trace", pc.StackTrace),
                    )
                    sendToSentry(r.Context(), pc)
                    w.Header().Set("Content-Type", "application/json")
                    w.WriteHeader(http.StatusInternalServerError)
                    w.Write([]byte(`{"error":"internal server error"}`))
                }
            }()
            next.ServeHTTP(w, r)
        })
    }
}
func buildPanicContext(r *http.Request, err interface{}, stack []byte) PanicContext {
    headers := make(map[string]string)
    for key, vals := range r.Header {
        if sensitiveHeaders[strings.ToLower(key)] {
            headers[key] = "[REDACTED]"
            continue
        }
        headers[key] = strings.Join(vals, ", ")
    }
    requestID := r.Header.Get("X-Request-ID")
    userID, _ := r.Context().Value(userIDKey{}).(string)
    return PanicContext{
        RequestID:  requestID,
        Method:     r.Method,
        Path:       r.URL.Path,
        UserID:     userID,
        Headers:    headers,
        PanicValue: err,
        StackTrace: string(stack),
    }
}
func sendToSentry(ctx context.Context, pc PanicContext) {
    hub := sentry.CurrentHub().Clone()
    hub.ConfigureScope(func(scope *sentry.Scope) {
        scope.SetTag("request_id", pc.RequestID)
        scope.SetTag("http.method", pc.Method)
        scope.SetTag("http.path", pc.Path)
        if pc.UserID != "" {
            scope.SetUser(sentry.User{ID: pc.UserID})
        }
        scope.SetExtras(map[string]interface{}{
            "headers": pc.Headers,
        })
    })
    var panicErr error
    switch v := pc.PanicValue.(type) {
    case error:
        panicErr = v
    case string:
        panicErr = fmt.Errorf("panic: %s", v)
    default:
        panicErr = fmt.Errorf("panic: %v", v)
    }
    hub.CaptureException(panicErr)
}

Registering it in your router (using chi as an example):

r := chi.NewRouter()
r.Use(middleware.PanicRecovery(logger))
r.Use(middleware.RequestID)     // sets X-Request-ID
r.Use(middleware.Auth)          // sets userIDKey in context
r.Get("/api/health", healthHandler)

The middleware order matters. PanicRecovery should be the outermost middleware — registered first — so it wraps everything else, including middlewares that might panic.

What a Recovered Panic Looks Like in Sentry

After wiring this up, your Sentry events for panics will look something like this:

Exception: panic: runtime error: index out of range [3] with length 2
Tags:
  request_id: 01HQ3M9VE4XBT5CNPF7YGKW8S
  http.method: GET
  http.path: /api/v1/orders/export
User:
  id: usr_8f3kd92
Extra:
  headers:
    X-Request-ID: 01HQ3M9VE4XBT5CNPF7YGKW8S
    Content-Type: application/json
    Authorization: [REDACTED]
    Cookie: [REDACTED]
Stack Trace:
  goroutine 47 [running]:
  runtime/debug.Stack(...)
      /usr/local/go/src/runtime/debug/stack.go:24
  yourapp/middleware.PanicRecovery.func1.1()
      /app/middleware/panic.go:31
  yourapp/handler.ExportOrders(...)
      /app/handler/orders.go:142
  ...

That’s actually useful. You know exactly which endpoint, which user, what the panic was, and where in the code it happened — without any sensitive data leaking into Sentry.

A Note on Testing This

Testing panic recovery is one of those things people skip until something breaks in production. Don’t.

func TestPanicMiddleware(t *testing.T) {
    logger, _ := zap.NewDevelopment()
    panicHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        panic("intentional test panic")
    })
    wrapped := PanicRecovery(logger)(panicHandler)
    req := httptest.NewRequest(http.MethodGet, "/test", nil)
    req.Header.Set("Authorization", "Bearer supersecret")
    req.Header.Set("X-Request-ID", "test-123")
    rr := httptest.NewRecorder()
    wrapped.ServeHTTP(rr, req)
    if rr.Code != http.StatusInternalServerError {
        t.Errorf("expected 500, got %d", rr.Code)
    }
    // Sentry assertions would need a mock hub - 
    // but at minimum verify the server didn't die
}

For Sentry assertions, you can swap in a test transport:

sentry.Init(sentry.ClientOptions{
    Transport: &sentry.HTTPSyncTransport{}, // or a mock
})

The Quiet Confidence of Good Error Handling

There’s something satisfying about good observability infrastructure. You build it once, you test it, and then you stop thinking about it — until the day a panic fires at 2am and your PagerDuty alert links directly to a Sentry event with the full stack trace, the affected user, and zero sensitive data exposed.

That’s the goal. Not heroic debugging. Just enough information to find the bug, fix it, and go back to sleep.

The naive middleware keeps your server up. The thoughtful middleware keeps your team sane.


메타데이터
post_id
f41d3f464ffc
slug
building-a-panic-recovery-middleware-that-actually-tells-you-what-happened-f41d3f464ffc
url
https://medium.com/@erwindev/building-a-panic-recovery-middleware-that-actually-tells-you-what-happened-f41d3f464ffc
canonical_url
https://medium.com/@erwindev/building-a-panic-recovery-middleware-that-actually-tells-you-what-happened-f41d3f464ffc
author_url
https://medium.com/@erwindev
status
ok
fetched_at
2026-07-14 00:44:25