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.
$ 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.
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
$ 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
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.
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.
$ go mod tidy
$ 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
$ go build -o checkout .
$ 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.
$ curl -sS http://localhost:8080/hello hello from checkout-api
$ 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.
$ docker stop otelcol-debug otelcol-debug