Skip to content
JavaGo
Part VI · Building Services

Logging & Observability

log/slog in place of SLF4J and Logback, request context in every log line without MDC, redacting secrets, and metrics, tracing and profiling.

Section 27 of 31 5 min read

Java logging is a layered ecosystem: SLF4J as the API, Logback or Log4j2 as the implementation, configured in XML. Since Go 1.21 the standard library includes log/slog, a structured logger with levels, attributes and pluggable handlers. It is both the API and a production-ready implementation.

log vs log/slog

the old log package and its structured successor
// log: unstructured text. Fine for scripts and small tools.
log.Printf("processing message id=%d", id)
// 2026/09/29 12:00:00 processing message id=42

// log/slog: structured key/value pairs, with levels
slog.Info("processing message", "id", id, "topic", topic)
// time=2026-09-29T12:00:00.000Z level=INFO msg="processing message" id=42 topic=orders
JavaGo
SLF4J (API)slog.Logger and the package-level slog.Info etc.
Logback / Log4j2 (implementation)slog.Handler: TextHandler and JSONHandler built in
logback.xmlA few lines in main(): slog.SetDefault(slog.New(handler))
log.info("user {}", id)slog.Info("user loaded", "id", id): a constant message plus attributes
log.error("failed", exception)slog.Error("failed", "err", err)
MDCAttributes from logger.With(…), or a handler reading the context
Multiple appendersslog.NewMultiHandler(h1, h2) (Go 1.26+)

Configuring the logger in main

JSON logs to stdout, level from configuration
func newLogger(level slog.Level) *slog.Logger {
    // JSON to stdout: what Kubernetes, Datadog, Loki and
    // Cloud Logging all expect from a container
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level:     level, // records below this are dropped cheaply
        AddSource: true,  // adds "source": file and line
    })
    return slog.New(handler)
}

func main() {
    logger := newLogger(slog.LevelInfo)
    slog.SetDefault(logger) // also redirects the old log package

    slog.Info("server starting", "addr", ":8080", "version", version)
    // {"time":"…","level":"INFO","source":{…},"msg":"server starting",
    //  "addr":":8080","version":"1.4.2"}
}

Attributes

Java — SLF4J 2 fluent API
log.atInfo()
    .setMessage("user logged in")
    .addKeyValue("userId", userId)
    .addKeyValue("method", "sso")
    .log();
Go — key/value pairs or typed attributes
// Alternating keys and values: concise
slog.Info("user logged in", "user_id", userID, "method", "sso")

// Typed attributes: no allocations for the pairs, and a
// mismatched key/value count becomes impossible
slog.Info("user logged in",
    slog.Int64("user_id", userID),
    slog.String("method", "sso"),
    slog.Duration("took", time.Since(start)),
)

Request context without MDC

Java — MDC (thread-local)
MDC.put("requestId", requestId);
try {
    log.info("processing request"); // requestId included
    service.handle(request);        // and in every log below
} finally {
    MDC.clear();
}
Go — a logger with attributes attached
func (h *Handler) Handle(w http.ResponseWriter, r *http.Request) {
    id, _ := RequestID(r.Context()) // helper from the Context section
    logger := slog.With(
        "request_id", id,
        "method", r.Method,
        "path", r.URL.Path,
    )
    logger.Info("processing request")

    if err := h.svc.Process(r.Context()); err != nil {
        logger.Error("processing failed", "err", err)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    logger.Info("request completed")
}

Passing a logger into every function gets tedious. The alternative is to keep request data in the context.Context (which you pass anyway), log with the …Context variants, and install a handler that copies context values into every record. The built-in handlers ignore the context, so this small wrapper is a common pattern:

a handler that adds the request ID from the context
type ContextHandler struct {
    slog.Handler // embed: inherit Enabled and everything else
}

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

// Keep the wrapper when attributes or groups are added
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)}
}

// main: slog.SetDefault(slog.New(ContextHandler{slog.NewJSONHandler(os.Stdout, nil)}))
// anywhere: slog.InfoContext(ctx, "charging card", "amount", amt)
// {"level":"INFO","msg":"charging card","amount":1999,"request_id":"req-42"}

Keeping secrets out of logs

slog.LogValuer controls how a type is logged
type Password string

// When a Password is logged as an attribute value, this is used instead.
// (A struct holding one needs its own LogValue, as User has below.)
func (Password) LogValue() slog.Value {
    return slog.StringValue("REDACTED")
}

// Log a summary of a large struct instead of every field
func (u User) LogValue() slog.Value {
    return slog.GroupValue(
        slog.Int64("id", u.ID),
        slog.String("plan", u.Plan),
    )
}

slog.Info("signup", "user", user, "password", pw)
// {"msg":"signup","user":{"id":7,"plan":"pro"},"password":"REDACTED"}

A log call flows through handlers

LogValuer keeps secrets out

Fig. 27.1A log call becomes a record that passes through handlers on its way out. A small ContextHandler copies the request ID from ctx into every record logged with a …Context call, which replaces MDC, and a type with a LogValue method decides what is printed when a value of that type is logged.

Metrics, tracing and profiling

  • Metrics: the Prometheus client (github.com/prometheus/client_golang) exposes counters, gauges and histograms plus Go runtime metrics such as goroutine count, GC pauses and heap size. OpenTelemetry metrics are the vendor-neutral alternative.
  • Tracing: OpenTelemetry for Go propagates trace context through context.Context, so spans cross function and service boundaries with no thread-local magic. otelhttp wraps handlers and clients.
  • Profiling: net/http/pprof serves CPU, heap, goroutine, mutex and block profiles; go tool pprof analyses them. Expose it only on an internal port.
  • Execution traces: go tool trace shows scheduling, GC and blocking over time. Go 1.25’s trace.FlightRecorder keeps the last few seconds in memory so you can dump a trace when something goes wrong.
↑ ↓ to navigateEnter to openEsc to close