Instrumenting a Go HTTP service with OpenTelemetry adds trace data to requests handled by net/http. It is useful when a Go API needs to expose request paths, status codes, latency, and service identity through an OpenTelemetry Collector before spans are sent to a vendor backend.
The otelhttp wrapper instruments the server handler, while the Go SDK owns the tracer provider, resource attributes, propagators, and OTLP exporter. The handler wrapper keeps request handling close to the standard library and sends traces over OTLP/gRPC to a Collector listener on port 4317.
The local smoke test uses the Collector debug exporter so the exported server span can be inspected before production credentials or backend endpoints are introduced. The sample service sets service.name and service.version, exports one span for /hello, and shuts down the tracer provider so queued spans flush cleanly.
Steps to instrument a Go HTTP service with OpenTelemetry:
- Add the OpenTelemetry API, SDK, OTLP/gRPC exporter, and otelhttp instrumentation modules.
$ go get go.opentelemetry.io/otel \ go.opentelemetry.io/otel/sdk \ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc \ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
otelhttp v0.69.0 requires Go 1.25 or newer. If go mod tidy reports a toolchain requirement, upgrade Go or pin a compatible OpenTelemetry module version before deploying.
- Create a local Collector configuration that receives OTLP/gRPC traces and prints them through the debug exporter.
- collector-config.yaml
receivers: otlp: protocols: grpc: endpoint: 0.0.0.0:4317 processors: batch: exporters: debug: verbosity: detailed service: pipelines: traces: receivers: [otlp] processors: [batch] exporters: [debug]
Use debug for local proof only because it writes service names, request paths, and span attributes to Collector logs.
Related: How to test OpenTelemetry Collector pipelines with the debug exporter
Tool: OpenTelemetry Collector Config Generator - Start the local Collector in a separate terminal.
$ docker run -d --rm --name otelcol-debug \ -p 4317:4317 \ -v "$PWD/collector-config.yaml:/etc/otelcol/config.yaml:ro" \ otel/opentelemetry-collector-contrib:latest \ --config=/etc/otelcol/config.yaml eb3ffec63822
Keep the Collector running while the Go service sends telemetry.
Related: How to run the OpenTelemetry Collector in Docker - Add the tracing bootstrap code to the Go service.
- otel.go
package main import ( "context" "os" "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.41.0" ) func setupTracing(ctx context.Context) (func(context.Context) error, error) { endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT") if endpoint == "" { endpoint = "localhost:4317" } exporter, err := otlptracegrpc.New(ctx, otlptracegrpc.WithEndpoint(endpoint), otlptracegrpc.WithInsecure(), ) if err != nil { return nil, err } res, err := resource.Merge( resource.Default(), resource.NewWithAttributes( semconv.SchemaURL, semconv.ServiceName("checkout-api"), semconv.ServiceVersion("1.0.0"), ), ) if err != nil { return nil, err } provider := sdktrace.NewTracerProvider( sdktrace.WithBatcher(exporter, sdktrace.WithBatchTimeout(time.Second)), sdktrace.WithResource(res), ) otel.SetTracerProvider(provider) otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( propagation.TraceContext{}, propagation.Baggage{}, )) return provider.Shutdown, nil }
WithInsecure() fits a local Collector without TLS. Use TLS credentials or a secured exporter configuration before sending telemetry to a shared Collector or backend.
- Wrap the net/http mux with otelhttp in the service entry point.
- main.go
package main import ( "context" "fmt" "log" "net/http" "os" "os/signal" "time" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() shutdown, err := setupTracing(ctx) if err != nil { log.Fatal(err) } defer func() { shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := shutdown(shutdownCtx); err != nil { log.Printf("trace shutdown failed: %v", err) } }() mux := http.NewServeMux() mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "hello from checkout-api") }) handler := otelhttp.NewHandler(mux, "checkout-api") server := &http.Server{ Addr: ":8080", Handler: handler, ReadHeaderTimeout: 5 * time.Second, } go func() { log.Println("listening on :8080") if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Printf("server stopped: %v", err) } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() _ = server.Shutdown(shutdownCtx) }
In an existing service, keep the real routes and wrap the outer handler with otelhttp.NewHandler() so every request entering that mux can produce a server span.
- Resolve the module graph after the code changes.
$ go mod tidy - Confirm the selected OpenTelemetry module versions.
$ go list -m go.opentelemetry.io/otel \ go.opentelemetry.io/otel/sdk \ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
- Build the service.
$ go build -o checkout .
- Start the service with the local Collector endpoint.
$ OTEL_EXPORTER_OTLP_ENDPOINT=localhost:4317 ./checkout 2026/06/18 06:22:20 listening on :8080
Run this terminal until the smoke request is complete. Press Ctrl+C after the Collector shows the span so the tracer provider can flush and shut down.
- Send one request from another terminal.
$ curl -sS http://localhost:8080/hello hello from checkout-api
- Check the Collector output for the Go server span.
$ docker logs otelcol-debug ##### snipped ##### ResourceSpans #0 Resource SchemaURL: https://opentelemetry.io/schemas/1.41.0 Resource attributes: -> service.name: Str(checkout-api) -> service.version: Str(1.0.0) -> telemetry.sdk.language: Str(go) -> telemetry.sdk.name: Str(opentelemetry) -> telemetry.sdk.version: Str(1.44.0) ScopeSpans #0 InstrumentationScope go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp 0.69.0 Span #0 Name : GET /hello Kind : Server Attributes: -> http.request.method: Str(GET) -> url.path: Str(/hello) -> http.response.status_code: Int(200)
The service is instrumented when the Collector sees the configured service.name and a server span for the tested HTTP route.
- Stop the debug Collector after the local proof is complete.
$ docker stop otelcol-debug otelcol-debug
Mohd Shakir Zakaria is a cloud architect with deep roots in software development and open-source advocacy. Certified in AWS, Red Hat, VMware, ITIL, and Linux, he specializes in designing and managing robust cloud and on-premises infrastructures.