OpenTelemetry for Go
One setup function that reads the standard OTEL_* variables, otelhttp for servers and clients, otelslog for logs with traces, and flushing on shutdown.
Go has no zero-code agent, so you add one setup function and wrap your HTTP server and client. Exporters, resource and sampling still come from the standard variables — nothing Belvador-specific is compiled in.
Tested on 2026-10-24 with go 1.27.3, go.opentelemetry.io/otel v1.47.0, otelhttp v0.72.0, otelslog v0.21.0: traces passed, logs passed, logs carry the request's trace and span.
Install#
go get go.opentelemetry.io/otel \ go.opentelemetry.io/otel/sdk \ go.opentelemetry.io/otel/sdk/log \ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \ go.opentelemetry.io/contrib/bridges/otelslogSet up once#
Add this file to your main package. It builds the trace and log pipelines from the environment and returns one function that flushes both:
package mainimport ( "context" "errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/propagation" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace")func setupOTel(ctx context.Context) (func(context.Context) error, error) { res, err := resource.New(ctx, resource.WithFromEnv(), resource.WithTelemetrySDK(), resource.WithHost()) if err != nil { return nil, err } traceExp, err := otlptracehttp.New(ctx) if err != nil { return nil, err } logExp, err := otlploghttp.New(ctx) if err != nil { return nil, err } tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(traceExp), sdktrace.WithResource(res)) lp := sdklog.NewLoggerProvider(sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)), sdklog.WithResource(res)) otel.SetTracerProvider(tp) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{}, propagation.Baggage{})) global.SetLoggerProvider(lp) return func(ctx context.Context) error { return errors.Join(tp.Shutdown(ctx), lp.Shutdown(ctx)) }, nil}Use it#
shutdown, err := setupOTel(ctx)if err != nil { log.Fatal(err)}defer shutdown(context.Background()) // flushes on exitlogger := otelslog.NewLogger("my-service")client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}srv := &http.Server{Addr: ":8080", Handler: otelhttp.NewHandler(mux, "server")}// Inside a handler: pass r.Context() so the log line carries the request's span.logger.WarnContext(r.Context(), "payment declined", "order", orderID)otelhttp.NewHandlercreates a server span per request and continues the caller's trace fromtraceparent.otelhttp.NewTransportcreates a client span per outgoing request and sendstraceparent, so the next service joins the same trace.otelslogturnslog/slogcalls into OpenTelemetry log records. Use the…Contextmethods (InfoContext,WarnContext,ErrorContext) with the request's context: that is how the record learns the trace id and span id. A plainlogger.Warn(...)is still sent, without them.
Shutdown#
Call the returned function before the process exits — the batch processors hold up to a few seconds of data. Handle SIGTERM (as signal.NotifyContext does) so a container stop or a rolling deploy flushes instead of dropping the last batch.
Other libraries#
The same pattern covers gRPC (otelgrpc), database drivers and frameworks with their own contrib packages under go.opentelemetry.io/contrib/instrumentation/…. Only net/http and log/slog are part of what Belvador runs.
With Sentry's Go SDK#
Keep sentry-go for errors with tracing off, so OpenTelemetry is the only tracing owner. sentryotel.NewOtelIntegration() puts span ids on errors captured with the request's context.Context — see With Sentry SDKs; that link is not marked tested yet.
Spotted something wrong or missing? Tell us: hello@belvador.dev