Skip to main content

Logging and Observability

For developers with .NET (C#) experience, this guide explains log/slog, structured logging, OpenTelemetry, and metrics. In C# you receive ILogger<T> through DI and plug providers into the Microsoft.Extensions.Logging infrastructure. Since Go 1.21, the standard library includes the structured logging package log/slog, which separates the logging API (slog.Logger) from the output destination (slog.Handler), giving you a similar structure with the standard library alone. For traces and metrics you use OpenTelemetry Go, and the basic flow is to correlate logs and traces through context.Context.

1. What This Document Covers​

  • slog handlers and levels
  • Correlation with request IDs and context
  • OpenTelemetry tracing and metrics instrumentation

slog handlers and levels​

log/slog is structured logging that treats a log record as a message plus attributes (key-value pairs).

  • slog.Logger: the API for writing logs. It has Debug / Info / Warn / Error, plus DebugContext / InfoContext / WarnContext / ErrorContext, which take a context.Context.
  • slog.Handler: the interface that decides the output format and destination. The standard library provides slog.NewTextHandler (key=value format) and slog.NewJSONHandler (JSON). JSON is typical in production.
  • Levels: slog.LevelDebug (-4) / LevelInfo (0) / LevelWarn (4) / LevelError (8). The default minimum level is Info, so Debug is not printed. You can change it with slog.HandlerOptions{Level: ...}, and using slog.LevelVar lets you switch levels at runtime. There are no built-in levels corresponding to C#'s Trace / Critical (you can define custom levels with numeric values).
  • Common attributes and groups: logger.With("service", "orders") creates a new Logger whose attributes are attached to every later log. slog.Group and logger.WithGroup nest attributes. You can use these much like C# log scopes (BeginScope).
  • The default logger: after slog.SetDefault, the package-level functions such as slog.Info(...) and the output of the legacy log.Printf go through the same handler.

Correlation with request IDs and context​

In C#, ILogger.BeginScope and Activity let you attach per-request information to later logs automatically. Go has no scope mechanism: you carry values in context.Context and read them out when writing a log.

  • Keep passing ctx along the call chain (see "The context Package" for details).
  • Write with logger.InfoContext(ctx, ...), passing ctx. logger.Info(...) behaves as if context.Background() were passed, so the handler cannot see the values in ctx.
  • If the Handle method of a custom handler (wrapper) around a slog.Handler extracts the request ID or trace ID from ctx and adds them as attributes, callers output correlation IDs without thinking about it.

OpenTelemetry tracing and metrics instrumentation​

OpenTelemetry (go.opentelemetry.io/otel) is the standard way to handle traces and metrics. As in .NET, the API and the SDK are separate.

  • API (go.opentelemetry.io/otel, .../otel/trace, .../otel/metric): what instrumentation code uses. Obtain it with otel.Tracer("name") or otel.Meter("name").
  • SDK (.../otel/sdk/trace, .../otel/sdk/metric): the side that actually processes and sends data. Create a TracerProvider / MeterProvider and register it globally with otel.SetTracerProvider / otel.SetMeterProvider. Note that nothing is emitted (no-op) until you register them.
  • Exporters: send data to a collector or backend over OTLP (for example .../exporters/otlp/otlptrace/otlptracehttp). For checking behavior, stdouttrace / stdoutmetric, which write to standard output, are handy.
  • Traces: tracer.Start(ctx, "name") returns a new ctx and a span. Child spans are created from that ctx, so passing ctx along is the precondition for parent-child relationships. Call span.End() when the work is done.
  • Metrics: create instruments such as meter.Int64Counter or meter.Float64Histogram, and pass ctx and attributes to Add / Record.
  • Automatic HTTP instrumentation: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp wraps server handlers and the client Transport to create spans and propagate trace context.

2. .NET Mapping​

.NET (C#)Go
ILogger<T> / Seriloglog/slog / zap / zerolog
ActivitySource / OpenTelemetry .NETgo.opentelemetry.io/otel
Health ChecksCustom /healthz handler

Additional correspondences that complement the table above:

.NET (C#)Go
ILogger (abstraction) and providersslog.Logger and slog.Handler
logger.LogInformation("... {OrderId}", id)logger.InfoContext(ctx, "...", "order_id", id)
LogLevel.Trace ... Criticalslog.LevelDebug / Info / Warn / Error
logger.BeginScope(...)logger.With(...), plus a custom handler that reads values placed in ctx
Logging:LogLevel in appsettings.jsonslog.LevelVar + changes from environment variables or config (implemented yourself)
[LoggerMessage] source generatorlogger.LogAttrs (an allocation-conscious style)
Activity / ActivitySource.StartActivityspan / tracer.Start(ctx, ...)
Meter / Counter<T> / Histogram<T>otel.Meter / Int64Counter / Float64Histogram
Activity.Currenttrace.SpanFromContext(ctx)
AddOpenTelemetry() (DI registration)Create a TracerProvider / MeterProvider and register with otel.Set...Provider
ASP.NET Core instrumentation librariesExplicit wrapping with contrib packages such as otelhttp
IHealthCheck / MapHealthChecksCustom /healthz (liveness) and /readyz (readiness) handlers

3. Common Pitfalls for C# Developers​

There is no DI for category loggers like ILogger<T>​

In C#, an ILogger<OrderService> is injected per type and the category is attached automatically. Go has neither a DI container nor a category concept. Either receive a *slog.Logger in the constructor and keep it in a struct field, or attach an identifying attribute with logger.With("component", "orders"). You can also keep the logger global (slog.Default()), but passing it explicitly as a dependency is easier to test.

Attributes are alternating key, value arguments with weak type checking​

The ...any style, as in logger.Info("msg", "user_id", id), produces a !BADKEY entry if the number of keys and values does not match, and is not a compile error. The slog check in go vet detects mismatched arguments. For type safety, pass slog.Attr values such as slog.String("user_id", id), or use logger.LogAttrs(ctx, slog.LevelInfo, "msg", attrs...).

Without ctx, the correlation ID is missing​

logger.Info(...) has no ctx, so a handler cannot read the values placed in ctx. Make it a habit to use InfoContext / ErrorContext while processing a request.

A wrapper handler must also implement WithAttrs / WithGroup​

If a custom handler that embeds slog.Handler overrides only Handle, calling logger.With(...) returns the inner handler and the wrapper is dropped (the correlation ID disappears). Implement WithAttrs and WithGroup so they return the wrapper itself (see the example in the next section).

Pass values to structured logs; do not embed them in strings​

If you embed everything in a message built with fmt.Sprintf, you cannot search or aggregate. Keep the message a fixed sentence and pass variable values separately as attributes. This is the same idea as C# message templates.

Pass errors as attributes​

Pass an error with slog.Any("error", err) or, by convention, "error", err. When errors cross call layers, add context while returning with fmt.Errorf("...: %w", err) and log in one place (the top level). If every layer logs and also returns, the same error is output many times.

Hide sensitive data with LogValuer​

If you pass a struct directly as an attribute, all fields are output. A type that implements LogValue() slog.Value (slog.LogValuer) lets the type control what is emitted, so you can manage masking of passwords and tokens in one place.

Argument evaluation cost and disabled levels​

In logger.Debug("dump", "data", expensive()), expensive() is called even when Debug is disabled. Wrap heavy work in if logger.Enabled(ctx, slog.LevelDebug) { ... }.

OpenTelemetry does not work automatically​

Go has almost nothing like the automatic instrumentation that .NET's AddOpenTelemetry() and the built-in ASP.NET Core instrumentation provide (because Go is natively compiled and has few ways to inject code at runtime). Keep the following in mind.

  • Unless you create a TracerProvider / MeterProvider yourself and register it globally, all instrumentation is a no-op.
  • Forgetting span.End() causes the span never to be sent. Write defer span.End() right after obtaining it.
  • If you do not call tp.Shutdown(ctx) / mp.Shutdown(ctx) before the app exits, buffered data can be lost.
  • Unless you explicitly wrap HTTP servers and clients with otelhttp, no spans or propagation are produced.
  • Parent-child relationships between spans are determined by ctx. Replacing it with context.Background() midway breaks the trace.
  • Errors are not recorded on the span automatically. Call span.RecordError(err) and span.SetStatus(codes.Error, ...) yourself.

Keep metric attributes low-cardinality​

Putting high-cardinality values such as user IDs or request IDs in metric attributes (labels) makes the number of time series explode and causes cost and performance problems. Put such identifiers in logs and traces, and use only a small set of values such as status or route in metrics.

4. Code Examples​

Example 1: slog basics (levels, JSON, common attributes)​

C# (ILogger<T>):

public class OrderService(ILogger<OrderService> logger)
{
public void Process(string orderId)
{
using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = orderId }))
{
logger.LogInformation("Processing order {OrderId}", orderId);
logger.LogWarning("Stock is low");
}
}
}

