Skip to main content

Python Logging Basics Practice Problems & Exercises

Practice: Logging Basics

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

Easy

#1Your First LoggerEasy
getLogger__name__basicConfiglog levels

Create a logger named __name__, configure it to show all five levels, and log one message at each level.

This is the standard module-level logger pattern used in every production Python application.

Python
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s:%(name)s:%(message)s")

logger = logging.getLogger(__name__)

logger.debug("Debug message")
logger.info("Info message")
logger.warning("Warning message")
logger.error("Error message")
logger.critical("Critical message")
Solution
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s:%(name)s:%(message)s")

logger = logging.getLogger(__name__)

logger.debug("Debug message")
logger.info("Info message")
logger.warning("Warning message")
logger.error("Error message")
logger.critical("Critical message")

Key points:

  • basicConfig must be called before any loggers are used — subsequent calls are ignored.
  • logging.getLogger(__name__) names the logger after the current module, placing it correctly in the hierarchy.
  • The format string %(levelname)s:%(name)s:%(message)s produces the clean output above.
import logging

# TODO: Configure basicConfig with level=DEBUG and format "%(levelname)s:%(name)s:%(message)s"
# TODO: Create a logger named after this module using __name__
# TODO: Log one message at each of the five levels

logger = None  # replace with getLogger call

# Log calls go here
Expected Output
DEBUG:__main__:Debug message
INFO:__main__:Info message
WARNING:__main__:Warning message
ERROR:__main__:Error message
CRITICAL:__main__:Critical message
Hints

Hint 1: Call logging.basicConfig(level=logging.DEBUG, format='%(levelname)s:%(name)s:%(message)s') before any logger calls.

Hint 2: Create your logger with: logger = logging.getLogger(__name__)

Hint 3: Use logger.debug(), logger.info(), logger.warning(), logger.error(), logger.critical().

#2Log Level Filtering — Predict the OutputEasy
log levelslevel filteringsetLevel

Predict which log lines will appear when the root level is set to WARNING. Assign the visible message strings to will_appear.

Understanding level filtering is fundamental — it is how you silence debug noise without touching your code.

Python
import logging

logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")

logger = logging.getLogger("filter_demo")

logger.debug("alpha")
logger.info("beta")
logger.warning("gamma")
logger.error("delta")
logger.critical("epsilon")

will_appear = ["gamma", "delta", "epsilon"]
print(f"will_appear = {will_appear}")
Solution
will_appear = ["gamma", "delta", "epsilon"]

Key points:

  • level=logging.WARNING means records at WARNING (30) and above pass. DEBUG (10) and INFO (20) are below the threshold and are dropped.
  • Setting the logger level is a gate — if a record does not pass the gate, no handler ever sees it.
  • In production, the typical default is WARNING or INFO to avoid verbose output.
import logging

logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")

logger = logging.getLogger("filter_demo")

logger.debug("alpha")
logger.info("beta")
logger.warning("gamma")
logger.error("delta")
logger.critical("epsilon")

# Which of these lines will actually appear in the output?
# Set the list to the correct message strings.
will_appear = []  # TODO: fill in
Expected Output
WARNING: gamma
ERROR: delta
CRITICAL: epsilon
will_appear = ['gamma', 'delta', 'epsilon']
Hints

Hint 1: When level is set to WARNING (30), only records at level 30 and above pass through.

Hint 2: DEBUG=10 and INFO=20 are both below WARNING=30 — they are filtered out.

Hint 3: WARNING, ERROR, and CRITICAL all pass the threshold.

#3Level Numbers — Fill in the TableEasy
log levelslevel valuesnumeric levels

Assign the correct integer value for each logging level constant. Knowing the numeric values explains why filtering works — only records with a value greater than or equal to the configured threshold pass through.

Python
import logging

debug_value    = logging.DEBUG     # 10
info_value     = logging.INFO      # 20
warning_value  = logging.WARNING   # 30
error_value    = logging.ERROR     # 40
critical_value = logging.CRITICAL  # 50

