Go for
Java Developers
Section 25

Logging & Observability

Java logging is a layered ecosystem: SLF4J as the facade, Logback or Log4j2 as the implementation, configured via XML. Go ships a production-ready structured logger, log/slog, in the standard library since Go 1.21. No dependencies, no XML.

log vs log/slog

old log package vs slog
// log package — unstructured, fine for small scripts
import "log"
log.Printf("processing message id=%d", id)
// Output: 2026/04/24 12:00:00 processing message id=42

// log/slog — structured, production-ready (Go 1.21+)
import "log/slog"
slog.Info("processing message", "id", id, "topic", topic)
// Output: time=2026-04-24T12:00:00Z level=INFO msg="processing message" id=42 topic=orders
SLF4J (facade)log/slog (facade + default implementation)
Logback / Log4j2 (implementation)slog.Handler (TextHandler / JSONHandler built-in)
log.xml / logback.xmlslog.New(handler) in main()
MDC (thread-local context)slog.With(...) or slog.InfoContext(ctx, ...)
logger.info("msg", kv)slog.Info("msg", "key", value)
logger.error("msg", exception)slog.Error("msg", "err", err)

Log levels

slog log levels
slog.Debug("cache miss", "key", key)           // debug — verbose, off by default
slog.Info("server started", "addr", ":8080")   // info — normal events
slog.Warn("retry attempt", "n", n, "err", err) // warn — degraded but continuing
slog.Error("db query failed", "err", err)      // error — needs attention

// Set minimum level (default is Info — Debug is suppressed):
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
handler := slog.NewTextHandler(os.Stderr, opts)
slog.SetDefault(slog.New(handler))

Structured attributes — key-value pairs

Java — SLF4J structured arguments
// SLF4J 2.x — structured key-value
logger.atInfo()
    .addKeyValue("userId", userId)
    .addKeyValue("action", "login")
    .log("user logged in");

// Logback / ELK picks up the key-value pairs as JSON fields
Go — slog key-value pairs
// slog — alternating key, value arguments
slog.Info("user logged in",
    "userId", userId,
    "action", "login",
    "duration", time.Since(start),
)

// Or use typed Attr for performance in hot paths:
slog.Info("user logged in",
    slog.Int64("userId", userId),
    slog.String("action", "login"),
    slog.Duration("duration", time.Since(start)),
)

JSON output for production

configure JSON handler in main()
package main

import (
    "log/slog"
    "os"
)

func main() {
    // JSON output — structured logs for Datadog, ELK, Cloud Logging etc.
    handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
    })
    slog.SetDefault(slog.New(handler))

    // All slog calls now emit JSON:
    // {"time":"2026-04-24T12:00:00Z","level":"INFO","msg":"server started","addr":":8080"}
    slog.Info("server started", "addr", ":8080")
}

slog.With — adding context fields

Java — MDC (thread-local)
// MDC — attached to the current thread
MDC.put("requestId", requestId);
MDC.put("userId", userId);
logger.info("processing request");  // MDC fields appear automatically
MDC.clear();  // must clean up — or use try-finally
Go — slog.With
// slog.With creates a child logger with pre-attached fields
// Pass it down explicitly — no thread-local storage in Go
func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
    log := slog.With(
        "requestId", r.Header.Get("X-Request-ID"),
        "method", r.Method,
        "path", r.URL.Path,
    )

    log.Info("request started")
    result, err := h.service.Process(r.Context())
    if err != nil {
        log.Error("processing failed", "err", err)
        http.Error(w, "error", 500)
        return
    }
    log.Info("request completed", "status", 200)
}

Context-aware logging

slog.InfoContext, slog.ErrorContext etc. accept a context.Context as the first argument. Custom handlers can extract values from the context (trace IDs, user IDs) and attach them to every log entry automatically — without passing a logger through every function.

log with context — trace ID propagation
// Middleware attaches trace ID to context
func withTraceID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        traceID := r.Header.Get("X-Trace-ID")
        ctx := context.WithValue(r.Context(), traceIDKey, traceID)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// Deep in the call tree — no logger threading required
func (r *Repository) Query(ctx context.Context, id string) (*Message, error) {
    slog.InfoContext(ctx, "querying database", "id", id)
    // A custom slog.Handler can read traceIDKey from ctx and add it to every line
    // ...
}

Goroutine count — the key operational metric

expose runtime metrics
import "runtime"

// Number of currently live goroutines
n := runtime.NumGoroutine()
slog.Info("runtime stats", "goroutines", n)

// Export to Prometheus / Datadog via a metrics endpoint:
// A steadily climbing goroutine count is the primary signal
// of a goroutine leak (blocked channel, missed ctx.Done()).

// expvar package — built-in HTTP endpoint at /debug/vars
import _ "expvar"
// GET /debug/vars returns a JSON snapshot of runtime stats

// Full pprof profiles (CPU, memory, goroutine traces):
import _ "net/http/pprof"
// GET /debug/pprof/goroutine?debug=2 — full goroutine dump