Skip to main content

Go

This guide walks you through setting up OpenTelemetry in your Go application and exporting logs to CtrlB. Unlike some languages, Go has no zero-code auto-instrumentation agent — you initialize the SDK explicitly in code and wrap your HTTP handlers, gRPC servers, and database clients using contrib middleware packages from the opentelemetry-go-contrib repository.

OpenTelemetry SDK status (mid-2026): Go log instrumentation is Beta. See the OpenTelemetry language status table.

Signal stability in Go

SignalStatusNotes
LogsBetaThe otel/log API, sdk/log, and bridges such as otelslog work but may still change between minor releases.

The logs signal is production-usable — plenty of teams run it — but pin your module versions and read the changelog when you upgrade, because the API surface is not yet frozen.


Prerequisites


Step 1. Install Dependencies

You can install OpenTelemetry in two ways — pull in all common contrib packages to get started quickly, or cherry-pick only what your stack needs for production.

Run the following go get commands to add the core SDK, OTLP HTTP exporter, propagators, and the most widely used contrib instrumentations:

go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk/trace \
go.opentelemetry.io/otel/sdk/resource \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/propagation \
go.opentelemetry.io/otel/sdk/log \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
go.opentelemetry.io/contrib/bridges/otelslog \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc

Then run go mod tidy to align dependency versions.

If your application only uses certain frameworks or transports, install only what you need:

# Core SDK and exporter (always required)
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk/trace \
go.opentelemetry.io/otel/sdk/resource \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/propagation

# Logs SDK, OTLP log exporter, and the slog bridge
go get go.opentelemetry.io/otel/sdk/log \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
go.opentelemetry.io/contrib/bridges/otelslog

# net/http server and client instrumentation
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp

# Gin web framework
go get go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin

# Echo web framework
go get go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho

# gRPC
go get go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc

To browse the full list of available Go instrumentation packages, see the opentelemetry-go-contrib instrumentation directory and the OpenTelemetry Registry for Go.


Step 2. Configure OpenTelemetry Instrumentation

Go requires explicit SDK initialization. Sections A–D set up request-scoped tracing so that every log record can be correlated with the request that produced it — the HTTP, gRPC, and framework middleware attach the active trace_id/span_id to the context your logs are written from — and section E wires up the OTLP logs pipeline itself. The recommended pattern is to set up the providers once at startup, register them globally, and defer a graceful shutdown.

Sections A–D are optional: include them when you want logs tied to traces (the common case); if you only need raw log records, skip straight to section E. In Go, each signal is exported only if you initialize its provider in code — there is no env var that turns a manually constructed provider on or off (see Step 3).

Create an otel.go (or equivalent) file in your application:

package main

import (
"context"
"errors"
"fmt"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"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.26.0"
)

// initTracer initializes a TracerProvider that exports spans via OTLP HTTP.
// It reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS from
// the environment, so no endpoint or auth is hard-coded here.
func initTracer(ctx context.Context) (func(context.Context) error, error) {
exporter, err := otlptracehttp.New(ctx)
if err != nil {
return nil, fmt.Errorf("creating OTLP trace exporter: %w", err)
}

res, err := resource.New(ctx,
resource.WithFromEnv(), // picks up OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
resource.WithTelemetrySDK(),
resource.WithProcess(),
resource.WithOS(),
resource.WithHost(),
resource.WithSchemaURL(semconv.SchemaURL),
)
if err != nil && !errors.Is(err, resource.ErrPartialResource) {
return nil, fmt.Errorf("creating resource: %w", 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
}

Call initTracer from your main function and defer the returned shutdown. Use a non-canceled context for shutdown so the exporter can flush (for example context.Background() rather than a signal-canceled context):

import (
"context"
"log"
)

func main() {
ctx := context.Background()

shutdown, err := initTracer(ctx)
if err != nil {
log.Fatalf("failed to initialize tracer: %v", err)
}
defer func() {
shutdownCtx := context.Background()
if err := shutdown(shutdownCtx); err != nil {
log.Printf("error shutting down tracer: %v", err)
}
}()

// ... start your server
}

Skip this section for a pure logs-only deployment. The TracerProvider here is built in code, so it exports traces whenever initTracer runs — regardless of OTEL_TRACES_EXPORTER. Only wire it in when you actually want traces, and give the trace exporter an endpoint (see Step 3).


B. Instrument net/http servers and clients

These middleware packages create a span per request and place it on the request context.Context. That is what lets the logs bridge in section E stamp each record with the active trace_id/span_id, so wrapping your handlers is what makes log-to-request correlation work.

Wrap your http.Handler with otelhttp.NewHandler and wrap outgoing client transports with otelhttp.NewTransport:

import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"

// Server — wrap the entire mux or individual handlers
http.Handle("/api/v1/items", otelhttp.NewHandler(itemsHandler, "items"))

// Or wrap the default mux
http.ListenAndServe(":8080", otelhttp.NewHandler(http.DefaultServeMux, "server"))

// Client — wrap the transport so outgoing requests carry trace context
client := &http.Client{
Transport: otelhttp.NewTransport(http.DefaultTransport),
}

C. Instrument gRPC servers and clients (optional)

Use the otelgrpc stats handlers to instrument gRPC without modifying individual RPCs:

import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"

// Server
grpcServer := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)

// Client
conn, err := grpc.NewClient(target,
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
)

D. Instrument Gin or Echo (optional)

For Gin, add the middleware once after creating the engine:

import (
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)

r := gin.Default()
r.Use(otelgin.Middleware("<service_name>"))

For Echo:

import (
"github.com/labstack/echo/v4"
"go.opentelemetry.io/contrib/instrumentation/github.com/labstack/echo/otelecho"
)

e := echo.New()
e.Use(otelecho.Middleware("<service_name>"))

Chi users: There is no official OpenTelemetry contrib package for Chi. A community package otelchi is available and follows the same middleware pattern.


E. Emit logs via the OTel logs pipeline

The logs signal in Go is designed as a bridge: you keep logging with a normal Go logging API — ideally log/slog from the standard library — and the otelslog bridge forwards records to the OpenTelemetry logs pipeline, which exports them over OTLP to CtrlB. The payoff is that every log written with a request context automatically carries the active trace_id and span_id, so you can pivot from a slow trace to its exact log lines.

Initialize a LoggerProvider once at startup, register it globally, and defer a graceful shutdown. The exporter reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS from the environment (see Step 3), so nothing is hard-coded here:

package main

import (
"context"
"fmt"

"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
"go.opentelemetry.io/otel/log/global"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)

func initLoggerProvider(ctx context.Context) (*sdklog.LoggerProvider, error) {
exporter, err := otlploghttp.New(ctx)
if err != nil {
return nil, fmt.Errorf("create log exporter: %w", err)
}

res, err := resource.New(ctx,
resource.WithFromEnv(), // picks up OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
resource.WithSchemaURL(semconv.SchemaURL),
)
if err != nil {
return nil, fmt.Errorf("create resource: %w", err)
}

lp := sdklog.NewLoggerProvider(
sdklog.WithResource(res),
sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)),
)
global.SetLoggerProvider(lp)
return lp, nil
}

