OpenTelemetry for Go: Traces, Metrics & Logs with OpenObserve
Instrument Go apps with the OpenTelemetry SDK — traces, metrics, and logs via OTLP to OpenObserve. Complete setup, code examples, and troubleshooting.
Go is a first-class citizen in the OpenTelemetry project: the tracing and metrics SDKs are stable, the instrumentation ecosystem covers the standard library and most popular packages, and the explicit, code-first setup style fits how Go programs are usually built. This guide walks through instrumenting a Go service for traces, metrics, and logs, and shipping all three to OpenObserve over OTLP.
If you are new to OpenTelemetry itself — the signals, the collector, OTLP — start with What is OpenTelemetry? A Complete Guide and come back here for the Go specifics.
Why OpenTelemetry for Go
Go services tend to sit in the hot path — API gateways, backends, workers — where you need request-level visibility without a heavyweight agent. OpenTelemetry gives you:
- Vendor-neutral instrumentation. Instrument once, export anywhere OTLP is accepted. No proprietary SDK lock-in.
- A stable, idiomatic API. Context propagation rides on
context.Context, which Go code already threads everywhere. - A mature library ecosystem.
otelhttp,otelgrpc,otelsql, and dozens of other instrumentation libraries in theopentelemetry-go-contribrepository wrap common dependencies with a line or two of code.
Signal stability in Go
Be precise about what “supported” means. As of mid-2026, the status of the Go implementation is:
| Signal | Status | Notes |
|---|---|---|
| Traces | Stable | API and SDK stable since 2021; safe for production |
| Metrics | Stable | API and SDK stable; OTLP metric exporters are GA |
| Logs | Beta | otel/log API, sdk/log, and bridges such as otelslog work but may see breaking changes between minor releases |
Traces and metrics are covered by the project’s stability guarantees. The logs signal is usable in production — many teams run it — but pin your versions and read the changelog when you upgrade, because the API surface is not yet frozen.
Prerequisites
- Go 1.25 or later. The OpenTelemetry Go SDK supports the two most recent Go releases; older toolchains fall out of support quickly.
- An OpenObserve instance. Either an OpenObserve Cloud account or a self-hosted instance. For local testing, a single container is enough:
docker run -d --name openobserve \
-p 5080:5080 -p 5081:5081 \
-e ZO_ROOT_USER_EMAIL="root@example.com" \
-e ZO_ROOT_USER_PASSWORD="Complexpass#123" \
public.ecr.aws/zinclabs/openobserve:latest
- Your OpenObserve credentials. OTLP ingestion uses HTTP Basic auth. Generate the token once:
echo -n 'root@example.com:Complexpass#123' | base64
This prints cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM= — the value you will pass in the Authorization: Basic header.
In OpenObserve Cloud, the ingestion page under Data Sources shows your organization-specific endpoint and a ready-made Basic auth token, so you rarely need to construct it by hand.
Zero-code instrumentation: the short answer for Go
Go has no runtime agent. A Go program is a statically compiled binary — there is no JVM-style bytecode manipulation or Python-style monkey patching to hook into. If you are coming from Java or .NET, this is the biggest mental shift: in Go, you wire up the SDK in main().
Two things exist that are adjacent to zero-code, and it is worth knowing their real status:
- OpenTelemetry eBPF Instrumentation (OBI) — the successor to the earlier
opentelemetry-go-instrumentationeBPF project — can capture HTTP and gRPC spans from unmodified binaries at the kernel level. It is Linux-only (kernel 5.8+, amd64/arm64) and still pre-1.0, with breaking changes expected between minor releases. Useful for coarse visibility into services you cannot modify; not a replacement for SDK instrumentation. - Instrumentation libraries such as
otelhttpare as close to “automatic” as idiomatic Go gets: wrap your handler or client once and every request gets a span, without touching handler logic.
The rest of this guide covers the recommended, production-proven path: explicit SDK setup plus instrumentation libraries. It works with any framework — Gin, Echo, Chi, Fiber — because they all sit on net/http or expose middleware hooks, but the examples below use the plain standard library so nothing is framework-specific.
Manual instrumentation
Install the core modules:
go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp \
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp \
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp \
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp \
go.opentelemetry.io/contrib/bridges/otelslog
Traces
The pattern is always the same: create an exporter, attach it to a TracerProvider with a batch span processor and a Resource, register the provider globally, and shut it down cleanly on exit. Here is a complete, runnable HTTP service:
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"time"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"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.37.0"
)
var tracer = otel.Tracer("checkout-service")
func initTracerProvider(ctx context.Context) (*sdktrace.TracerProvider, error) {
// Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
// from the environment — see the OpenObserve section below.
exporter, err := otlptracehttp.New(ctx)
if err != nil {
return nil, fmt.Errorf("create trace exporter: %w", err)
}
// Merge with resource.Default() so OTEL_SERVICE_NAME and
// OTEL_RESOURCE_ATTRIBUTES are honored. service.name and
// deployment.environment come from the environment; service.version
// is fixed in code here.
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceVersion("1.4.2"),
),
)
if err != nil {
return nil, fmt.Errorf("create resource: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
// W3C Trace Context + Baggage propagation for distributed traces.
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return tp, nil
}
func checkoutHandler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // otelhttp already started a server span here
// Child span for a unit of work inside the request.
ctx, span := tracer.Start(ctx, "process-payment")
defer span.End()
span.SetAttributes(
attribute.String("payment.provider", "stripe"),
attribute.Int("cart.items", 3),
)
time.Sleep(40 * time.Millisecond) // simulate work
fmt.Fprintln(w, "order confirmed")
_ = ctx
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
tp, err := initTracerProvider(ctx)
if err != nil {
log.Fatal(err)
}
// Flush remaining spans before exit — skipping this drops the last batch.
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := tp.Shutdown(shutdownCtx); err != nil {
log.Printf("tracer shutdown: %v", err)
}
}()
// otelhttp wraps the handler: one server span per request, automatic
// http.* semantic-convention attributes, and context extraction from
// incoming traceparent headers.
handler := otelhttp.NewHandler(http.HandlerFunc(checkoutHandler), "POST /checkout")
http.Handle("/checkout", handler)
srv := &http.Server{Addr: ":8080"}
go func() { log.Println(srv.ListenAndServe()) }()
<-ctx.Done()
srv.Shutdown(context.Background())
}
Two details matter more than anything else in this file:
- Context flows through
context.Context.tracer.Start(ctx, ...)parents the new span to whatever span lives inctx. If you drop the context — or start goroutines withcontext.Background()— you break the trace. tp.Shutdownis not optional. The batch processor holds spans in memory; a process that exits without shutting down the provider silently loses its final batch.
For outgoing calls, wrap your HTTP client the same way so the traceparent header propagates downstream:
client := http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "http://inventory:8081/stock", nil)
resp, err := client.Do(req)
For a deeper walk-through of tracing a multi-service Go system — including gRPC propagation — see Distributed Tracing in Go with OpenTelemetry.
Metrics
Metrics use the same provider pattern, with a periodic reader instead of a batch processor. The reader collects and exports on an interval (60 s by default; 15–30 s is a common production choice):
package main
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
)
func initMeterProvider(ctx context.Context) (*sdkmetric.MeterProvider, error) {
exporter, err := otlpmetrichttp.New(ctx)
if err != nil {
return nil, fmt.Errorf("create metric exporter: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceVersion("1.4.2"),
),
)
if err != nil {
return nil, fmt.Errorf("create resource: %w", err)
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(
exporter,
sdkmetric.WithInterval(15*time.Second),
)),
)
otel.SetMeterProvider(mp)
return mp, nil
}
func main() {
ctx := context.Background()
mp, err := initMeterProvider(ctx)
if err != nil {
panic(err)
}
defer mp.Shutdown(context.Background())
meter := otel.Meter("checkout-service")
orders, _ := meter.Int64Counter("orders.processed",
metric.WithDescription("Number of orders processed"),
metric.WithUnit("{order}"),
)
latency, _ := meter.Float64Histogram("payment.duration",
metric.WithDescription("Payment processing duration"),
metric.WithUnit("ms"),
)
// Record from anywhere in your request path:
start := time.Now()
// ... process a payment ...
orders.Add(ctx, 1, metric.WithAttributes(attribute.String("payment.provider", "stripe")))
latency.Record(ctx, float64(time.Since(start).Milliseconds()))
time.Sleep(20 * time.Second) // let one export cycle run in this demo
}
Instruments are cheap to create but should be created once (package-level or in a struct), not per request. If you use otelhttp from the traces section, you also get standard HTTP server metrics — request duration and counts — without writing any of this by hand: it records them through the global MeterProvider automatically once one is registered.
Logs
The logs signal in Go is beta. It 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. 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 in OpenObserve.
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.37.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.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceVersion("1.4.2"),
),
)
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
}
func main() {
ctx := context.Background()
lp, err := initLoggerProvider(ctx)
if err != nil {
panic(err)
}
defer lp.Shutdown(context.Background())
// A drop-in *slog.Logger backed by the OTel pipeline.
logger := otelslog.NewLogger("checkout-service")
// 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 OpenObserve.
Sending data to OpenObserve
OpenObserve ingests OTLP natively — no collector required in between, although running one is a fine architecture too. Two transports are supported:
- OTLP/HTTP at
<host>/api/<org>/v1/traces,/v1/metrics, and/v1/logs(port5080self-hosted). This is what theotlptracehttp/otlpmetrichttp/otlploghttpexporters above speak. - OTLP/gRPC on port
5081(self-hosted), which additionally requires anorganizationheader alongsideAuthorization.
Stick with HTTP unless you have a reason not to; it needs the least configuration.
The exporters in the code above take no hardcoded endpoint on purpose — configuration comes from standard environment variables, so the same binary runs against local, staging, and cloud backends.
OpenObserve Cloud:
export OTEL_EXPORTER_OTLP_ENDPOINT="https://api.openobserve.ai/api/<your-org-name>"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20<your-base64-token>"
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production"
go run .
Self-hosted OpenObserve:
export OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:5080/api/default"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic%20cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM="
export OTEL_SERVICE_NAME="checkout-service"
export OTEL_RESOURCE_ATTRIBUTES="deployment.environment=dev"
go run .
Three rules that prevent 90% of setup failures:
- No trailing slash on the endpoint. The exporter appends
/v1/traces(or/v1/metrics,/v1/logs) itself..../api/default/produces.../api/default//v1/tracesand a 404. - Percent-encode the space in the header value.
OTEL_EXPORTER_OTLP_HEADERSuses akey=value,key=valueformat in which the value must be URL-encoded — henceBasic%20<token>, notBasic <token>. - The organization is part of the URL path.
/api/defaulttargets thedefaultorganization; replace it with your org name in OpenObserve Cloud (visible on the ingestion/Data Sources page, which also shows the exact endpoint and token to copy).
If you would rather configure in code than via the environment — common for CLIs or when headers come from a secret manager — every exporter accepts options:
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL("http://localhost:5080/api/default/v1/traces"),
otlptracehttp.WithHeaders(map[string]string{
"Authorization": "Basic cm9vdEBleGFtcGxlLmNvbTpDb21wbGV4cGFzcyMxMjM=",
}),
)
Note that WithEndpointURL takes the full signal path, whereas the environment variable takes the base URL. Environment variables win in fewer surprises; prefer them. For a broader tour of exporter configuration, see A Beginner’s Guide to OTLP Exporters.
Verifying data arrival in OpenObserve
Run the service, send a few requests (curl http://localhost:8080/checkout), then open the OpenObserve UI (http://localhost:5080 self-hosted, or your cloud URL):
- Streams — you should see a
defaulttraces stream plus streams for your metrics and logs. If a stream exists, ingestion is working end to end. - Traces — pick your service (
checkout-service) in the Traces page, and open a trace. You should see thePOST /checkoutserver span fromotelhttpwith yourprocess-paymentchild span nested under it, along with the attributes you set. - Logs — query the log stream and confirm records include
trace_idandspan_idfields; that confirms theotelslogbridge is correlating correctly. - Metrics — your
orders.processedandpayment.durationinstruments appear as metric streams you can chart or attach to dashboards and alerts.
If nothing appears within ~30 seconds, jump to Troubleshooting below — and check your process’s stderr first, because the Go SDK reports export failures there.
Production tips
-
Keep the batch processor (never
SimpleSpanProcessorin production).WithBatcherbatches and compresses exports off the hot path. The defaults — 5 s schedule delay, 512-span batches, 2048-span queue — are sensible; tuneOTEL_BSP_*variables only if you see queue-full drops. -
Sample at the head, keep parents authoritative. For high-traffic services, ratio sampling with parent-based override is the standard starting point. Unlike some other language SDKs, the Go SDK does not read
OTEL_TRACES_SAMPLER/OTEL_TRACES_SAMPLER_ARG— configure the sampler in code when you build theTracerProvider:tp := sdktrace.NewTracerProvider( sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(0.1))), sdktrace.WithBatcher(exporter), sdktrace.WithResource(res), )ParentBased(...)matters: it ensures a downstream Go service honors the sampling decision already made upstream, so you get complete traces instead of fragments. Tune the ratio (0.1keeps 10% of root traces) to match your traffic. -
Invest in resource attributes.
service.name,service.version, anddeployment.environmentare the dimensions you will filter by in every OpenObserve query. Because the resource is built withresource.Merge(resource.Default(), ...),service.nameanddeployment.environmentare picked up fromOTEL_SERVICE_NAME/OTEL_RESOURCE_ATTRIBUTES(so operations can override them per environment without a rebuild), whileservice.versionis set in code. -
Propagate context into goroutines deliberately.
go func() { ... }()with a freshcontext.Background()orphans any spans started inside. Capture the request context (ortrace.SpanContext) explicitly when fanning out work, and remember the request context is canceled when the handler returns — derive withcontext.WithoutCancel(ctx)(Go 1.21+) for fire-and-forget work that should still be trace-linked. -
Shut down every provider on exit. Tracer, meter, and logger providers each buffer data. Wire all three
Shutdowncalls into your signal-handling path with a timeout, as in the trace example. -
Record errors on spans, not just in logs.
span.RecordError(err)plusspan.SetStatus(codes.Error, err.Error())is what makes failed requests filterable in the Traces UI. -
Pin and upgrade
otelmodules together. The API, SDK, exporters, and contrib modules are versioned in lockstep; mixing releases is the most common cause of confusing compile errors after an upgrade.go get go.opentelemetry.io/otel@latestalone is not enough — update the exporters and contrib packages in the same commit.
Troubleshooting
Symptom: exporter logs 404 Not Found and no data arrives.
Fix: almost always a URL problem. Remove any trailing slash from OTEL_EXPORTER_OTLP_ENDPOINT, and confirm the path includes your organization (/api/default, not just the host). The final URL the exporter calls should look like http://localhost:5080/api/default/v1/traces.
Symptom: 401 Unauthorized on every export.
Fix: regenerate the token with echo -n 'email:password' | base64 (the -n matters — a trailing newline corrupts the token), and make sure the header value in OTEL_EXPORTER_OTLP_HEADERS is Authorization=Basic%20<token> with the space percent-encoded. If you set headers in code via WithHeaders, use a literal space there instead — the encoding rule applies only to the environment variable.
Symptom: traces appear but each service shows disconnected fragments instead of one distributed trace.
Fix: context propagation is broken. Confirm otel.SetTextMapPropagator is called with propagation.TraceContext{} in every service, outgoing clients use otelhttp.NewTransport, and requests are created with http.NewRequestWithContext(ctx, ...) — a request built without the context sends no traceparent header.
Symptom: the service exits cleanly but the last few spans/logs never arrive.
Fix: the batch processors were never flushed. Call Shutdown (or ForceFlush) on the tracer, meter, and logger providers before exit, with a context timeout so a dead backend cannot hang your shutdown. This bites short-lived jobs and CLIs hardest — for those, consider ForceFlush after each unit of work.
Symptom: spans created inside goroutines are missing or parented to the wrong trace.
Fix: the goroutine started from context.Background(). Pass the request-scoped ctx into the goroutine; if the work outlives the request, wrap it with context.WithoutCancel(ctx) so cancellation doesn’t kill the span mid-flight.
Symptom: compile errors like mismatched types across otel packages after go get -u.
Fix: partially upgraded modules. Update go.opentelemetry.io/otel, the sdk, all exporters/otlp/... modules, and go.opentelemetry.io/contrib/... packages together, then run go mod tidy. Check each module’s release notes — the log-signal packages in particular can change between minor versions while in beta.
With the SDK wired up once in main(), instrumentation libraries handling the boilerplate, and OTLP pointed at OpenObserve, a Go service gets correlated traces, metrics, and logs in a single backend — searchable side by side, with no vendor-specific code anywhere in your application.