A typical application log line is a small essay addressed to a human: timestamps, free-form sentences, half a dozen values glued into the message with string formatting. Humans can read it, but nobody can query it. The moment you need “all requests for tenant X that failed in the last hour,” you are writing regular expressions against prose. Structured logging replaces the essay with an event: a message plus a fixed set of key-value fields that machines can parse, filter, and aggregate without guessing where each value begins and ends.
This post walks through what changes in practice when you commit to structured events: how to emit them from Go with the standard library’s slog package, how to correlate every event with its request without threading the request ID through every call, how to keep the firehose affordable with levels and lazy values, and how to design a field schema that stays queryable for years.
From Strings to Events
The core shift is that the log message stops carrying data inside it. Compare the unstructured form:
2026/11/16 01:12:04 processed order ord_8321 (3 items) in 1.8ms
with the structured equivalent, where the message is a short constant and the values live in named fields:
level=INFO msg="order processed" order_id=ord_8321 item_count=3 elapsed=1.8ms
The second form is trivially parseable, but the real gain shows up downstream. Storage systems such as Grafana Loki can index field labels instead of the whole line, so filtering by order ID becomes an index lookup rather than a substring scan. Aggregations — count of orders per tenant, 99th-percentile duration — become group-by operations over fields instead of extraction pipelines. And the OpenTelemetry Logs Data Model gives these fields a standard vocabulary, so the same event can move between backends without a bespoke converter per format.
Emitting Events with slog
Since Go 1.21, the standard library includes log/slog, which means structured logging no longer requires pulling in a third-party dependency like zap or zerolog. A minimal setup writes one JSON object per line to standard output, which is exactly what container runtimes and log shippers expect:
package main
import (
"log/slog"
"os"
"time"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
start := time.Now()
slog.Info("order processed",
slog.String("order_id", "ord_8321"),
slog.Int("item_count", 3),
slog.Duration("elapsed", time.Since(start)),
)
}
Each record arrives as a single JSON object with time, level, msg, and the three custom fields as siblings:
{"time":"2026-11-16T01:12:04.117+02:00","level":"INFO","msg":"order processed","order_id":"ord_8321","item_count":3,"elapsed":18412}
Two habits matter more than the choice of library. First, the message should be a stable, short string — a constant that identifies the event type — while everything variable goes into fields. If the message is dynamically formatted, you have rebuilt the unstructured problem one level down. Second, values must be passed as typed attributes, not pre-formatted into strings. slog.Duration("elapsed", d) preserves the numeric value, so dashboards can average and bucket it; slog.String("elapsed", "1.8ms") destroys that ability.
Correlating Events with Their Request
The most valuable field in any log line is the correlation ID, because it turns thousands of scattered events into one coherent story. The naive approach — passing a logger carrying the request ID into every function — pollutes every signature. The idiomatic trick with slog is that handlers receive the context.Context on each call, so a thin wrapper can inject request-scoped fields automatically.
The handler below implements slog’s four-method Handler interface and adds the request ID stored in the context to every record it processes:
package main
import (
"context"
"log/slog"
)
type requestIDKey struct{}
type ctxHandler struct {
inner slog.Handler
}
func (h ctxHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h ctxHandler) Handle(ctx context.Context, r slog.Record) error {
if id, ok := ctx.Value(requestIDKey{}).(string); ok && id != "" {
r.AddAttrs(slog.String("request_id", id))
}
return h.inner.Handle(ctx, r)
}
func (h ctxHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return ctxHandler{inner: h.inner.WithAttrs(attrs)}
}
func (h ctxHandler) WithGroup(name string) slog.Handler {
return ctxHandler{inner: h.inner.WithGroup(name)}
}
package main
import (
"context"
"log/slog"
"net/http"
"os"
)
type requestIDKey struct{}
func withRequestID(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_generated"
}
ctx := context.WithValue(r.Context(), requestIDKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func main() {
logger := slog.New(ctxHandler{inner: slog.NewJSONHandler(os.Stdout, nil)})
slog.SetDefault(logger)
mux := http.NewServeMux()
mux.HandleFunc("/orders", func(w http.ResponseWriter, r *http.Request) {
slog.InfoContext(r.Context(), "order listed",
slog.String("path", r.URL.Path),
)
w.WriteHeader(http.StatusOK)
})
if err := http.ListenAndServe(":8080", withRequestID(mux)); err != nil {
logger.Error("server exited", slog.Any("err", err))
}
}
Business logic calls slog.InfoContext(ctx, ...) and never mentions the request ID. The middleware is the only place that knows where it comes from, and the handler wrapper is the only place that knows where it goes. When you later adopt distributed tracing, the same wrapper is where you would pull the trace ID from the context instead, giving every log line an automatic join key into your traces.
Keeping the Firehose Affordable
Structured logs are easy to over-emit because adding a field is cheap. Three mechanisms keep the volume sane:
- Levels as gates, not decoration. Debug records should carry the deep detail — loop counters, cache keys, raw sizes — and the
Leveloption on the JSON handler filters them at emit time, so the cost never reaches disk or the network in production. - Lazy values for expensive fields. Implementing slog’s
LogValuerinterface on a type defers serialization until the record is actually emitted. A Debug-level record that includes a computed summary does no work at all when Debug is filtered out. - Sampling. For hot paths that emit per-request, emit full detail for failures and a fraction of successes. The failure records are the ones you will actually search for.
The LogValuer interface is also the cleanest place to enforce privacy rules. Rather than remembering, at every call site, to redact a payment object, you teach the type itself how to present itself in logs:
package main
import (
"log/slog"
"os"
)
type Payment struct {
CardNumber string
Amount int64
Currency string
}
// LogValue keeps the full card number out of every log line.
func (p Payment) LogValue() slog.Value {
return slog.GroupValue(
slog.String("card_last4", p.CardNumber[len(p.CardNumber)-4:]),
slog.Int64("amount", p.Amount),
slog.String("currency", p.Currency),
)
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
p := Payment{CardNumber: "4242424242424242", Amount: 4900, Currency: "EUR"}
logger.Info("payment captured", slog.Any("payment", p))
}
Every event that carries a Payment now shows the last four digits and never the full number — regardless of who logged it. Centralizing that decision in one method beats auditing hundreds of call sites.
A Field Schema Worth Committing To
Fields are only queryable if their names and shapes are consistent across services. Treat the field schema like a public API contract:
- Use one naming convention (snake_case is the common default) and never mix
order_idwithorderID— half your dashboards will silently break. - Bound your cardinality deliberately. IDs, ports, and status codes are fine as fields; full URLs, email addresses, and SQL statements explode storage and slow every index. If a value has more distinct values than users, ask whether it belongs in the event at all.
- Log identifiers, not payloads. The event should say
order_id=ord_8321so a reader can fetch the order from the system of record — not embed the entire order body, which ages badly, violates retention policies, and leaks data. - Version deliberately. When a field’s meaning changes, give it a new name instead of quietly reinterpreting the old one; stored logs outlive the code that wrote them.
Where the Events Go
A structured event written to standard output is only the beginning of the pipeline. In containerized deployments, the runtime captures stdout, a shipper forwards it, and a backend stores and indexes it. The OpenTelemetry logs data model defines a stable representation — timestamp, severity, body, and attributes — that lets a collector translate vendor formats once, at the edge, instead of inside every application. Emitting JSON to stdout keeps the application side of that contract trivial: the app logs, the infrastructure does the moving.
The payoff for the discipline is felt on the worst day. When an incident hits, the investigation becomes a sequence of field filters — all events with this request ID, then all events with this customer, then a histogram of durations bucketed by endpoint — none of which requires reading a single log line as prose. Structured logging moves the work of understanding your system from the middle of an outage to a quiet afternoon of schema design, and that trade is one of the best bargains in operations.