Metrics, Traces, and Logs: The OpenTelemetry Way

Observability in production means three things: metrics (what’s happening), traces (how requests flow), and logs (what went wrong). OpenTelemetry unifies all three.

Go’s OpenTelemetry support is mature—the project itself is Go-native. Let’s instrument a Go service properly.

OpenTelemetry Setup

Add dependencies:

go get go.opentelemetry.io/otel
go get go.opentelemetry.io/otel/sdk
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
go get go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp

Initialize in main:

package main

import (
    "context"
    "log"
    "time"
    
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
    "go.opentelemetry.io/otel/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initTracer(ctx context.Context) (*sdktrace.TracerProvider, error) {
    exporter, err := otlptracehttp.New(ctx)
    if err != nil {
        return nil, err
    }
    
    res, err := resource.New(ctx,
        resource.WithAttributes(
            semconv.ServiceName("myservice"),
            semconv.ServiceVersion("1.0.0"),
        ),
    )
    if err != nil {
        return nil, err
    }
    
    tp := sdktrace.NewTracerProvider(
        sdktrace.WithBatcher(exporter),
        sdktrace.WithResource(res),
    )
    
    otel.SetTracerProvider(tp)
    return tp, nil
}

func main() {
    ctx := context.Background()
    
    tp, err := initTracer(ctx)
    if err != nil {
        log.Fatal(err)
    }
    defer tp.Shutdown(ctx)
    
    // Your app...
}

Automatic HTTP Instrumentation

Wrap your HTTP handler:

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

func main() {
    handler := http.HandlerFunc(myHandler)
    wrappedHandler := otelhttp.NewHandler(handler, "my-server")
    
    http.ListenAndServe(":8080", wrappedHandler)
}

Every request automatically creates a span with:

  • HTTP method and path
  • Status code
  • Request duration
  • Error information

Manual Spans

For business logic:

import "go.opentelemetry.io/otel"

var tracer = otel.Tracer("myservice")

func processOrder(ctx context.Context, orderID string) error {
    ctx, span := tracer.Start(ctx, "processOrder")
    defer span.End()
    
    span.SetAttributes(
        attribute.String("order.id", orderID),
    )
    
    // Process order...
    if err := validateOrder(ctx, orderID); err != nil {
        span.RecordError(err)
        span.SetStatus(codes.Error, err.Error())
        return err
    }
    
    return nil
}

func validateOrder(ctx context.Context, orderID string) error {
    ctx, span := tracer.Start(ctx, "validateOrder")
    defer span.End()
    
    // Validation logic...
    return nil
}

The context carries trace information. Child spans automatically link to parents.

Metrics

import (
    "go.opentelemetry.io/otel/metric"
    "go.opentelemetry.io/otel"
)

var (
    meter = otel.Meter("myservice")
    requestCounter metric.Int64Counter
    requestDuration metric.Float64Histogram
)

func init() {
    var err error
    requestCounter, err = meter.Int64Counter("http_requests_total",
        metric.WithDescription("Total HTTP requests"),
    )
    if err != nil {
        log.Fatal(err)
    }
    
    requestDuration, err = meter.Float64Histogram("http_request_duration_seconds",
        metric.WithDescription("HTTP request duration"),
    )
    if err != nil {
        log.Fatal(err)
    }
}

func handler(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    
    // Handle request...
    
    requestCounter.Add(r.Context(), 1,
        metric.WithAttributes(
            attribute.String("method", r.Method),
            attribute.String("path", r.URL.Path),
        ),
    )
    
    requestDuration.Record(r.Context(), time.Since(start).Seconds(),
        metric.WithAttributes(
            attribute.String("method", r.Method),
        ),
    )
}

Structured Logging with Trace Context

Connect logs to traces:

import (
    "log/slog"
    "go.opentelemetry.io/otel/trace"
)

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    span := trace.SpanFromContext(ctx)
    
    logger := slog.Default().With(
        "trace_id", span.SpanContext().TraceID().String(),
        "span_id", span.SpanContext().SpanID().String(),
    )
    
    logger.Info("processing request",
        "method", r.Method,
        "path", r.URL.Path,
    )
}