Call it from main, defer the shutdown, and log through an *slog.Logger backed by the OTel pipeline:

import (
"context"
"log"

"go.opentelemetry.io/contrib/bridges/otelslog"
)

func main() {
ctx := context.Background()

lp, err := initLoggerProvider(ctx)
if err != nil {
log.Fatal(err)
}
// Flush buffered records before exit — skipping this drops the last batch.
defer lp.Shutdown(context.Background())

// A drop-in *slog.Logger backed by the OTel pipeline.
logger := otelslog.NewLogger("<service_name>")

// Pass the request context so trace/span IDs are attached automatically.
logger.InfoContext(ctx, "order processed",
"order_id", "ord_8412",
"amount_cents", 4599,
)
logger.ErrorContext(ctx, "payment declined", "reason", "insufficient_funds")
}

Always use the ...Context variants (InfoContext, ErrorContext) inside request handlers — that is how the bridge correlates a log record with the span active in that context. Because the signal is beta, pin go.opentelemetry.io/otel/sdk/log and go.opentelemetry.io/contrib/bridges/otelslog in go.mod and check the changelog before bumping versions. If you prefer to defer adoption, a pragmatic interim setup is: traces and metrics via the SDK, plus JSON logs to stdout collected by an OpenTelemetry Collector or FluentBit and forwarded to CtrlB.


Step 3. Configure Exporter for CtrlB

Once your application is instrumented, configure the OTLP exporter to send telemetry data directly to CtrlB.

Set the following environment variables before starting your app:

OTEL_EXPORTER=otlp \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_SERVICE_NAME=<service_name> \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>" \
go run .

<service_name> is the name of your service as it should appear in CtrlB.
Replace go run . with your usual start command (for example go run ./cmd/server or the path to your built binary).
OTEL requires headers to be in comma-separated key=value format as shown in the command above.
OTEL_SERVICE_NAME is also read automatically by resource.WithFromEnv() in the SDK initialization in Step 2 — no code change is needed when deploying to different environments.

Go exports a signal only if you initialize its provider

The Go SDK does not honor OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER for providers you build in code — a signal is exported only if you construct and register its provider. The command above is genuinely logs only when your main() calls initLoggerProvider (section E) and does not call initTracer (section A).

If you do initialize tracing for correlation, you are also exporting traces — switch to the shared-endpoint block below so the trace exporter has a destination. Otherwise otlptracehttp falls back to its default (localhost:4318) and floods your process with export errors while you believe traces are suppressed.

Instrument all telemetry together

The block above sends logs only. To export traces, metrics, and logs from the same application, use the shared OTLP endpoint and headers instead of the signal-specific endpoint above:

OTEL_EXPORTER_OTLP_ENDPOINT=https://<INGESTION_HOST>/api/default \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=https://<INGESTION_HOST>/api/default/<STREAM_NAME>/_otel/v1/logs \
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <API_TOKEN>,stream-name=<STREAM_NAME>"

Step 4. Use OpenTelemetry Collector (Optional)

For production setups, it's recommended to send logs through an OpenTelemetry Collector before CtrlB. This enables buffering, batching, retries, and multi-sink routing.

See OpenTelemetry Collector for Logs for configuration details.


Troubleshooting

go: inconsistent vendoring or version conflicts Run go mod tidy after adding any new go.opentelemetry.io packages. All go.opentelemetry.io/otel/* modules must use compatible versions — mixing minor versions (e.g. v1.27 and v1.24) causes build errors.

resource.WithFromEnv() not picking up OTEL_SERVICE_NAME Verify the environment variable is exported in the shell or process environment before the binary starts. In Docker or Kubernetes, confirm the variable is set in the container's env block, not just in the host shell.