Guide Errors ~14 min read Updated May 18, 2026

Go error tracking: the 2026 guide.

Go’s error model is a feature and a trap at the same time. Errors are values, which means they compose cleanly and travel through your call stack without surprises. It also means there is no automatic exception capture, no built-in stack trace, and no global handler that catches what you forget. This guide covers how to track errors in Go correctly in 2026: the SDK setup, the pitfalls that catch every team, the middleware patterns for chi, gin, and fiber, and how urgentry fits as the receiving server.

TL;DR

20 seconds. Initialize sentry-go once at startup with your DSN. Call sentry.CaptureException(err) at your service boundaries. Add defer sentry.Recover() to every goroutine that can panic. Use HTTP middleware from your framework’s sentryX package. Swap the DSN to an urgentry DSN and nothing else changes.

60 seconds. Go does not capture stack traces for plain errors. A naked errors.New("something went wrong") sent to Sentry or urgentry arrives with no stack. You have three options: wrap errors with github.com/pkg/errors at origin, call runtime/debug.Stack() manually, or use fmt.Errorf("context: %w", err) for wrapping and accept that the SDK will use the capture site as the stack root. All three are valid; the choice depends on how your team structures error propagation.

Goroutine panics are the silent killer. A panic in a spawned goroutine crashes the entire process. The main goroutine cannot recover it. Every background goroutine, every worker, every queue consumer needs its own defer sentry.Recover(). This is not optional.

Why Go error tracking is different from Python or Node

In Python, an unhandled exception bubbles up automatically. In Node, an uncaught promise rejection fires a global handler. The runtime does the catching; you opt into more granularity. In Go, none of that is true.

Go errors are values. An error is just an interface with one method: Error() string. The language has no exception machinery. When a function fails, it returns an error value as a second return. You check it, wrap it, or ignore it at every step. Nothing happens automatically.

This has real consequences for error tracking:

  • There is no automatic stack capture on error creation. The stack at the time you call errors.New() is not attached to the error value unless you do it yourself.
  • There is no global uncaught-error handler. If you call sentry.CaptureException(err) zero times for a given error, it is never tracked.
  • Goroutine panics are not recovered by anything except code you write yourself, in each goroutine.
  • The Sentry SDK for Go is not magic. It instruments panics in the main goroutine and in the HTTP handlers you wrap. Everything else is opt-in.

The practical implication: Go error tracking requires more explicit code than most other languages, but it is also more predictable. Every tracked error is there because you put it there. No surprises, no ghost events from framework internals.

The sentry-go SDK: init, capture, recover

Initialization

Initialize the SDK once, at process startup, before any goroutines start. A minimal init looks like this:

package main