Go:

package main

import (
"log/slog"
"os"
)

func main() {
// the log level can be switched at runtime
var level slog.LevelVar
level.Set(slog.LevelInfo)

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: &level,
})
logger := slog.New(handler).With("service", "orders") // attached to every later log

orderLogger := logger.With("order_id", "A-1001")
orderLogger.Info("processing order", "items", 3)
orderLogger.Warn("stock is low", slog.Group("sku", slog.String("id", "X-1"), slog.Int("left", 2)))

logger.Debug("not printed") // below Info, so it is not output
level.Set(slog.LevelDebug)
logger.Debug("now printed")

slog.SetDefault(logger) // slog.Info(...) and log.Printf output also go through this handler
slog.Info("started")
}

The output is one JSON record per line, such as {"time":"...","level":"INFO","msg":"processing order","service":"orders","order_id":"A-1001","items":3}.

Example 2: A custom handler that reads the request ID from context​

This example handles values you would have put in HttpContext.Items or a log scope by using context.Context and a custom handler.

package main

import (
"context"
"log/slog"
"net/http"
"os"
)

type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}

// contextHandler extracts the request ID from ctx and adds it as an attribute
type contextHandler struct {
slog.Handler
}

func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok {
r.AddAttrs(slog.String("request_id", id))
}
return h.Handler.Handle(ctx, r)
}