print(f"DEBUG    = {debug_value}")
print(f"INFO     = {info_value}")
print(f"WARNING  = {warning_value}")
print(f"ERROR    = {error_value}")
print(f"CRITICAL = {critical_value}")
Solution
debug_value = 10
info_value = 20
warning_value = 30
error_value = 40
critical_value = 50

Key points:

  • The 10-point gaps allow you to insert custom levels (e.g., logging.addLevelName(25, "NOTICE")).
  • You can also pass integers directly: logger.setLevel(30) is equivalent to logger.setLevel(logging.WARNING).
  • logging.getLevelName(30) returns "WARNING" — useful for dynamic configuration from environment variables.
import logging

# Fill in the correct integer values for each level constant.
# Use logging.LEVELNAME to check your answers.

debug_value    = None  # TODO: integer value of logging.DEBUG
info_value     = None  # TODO: integer value of logging.INFO
warning_value  = None  # TODO: integer value of logging.WARNING
error_value    = None  # TODO: integer value of logging.ERROR
critical_value = None  # TODO: integer value of logging.CRITICAL

print(f"DEBUG    = {debug_value}")
print(f"INFO     = {info_value}")
print(f"WARNING  = {warning_value}")
print(f"ERROR    = {error_value}")
print(f"CRITICAL = {critical_value}")
Expected Output
DEBUG    = 10
INFO     = 20
WARNING  = 30
ERROR    = 40
CRITICAL = 50
Hints

Hint 1: The levels are spaced 10 apart to allow custom levels in between.

Hint 2: Check with: logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL.

Hint 3: Knowing the numeric values helps when you call logger.setLevel(logging.WARNING) — you can also pass the integer directly.

#4NullHandler for LibrariesEasy
NullHandlerlibrary loggingbest practices

Write the standard library __init__.py logging setup: obtain the "mylib" logger and add a NullHandler to it.

This is the PEP 391-recommended pattern — every library you publish should follow it.

Python
import logging

logger = logging.getLogger("mylib")
logger.addHandler(logging.NullHandler())

handler_type = type(logger.handlers[0]).__name__
print(f"Handler type: {handler_type}")
print(f"Handler count: {len(logger.handlers)}")
Solution
import logging

logger = logging.getLogger("mylib")
logger.addHandler(logging.NullHandler())

Key points:

  • NullHandler is a do-nothing handler — it discards every record passed to it.
  • Without it, if an application has no logging configured, Python prints a "No handlers could be found for logger 'mylib'" warning to stderr.
  • By adding NullHandler, you let the application developer decide whether to attach real handlers to "mylib".
  • This is the official Python logging cookbook recommendation for library authors.
import logging

# Imagine this is your library's __init__.py.
# A library should NEVER configure handlers for the application.
# Instead, it should add a NullHandler to silence the
# "No handlers could be found" warning.

# TODO: Get the logger named "mylib"
# TODO: Add a NullHandler to it

logger = None  # replace this

# Verify: the logger should have exactly one handler, a NullHandler
handler_type = type(logger.handlers[0]).__name__
print(f"Handler type: {handler_type}")
print(f"Handler count: {len(logger.handlers)}")
Expected Output
Handler type: NullHandler
Handler count: 1
Hints

Hint 1: Get the logger with logging.getLogger('mylib').

Hint 2: Add a NullHandler with: logger.addHandler(logging.NullHandler())

Hint 3: NullHandler discards all records — it is a no-op that prevents the 'No handlers found' warning.


Medium

#5Attach a Formatter to a HandlerMedium
FormatterStreamHandlerformat stringhandler setup

Build a complete logger-handler-formatter chain for "webapp". The output must include a date, bracketed level, logger name, and message.

This is the fundamental composition pattern: Logger → Handler → Formatter.

Python
import logging
import sys

logger = logging.getLogger("webapp")
logger.setLevel(logging.DEBUG)
logger.propagate = False

handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)

fmt = logging.Formatter(
    "%(asctime)s [%(levelname)s] %(name)s: %(message)s",
    datefmt="%Y-%m-%d",
)
handler.setFormatter(fmt)

logger.addHandler(handler)

logger.info("Service started")
logger.warning("High memory usage")
Solution
import logging
import sys

