Skip to content

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.

  • Go 1.22 or newer.
  • A Go module (go mod init) for your service.
  • An Osuite ingest endpoint and token for your region.
  1. Install the packages.

    Terminal window
    go get go.opentelemetry.io/otel
    go get go.opentelemetry.io/otel/sdk
    go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc
    go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
  2. Create otel.go next to your main package. It initialises the TracerProvider, the propagator, and the service resource, and returns a shutdown function.

    package main
    import (
    "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
    }
  3. Wrap your handler in main. Call setupOTel first and defer the shutdown so buffered spans flush on exit.

    package main
    import (
    "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)
    }
    }

Set the ingest environment variables for your deployment target, then start the service. Every tab is a complete path.

Terminal window
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 .

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.

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.

  • 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 of r.Context().

No data in Osuite after a few minutes? Work through these checks.

  • Endpoint — confirm the exporter targets exactly ingest.<region>.osuite.io:443 for 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/osuite exporter 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.