// WithAttrs / WithGroup also return the wrapper itself (otherwise logger.With drops the wrapper)
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return contextHandler{h.Handler.WithAttrs(attrs)}
}

func (h contextHandler) WithGroup(name string) slog.Handler {
return contextHandler{h.Handler.WithGroup(name)}
}

var logger = slog.New(contextHandler{slog.NewJSONHandler(os.Stdout, nil)})

func requestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = "req-12345" // in practice, generate a UUID or similar
}
next.ServeHTTP(w, r.WithContext(WithRequestID(r.Context(), id)))
})
}

func handler(w http.ResponseWriter, r *http.Request) {
// writing with ctx attaches request_id automatically
logger.InfoContext(r.Context(), "handling request", "path", r.URL.Path)
w.WriteHeader(http.StatusOK)
}

func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", handler)
http.ListenAndServe(":8080", requestIDMiddleware(mux))
}

Example 3: Correlating OpenTelemetry traces and metrics with logs​

In C#, this is the scenario of creating a span with ActivitySource and recording a counter with Meter.

C#:

static readonly ActivitySource Source = new("Orders");
static readonly Meter Meter = new("Orders");
static readonly Counter<long> Processed = Meter.CreateCounter<long>("orders.processed");

using var activity = Source.StartActivity("ProcessOrder");
activity?.SetTag("order.id", orderId);
Processed.Add(1, new KeyValuePair<string, object?>("result", "ok"));

Go (a setup for verification that uses exporters writing to standard output; replace them with OTLP exporters in production):

go get go.opentelemetry.io/otel \
go.opentelemetry.io/otel/sdk \
go.opentelemetry.io/otel/sdk/metric \
go.opentelemetry.io/otel/exporters/stdout/stdouttrace \
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric
package main

import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"

"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/exporters/stdout/stdoutmetric"
"go.opentelemetry.io/otel/exporters/stdout/stdouttrace"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)

var (
tracer = otel.Tracer("example/orders")
processed metric.Int64Counter
)

// setupOTel creates the TracerProvider / MeterProvider and registers them globally
func setupOTel(ctx context.Context) (func(context.Context) error, error) {
res := resource.NewSchemaless(attribute.String("service.name", "orders-api"))

traceExp, err := stdouttrace.New()
if err != nil {
return nil, err
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(traceExp),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)

metricExp, err := stdoutmetric.New()
if err != nil {
return nil, err
}
mp := sdkmetric.NewMeterProvider(
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
sdkmetric.WithResource(res),
)
otel.SetMeterProvider(mp)

otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{}, propagation.Baggage{},
))

// flush buffered data on shutdown
return func(ctx context.Context) error {
return errors.Join(tp.Shutdown(ctx), mp.Shutdown(ctx))
}, nil
}

// otelHandler adds trace_id / span_id from the span in ctx to the log
type otelHandler struct {
slog.Handler
}