import (
    "log"
    "os"
    "time"

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

func main() {
    err := sentry.Init(sentry.ClientOptions{
        Dsn:              os.Getenv("SENTRY_DSN"),
        Environment:      os.Getenv("APP_ENV"),    // "production", "staging"
        Release:          os.Getenv("APP_VERSION"), // "v1.4.2"
        TracesSampleRate: 0.2,                      // 20% of transactions
    })
    if err != nil {
        log.Fatalf("sentry.Init: %v", err)
    }
    // Flush buffered events before the program terminates.
    defer sentry.Flush(2 * time.Second)

    // ... rest of main
}

Two things worth noting here. First, sentry.Flush is not optional for short-lived programs. The SDK buffers events asynchronously; without a flush, events generated near process exit may never reach the server. For a long-running HTTP server this matters less at runtime but still matters during graceful shutdown. Second, set Environment and Release from the environment rather than hardcoding them. Every deployment pipeline should inject these; source map symbolication and release-based queries both depend on Release being set correctly.

Capturing errors

The main capture function is sentry.CaptureException. Call it at the point where an error has been diagnosed and is not going to be handled by a higher layer.

func processOrder(ctx context.Context, orderID string) error {
    order, err := db.GetOrder(ctx, orderID)
    if err != nil {
        sentry.CaptureException(err)
        return fmt.Errorf("processOrder: %w", err)
    }

    if err := chargePayment(ctx, order); err != nil {
        // Add structured context before capturing
        sentry.WithScope(func(scope *sentry.Scope) {
            scope.SetExtra("order_id", orderID)
            scope.SetExtra("amount", order.Total)
            scope.SetTag("payment_provider", order.PaymentMethod)
            sentry.CaptureException(err)
        })
        return fmt.Errorf("processOrder: charge failed: %w", err)
    }

    return nil
}

The sentry.WithScope callback lets you attach context that applies only to this capture, without polluting the global scope. Use it for order IDs, user IDs, request IDs, or any value that identifies the specific failure.

If you are capturing from inside an HTTP handler and the request context carries a Sentry hub (which it will if you use the framework middleware described later), use hub.CaptureException on the hub extracted from the context rather than the global function. That keeps the event scoped to the request:

func handler(w http.ResponseWriter, r *http.Request) {
    if hub := sentry.GetHubFromContext(r.Context()); hub != nil {
        hub.CaptureException(err)
    } else {
        sentry.CaptureException(err)
    }
}

Breadcrumbs

Breadcrumbs are structured log entries attached to an event to show what happened before the error. Add them as your code progresses through significant steps:

sentry.AddBreadcrumb(&sentry.Breadcrumb{
    Category: "auth",
    Message:  "User authenticated via API key",
    Level:    sentry.LevelInfo,
    Data: map[string]interface{}{
        "user_id": user.ID,
        "key_prefix": keyPrefix,
    },
})

The SDK keeps a ring buffer of the last 100 breadcrumbs per scope (configurable). They attach automatically to the next event captured on that scope.

Panic recovery: the most-missed step

A panic unwinds the current goroutine’s stack. If nothing recovers it, the runtime prints the goroutine’s stack trace to stderr and exits the process. The Sentry SDK does not intercept this at the process level by default. You have to call sentry.Recover inside a deferred function where the panic happens.

In the main goroutine

func main() {
    // ... sentry.Init ...

    defer func() {
        if err := recover(); err != nil {
            sentry.CurrentHub().Recover(err)
            sentry.Flush(2 * time.Second)
        }
    }()

    run() // your actual program
}

The sentry.Flush call inside the recovery is mandatory here. The process is about to exit after a panic; without flushing, the event never reaches the server.

In HTTP handlers

The sentryX middleware packages handle this for you. If you are using raw net/http, you need to wrap each handler:

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if err := recover(); err != nil {
                hub := sentry.GetHubFromContext(r.Context())
                if hub == nil {
                    hub = sentry.CurrentHub().Clone()
                }
                hub.Recover(err)
                hub.Flush(2 * time.Second)
                http.Error(w, "Internal Server Error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}

HTTP middleware: chi, gin, and fiber

Each of the major Go HTTP frameworks has either an official or well-maintained sentry middleware package. All three follow the same pattern: clone a hub per request, attach it to the request context, recover panics, and capture the event with full request context.

chi

package main

import (
    "net/http"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/getsentry/sentry-go"
    sentrychi "github.com/getsentry/sentry-go/chi"
)

func main() {
    // sentry.Init already called above

    r := chi.NewRouter()

    r.Use(sentrychi.New(sentrychi.Options{
        Repanic:         true,  // re-panic after capturing so your own recovery runs
        WaitForDelivery: false, // async delivery; set true only in test
    }))

    r.Get("/orders/{id}", getOrder)

    http.ListenAndServe(":8080", r)
}

func getOrder(w http.ResponseWriter, r *http.Request) {
    id := chi.URLParam(r, "id")
    order, err := db.Get(r.Context(), id)
    if err != nil {
        // Hub is in context from sentrychi middleware
        if hub := sentry.GetHubFromContext(r.Context()); hub != nil {
            hub.CaptureException(err)
        }
        http.Error(w, "not found", http.StatusNotFound)
        return
    }
    // ...
    _ = order
}

gin

package main

import (
    "github.com/gin-gonic/gin"
    "github.com/getsentry/sentry-go"
    sentrygin "github.com/getsentry/sentry-go/gin"
)

func main() {
    r := gin.New()

    r.Use(sentrygin.New(sentrygin.Options{
        Repanic: true,
    }))

    r.GET("/orders/:id", func(c *gin.Context) {
        order, err := db.Get(c.Request.Context(), c.Param("id"))
        if err != nil {
            // sentrygin puts the hub on the gin context
            hub := sentrygin.GetHubFromContext(c)
            hub.WithScope(func(scope *sentry.Scope) {
                scope.SetTag("order_id", c.Param("id"))
                hub.CaptureException(err)
            })
            c.AbortWithStatus(500)
            return
        }
        _ = order
    })

    r.Run(":8080")
}

fiber

Fiber uses fasthttp under the hood, which means it does not share the standard context.Context plumbing. The sentry-go project includes a fiber adapter:

package main

import (
    "github.com/getsentry/sentry-go"
    sentryfiber "github.com/getsentry/sentry-go/fiber"
    "github.com/gofiber/fiber/v2"
)

func main() {
    app := fiber.New()

    app.Use(sentryfiber.New(sentryfiber.Options{
        Repanic: true,
    }))

    app.Get("/orders/:id", func(c *fiber.Ctx) error {
        order, err := db.Get(c.Context(), c.Params("id"))
        if err != nil {
            hub := sentryfiber.GetHubFromContext(c)
            hub.CaptureException(err)
            return c.SendStatus(500)
        }
        _ = order
        return nil
    })

    app.Listen(":8080")
}

One point to know about the fiber adapter: sentryfiber.GetHubFromContext takes a *fiber.Ctx, not a standard context.Context. If you have helper functions that accept context.Context and try to extract the Sentry hub, they will not find it through the standard path. Pass the hub as a parameter or use a different context carrier.

gRPC interceptors

gRPC does not have a pre-built sentry-go interceptor in the main SDK, but the pattern is straightforward. Add a unary server interceptor that clones a hub, attaches it to the gRPC context, and defers a recovery:

package grpcutil

import (
    "context"
    "fmt"

    "github.com/getsentry/sentry-go"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
)

func SentryUnaryInterceptor(
    ctx context.Context,
    req interface{},
    info *grpc.UnaryServerInfo,
    handler grpc.UnaryHandler,
) (resp interface{}, err error) {
    hub := sentry.CurrentHub().Clone()
    hub.Scope().SetTag("grpc.method", info.FullMethod)
    ctx = sentry.SetHubOnContext(ctx, hub)

    defer func() {
        if p := recover(); p != nil {
            hub.Recover(p)
            hub.Flush(2 * time.Second)
            err = status.Errorf(codes.Internal, "internal error")
        }
    }()

    resp, err = handler(ctx, req)
    if err != nil {
        hub.CaptureException(err)
    }
    return resp, err
}

Register it at server construction:

server := grpc.NewServer(
    grpc.UnaryInterceptor(grpcutil.SentryUnaryInterceptor),
)

Goroutines and background workers

This is where Go error tracking breaks for most teams. The rule is simple and non-negotiable: every goroutine you spawn needs its own panic recovery if it can panic.

Here is why. When you call go someFunc(), that goroutine runs independently. If someFunc panics, Go’s runtime prints the stack and kills the entire process. No recovery anywhere else in the program catches it. Not in main. Not in any other goroutine.

Spawning goroutines safely

func spawnWithRecovery(hub *sentry.Hub, fn func()) {
    go func() {
        // Clone the hub so this goroutine gets its own scope
        localHub := hub.Clone()
        defer func() {
            if err := recover(); err != nil {
                localHub.Recover(err)
                localHub.Flush(2 * time.Second)
            }
        }()
        fn()
    }()
}

// Usage
spawnWithRecovery(sentry.CurrentHub(), func() {
    processLargeExport(ctx, exportID)
})

Cloning the hub is important. Each goroutine should have its own scope so that extra context you set inside the goroutine does not bleed into other events. hub.Clone() copies the current breadcrumbs and tags but creates a fresh scope for further mutations.

Queue and background workers

Worker loops that process jobs off a queue are a common source of untracked errors. The pattern is: recover at the worker loop level, capture per job, and never let a single job crash the worker.

func runWorker(ctx context.Context, jobs <-chan Job) {
    hub := sentry.CurrentHub().Clone()

    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return
            }
            processJobSafe(ctx, hub, job)
        case <-ctx.Done():
            return
        }
    }
}

