net/http OpenTelemetry instrumentation
Instrument a standard-library net/http service with OpenTelemetry. The otelhttp handler wrapper turns every inbound request into a span, and otelhttp.NewTransport propagates trace context to the services you call.
Prerequisites
Section titled “Prerequisites”- Go 1.22 or newer.
- A Go module (
go mod init) for your service. - An Osuite ingest endpoint and token for your region.
Instrument the service
Section titled “Instrument the service”-
Install the packages.
Terminal window go get go.opentelemetry.io/otelgo get go.opentelemetry.io/otel/sdkgo get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpcgo get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp -
Create
otel.gonext to yourmainpackage. It initialises theTracerProvider, the propagator, and the service resource, and returns a shutdown function.package mainimport ("context""go.opentelemetry.io/otel""go.opentelemetry.io/otel/attribute""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.37.0")func setupOTel(ctx context.Context, serviceName, environment string) (func(context.Context) error, error) {res, err := resource.New(ctx,resource.WithAttributes(semconv.ServiceName(serviceName),attribute.String("service.environment", environment),),)if err != nil {return nil, err}exporter, err := otlptracegrpc.New(ctx)if err != nil {return nil, err}tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter),sdktrace.WithResource(res),)otel.SetTracerProvider(tp)otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(propagation.TraceContext{},propagation.Baggage{},))return tp.Shutdown, nil} -
Wrap your handler in
main. CallsetupOTelfirst and defer the shutdown so buffered spans flush on exit.package mainimport ("context""log""net/http""go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp")func main() {ctx := context.Background()shutdown, err := setupOTel(ctx, "checkout-service", "production")if err != nil {log.Fatalf("otel setup: %v", err)}defer func() {if err := shutdown(context.Background()); err != nil {log.Printf("otel shutdown: %v", err)}}()mux := http.NewServeMux()mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {_, _ = w.Write([]byte("hello"))})handler := otelhttp.NewHandler(mux, "http.server")log.Println("listening on :8080")if err := http.ListenAndServe(":8080", handler); err != nil {log.Fatal(err)}}
Configure and run
Section titled “Configure and run”Set the ingest environment variables for your deployment target, then start the service. Every tab is a complete path.
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.<region>.osuite.io:443"export OTEL_EXPORTER_OTLP_HEADERS="x-osuite-ingest-token=<your-ingest-token>"go run .docker run \ -e OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.<region>.osuite.io:443" \ -e OTEL_EXPORTER_OTLP_HEADERS="x-osuite-ingest-token=<your-ingest-token>" \ checkout-serviceCreate a secret with the ingest header, then reference it from the pod spec.
kubectl create secret generic osuite-ingest \ --from-literal=otlp-headers="x-osuite-ingest-token=<your-ingest-token>"env: - name: OTEL_EXPORTER_OTLP_ENDPOINT value: "https://ingest.<region>.osuite.io:443" - name: OTEL_EXPORTER_OTLP_HEADERS valueFrom: secretKeyRef: name: osuite-ingest key: otlp-headersFramework notes
Section titled “Framework notes”Name spans per route. The second argument to NewHandler is a single operation name for the whole wrapped handler. ServeMux does not expose the matched pattern to otelhttp, so add a span-name formatter if you want the method and path on each span.
handler := otelhttp.NewHandler(mux, "http.server", otelhttp.WithSpanNameFormatter(func(operation string, r *http.Request) string { return r.Method + " " + r.URL.Path }),)Instrument outbound calls. Server spans only cover the inbound request. To continue the trace into services you call, wrap the client transport and pass the incoming request context into every outbound request.
client := &http.Client{ Transport: otelhttp.NewTransport(http.DefaultTransport),}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://payments/charge", nil)if err != nil { return err}resp, err := client.Do(req)Passing r.Context() is what links the outbound span to the request. Starting downstream work from context.Background() silently breaks the trace.
Verify in Osuite
Section titled “Verify in Osuite”What you should see
Send a request (curl localhost:8080/hello). The service appears in APM within a minute, and the request shows as a span in the trace explorer with its HTTP method, route, and status code.
Troubleshooting
Section titled “Troubleshooting”- Spans all share one operation name. You did not add a span-name formatter, or you are formatting from the raw URL. Format from the route template.
- Downstream service is missing from the trace. The outbound client is not wrapped with
otelhttp.NewTransport, or the request was built with a background context instead ofr.Context().
No data in Osuite after a few minutes? Work through these checks.
- Endpoint — confirm the exporter targets exactly
ingest.<region>.osuite.io:443for your region, over TLS. - Token — confirm the ingest header carries a valid
<your-ingest-token>and has not been rotated. - Pipeline — confirm the signal you expect (traces, logs, or metrics) is wired into an active pipeline with the
otlp/osuiteexporter attached. - Export errors — check the application or Collector logs for OTLP export failures (auth, DNS, TLS, connection refused).
- Network — confirm the host has outbound access to the ingest endpoint on port
443. - Timing — allow up to a minute for the first data to appear before assuming a failure.
Still stuck? Ask the Investigation Agent or contact support.