Filter concepts by levelShowing all levels.

Python · Logging and Observability

Logging and Observability

Beyond print("HERE") — levels, structured JSON logs, correlation IDs, metrics, distributed tracing, and health checks

  • Two-gate filteringa record needs BOTH the logger's and the handler's level to clear
  • Structured/JSON logsnamed fields a log aggregator can query, not just full-text search
  • Correlation IDsone ID stamped on every line touched by a single request
  • Liveness ≠ readinessone restarts the process, the other just stops routing traffic

1.Logging levels, handlers, and formatters

The stdlib `logging` module replaces `print("HERE")` with a system that has a severity level, can route output to multiple destinations (handlers), and can format each destination differently — all without touching the calling code.

Key features

  • Level order, low to high: DEBUG (10) < INFO (20) < WARNING (30) < ERROR (40) < CRITICAL (50)
  • A logger AND each of its handlers has its own level — a record must clear both to be emitted
  • logging.getLogger(__name__) gives each module its own named logger in one shared hierarchy
  • A formatter controls the TEXT shape of a record (timestamp, level, message) — it does not filter anything
  • logging.config.dictConfig() configures loggers/handlers/formatters from one data structure, not scattered setup calls

The five standard levels

The five standard levels
LevelValueUse for
DEBUG10Detailed diagnostic info, off in production
INFO20Confirmation things are working as expected
WARNING30Something unexpected, but the program continues
ERROR40A specific operation failed
CRITICAL50The whole program may be about to stop working
A record must clear every gate to be emitted

logger.debug(...)

gate 1: logger.setLevel(DEBUG) — passes

handler.setLevel(WARNING)

gate 2: DEBUG < WARNING — dropped here

Formatter

only shapes text — never filters what reaches it

Output

only WARNING and ERROR records actually appear

  1. logger.debug(...) — gate 1: logger.setLevel(DEBUG) — passes
  2. handler.setLevel(WARNING) — gate 2: DEBUG < WARNING — dropped here
  3. Formatter — only shapes text — never filters what reaches it
  4. Output — only WARNING and ERROR records actually appear

Command

python
import logging

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)

handler = logging.StreamHandler()
handler.setLevel(logging.WARNING)          # handler filters independently
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s'))
logger.addHandler(handler)

A record needs to clear the logger's level AND the handler's level — a formatter only changes how it looks, never whether it appears.

2.Structured and JSON logging

Structured logging emits each record as a machine-parseable object (usually JSON) with named fields instead of a free-text sentence, so a log aggregator can filter, group, and alert on a specific field instead of grepping strings.

Key features

  • A custom logging.Formatter subclass can return json.dumps(...) from format() instead of a text template
  • The extra= keyword on any log call attaches custom fields to that one record, read back via record.<name>
  • Structured logs make grep-free querying possible: "show every ERROR where service=payments" instead of text search
  • python-json-logger and structlog are the common third-party choices; the stdlib supports the pattern with no extra install
  • Log aggregators (Elasticsearch, Datadog, CloudWatch Logs Insights) index structured fields directly — free text they can only full-text search
A structured log line, field by field

{"level": "INFO", "logger": "api", "message": "order created", "request_id": "req-abc123"}

"level": "INFO"

record.levelname — queryable severity, not just a text prefix

"logger": "api"

record.name — which logger emitted this record

"message": "order created"

record.getMessage() — the human-readable text, still present

"request_id": "req-abc123"

extra={...} — a custom field injected on this one call

  • Whole: {"level": "INFO", "logger": "api", "message": "order created", "request_id": "req-abc123"}
  • "level": "INFO" — record.levelname: queryable severity, not just a text prefix
  • "logger": "api" — record.name: which logger emitted this record
  • "message": "order created" — record.getMessage(): the human-readable text, still present
  • "request_id": "req-abc123" — extra={...}: a custom field injected on this one call

Command

python
import logging, json

class JsonFormatter(logging.Formatter):
    def format(self, record):
        payload = {
            'level': record.levelname,
            'logger': record.name,
            'message': record.getMessage(),
            'request_id': getattr(record, 'request_id', None),
        }
        return json.dumps(payload)

A structured/JSON log line is a filled-in form with named fields, queryable by field — not a text sentence formatted to look like one.

3.Correlation IDs, request IDs, and stack traces

A correlation ID (often a request ID) is one value attached to every log line produced while handling a single request, so every line touched by that request can be grepped as one group — across log statements, and across services if the ID is forwarded.

Key features

  • logging.LoggerAdapter wraps a logger and injects extra context (like a request ID) into every call automatically
  • A request ID is usually generated once per incoming request (uuid4, or read from an incoming X-Request-ID header)
  • Forwarding the SAME correlation ID to downstream services turns a single-service ID into a distributed trace anchor
  • logger.exception(msg) — called only inside an except block — automatically attaches the full traceback to the log record
  • logger.error(msg, exc_info=True) does the same as logger.exception() but can be called with any level, not just ERROR
The same request_id stamped on every log line
request
RequestIdAdapter
log output
  1. 1request_id = uuid4()[:8]generated once, at request start
  2. 2[a1b2c3d4] validating input
  3. 3[a1b2c3d4] charging card
  4. 4[a1b2c3d4] charge failedlogger.exception() attaches the traceback too
  1. request → RequestIdAdapter: request_id = uuid4()[:8] (generated once, at request start)
  2. RequestIdAdapter → log output: [a1b2c3d4] validating input
  3. RequestIdAdapter → log output: [a1b2c3d4] charging card
  4. RequestIdAdapter → log output: [a1b2c3d4] charge failed (logger.exception() attaches the traceback too)

