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
// 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| Java | Go |
|---|---|
| SLF4J (API) | slog.Logger and the package-level slog.Info etc. |
| Logback / Log4j2 (implementation) | slog.Handler: TextHandler and JSONHandler built in |
logback.xml | A 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) |
| MDC | Attributes from logger.With(…), or a handler reading the context |
| Multiple appenders | slog.NewMultiHandler(h1, h2) (Go 1.26+) |
Configuring the logger in main
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
log.atInfo()
.setMessage("user logged in")
.addKeyValue("userId", userId)
.addKeyValue("method", "sso")
.log();// 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
MDC.put("requestId", requestId);
try {
log.info("processing request"); // requestId included
service.handle(request); // and in every log below
} finally {
MDC.clear();
}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:
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
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
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.otelhttpwraps handlers and clients. - Profiling:
net/http/pprofserves CPU, heap, goroutine, mutex and block profiles;go tool pprofanalyses them. Expose it only on an internal port. - Execution traces:
go tool traceshows scheduling, GC and blocking over time. Go 1.25’strace.FlightRecorderkeeps the last few seconds in memory so you can dump a trace when something goes wrong.