logger = logging.getLogger("webapp")
logger.setLevel(logging.DEBUG)
logger.propagate = False

handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)

fmt = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d",
)
handler.setFormatter(fmt)

logger.addHandler(handler)

logger.info("Service started")
logger.warning("High memory usage")

Key points:

  • The Formatter is attached to the Handler, not the Logger. The Logger decides whether to pass a record; the Handler decides where it goes; the Formatter decides how it looks.
  • datefmt uses strftime format codes. %Y-%m-%d gives ISO date-only format.
  • logger.propagate = False prevents the record from also travelling to the root logger (which would cause duplicate output if root has its own handler).
import logging
import sys

# TODO: Create a logger named "webapp"
# TODO: Set its level to DEBUG
# TODO: Create a StreamHandler writing to sys.stderr
# TODO: Create a Formatter with format:
#       "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
#       and datefmt "%Y-%m-%d"
# TODO: Attach the formatter to the handler
# TODO: Attach the handler to the logger
# TODO: Set logger.propagate = False to avoid duplicate output

logger = None  # replace this

logger.info("Service started")
logger.warning("High memory usage")
Expected Output
2024-01-15 [INFO] webapp: Service started
2024-01-15 [WARNING] webapp: High memory usage
Hints

Hint 1: Create the handler: handler = logging.StreamHandler(sys.stderr)

Hint 2: Create the formatter: fmt = logging.Formatter('%(asctime)s [%(levelname)s] %(name)s: %(message)s', datefmt='%Y-%m-%d')

Hint 3: Attach: handler.setFormatter(fmt), then logger.addHandler(handler).

Hint 4: Set logger.propagate = False so records do not also go to the root logger.

#6Multiple Handlers at Different LevelsMedium
multiple handlershandler levelStreamHandlerlevel routing

Wire two handlers to one logger at different severity thresholds. This demonstrates that a single log call can fan out to multiple destinations with independent filtering.

Python
import logging
import sys

logger = logging.getLogger("router_demo")
logger.setLevel(logging.DEBUG)
logger.propagate = False

# Handler 1: stdout, all DEBUG+ records
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.DEBUG)
stdout_handler.setFormatter(logging.Formatter("DEBUG: %(message)s"))

# Handler 2: stderr, only ERROR+ records
stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.ERROR)
stderr_handler.setFormatter(logging.Formatter("ERROR: %(message)s"))

logger.addHandler(stdout_handler)
logger.addHandler(stderr_handler)

logger.debug("trace detail")
logger.info("request received")
logger.error("database timeout")
logger.critical("out of memory")
Solution
import logging
import sys

logger = logging.getLogger("router_demo")
logger.setLevel(logging.DEBUG)
logger.propagate = False

stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setLevel(logging.DEBUG)
stdout_handler.setFormatter(logging.Formatter("DEBUG: %(message)s"))

stderr_handler = logging.StreamHandler(sys.stderr)
stderr_handler.setLevel(logging.ERROR)
stderr_handler.setFormatter(logging.Formatter("ERROR: %(message)s"))

logger.addHandler(stdout_handler)
logger.addHandler(stderr_handler)

logger.debug("trace detail")
logger.info("request received")
logger.error("database timeout")
logger.critical("out of memory")

Key points:

  • There are two independent level filters: the Logger's own level (first gate) and each Handler's level (second gate per handler).
  • logger.debug("trace detail") passes the logger's DEBUG gate, then passes Handler 1's DEBUG gate, then fails Handler 2's ERROR gate — so it only appears on stdout.
  • logger.error("database timeout") passes both gates on both handlers — it appears on stdout AND stderr.
import logging
import sys

# Set up a logger "router_demo" with TWO handlers:
#   Handler 1: StreamHandler to sys.stdout, level=DEBUG, format "DEBUG: %(message)s"
#   Handler 2: StreamHandler to sys.stderr, level=ERROR, format "ERROR: %(message)s"
# Logger level: DEBUG, propagate=False

# Then log these messages:
#   logger.debug("trace detail")
#   logger.info("request received")
#   logger.error("database timeout")
#   logger.critical("out of memory")