Now logs correlate with traces.

Database Instrumentation

For database/sql:

import (
    "github.com/XSAM/otelsql"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
)

func initDB() (*sql.DB, error) {
    db, err := otelsql.Open("postgres", connString,
        otelsql.WithAttributes(
            semconv.DBSystemPostgreSQL,
        ),
    )
    if err != nil {
        return nil, err
    }
    
    otelsql.RegisterDBStatsMetrics(db,
        otelsql.WithAttributes(semconv.DBSystemPostgreSQL),
    )
    
    return db, nil
}

Complete Example

package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "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/sdk/resource"
    sdktrace "go.opentelemetry.io/otel/sdk/trace"
    semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
    "go.opentelemetry.io/otel/trace"
)

var tracer = otel.Tracer("myservice")

func main() {
    ctx := context.Background()
    
    // Setup tracing
    tp, err := initTracer(ctx)
    if err != nil {
        slog.Error("failed to init tracer", "error", err)
        os.Exit(1)
    }
    defer tp.Shutdown(ctx)
    
    // Routes
    mux := http.NewServeMux()
    mux.HandleFunc("GET /users/{id}", getUserHandler)
    
    // Wrap with OTel
    handler := otelhttp.NewHandler(mux, "myservice")
    
    slog.Info("starting server", "addr", ":8080")
    http.ListenAndServe(":8080", handler)
}

func getUserHandler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    span := trace.SpanFromContext(ctx)
    
    userID := r.PathValue("id")
    span.SetAttributes(attribute.String("user.id", userID))
    
    logger := slog.Default().With(
        "trace_id", span.SpanContext().TraceID().String(),
        "user_id", userID,
    )
    
    user, err := fetchUser(ctx, userID)
    if err != nil {
        span.RecordError(err)
        logger.Error("failed to fetch user", "error", err)
        http.Error(w, "user not found", http.StatusNotFound)
        return
    }
    
    logger.Info("user fetched successfully")
    json.NewEncoder(w).Encode(user)
}

func fetchUser(ctx context.Context, id string) (*User, error) {
    ctx, span := tracer.Start(ctx, "fetchUser")
    defer span.End()
    
    // Simulated database call
    time.Sleep(10 * time.Millisecond)
    
    return &User{ID: id, Name: "Alice"}, nil
}

Comparing to .NET

Aspect.NETGo
OTel SDKMatureMature (Go-native)
Auto-instrumentationYesYes (via contrib)
ASP.NET integrationBuilt-inManual wrapping
DI integrationBuilt-inManual
Logging integrationILogger + OTelslog + manual

.NET’s integration is more automatic. Go requires more explicit setup but gives more control.

Exporters

Send to your observability backend:

OTLP (Jaeger, Tempo, etc.):

exporter, _ := otlptracehttp.New(ctx,
    otlptracehttp.WithEndpoint("tempo:4318"),
    otlptracehttp.WithInsecure(),
)

Prometheus (metrics):

import "go.opentelemetry.io/otel/exporters/prometheus"

exporter, _ := prometheus.New()
http.Handle("/metrics", promhttp.Handler())

stdout (debugging):

import "go.opentelemetry.io/otel/exporters/stdout/stdouttrace"

exporter, _ := stdouttrace.New(stdouttrace.WithPrettyPrint())

The Honest Take

Go’s OpenTelemetry support is excellent—the project is Go-native.

What Go does well:

  • OTel is Go-native
  • Explicit instrumentation is clear
  • Good contrib library coverage
  • Low overhead

What .NET does better:

  • More automatic instrumentation
  • Better DI integration
  • Richer ASP.NET Core support
  • Activity API is mature

The verdict: OpenTelemetry in Go requires more explicit setup than .NET’s automatic instrumentation. But once configured, it works well.

The key patterns—wrap HTTP handlers, propagate context, create spans for significant operations—are the same in any language. Go just makes you write them out.


Next up: health checks and readiness probes—patterns that work in container orchestrators.