func processJobSafe(ctx context.Context, hub *sentry.Hub, job Job) {
    defer func() {
        if p := recover(); p != nil {
            hub.WithScope(func(scope *sentry.Scope) {
                scope.SetExtra("job_id", job.ID)
                scope.SetExtra("job_type", job.Type)
                hub.Recover(p)
            })
        }
    }()

    if err := processJob(ctx, job); err != nil {
        hub.WithScope(func(scope *sentry.Scope) {
            scope.SetExtra("job_id", job.ID)
            scope.SetExtra("job_type", job.Type)
            hub.CaptureException(err)
        })
    }
}

Notice that processJobSafe handles both panics and returned errors. Both matter: a panic that crashes a worker is usually worse than an error that fails a single job, but both need to reach your error tracker.

Structured context: errors.Join, errors.As, and custom types

Wrapping for context

Go 1.13 introduced %w in fmt.Errorf for wrapping. Go 1.20 added errors.Join for combining multiple errors. Both are useful at different layers:

// Wrap with context at each layer
func fetchUserOrders(ctx context.Context, userID string) ([]*Order, error) {
    orders, err := db.QueryOrders(ctx, userID)
    if err != nil {
        return nil, fmt.Errorf("fetchUserOrders user=%s: %w", userID, err)
    }
    return orders, nil
}

