python-json-logger
Flat text logs force every consumer to re-parse them. Loki, CloudWatch, Datadog, and every other log pipeline prefers one JSON object per line. You get free indexing on fields like level, logger, and your request id, and you keep unstructured message text for humans.
import logging
import os
import sys
from pythonjsonlogger import jsonlogger
def configure_logging() -> None:
"""Configure the root logger to emit JSON to stdout."""
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
root = logging.getLogger()
for handler in list(root.handlers):
root.removeHandler(handler)
handler = logging.StreamHandler(sys.stdout)
formatter = jsonlogger.JsonFormatter(
"%(asctime)s %(levelname)s %(name)s %(message)s",
rename_fields={"asctime": "timestamp", "levelname": "level"},
)
handler.setFormatter(formatter)
root.addHandler(handler)
root.setLevel(level)
for name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
lg = logging.getLogger(name)
lg.handlers = [handler]
lg.setLevel(level)
lg.propagate = FalseClear the default handlers so your JSON formatter is the only one active. The rename_fields call aligns the log schema with what most aggregators expect (timestamp, level). Reassign uvicorn loggers so access logs and error logs also come out as JSON, not as the default text format.
{"timestamp": "2026-04-15 12:03:47", "level": "INFO", "name": "service", "message": "job_started", "job_id": "b4b9...", "request_id": "9f81..."}Both request_id (from the log filter) and job_id (passed as extra on the log call) appear as top-level fields. Queries in Loki or CloudWatch can filter on either one without regex parsing.
Pass them as the extra argument: logger.info("job_started", extra={"job_id": job_id}). python-json-logger lifts any extras into top-level JSON keys on that record. Combine this with the filter-based request id and you get high-signal structured logs with no glue code at call sites.
Quiz: Quiz
Loading practice…