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 hasDebug/Info/Warn/Error, plusDebugContext/InfoContext/WarnContext/ErrorContext, which take acontext.Context.slog.Handler: the interface that decides the output format and destination. The standard library providesslog.NewTextHandler(key=valueformat) andslog.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 withslog.HandlerOptions{Level: ...}, and usingslog.LevelVarlets you switch levels at runtime. There are no built-in levels corresponding to C#'sTrace/Critical(you can define custom levels with numeric values). - Common attributes and groups:
logger.With("service", "orders")creates a newLoggerwhose attributes are attached to every later log.slog.Groupandlogger.WithGroupnest attributes. You can use these much like C# log scopes (BeginScope). - The default logger: after
slog.SetDefault, the package-level functions such asslog.Info(...)and the output of the legacylog.Printfgo 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
ctxalong the call chain (see "The context Package" for details). - Write with
logger.InfoContext(ctx, ...), passingctx.logger.Info(...)behaves as ifcontext.Background()were passed, so the handler cannot see the values inctx. - If the
Handlemethod of a custom handler (wrapper) around aslog.Handlerextracts the request ID or trace ID fromctxand 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 withotel.Tracer("name")orotel.Meter("name"). - SDK (
.../otel/sdk/trace,.../otel/sdk/metric): the side that actually processes and sends data. Create aTracerProvider/MeterProviderand register it globally withotel.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 newctxand aspan. Child spans are created from thatctx, so passingctxalong is the precondition for parent-child relationships. Callspan.End()when the work is done. - Metrics: create instruments such as
meter.Int64Counterormeter.Float64Histogram, and passctxand attributes toAdd/Record. - Automatic HTTP instrumentation:
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttpwraps server handlers and the clientTransportto create spans and propagate trace context.
2. .NET Mapping
| .NET (C#) | Go |
|---|---|
ILogger<T> / Serilog | log/slog / zap / zerolog |
| ActivitySource / OpenTelemetry .NET | go.opentelemetry.io/otel |
| Health Checks | Custom /healthz handler |
Additional correspondences that complement the table above:
| .NET (C#) | Go |
|---|---|
ILogger (abstraction) and providers | slog.Logger and slog.Handler |
logger.LogInformation("... {OrderId}", id) | logger.InfoContext(ctx, "...", "order_id", id) |
LogLevel.Trace ... Critical | slog.LevelDebug / Info / Warn / Error |
logger.BeginScope(...) | logger.With(...), plus a custom handler that reads values placed in ctx |
Logging:LogLevel in appsettings.json | slog.LevelVar + changes from environment variables or config (implemented yourself) |
[LoggerMessage] source generator | logger.LogAttrs (an allocation-conscious style) |
Activity / ActivitySource.StartActivity | span / tracer.Start(ctx, ...) |
Meter / Counter<T> / Histogram<T> | otel.Meter / Int64Counter / Float64Histogram |
Activity.Current | trace.SpanFromContext(ctx) |
AddOpenTelemetry() (DI registration) | Create a TracerProvider / MeterProvider and register with otel.Set...Provider |
| ASP.NET Core instrumentation libraries | Explicit wrapping with contrib packages such as otelhttp |
IHealthCheck / MapHealthChecks | Custom /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/MeterProvideryourself and register it globally, all instrumentation is a no-op. - Forgetting
span.End()causes the span never to be sent. Writedefer 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 withcontext.Background()midway breaks the trace. - Errors are not recorded on the span automatically. Call
span.RecordError(err)andspan.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
ctxreturned bytracer.Start(ctx, ...)at the entry of the work to later calls and logs. This establishes the span parent-child relationship and the correlation withtrace_id/span_idin logs. - Limit metric attributes to a small set of values such as
result. - To send over OTLP, replace the standard output exporters with
otlptracehttp/otlpmetrichttpand the like. You can specify the endpoint with environment variables such asOTEL_EXPORTER_OTLP_ENDPOINT. - To send logs into the OpenTelemetry logs pipeline, there is the contrib
otelslogbridge (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) andHandler(format and destination) are separate, andNewJSONHandleris typical in production. - Log levels are Debug / Info / Warn / Error, and
slog.LevelVarlets you switch them at runtime. - Put correlation IDs in
context.Contextand attach them to logs automatically withInfoContextand a custom handler that implementsHandle/WithAttrs/WithGroup. - Arguments are alternating key, value pairs with weak type checking, so use
go vetandslog.Attr, and pass values as attributes instead of embedding them in strings. - OpenTelemetry separates the API and the SDK. Create and register the
TracerProvider/MeterProvideryourself, and do not forgetdefer span.End()andShutdown. - Span parent-child relationships are determined by
ctx, so keep passing thectxreturned bytracer.Start. Record errors explicitly withRecordErrorandSetStatus. - Keep metric attributes low-cardinality.
- Health checks are custom handlers: separate liveness from readiness, and check dependencies in readiness.