# Expected: debug/info appear only on stdout; error/critical appear on BOTH stdout and stderr

logger = None  # replace this
Expected Output
stdout receives: trace detail, request received, database timeout, out of memory
stderr receives: database timeout, out of memory
Hints

Hint 1: Set logger.setLevel(logging.DEBUG) so the logger gate passes everything.

Hint 2: Each handler has its own level — Handler 1 at DEBUG passes all, Handler 2 at ERROR passes only ERROR+.

Hint 3: A record that passes the logger level is sent to ALL handlers. Each handler then applies its own level filter.

#7Capture Tracebacks with logger.exception()Medium
logger.exceptionexc_infotracebacksexcept blocks

Rewrite parse_config so it catches all exceptions, logs them with logger.exception() (capturing the full traceback), and returns None instead of crashing.

This is the most important pattern for production code: never silently swallow exceptions, and always preserve the traceback.

Python
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


def parse_config(data):
    try:
        host = data["host"]
        port = int(data["port"])
        return (host, port)
    except Exception:
        logger.exception("Failed to parse config")
        return None


result = parse_config({"host": "localhost"})
print(f"Result 1: {result}")

result = parse_config({"host": "localhost", "port": "not_a_number"})
print(f"Result 2: {result}")
Solution
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


def parse_config(data):
try:
host = data["host"]
port = int(data["port"])
return (host, port)
except Exception:
logger.exception("Failed to parse config")
return None

Key points:

  • logger.exception(msg) is shorthand for logger.error(msg, exc_info=True) — it logs at ERROR level AND captures sys.exc_info() to append the full traceback.
  • It must be called inside an except block (or at least within active exception context) — otherwise there is no current exception to capture.
  • Never use logger.error(msg) alone inside an except block — you lose the traceback that explains what failed.
import logging

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)


def parse_config(data):
  """
  Parse a config dict expecting keys: 'host', 'port'.
  Port must be a valid integer.
  Log exceptions with full traceback — do NOT let the function crash.
  Return None on any error.
  """
  # TODO: wrap the logic in try/except
  # TODO: use logger.exception() to capture the full traceback
  # TODO: return None on error
  host = data["host"]
  port = int(data["port"])
  return (host, port)


# Test 1: missing key
result = parse_config({"host": "localhost"})
print(f"Result 1: {result}")

# Test 2: invalid port
result = parse_config({"host": "localhost", "port": "not_a_number"})
print(f"Result 2: {result}")
Expected Output
ERROR: Failed to parse config
...KeyError: 'port'
Result 1: None
ERROR: Failed to parse config
...ValueError: invalid literal for int() with base 10: 'not_a_number'
Result 2: None
Hints

Hint 1: Wrap the function body in try/except Exception.

Hint 2: Inside the except block, call logger.exception('Failed to parse config') — this logs at ERROR and appends the full traceback automatically.

Hint 3: Return None in the except block. Let the try block return the tuple.

#8Fix the Duplicate Log BugMedium
propagationduplicate logspropagatehandler setup

The code logs "Something happened" twice. Add one line to fix the duplicate output without removing either handler.

Duplicate log messages are one of the most common logging bugs. Understanding propagation is the key.

Python
import logging

root = logging.getLogger()
root.setLevel(logging.DEBUG)
root.addHandler(logging.StreamHandler())

app = logging.getLogger("app")
app.setLevel(logging.DEBUG)
app.addHandler(logging.StreamHandler())
app.propagate = False   # FIX: stop propagation to root logger

app.warning("Something happened")
Solution
app.propagate = False

Key points:

  • By default, every logger has propagate = True, which means log records travel up to the parent logger (and eventually the root).
  • When both "app" and root have a StreamHandler, the same record gets handled twice — producing two identical output lines.
  • Setting app.propagate = False breaks the chain: records handled by "app" stop there and never reach the root logger.
  • Alternative fix: remove the handler from "app" and rely on root alone, but propagate = False is correct when the child has its own dedicated handler.
import logging

# This code produces DUPLICATE log output.
# Identify the cause and fix it with a one-line change.

