← Back to list

Junior Devs Use log.info. Senior Devs Master These 4 Structured Logging Patterns

The difference between a log you scroll past and a log that ends an incident in ninety seconds — in four patterns you’ll come back to

Daniel Valev · 2026-06-05 13:01 · 0 claps · 6.5 min read
#programming #python #software-engineering #devops #golang
Open on Medium ↗
Wiki topics: 💻 · Programming ☁️ · DevOps & Cloud

Junior Devs Use log.info. Senior Devs Master These 4 Structured Logging Patterns

The difference between a log you scroll past and a log that ends an incident in ninety seconds — in four patterns you’ll come back to

It’s 3:11 a.m., and payments are failing. The alert fired, I’m on call, and the only thing the service is telling me is this, repeated forty thousand times:

2024-11-03 03:11:04 INFO payment failed

That line is technically true and operationally worthless. I can’t filter it by user because there’s no user in it. I can’t follow a single customer’s request as it bounces from the API gateway to the payments service to the ledger, because nothing ties those three hops together. And buried two lines up, some helpful soul has logged the full request body — card number and all — in plaintext, which is now sitting in our log aggregator under a 90-day retention policy. Three problems, one log line.

A senior engineer joins the call, runs one query, and finds the root cause in under a minute: a single downstream provider timing out for users on one specific payment method. She didn’t get there because she’s smarter at reading logs. She got there because her logs aren’t sentences — they’re data you can query. That’s the whole game. log.info isn’t wrong. It’s just an input to a system that most people never bother to build. Here are the four patterns that build it.

Pattern 1 — Log events as data, not sentences

The first mental shift is the one everything else depends on: stop writing prose for a human to read, and start emitting events for a machine to index.

Compare these two. The first is a string. The second is a record.

# Dead text. The only thing you can do with this is grep and pray.
logger.info("user 42 did checkout for $49.99")
# An event with fields. Now it's queryable.
logger.info(
    "user_action",
    user_id=42,
    action="checkout",
    amount_cents=4999,
)

The second version means you can ask your log store real questions — every checkout over $500 in the last hour, grouped by user — without writing a fragile regex against free text. The message becomes a stable event name you can pivot on, and the variable stuff lives in typed fields.

In Go, this is now the standard library. The log/slog package shipped in Go 1.21 and gives you JSON output with key-value attributes out of the box, no third-party dependency:

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

// Prefer the typed helpers (slog.Int, slog.String) over loose
// key/value pairs — the compiler and go vet will catch mismatches.
logger.Info("user_action",
    slog.Int("user_id", 42),
    slog.String("action", "checkout"),
    slog.Int("amount_cents", 4999),
)

In Python, I reach for structlog, though stdlib logging with a JSON formatter gets you most of the way there. The point isn’t the library. The point is that amount_cents=4999 is a number you can filter and aggregate, and “for $49.99” is a substring you can only hope to match. Everything below is built on this.

Pattern 2 — Correlation IDs across the whole call graph

A single structured event is useful. The ability to pull every event for one request across every service is what actually ends incidents.

The pattern: generate one request_id (or accept a trace_id from upstream) at the edge, attach it to every log line, and propagate it across service boundaries. Then “show me everything that happened to this one user’s request” becomes a single filter instead of an archaeology project.

The trap juniors fall into is threading the ID manually through every function signature. You don’t. You put it in ambient context once and let your logger pick it up. In Python, that’s contextvars, and structlog has first-class support for it:

import uuid
import structlog
from structlog.contextvars import bind_contextvars, clear_contextvars

# ASGI middleware — runs once at the start of every request.
async def correlation_middleware(request, call_next):
    clear_contextvars()
    request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
    bind_contextvars(request_id=request_id)
    return await call_next(request)

One caveat worth being explicit about: this only works if structlog.contextvars.merge_contextvars is in your processor chain. Bind without the merge processor, and the ID silently never shows up — bind it once, configure it once.

In Go, the idiomatic home for request-scoped values is context.Context, injected by middleware, with a custom slog handler that reads it:

type ctxKey string
const requestIDKey ctxKey = "request_id"

// Middleware stamps the ID onto the request context.
func RequestID(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 = uuid.NewString()
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        next.ServeHTTP(w, r.WithContext(ctx))
    })
}

// A handler wrapper that pulls the ID out of context automatically,
// so every InfoContext call gets request_id without you passing it.
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)
}

From then on, logger.InfoContext(ctx, …) carries the correlation ID for free. The senior question that this pattern answers is simple and ruthless: can I pull every log line for one single request, across the entire system, with one filter? If the answer is no, you’re going to be slow at 3 a.m.

