Why OpenTelemetry for Go
OpenTelemetry is the standard observability SDK across languages, and the Go implementation is one of the most mature. It is the same OTLP wire format whether you instrument Go, Python, Node.js, Java, or .NET, which means your Go services, your Python workers, and your Java middleware all show up in one Service Map in Telemtra with consistent service names and trace context.
Compared to Go-specific solutions (custom net/http middleware shipping to a single vendor, or wrappers over database/sql that emit Prometheus only), OpenTelemetry gives you a single SDK that emits traces, metrics, and logs together, with auto-context propagation through goroutines, gRPC, and HTTP headers.
Install
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/instrumentation/google.golang.org/grpc/otelgrpc \
go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql Where this goes in your project
Go does not have an "agent" or launcher. Telemetry setup is a package you initialize from main(). Typical project layout:
my-service/
├── cmd/server/main.go # entry point — call telemetry.Setup() here
├── internal/telemetry/setup.go # NEW: the Setup function from the next section
├── go.mod
└── deploy/
└── env # TELEMTRA_API_KEY and OTEL_* vars 1. Save the setup as internal/telemetry/setup.go (the package telemetry, the function shown in the next section).
2. Call it from main() first, defer shutdown last:
package main
import (
"context"
"log"
"os"
"your-module/internal/telemetry"
)
func main() {
ctx := context.Background()
shutdown, err := telemetry.Setup(ctx, "checkout-api", os.Getenv("APP_ENV"))
if err != nil { log.Fatal(err) }
defer shutdown(ctx)
// ... your existing server startup here ...
} The defer shutdown(ctx) is critical: BatchSpanProcessor holds spans in memory until the batch fills or the timer fires, so without shutdown you will lose the last few seconds of telemetry on every restart.
3. Environment variables:
TELEMTRA_API_KEY=tk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
APP_ENV=production Bootstrap the SDK
package telemetry
import (
"context"
"os"
"time"
"go.opentelemetry.io/otel"
"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/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
)
func Setup(ctx context.Context, service, env string) (func(context.Context) error, error) {
headers := map[string]string{"x-api-key": os.Getenv("TELEMTRA_API_KEY")}
res, _ := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(service),
semconv.DeploymentEnvironmentName(env),
),
)
traceExp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL("https://ingest.telemtra.io"),
otlptracehttp.WithHeaders(headers),
)
if err != nil { return nil, err }
metricExp, err := otlpmetrichttp.New(ctx,
otlpmetrichttp.WithEndpointURL("https://ingest.telemtra.io"),
otlpmetrichttp.WithHeaders(headers),
)
if err != nil { return nil, err }
logExp, err := otlploghttp.New(ctx,
otlploghttp.WithEndpointURL("https://ingest.telemtra.io"),
otlploghttp.WithHeaders(headers),
)
if err != nil { return nil, err }
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
mp := metric.NewMeterProvider(
metric.WithReader(metric.NewPeriodicReader(metricExp, metric.WithInterval(10*time.Second))),
metric.WithResource(res),
)
lp := log.NewLoggerProvider(
log.WithProcessor(log.NewBatchProcessor(logExp)),
log.WithResource(res),
)
otel.SetTracerProvider(tp)
otel.SetMeterProvider(mp)
return func(ctx context.Context) error {
_ = lp.Shutdown(ctx)
_ = mp.Shutdown(ctx)
return tp.Shutdown(ctx)
}, nil
} Call Setup early in main and defer the returned shutdown function so spans flush on exit.
Instrument net/http
import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
mux := http.NewServeMux()
mux.HandleFunc("/checkout", checkoutHandler)
handler := otelhttp.NewHandler(mux, "http.server")
http.ListenAndServe(":8080", handler) For outbound calls, wrap the transport:
client := &http.Client{Transport: otelhttp.NewTransport(http.DefaultTransport)}
resp, err := client.Get("https://payments.example.com/charge") Trace context propagates over standard W3C headers so the downstream service's spans nest under your request.
Instrument gRPC
import "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
server := grpc.NewServer(
grpc.StatsHandler(otelgrpc.NewServerHandler()),
)
conn, err := grpc.NewClient("payments:9000",
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
) Instrument database/sql
import "go.opentelemetry.io/contrib/instrumentation/database/sql/otelsql"
db, err := otelsql.Open("pgx", dsn,
otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
) Every QueryContext and ExecContext becomes a child span on whatever request triggered it.
Manual spans for business logic
tracer := otel.Tracer("checkout-api")
func SubmitOrder(ctx context.Context, orderID string, totalCents int64) error {
ctx, span := tracer.Start(ctx, "checkout.submit_order",
trace.WithAttributes(
attribute.String("order.id", orderID),
attribute.Int64("order.total_cents", totalCents),
),
)
defer span.End()
if err := chargeCard(ctx, orderID, totalCents); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
return writeLedger(ctx, orderID)
} Metrics
meter := otel.Meter("checkout-api")
ordersTotal, _ := meter.Int64Counter("orders_total")
checkoutLatency, _ := meter.Float64Histogram("checkout_latency_ms",
metric.WithUnit("ms"),
)
ordersTotal.Add(ctx, 1, metric.WithAttributes(attribute.String("plan", "team")))
checkoutLatency.Record(ctx, 348, metric.WithAttributes(attribute.String("region", "eu-west-1"))) Logs via the slog bridge
The OpenTelemetry Go SDK ships an slog bridge so the standard library's structured logger emits OTLP log records:
import (
"log/slog"
"go.opentelemetry.io/contrib/bridges/otelslog"
)
logger := otelslog.NewLogger("checkout-api")
logger.InfoContext(ctx, "order accepted", "order.id", orderID) Because the log call is bound to ctx, the active trace and span IDs are attached automatically. In Telemtra you can jump from a slow trace to the exact log lines it produced.
Context propagation across goroutines
Go's context.Context carries the active span; if you start a goroutine, pass ctx into it and start a new span with it. The propagator is tracecontext,baggage by default for HTTP and gRPC, which is what Telemtra and every other OTel-compatible backend expects.
Troubleshooting
- Spans flush late or not at all. Call the shutdown function returned from
Setupin a defer.BatchSpanProcessorholds spans until the batch fills or the timer fires. - 403 from Telemtra. Set the
x-api-keyheader on the exporter, not via Go'shttp.Clientheaders; the exporter has its own. - Disconnected spans across services. Confirm both sides use the W3C tracecontext propagator (the default in current Go SDK versions).
- High cardinality metrics. Avoid putting request IDs or unbounded user input in attribute values. Stick to enumerable dimensions (region, plan, status code).
For protocol details see What is OTLP?. To run a sidecar Collector for batching and buffering, follow the Collector quickstart. New to Telemtra? Start with the quickstart for the minimum config. To go live, create a Telemtra workspace.
OpenTelemetry Go FAQ
Why does OpenTelemetry Go have no auto-instrumentation?
Go's static linking and lack of runtime reflection makes monkey-patching libraries the way Python or Node.js do it impractical. Instead, OpenTelemetry Go ships per-library instrumentation packages (otelhttp, otelgin, otelgrpc, otelpgx) that you import explicitly. Once wired, the surface is identical.
How do I instrument gin, chi, fiber, or echo?
Each framework has a matching package in opentelemetry-go-contrib: otelgin, otelchi, otelfiber, otelecho. They register as middleware and produce HTTP server spans with the same semantic conventions as net/http.
Does OpenTelemetry Go handle context propagation across goroutines?
Yes - through context.Context. Pass ctx into every goroutine and start child spans with it. The default propagator is W3C tracecontext + baggage, which Telemtra and every other OTel-compatible backend expects.
Can I use OpenTelemetry Go with the standard library's slog?
Yes. The otelslog bridge from go.opentelemetry.io/contrib/bridges/otelslog wraps log/slog so each log call emits an OTLP log record with the active trace_id and span_id, linking logs to their parent trace in Telemtra.
How do I avoid high-cardinality metrics?
Never put unbounded values (request IDs, user IDs, full URLs) in metric attribute values. Stick to enumerable dimensions (region, plan, http.method, http.status_code). For traces and logs the cardinality concern does not apply - high cardinality is fine there.
Further reading
- Telemtra quickstart — minimum Go config and snippets for every other supported stack.
- opentelemetry.io — Go documentation
- github.com/open-telemetry/opentelemetry-go — SDK source
- github.com/open-telemetry/opentelemetry-go-contrib — contrib instrumentations
- pkg.go.dev — go.opentelemetry.io/otel — API reference
- OpenTelemetry semantic conventions