root = logging.getLogger()
root.setLevel(logging.DEBUG)
root.addHandler(logging.StreamHandler())   # Root has a handler

app = logging.getLogger("app")
app.setLevel(logging.DEBUG)
app.addHandler(logging.StreamHandler())   # App also has a handler

app.warning("Something happened")

# Expected: "Something happened" appears exactly ONCE
# Current: it appears TWICE

# TODO: Add exactly one line to fix the duplicate output
Expected Output
WARNING:app:Something happened
Hints

Hint 1: The 'app' logger propagates to the root logger by default (propagate=True).

Hint 2: Both the 'app' handler AND the root handler fire — producing two outputs.

Hint 3: Fix: set app.propagate = False to stop the record from reaching the root logger.


Hard

#9Build a dictConfig ConfigurationHard
dictConfigproduction configurationformattershandlersloggers

Build a complete dictConfig dict with formatters, handlers, named loggers, and root logger configuration. This is the production-standard approach used in Django, FastAPI, and Flask applications.

Python
import logging
import logging.config

LOGGING_CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,

    "formatters": {
        "standard": {
            "format": "%(levelname)s %(name)s: %(message)s",
        },
    },

    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "level": "INFO",
            "formatter": "standard",
        },
    },

    "loggers": {
        "myapp": {
            "level": "DEBUG",
            "handlers": ["console"],
            "propagate": False,
        },
    },

    "root": {
        "level": "WARNING",
        "handlers": ["console"],
    },
}

logging.config.dictConfig(LOGGING_CONFIG)

app_logger = logging.getLogger("myapp")
root_logger = logging.getLogger()

app_logger.debug("app debug")
app_logger.info("app info")
app_logger.warning("app warning")

root_logger.warning("root warning")
root_logger.debug("root debug")
Solution
import logging
import logging.config

LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"standard": {
"format": "%(levelname)s %(name)s: %(message)s",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"level": "INFO",
"formatter": "standard",
},
},
"loggers": {
"myapp": {
"level": "DEBUG",
"handlers": ["console"],
"propagate": False,
},
},
"root": {
"level": "WARNING",
"handlers": ["console"],
},
}

logging.config.dictConfig(LOGGING_CONFIG)

Key points:

  • "version": 1 is required — it is the only version currently supported.
  • "disable_existing_loggers": False is critical in production. Setting it to True silences every logger created before dictConfig runs — including those in third-party libraries that registered loggers at import time.
  • The "myapp" logger has "level": "DEBUG" (the logger gate is open) but the "console" handler has "level": "INFO" — so debug records are created but discarded at the handler level.
  • "propagate": False on "myapp" prevents records from also being handled by the root logger's console handler (which would cause duplicates).
import logging
import logging.config

# Build a LOGGING_CONFIG dict that, when passed to logging.config.dictConfig(), produces:
#   - A "standard" formatter: "%(levelname)s %(name)s: %(message)s"
#   - A "console" handler: StreamHandler, level=INFO, uses "standard" formatter
#   - A "myapp" logger: level=DEBUG, handlers=["console"], propagate=False
#   - root logger: level=WARNING, handlers=["console"]

LOGGING_CONFIG = {
  # TODO: fill in "version", "disable_existing_loggers",
  #       "formatters", "handlers", "loggers", "root"
}

logging.config.dictConfig(LOGGING_CONFIG)

app_logger = logging.getLogger("myapp")
root_logger = logging.getLogger()

app_logger.debug("app debug")     # filtered by console handler (INFO threshold)
app_logger.info("app info")       # appears
app_logger.warning("app warning") # appears

root_logger.warning("root warning") # appears
root_logger.debug("root debug")     # filtered by root level=WARNING
Expected Output
INFO myapp: app info
WARNING myapp: app warning
WARNING root: root warning
Hints

Hint 1: The top-level keys are: version, disable_existing_loggers, formatters, handlers, loggers, root.

Hint 2: version must be 1. disable_existing_loggers should be False.

Hint 3: Formatter dict needs a 'format' key. Handler dict needs 'class', 'level', 'formatter' keys.

Hint 4: Set 'class': 'logging.StreamHandler' for the console handler.

