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…
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