Command

python
import logging, uuid

class RequestIdAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        return f"[{self.extra['request_id']}] {msg}", kwargs

logger = logging.getLogger(__name__)
request_logger = RequestIdAdapter(logger, {'request_id': str(uuid.uuid4())[:8]})
request_logger.info('handling request')   # every call carries the same ID

Stamp one correlation/request ID onto every log line for a request (LoggerAdapter), and only call logger.exception() from inside an except block.

4.Metrics and distributed tracing

Logs answer "what happened on this one request"; metrics answer "how is the whole system doing over time" (a number that goes up or down); distributed tracing answers "which service in the chain was slow" by linking spans across service boundaries with the same trace ID.

Key features

  • A Counter metric only goes up (requests served, errors); a Histogram/Gauge captures a distribution or a point-in-time value (latency, queue depth)
  • A trace is one request's full path; a span is one unit of work within it — spans nest, forming a tree
  • A child span's parent_id equals its parent span's span_id — that shared ID is what stitches spans from DIFFERENT services into one trace
  • OpenTelemetry is the vendor-neutral standard API/SDK for both metrics and tracing — instrument once, export to any compatible backend
  • A SpanProcessor + Exporter pair decides where spans go (console, Jaeger, an OTLP collector) — the instrumentation code itself never changes

Observability signal comparison

Observability signal comparison
SignalAnswersExample
LogsWhat happened, in detail, for one event"Payment failed: card declined"
MetricsHow is the system trending, in aggregaterequests_total, p99 latency
TracesWhere did time actually go, across servicesAPI gateway → auth → database, 4 spans

Command

python
from opentelemetry import trace

tracer = trace.get_tracer(__name__)

with tracer.start_as_current_span('handle-request') as span:
    span.set_attribute('http.method', 'GET')
    with tracer.start_as_current_span('query-database'):
        ...   # this span is automatically nested under handle-request

Logs = one event in detail; metrics = a trend over time; traces = a shared ID stitching spans across services into one request's real path.

5.Health, readiness, and liveness checks

A liveness check answers "is this process still running correctly, or should it be restarted"; a readiness check answers "can this instance accept traffic right now" — the same service can be alive but not ready (still loading, or a dependency is down).

Key features

  • Liveness check: is the process itself deadlocked/broken → orchestrator restarts the container on failure
  • Readiness check: can this instance handle a request right now → orchestrator stops routing traffic, does NOT restart
  • A readiness check should verify real dependencies (database, cache) — a liveness check should stay cheap and dependency-free
  • A liveness check that pings the database can cause a restart LOOP if the database itself is briefly down — the process was fine
  • Kubernetes calls these livenessProbe and readinessProbe; both usually map to a lightweight HTTP endpoint (GET /healthz, /readyz)

Liveness vs. readiness

Liveness vs. readiness
CheckQuestionOn failure
LivenessIs the process itself stuck or broken?Orchestrator RESTARTS the instance
ReadinessCan this instance serve a request right now?Orchestrator STOPS routing traffic, no restart
Liveness vs. readiness — different question, different response

Liveness

  • Is the process itself stuck or broken?
  • Cheap — no external dependency checks
  • On failure: orchestrator RESTARTS the instance

Readiness

  • Can this instance serve a request right now?
  • Checks real dependencies — db, cache
  • On failure: orchestrator stops routing traffic, no restart
  • Liveness
    • Is the process itself stuck or broken?
    • Cheap — no external dependency checks
    • On failure: orchestrator RESTARTS the instance
  • Readiness
    • Can this instance serve a request right now?
    • Checks real dependencies — db, cache
    • On failure: orchestrator stops routing traffic, no restart

Command

python
def liveness() -> dict:
    return {'status': 'ok'}          # cheap, no external dependency checks

def readiness(db_ok: bool, cache_ok: bool) -> tuple[dict, int]:
    ready = db_ok and cache_ok
    body = {'status': 'ok' if ready else 'unavailable', 'checks': {'db': db_ok, 'cache': cache_ok}}
    return body, 200 if ready else 503

Liveness failing restarts the process; readiness failing only stops traffic — never let a downstream dependency fail your liveness check.

Remember

  • A log record must clear both the logger's level and the handler's level — a formatter only changes how it looks.
  • Structured/JSON logs turn "grep the text" into "filter by field" — index-friendly for any log aggregator.
  • A correlation/request ID stamped on every line for one request is what makes reconstructing that request possible.
  • Liveness failing restarts the process; readiness failing only stops traffic — never let a dependency fail liveness.

Pro tip

  • logging.config.dictConfig() configures loggers/handlers/formatters from one data structure, not scattered calls.
  • logger.exception() only works correctly inside an except block — it silently logs a placeholder otherwise.
  • A child span's parent_id is literally its parent's span_id — that shared value is what stitches a distributed trace.
  • Metrics answer "how is the system trending"; traces answer "where did time actually go" — logs alone answer neither.

Next up

  • Web and HTTP Fundamentals

Sources

  • logging — docs.python.org/3/library/logging.html
  • OpenTelemetry Python — opentelemetry.io/docs/languages/python
  • Kubernetes probes — kubernetes.io/docs/tasks/configure-pod-container
  • logging + OpenTelemetry tracing run for real on Python 3.14.3 · checked 2026-08-21

Spotted a mistake on this page? Tell us.