Observability for Go with OpenTelemetry

Khimananda Oli 8 min read Programming and Languages
Observability for Go with OpenTelemetry

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.

Go ApplicationHTTP / gRPC HandlersDB / External CallsOpenTelemetry SDKTracerProviderMeterProviderLoggerProviderOTLP ExporterJaeger / Tempo(Traces)Prometheus(Metrics)Loki / ELK(Logs)
Observability for Go with OpenTelemetry architecture: signals flow from the application through the SDK to specialized backends via OTLP.

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.

POST /api/orders (otelhttp) — 245msprocessOrder (custom span) — 210msvalidateInventory — 120msdb.Query SELECT inventory — 95mscache.Get — 15msserializeResponse — 25msTrace ID: 4bf92f3577b34da6a3ce929d0e0e4736Parent-child relationships preserved via context propagation
Trace waterfall for observability for Go with OpenTelemetry: nested spans show exact time spent in HTTP handling, business logic, database queries, and serialization.

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 StrategyBest ForTrade-offsGo SDK Package
OTLP/gRPCProduction microservices, high throughputRequires gRPC-compatible ingress; lowest overheadotlptracegrpc
OTLP/HTTPServerless, edge, restricted networksHigher payload size; broader compatibilityotlptracehttp
Prometheus PullMetrics-only, existing Prometheus infraNo traces/logs; requires /metrics endpointprometheus exporter
Stdout/ConsoleLocal debugging onlyNever use in production; blocks stdoutstdouttrace

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.

Go Service Requestctx with trace_idspan createdStructured Logs{"msg":"processing""trace_id":"abc123""order_id":"ORD-99"}Metrics + Exemplarshttp.request.durationbucket{le="0.5"} = 42exemplar: trace_id=abc123Distributed TraceSpan: processOrdertrace_id: abc123duration: 245msShared Key:trace_idEnables click-throughfrom metrics → traces → logs
Correlation mechanism in observability for Go with OpenTelemetry: a single trace_id injected via context links structured logs, metric exemplars, and distributed trace spans.

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.

Frequently Asked Questions

OpenTelemetry Go SDK v1.35 requires Go 1.23 or later. Older versions lack necessary generics and context propagation improvements needed for reliable tracing. Always check the official compatibility matrix before upgrading production services to avoid runtime panics or missing span data during deployment cycles.

Use otelhttp.NewHandler wrapper around your mux or router. This automatically captures method, URL, status code, and latency without manual span creation. Ensure the handler receives a context with an active tracer provider, otherwise spans will be dropped silently during request processing in high-throughput environments.

Overhead is typically under two percent when using batch exporters and sampling. Synchronous exports or debug logging increase latency noticeably. Always use asynchronous batch processors and head-based sampling in production to maintain p99 performance targets while collecting sufficient observability data for debugging distributed systems effectively.

Use the official OTLP HTTP exporter pointing to the CloudWatch Logs endpoint. The legacy aws-xray exporter is deprecated as of 2026. Configure authentication via IRSA on EKS or instance profiles on EC2 to avoid embedding credentials in application configuration files or environment variables unnecessarily.

Yes, inject trace ID and span ID into your slog or zap logger context using the otel bridge package. This enables log-to-trace correlation in backends like Grafana Loki or Datadog. Without this linkage, debugging requires manual timestamp matching across separate telemetry signals which wastes engineering time.

Implement a custom Sampler interface that returns RecordAndSample only when span status is Error. Combine with ParentBasedSampler to preserve upstream sampling decisions. This reduces storage costs by ninety percent while retaining full visibility into failure paths critical for incident response and root cause analysis workflows.

Yes, all SDK components including TracerProvider and MeterProvider are safe for concurrent use. However, custom span processors and exporters must implement their own synchronization. Race conditions in user-defined processors cause silent data loss or crashes under load, so always test with go race detector enabled.

Set OTEL_METRICS_EXPORTER=none environment variable or configure a NoopMeterProvider at initialization. This disables metric recording overhead entirely without code changes. Useful during load testing or when backend ingestion limits are exceeded, allowing you to isolate performance issues from telemetry pipeline bottlenecks quickly.

Missing spans usually result from context not being propagated through goroutines, message queues, or gRPC metadata. Verify context.Context is passed explicitly and use otelpropagators for cross-service headers. Async operations require manual context extraction; automatic propagation only works within synchronous HTTP or gRPC call chains natively.

Run the otel-collector contrib image with debug exporter configured. Point your Go service to localhost:4318 and inspect console output for well-formed spans and attributes. This confirms instrumentation correctness before deploying to staging, preventing costly debugging sessions caused by misconfigured exporters or missing required resource attributes.

Leaks occur when BatchSpanProcessor shutdown is skipped or contexts accumulate unexported spans. Always defer Shutdown on providers and set reasonable queue sizes. Monitor goroutine count and heap allocations via pprof; sustained growth indicates processor backpressure or exporter failures requiring timeout tuning or circuit breaker implementation.

OpenTelemetry provides unified traces, metrics, and logs with vendor-neutral APIs, while Prometheus client-go focuses solely on pull-based metrics. For new projects in 2026, OTel is preferred for full-stack observability. Migrate existing Prometheus instrumentation gradually using the prometheus bridge to avoid dual-library maintenance burden.

Absolutely. Never record user emails, tokens, or IPs in attributes without hashing or masking. Use attribute transformers in your processor pipeline to scrub sensitive fields automatically. Compliance violations from leaked PII in trace backends are common audit findings; treat span data with same rigor as database records.

Yes, use otelgraphql middleware for gqlgen or graphql-go. It creates spans per resolver with field-level timing. Ensure context flows through dataloaders and resolvers; many implementations break propagation at async boundaries. Test thoroughly with nested queries to verify complete trace coverage across complex schema resolutions.

Default batch size of 512 spans with five-second timeout suits most workloads. High-throughput services may increase to 2048 with ten-second intervals to reduce network calls. Monitor exporter queue saturation metrics; frequent flushes indicate undersized batches while growing queues suggest backend pressure requiring backoff or sampling adjustments.