/docs/opentelemetry-go
net/http + gRPC + database/sql
free plan / 1 gb-mo
telemtra.io
// go instrumentation

OpenTelemetry Go:
instrument Go services and ship OTLP.

Set up the OpenTelemetry Go SDK once, instrument net/http, gRPC, database/sql, and your business code, and stream logs, traces, and metrics into Telemtra over OTLP.

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 Setup in a defer. BatchSpanProcessor holds spans until the batch fills or the timer fires.
  • 403 from Telemtra. Set the x-api-key header on the exporter, not via Go's http.Client headers; 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