#10Logger Hierarchy and Level InheritanceHard
logger hierarchylevel inheritancegetLoggerparent logger

Set a level only on the root "myapp" logger and verify that child loggers "myapp.api" and "myapp.api.users" inherit it automatically via the hierarchy.

Level inheritance is the mechanism that lets you configure logging once and have all sub-modules respect it without any per-module configuration.

Python
import logging

logging.basicConfig(format="%(name)s: %(message)s")

myapp = logging.getLogger("myapp")
myapp.setLevel(logging.WARNING)

api = logging.getLogger("myapp.api")
users = logging.getLogger("myapp.api.users")

# getEffectiveLevel() walks up until it finds a non-NOTSET level
myapp_effective  = myapp.getEffectiveLevel()   # 30 (WARNING — set directly)
api_effective    = api.getEffectiveLevel()     # 30 (inherited from myapp)
users_effective  = users.getEffectiveLevel()   # 30 (inherited from myapp via myapp.api)

print(f"myapp effective level:          {logging.getLevelName(myapp_effective)}")
print(f"myapp.api effective level:      {logging.getLevelName(api_effective)}")
print(f"myapp.api.users effective level:{logging.getLevelName(users_effective)}")

api.info("this should NOT appear")
users.info("this should NOT appear either")
myapp.warning("this SHOULD appear")
Solution
myapp_effective = myapp.getEffectiveLevel() # 30
api_effective = api.getEffectiveLevel() # 30
users_effective = users.getEffectiveLevel() # 30

Key points:

  • logger.level is what you explicitly set. If you never call setLevel, it is logging.NOTSET (0).
  • logger.getEffectiveLevel() walks up the parent chain until it finds a level that is not NOTSET. This is the level actually used for filtering.
  • The parent of "myapp.api" is "myapp" (Python derives the hierarchy from the dotted name). The parent of "myapp.api.users" is "myapp.api", whose parent is "myapp".
  • This design means you can configure logging.getLogger("myapp").setLevel(logging.INFO) in your application startup and all "myapp.*" loggers created in any module automatically respect it — no per-file configuration required.
import logging

# Demonstrate logger hierarchy and effective level inheritance.
# Create three loggers: "myapp", "myapp.api", "myapp.api.users"
# Set level=WARNING only on "myapp"
# Leave "myapp.api" and "myapp.api.users" with NO level set (NOTSET)

# Then answer: what is the effective level of each child logger?
# The effective level is the first non-NOTSET level found walking up the hierarchy.

logging.basicConfig(format="%(name)s: %(message)s")

myapp = logging.getLogger("myapp")
myapp.setLevel(logging.WARNING)

api = logging.getLogger("myapp.api")
users = logging.getLogger("myapp.api.users")

# TODO: Fill in the effective levels
myapp_effective  = None  # logging.WARNING? logging.DEBUG? something else?
api_effective    = None  # inherits from parent?
users_effective  = None  # inherits from grandparent?

print(f"myapp effective level:          {logging.getLevelName(myapp_effective)}")
print(f"myapp.api effective level:      {logging.getLevelName(api_effective)}")
print(f"myapp.api.users effective level:{logging.getLevelName(users_effective)}")

# Verify by attempting to log at INFO (should be silenced for all three)
api.info("this should NOT appear")
users.info("this should NOT appear either")
myapp.warning("this SHOULD appear")
Expected Output
myapp effective level:          WARNING
myapp.api effective level:      WARNING
myapp.api.users effective level:WARNING
__main__: this SHOULD appear
Hints

Hint 1: Use logger.getEffectiveLevel() to check the computed level — it walks up the hierarchy until it finds a non-NOTSET level.

Hint 2: NOTSET (0) means 'defer to parent'. logging.NOTSET == 0.

Hint 3: A child logger with NOTSET inherits the first ancestor level that is not NOTSET.

Hint 4: All three loggers here have an effective level of WARNING because 'myapp' is the closest ancestor with a real level set.

#11Structured JSON Logging with LoggerAdapterHard
LoggerAdapterstructured loggingextraJSON loggingcontext