Pattern 3 — The canonical log line

Here’s the pattern people screenshot and save, because it cuts noise and cost at the same time.

Instead of scattering ten thin log lines across a request — “received request,” “validating,” “calling DB,” “DB returned,” “responding” — you emit one fat, wide event at the end of each unit of work that pulls all the key telemetry into one place. Brandur Leach wrote this up back in 2016, and Stripe later detailed how they run it in production; the broader observability world now calls the same idea “wide events.”

One line per request, carrying everything you’d want during an incident — route, status, latency, user, outcome:

func CanonicalLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        rec := &statusRecorder{ResponseWriter: w, status: 200}

        // Deferred so the line is emitted even if a handler panics
        // and unwinds the stack — this is your observability when
        // things are actively on fire. Stripe wraps theirs in a
        // Ruby ensure block for the same reason.
        defer func() {
            slog.InfoContext(r.Context(), "canonical_log_line",
                slog.String("method", r.Method),
                slog.String("route", routePattern(r)),
                slog.Int("status", rec.status),
                slog.Duration("duration", time.Since(start)),
                slog.String("user_id", userIDFrom(r.Context())),
            )
        }()

        next.ServeHTTP(rec, r)
    })
}

The reason this is magic for aggregates: one wide row per request is the friendliest possible shape for “are we failing more requests than an hour ago,” “is p99 latency creeping up on this one route,” “is a single user hammering us.” You’re not joining ten lines at query time — the context is already colocated. And because it’s one line instead of ten, your log volume and your bill drop hard. Keep your detailed lines if you want them, but tag them with the same request_id from Pattern 2 so you can drill from the canonical summary down into the trace when you actually need to.

Pattern 4 — Redaction and volume control as a feature, not an afterthought

The PII leak from my 3 a.m. story is the most common self-inflicted wound in logging, and the fix is a philosophy, not a regex.

Build redaction as an allow-list, not a deny-list. A deny-list says “log everything, then try to scrub the fields I remembered are sensitive” — and the field you forget is the one that ends up in front of an auditor. An allow-list inverts it: nothing about an object gets logged unless you explicitly named it safe. You don’t log the user object; you log user_id and the three fields you chose on purpose. The default is silence.

A deny-list redactor is still worth having as a seatbelt — a last line of defense for the field someone adds next quarter without thinking. In the slog that’s ReplaceAttr:

// Defense in depth, not your primary control. The real protection
// is choosing what to log at the call site (allow-list). This just
// catches the obvious sins if one slips through.
func redact(groups []string, a slog.Attr) slog.Attr {
    switch a.Key {
    case "password", "token", "authorization", "card_number":
        return slog.String(a.Key, "[REDACTED]")
    }
    return a
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    ReplaceAttr: redact,
}))

The other half of this pattern is discipline about volume — specifically, never log inside a hot loop. A debug line inside a loop that runs a million times per request will flood your aggregator, blow your budget, and bury the signal. Even a guarded call has a cost, so gate it and mean it:

// Don't pay to build attributes for a line nobody will read.
if logger.Enabled(ctx, slog.LevelDebug) {
    logger.Debug("cache miss", slog.String("key", key))
}

I’m deliberately keeping this about the content and safety of your logs — what goes in a line, and whether it should exist at all. The economics of sampling high-volume telemetry is a genuinely separate craft that lives closer to your tracing and OpenTelemetry pipeline, and it deserves its own treatment rather than getting jammed in here.

What actually separates the two

The junior question is “Did I log it?” The senior question is “when this blows up at 3 a.m., will I be able to query it, trace it across services, and be confident I’m not leaking data while I do?”

That’s the whole distance between the two engineers on my incident call. Same language, same log levels, same aggregator. One of them was writing sentences. The other was building a queryable system, one structured event at a time. log.info was never the problem — it’s just the cheapest possible input to a system most people never finish building. Build the system.

If this matched the way you think about production — or made you want to argue with me about logfmt versus JSON — follow me here on Medium. I write about backend and infra from the on-call seat, not the conference stage.


메타데이터
post_id
edc131f7aee3
slug
junior-devs-use-log-info-senior-devs-master-these-4-structured-logging-patterns-edc131f7aee3
url
https://medium.com/@danielvalev/junior-devs-use-log-info-senior-devs-master-these-4-structured-logging-patterns-edc131f7aee3
canonical_url
https://medium.com/@danielvalev/junior-devs-use-log-info-senior-devs-master-these-4-structured-logging-patterns-edc131f7aee3
author_url
https://medium.com/@danielvalev
status
ok
fetched_at
2026-06-09 15:37:30