
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Debugging latency spikes or silent failures in Go microservices is nearly impossible without correlated telemetry data. Implementing observability for Go with OpenTelemetry solves this by unifying traces, metrics, and logs into a single, vendor-neutral standard that works across AWS, Azure, and on-premise environments. This guide walks you through the exact SDK configuration, instrumentation patterns, and export strategies I use in production systems to ensure every request is traceable and every error is contextualized.
How do you configure the OpenTelemetry SDK in Go?
The foundation of observability for Go with OpenTelemetry is a correctly initialized SDK. A common mistake in development is skipping proper shutdown hooks, which causes trace data loss during deployments or restarts. In production Go services, I always wrap initialization in a reusable function that returns a shutdown closure. This ensures buffers flush before the process exits.
You need three core providers: TracerProvider for distributed traces, MeterProvider for metrics, and optionally LoggerProvider if you are adopting the newer OTel logging bridge. For most Go teams in 2026, I recommend starting with traces and metrics, then bridging existing structured logs via context propagation. See my guide on structured logging best practices for aligning log fields with trace IDs.
package telemetry
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func InitOTel(ctx context.Context, serviceName string) (func(context.Context) error, error) {
exporter, err := otlptracegrpc.New(ctx)
if err != nil {
return nil, fmt.Errorf("creating OTLP exporter: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceNameKey.String(serviceName),
semconv.DeploymentEnvironmentKey.String("production"),
),
)
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))),
)
otel.SetTracerProvider(tp)
return tp.Shutdown, nil
} This configuration uses batched exporting to reduce network overhead and parent-based sampling at 10%. In high-throughput Go services processing thousands of requests per second, sampling is mandatory. Without it, your egress costs and backend storage will scale linearly with traffic. Always set DeploymentEnvironment and ServiceName explicitly; auto-detection often fails in containerized environments like Kubernetes.
How do you instrument HTTP handlers and database calls in Go?
Manual span creation is tedious and error-prone. For observability for Go with OpenTelemetry, rely on official instrumentation libraries that follow semantic conventions. These libraries automatically capture HTTP method, status code, URL, and error attributes in a standardized format that backends like Jaeger and Grafana understand natively.
Instrumenting net/http and chi/gin routers
Wrap your router or handler with the appropriate middleware. The otelhttp package works with standard library net/http, while framework-specific packages exist for Chi, Gin, Echo, and Fiber. This middleware creates a root span for each inbound request and propagates the trace context downstream.
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/api/orders", handleOrders)
// Wrap the entire mux to auto-instrument all routes
wrappedHandler := otelhttp.NewHandler(mux, "orders-service")
server := &http.Server{
Addr: ":8080",
Handler: wrappedHandler,
}
log.Fatal(server.ListenAndServe())
} Adding custom spans for business logic
Middleware covers infrastructure boundaries, but internal processing steps require manual spans. Use the tracer from the global provider or inject it via dependency injection. Always pass the context through; breaking the context chain severs the parent-child relationship and fragments your trace.
func processOrder(ctx context.Context, orderID string) error {
ctx, span := otel.Tracer("orders").Start(ctx, "processOrder")
defer span.End()
span.SetAttributes(attribute.String("order.id", orderID))
if err := validateInventory(ctx, orderID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, "inventory validation failed")
return err
}
return nil
} A critical detail: call span.End() via defer immediately after starting the span. If you end it manually at the bottom of the function, early returns or panics will leave the span open, corrupting duration calculations. Record errors explicitly with RecordError; OTel does not automatically mark spans as failed just because an error was returned.
What are the best practices for exporting OpenTelemetry data from Go?
Choosing the right export protocol and backend determines whether your telemetry is actionable or just expensive noise. OTLP (OpenTelemetry Protocol) over gRPC is the default recommendation for Go services in 2026 due to its binary efficiency and streaming support. HTTP/protobuf is a fallback for environments where gRPC is blocked by proxies or firewalls.
| Export Strategy | Best For | Trade-offs | Go SDK Package |
|---|---|---|---|
| OTLP/gRPC | Production microservices, high throughput | Requires gRPC-compatible ingress; lowest overhead | otlptracegrpc |
| OTLP/HTTP | Serverless, edge, restricted networks | Higher payload size; broader compatibility | otlptracehttp |
| Prometheus Pull | Metrics-only, existing Prometheus infra | No traces/logs; requires /metrics endpoint | prometheus exporter |
| Stdout/Console | Local debugging only | Never use in production; blocks stdout | stdouttrace |
In Nepal-based deployments or hybrid setups where services span local data centers and cloud regions, I often deploy an OpenTelemetry Collector as a sidecar or gateway. The Go app exports locally to the collector via OTLP/gRPC on localhost, eliminating TLS overhead and DNS resolution delays. The collector then handles batching, retry logic, and fan-out to multiple backends. This decouples your application from backend availability and simplifies credential management.
Configure environment variables for exporter endpoints rather than hardcoding them. The Go SDK respects OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, and OTEL_TRACES_SAMPLER natively. This makes your binary portable across staging, production, and local development without recompilation. For teams managing multiple Go services, standardizing these variables via Helm charts or Kubernetes ConfigMaps reduces configuration drift significantly.
How do you correlate logs, metrics, and traces in Go applications?
Isolated signals are useless during incidents. True observability for Go with OpenTelemetry means clicking a slow trace span and instantly seeing related log lines and metric anomalies. Correlation happens through shared context attributes, primarily trace_id and span_id.
Use the otelzap, otelslog, or otellogrus bridges to inject trace context into every log entry automatically. Never manually extract and format trace IDs; this leads to inconsistencies and missed correlations when context isn't propagated correctly through goroutines or async workers.
import "go.opentelemetry.io/contrib/bridges/otelslog"
logger := otelslog.NewLogger("orders-service")
func handleOrder(ctx context.Context, w http.ResponseWriter, r *http.Request) {
logger.InfoContext(ctx, "processing order",
"order_id", r.PathValue("id"),
"user_id", userIDFromContext(ctx),
)
// Log output includes trace_id and span_id automatically
} For metrics, use attribute keys that match your trace attributes. If your traces tag service.name=orders and region=ap-south-1, your Prometheus queries should filter on those same labels. This alignment lets you build Grafana dashboards that link metric panels directly to exemplar traces. Exemplars are the glue between aggregated metrics and individual request traces; enable them in your MeterProvider configuration to embed trace IDs into histogram buckets.
A frequent pitfall in Go is losing context when spawning goroutines. Always pass the parent context explicitly to background work. If you use worker pools or message queue consumers, extract the trace context from message headers before creating a new span. The propagation.TraceContext{} propagator handles W3C Trace Context headers natively with otelhttp and otelgrpc. Without this, async operations appear as orphaned traces disconnected from the originating request.
Implementing Production-Ready Observability for Go with OpenTelemetry
Shipping reliable Go services requires treating telemetry as a first-class dependency, not an afterthought. Start by initializing the SDK with proper shutdown hooks and resource attributes. Instrument inbound traffic with otelhttp or framework middleware, add manual spans for business-critical paths, and bridge your existing logger to propagate trace context. Export via OTLP/gRPC to a collector for resilience, and enforce correlation through consistent attribute naming across all three signal types. If your team needs help designing an audit-ready observability stack or optimizing high-cardinality metrics in Go, reach out to discuss your architecture.