func (h otelHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}

func (h otelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return otelHandler{h.Handler.WithAttrs(attrs)}
}

func (h otelHandler) WithGroup(name string) slog.Handler {
return otelHandler{h.Handler.WithGroup(name)}
}

func processOrder(ctx context.Context, logger *slog.Logger, orderID string) error {
ctx, span := tracer.Start(ctx, "ProcessOrder")
defer span.End()
span.SetAttributes(attribute.String("order.id", orderID))

logger.InfoContext(ctx, "processing order", "order_id", orderID)

if err := charge(ctx, orderID); err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
processed.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "error")))
return fmt.Errorf("process order %s: %w", orderID, err)
}

processed.Add(ctx, 1, metric.WithAttributes(attribute.String("result", "ok")))
return nil
}

func charge(ctx context.Context, orderID string) error {
_, span := tracer.Start(ctx, "Charge") // child span (inherits ctx)
defer span.End()
time.Sleep(20 * time.Millisecond)
return nil
}

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

shutdown, err := setupOTel(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "setup:", err)
os.Exit(1)
}
defer shutdown(ctx)

processed, err = otel.Meter("example/orders").Int64Counter(
"orders.processed",
metric.WithDescription("Number of processed orders"),
)
if err != nil {
fmt.Fprintln(os.Stderr, "counter:", err)
os.Exit(1)
}

logger := slog.New(otelHandler{slog.NewJSONHandler(os.Stdout, nil)})
if err := processOrder(ctx, logger, "A-1001"); err != nil {
logger.ErrorContext(ctx, "failed", "error", err)
}
}

Key points:

  • Keep passing the ctx returned by tracer.Start(ctx, ...) at the entry of the work to later calls and logs. This establishes the span parent-child relationship and the correlation with trace_id / span_id in logs.
  • Limit metric attributes to a small set of values such as result.
  • To send over OTLP, replace the standard output exporters with otlptracehttp / otlpmetrichttp and the like. You can specify the endpoint with environment variables such as OTEL_EXPORTER_OTLP_ENDPOINT.
  • To send logs into the OpenTelemetry logs pipeline, there is the contrib otelslog bridge (go.opentelemetry.io/contrib/bridges/otelslog). The logs SDK is a newer area than the other signals, so check its stability before you adopt it.

Example 4: Health checks (/healthz and /readyz)​

You write the equivalent of C#'s MapHealthChecks with your own handlers.

C#:

builder.Services.AddHealthChecks().AddSqlServer(connectionString);
app.MapHealthChecks("/healthz");

Go:

package main

import (
"context"
"database/sql"
"net/http"
"time"
)

// liveness: return 200 if the process is alive (does not check dependencies)
func healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
}

// readiness: check that the dependency (DB) is reachable
func readyz(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()

if err := db.PingContext(ctx); err != nil {
http.Error(w, "db unavailable", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
}
}

func newMux(db *sql.DB) *http.ServeMux {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.Handle("GET /readyz", readyz(db))
return mux
}

func main() {
// in practice, connect with sql.Open and a driver
var db *sql.DB
_ = http.ListenAndServe(":8080", newMux(db))
}

In Kubernetes it is common to assign /healthz to the livenessProbe and /readyz to the readinessProbe. If liveness checks dependencies, a database outage can make every Pod restart repeatedly, so limit dependency checks to readiness.

5. Summary​

  • Structured logging is available in the standard log/slog. Logger (the writing side) and Handler (format and destination) are separate, and NewJSONHandler is typical in production.
  • Log levels are Debug / Info / Warn / Error, and slog.LevelVar lets you switch them at runtime.
  • Put correlation IDs in context.Context and attach them to logs automatically with InfoContext and a custom handler that implements Handle / WithAttrs / WithGroup.
  • Arguments are alternating key, value pairs with weak type checking, so use go vet and slog.Attr, and pass values as attributes instead of embedding them in strings.
  • OpenTelemetry separates the API and the SDK. Create and register the TracerProvider / MeterProvider yourself, and do not forget defer span.End() and Shutdown.
  • Span parent-child relationships are determined by ctx, so keep passing the ctx returned by tracer.Start. Record errors explicitly with RecordError and SetStatus.
  • Keep metric attributes low-cardinality.
  • Health checks are custom handlers: separate liveness from readiness, and check dependencies in readiness.

References​