Structured logging

Observability and operations Junior Python 3.13+ PostgreSQL 17+ Reviewed By Best Latest Apps

Predict the output

What line does the logger print?

Python 3.13
import json
import logging
import sys


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        doc = {"level": record.levelname, "msg": record.getMessage()}
        doc.update(getattr(record, "ctx", {}))
        return json.dumps(doc, sort_keys=True)


handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("orders")
log.addHandler(handler)
log.setLevel(logging.INFO)
ctx = {"order_id": 42, "request_id": "r-9"}
log.info("payment captured", extra={"ctx": ctx})

Options
Hint 1 of 1

Which formatter is attached?

Choose an answer, then open Show answer below to check it.

Show answer

Answer: A {"level": "INFO", "msg": "payment captured", "order_id": 42, "request_id": "r-9"}

Correct. The formatter merges the context into one JSON object with sorted keys.

The custom formatter builds one dict from the level, message and the ctx passed via extra, and json.dumps with sort_keys writes the keys alphabetically. One JSON object per line is easy for log tools to query.

Why not the others?
  • B INFO:orders:payment captured. That's the default text format; this handler uses JsonFormatter.
  • C {"msg": "payment captured"}. Level and context are added too.
  • D Nothing. The logger level is INFO, so info lines go out.

30-second answer

Structured logs (usually JSON, one event per line) let you filter and aggregate by field instead of grepping text: order_id = 42 AND level = ERROR. Every line should carry a timestamp, level, service and version, a request or trace id to tie lines together, and the identifiers of the thing being worked on. Keep messages constant ('payment captured') and put variable data in fields, so identical events group together.

Practise saying it · 30 s. No recording, just a timer

Structured logging.

Say your answer out loud, then check which of these points you covered:

  • JSON per event
  • Standard fields + request id
  • Constant messages
  • Log at boundaries
  • No secrets

Deep dive

Add trace and span ids so logs link to traces.

Log at boundaries (request in/out, external calls, decisions), not every line.

Sample noisy debug logs; keep all errors.

Never log secrets, tokens or full card numbers.

A tiny JSON formatter Ran on Python 3.13.5 · mypy --strict
Python 3.13
import json
import logging
import sys


class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        doc = {"level": record.levelname, "msg": record.getMessage()}
        doc.update(getattr(record, "ctx", {}))
        return json.dumps(doc, sort_keys=True)


handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
log = logging.getLogger("orders")
log.addHandler(handler)
log.setLevel(logging.INFO)
ctx = {"order_id": 42, "request_id": "r-9"}
log.info("payment captured", extra={"ctx": ctx})

Common mistakes

  • Interpolating values into the message text.
  • Logging the same error at every layer.
  • No request id, so one request's lines can't be found.

Senior take & follow-ups

Fields over text, the standard fields, constant messages, and what not to log.

Follow-up questions

Think of your answer first, then open the hint.

  1. Why constant messages?

    Show hint

    So the same event aggregates and counts correctly.

  2. How do logs connect to traces?

    Show hint

    Include the trace id in each log line.

Suggest a fix if something here is wrong or out of date.