Combine a custom JSONFormatter with a LoggerAdapter to produce structured log lines where every message automatically carries a request_id. This is the foundation of production observability pipelines.

Python
import logging
import json


class JSONFormatter(logging.Formatter):
    def format(self, record):
        log_entry = {
            "level": record.levelname,
            "logger": record.name,
            "request_id": getattr(record, "request_id", "n/a"),
            "message": record.getMessage(),
        }
        return json.dumps(log_entry)


class RequestAdapter(logging.LoggerAdapter):
    def process(self, msg, kwargs):
        kwargs.setdefault("extra", {})
        kwargs["extra"]["request_id"] = self.extra.get("request_id", "n/a")
        return msg, kwargs


base_logger = logging.getLogger("myapp.api")
base_logger.setLevel(logging.DEBUG)
base_logger.propagate = False

handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
base_logger.addHandler(handler)

logger = RequestAdapter(base_logger, {"request_id": "req-001"})

logger.info("Request started")
logger.warning("Slow response detected")
Solution
import logging
import json


class JSONFormatter(logging.Formatter):
def format(self, record):
log_entry = {
"level": record.levelname,
"logger": record.name,
"request_id": getattr(record, "request_id", "n/a"),
"message": record.getMessage(),
}
return json.dumps(log_entry)


class RequestAdapter(logging.LoggerAdapter):
def process(self, msg, kwargs):
kwargs.setdefault("extra", {})
kwargs["extra"]["request_id"] = self.extra.get("request_id", "n/a")
return msg, kwargs

Key points:

  • LoggerAdapter.process(msg, kwargs) intercepts every log call and can modify both the message and the keyword arguments (including extra). The modified kwargs are passed to the underlying logger's method.
  • Fields injected via extra= become attributes on the LogRecord object. record.request_id is set because extra={"request_id": "req-001"} was passed.
  • record.getMessage() returns the final formatted message string, handling both %-style and direct string messages.
  • In production FastAPI/Django apps, you create one RequestAdapter per request in middleware — binding a unique request_id so every log line in that request shares the same correlation ID. This makes log aggregation and debugging vastly easier.
import logging
import json

# Build a minimal structured JSON logger using a custom Formatter
# and a LoggerAdapter that injects a request_id into every record.

# Step 1: Create a JSONFormatter that emits each record as a JSON line:
#   {"level": "INFO", "logger": "myapp.api", "request_id": "abc123", "message": "..."}
#
# Step 2: Create a RequestAdapter(LoggerAdapter) that overrides process()
#   to inject self.extra["request_id"] into kwargs["extra"]
#
# Step 3: Wire it up and log two messages with request_id="req-001"

class JSONFormatter(logging.Formatter):
  def format(self, record):
      # TODO: build and return a JSON string with keys:
      #   level, logger, request_id (from record if present, else "n/a"), message
      pass


class RequestAdapter(logging.LoggerAdapter):
  def process(self, msg, kwargs):
      # TODO: inject self.extra["request_id"] into kwargs["extra"]["request_id"]
      return msg, kwargs


# Wire up
base_logger = logging.getLogger("myapp.api")
base_logger.setLevel(logging.DEBUG)
base_logger.propagate = False

handler = logging.StreamHandler()
handler.setFormatter(JSONFormatter())
base_logger.addHandler(handler)

logger = RequestAdapter(base_logger, {"request_id": "req-001"})

logger.info("Request started")
logger.warning("Slow response detected")
Expected Output
{"level": "INFO", "logger": "myapp.api", "request_id": "req-001", "message": "Request started"}
{"level": "WARNING", "logger": "myapp.api", "request_id": "req-001", "message": "Slow response detected"}
Hints

Hint 1: In JSONFormatter.format(), build a dict and return json.dumps(dict).

Hint 2: Access the request_id with: getattr(record, 'request_id', 'n/a')

Hint 3: In RequestAdapter.process(), do: kwargs.setdefault('extra', {}); kwargs['extra']['request_id'] = self.extra['request_id']; return msg, kwargs

Hint 4: record.getMessage() gives the final formatted message string.

© 2026 EngineersOfAI. All rights reserved.