// Combine multiple independent errors (Go 1.20+)
func validateOrder(order *Order) error {
    var errs []error
    if order.Total <= 0 {
        errs = append(errs, errors.New("total must be positive"))
    }
    if order.Currency == "" {
        errs = append(errs, errors.New("currency is required"))
    }
    return errors.Join(errs...) // nil if slice is empty
}

When you capture a wrapped error, the sentry-go SDK uses the full string (all the wrapping context) as the error message. That gives you useful event titles in the UI without any extra work.

errors.As for custom types

Custom error types let you attach machine-readable context to an error value. Use errors.As to extract that context when you capture:

// Custom error type with structured fields
type PaymentError struct {
    Code       string
    ProviderID string
    Retryable  bool
    Err        error
}

func (e *PaymentError) Error() string {
    return fmt.Sprintf("payment error %s: %v", e.Code, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

// At the capture site
func capturePaymentErr(ctx context.Context, err error) {
    hub := sentry.GetHubFromContext(ctx)
    if hub == nil {
        hub = sentry.CurrentHub()
    }

    hub.WithScope(func(scope *sentry.Scope) {
        var payErr *PaymentError
        if errors.As(err, &payErr) {
            scope.SetTag("payment.code", payErr.Code)
            scope.SetTag("payment.provider_id", payErr.ProviderID)
            scope.SetTag("payment.retryable", fmt.Sprintf("%t", payErr.Retryable))
        }
        hub.CaptureException(err)
    })
}

Tags appear as indexed fields in urgentry and Sentry. That means you can filter issues by payment.code:insufficient_funds or payment.retryable:true without scanning event payloads. Set tags for things you will actually filter on; use SetExtra for everything else.

Stack traces: what you actually get

This is the part of Go error tracking that surprises teams the most when they first look at their issues in the UI.

By default, when you call sentry.CaptureException(err), the SDK walks up the call stack at the moment of the capture call. The stack you see in the UI reflects where you called CaptureException, not where the error was created. For a layered service that wraps errors at each tier, this means the stack points at your logging or capture boundary rather than the root cause.

Stack traces from panic recovery

Panics are the exception. When sentry.Recover is called inside a recover(), the SDK captures the goroutine stack from the moment the panic occurred. You get the real origin. This is one reason panic-based errors are often easier to debug than returned errors in Go.

Capturing stacks at error origin with pkg/errors

The best general solution is github.com/pkg/errors, which attaches a stack trace to an error at the point of creation:

import "github.com/pkg/errors"

func queryDatabase(ctx context.Context, id string) (*Record, error) {
    row, err := db.QueryRowContext(ctx, "SELECT * FROM records WHERE id = $1", id)
    if err != nil {
        // Stack captured here, at the origin
        return nil, errors.Wrap(err, "queryDatabase")
    }
    // ...
    return nil, nil
}

When this error reaches sentry.CaptureException, the SDK reads the embedded stack from the pkg/errors interface and uses that as the stack trace in the event. The UI shows the database call site rather than the capture site.

If you are starting a new codebase in 2026, consider the alternative: golang.org/x/xerrors is the upstream experiment that influenced the standard library, but it never shipped stack traces in the standard package. For stack traces at origin, pkg/errors is still the practical choice.

Manual stack capture

For cases where you cannot change the error origin, capture the stack at the point you decide to track the error:

import "runtime/debug"

func captureWithStack(ctx context.Context, err error) {
    stack := debug.Stack()
    sentry.WithScope(func(scope *sentry.Scope) {
        scope.SetExtra("stack_trace", string(stack))
        sentry.CaptureException(err)
    })
}

This attaches the stack as extra data rather than a parsed frame list, so it does not render as a clickable stack in the UI. Use it as a fallback, not a primary strategy.

Stack truncation

Go’s runtime truncates goroutine stacks in stack dumps when the goroutine count is high. debug.Stack() returns the calling goroutine’s stack only and is not subject to truncation in normal use. debug.PrintStack() prints to stderr. For ingest purposes, debug.Stack() is the right call.

Panic vs error: when to capture each

The Go convention is clear: panics are for programming errors, not operational ones. A nil pointer dereference, an out-of-bounds slice access, or an assertion about invariants that should always hold — these are panics. A database connection timeout, a downstream API returning 503, or a user submitting invalid input — these are errors.

From an error tracking standpoint, both need capturing but with different semantics:

  • Panics: always capture and always alert. A panic in production is a bug. The stack trace from recovery is your most useful diagnostic artifact.
  • Errors: capture selectively. Not every error is worth sending to your error tracker. A user who provides an invalid email address does not generate an issue. A database query that fails after three retries does.

The practical rule: track errors that indicate something wrong with your system, not errors that indicate something wrong with the input. Capture at the boundary where you have confirmed the error is your problem, not the caller’s.

OTLP-native: the OTel Go SDK alternative

OpenTelemetry Go (go.opentelemetry.io/otel) is a different approach to observability. Instead of sending Sentry envelopes, it emits OTLP (OpenTelemetry Protocol) spans, traces, and logs. The two are not mutually exclusive.

When to use OTel Go alongside sentry-go

sentry-go is better at error grouping, issue management, and alerting on individual errors. OTel Go is better at distributed tracing, service-to-service timing, and vendor-neutral telemetry. Many teams run both: sentry-go for errors, OTel for traces.

A minimal OTel setup with OTLP/HTTP export:

package main

import (
    "context"
    "log"

    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracehttp.New(ctx,
        otlptracehttp.WithEndpoint("localhost:4318"), // urgentry OTLP endpoint
        otlptracehttp.WithInsecure(),
    )
    if err != nil {
        return nil, err
    }

    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(resource.NewWithAttributes(
            semconv.SchemaURL,
            semconv.ServiceNameKey.String("my-service"),
            semconv.ServiceVersionKey.String("v1.0.0"),
        )),
        sdktrace.WithSampler(sdktrace.TraceIDRatioBased(0.1)),
    )

    otel.SetTracerProvider(tp)
    return tp, nil
}

// Instrument a function with a span and record errors
func processOrder(ctx context.Context, orderID string) error {
    tracer := otel.Tracer("orders")
    ctx, span := tracer.Start(ctx, "processOrder")
    defer span.End()

    order, err := db.Get(ctx, orderID)
    if err != nil {
        span.RecordError(err)
        return fmt.Errorf("processOrder: %w", err)
    }

    // ... process order
    _ = order
    return nil
}

span.RecordError(err) adds the error as a structured event on the span. OTel-compatible backends (including urgentry’s OTLP receiver) record this alongside the span timing. You get the full trace and the error in one view.

OTel Go for logs

OTel’s logging bridge for Go (go.opentelemetry.io/otel/log) is stable as of v1.26. You can bridge slog output to OTLP logs:

import (
    "log/slog"

    "go.opentelemetry.io/contrib/bridges/otelslog"
)

// Replace the default slog logger with the OTel bridge
logger := otelslog.NewLogger("my-service")
slog.SetDefault(logger)

// Now slog calls emit OTLP logs
slog.ErrorContext(ctx, "payment failed",
    "order_id", orderID,
    "amount", order.Total,
    "err", err,
)

OTLP logs emitted this way carry the trace context from the current span, so logs and traces correlate in the receiving backend.

urgentry as the receiving server

urgentry accepts both the Sentry envelope protocol and OTLP/HTTP in the same binary. That is not a common property of self-hosted error trackers. It means you can point sentry-go at one DSN and OTel at one OTLP endpoint and receive both in one place.

For sentry-go: swap the DSN

Change SENTRY_DSN to your urgentry project DSN. Nothing else changes in the SDK configuration:

err := sentry.Init(sentry.ClientOptions{
    // Before: "https://abc123@o1234.ingest.sentry.io/5678"
    // After:
    Dsn:         os.Getenv("SENTRY_DSN"), // set to urgentry DSN
    Environment: os.Getenv("APP_ENV"),
    Release:     os.Getenv("APP_VERSION"),
})

A DSN looks like https://<key>@your.urgentry.host/<project_id>. Generate it in the urgentry project settings page. The format is identical to a Sentry DSN; the SDK cannot tell the difference.

For OTel Go: point at urgentry’s OTLP port

exporter, err := otlptracehttp.New(ctx,
    otlptracehttp.WithEndpoint("your.urgentry.host:4318"),
    // Add WithTLSClientConfig if using TLS
)

urgentry listens for OTLP/HTTP on port 4318 by default. Spans, logs, and trace context all land in the same urgentry project. You can correlate an error event from sentry-go with a trace from OTel if you set the same traceId in both, which the sentry-go SDK does automatically when OTel is also initialized.

Urgentry is itself written in Go. The project runs on a single binary, uses SQLite by default, and runs at around 52 MB resident memory at 400 events per second. If you are evaluating error trackers for a Go service, there is something to be said for the server being the same language as the client. The operational model is familiar: one binary, one config file, systemd unit, done.

The full SDK setup guide is at /docs/.

Common pitfalls

Not flushing before exit

The SDK sends events asynchronously. For short-lived programs, scripts, or Lambda-style functions, always call sentry.Flush(2 * time.Second) before the process ends. Without it, events may be dropped.

Capturing at every layer

If you call CaptureException at every function that sees the error, you get one event per layer in the call stack. Capture once, at the boundary where you have decided the error is terminal. Use wrapping (fmt.Errorf) to add context at intermediate layers.

Ignoring error returns

Go lets you discard error returns with _. If you do this and the call fails, you have a silent failure with no event in your tracker. Use go vet or errcheck in your CI pipeline to catch ignored errors.

Global scope pollution

Calling sentry.ConfigureScope on the global scope from inside a request handler attaches that data to every subsequent event, not just the current request. Use sentry.WithScope for request-scoped data. Reserve global scope for process-level facts like the release version.

No context propagation across goroutines

When you spawn a goroutine from inside an HTTP handler, the Sentry hub is on the request context. If you pass a context.Background() to the goroutine instead of the request context, the hub is gone. Either clone the hub explicitly and pass it, or pass the original request context.

Sampling the wrong way

TracesSampleRate applies to performance transactions. It does not affect error capture. All errors are captured regardless of the trace sampling rate. If you are seeing too many events, add a BeforeSend hook to filter, not sampling.

FAQ

Does sentry-go capture stack traces automatically?

Not the way exception-based languages do. sentry-go captures a stack trace when you call sentry.CaptureException or when the SDK recovers a panic. For plain errors passed through your call chain, you need to wrap them with github.com/pkg/errors or use runtime/debug.Stack manually.

What happens when a goroutine panics?

The process crashes if nothing recovers it. The main goroutine’s recover() does not catch panics in other goroutines. Every goroutine that can panic needs its own deferred recovery. sentry-go provides sentry.Recover() as a convenience wrapper.

Can I use sentry-go and OTel Go at the same time?

Yes. The common pattern is sentry-go for error grouping and alerting, OTel for distributed traces and spans. The two SDKs do not conflict. urgentry accepts both the Sentry envelope protocol and OTLP/HTTP in the same binary.

Does urgentry work with the sentry-go SDK without code changes?

The only change is the DSN. Set SENTRY_DSN to your urgentry project DSN and restart. The SDK sends the same envelope it always sent; urgentry receives it.

How do I get a stack trace for a non-panic error in Go?

Two approaches. First, use github.com/pkg/errors Wrap or WithStack at the point you create or receive the error — sentry-go reads the embedded stack. Second, call runtime/debug.Stack() yourself and attach the bytes as extra context on the event. Both work; the pkg/errors approach is cleaner because the stack is captured at origin, not at capture time.

Sources

Ready to wire up your Go service?

urgentry runs as a single Go binary, accepts sentry-go and OTel out of the box, and takes under ten minutes to start receiving events. The DSN swap is the only change your Go code needs.

Read the setup docs Run the switch proof