3 min read

FastAPI and Rich Tracebacks in Development

Let's take a look at how we can integrate Rich's logging and traceback behavior into FastAPI.
FastAPI and Rich Tracebacks in Development

Rich and FastAPI are two newer python libraries that are making a splash in the community and for good reason. Rich brings style to the terminal and FastAPI brings ease to creating web APIs.

One of my favorite features of Rich is the LogHandler with Rich Traceback Support. Rich Traceback offers a robust and easy-to-read traceback that has made developing applications easier and faster. Check out Will's blog post to see some examples.

From the Rich Creator Will McGugan

There is highlighting to help pick out filename, line, and function, etc. There's also a snippet of code for each stack frame, with line numbers and syntax highlighting. It's configurable, but I find that 7 lines of code are enough to make it relatable to the file in my editor, and give me a better understanding of the context that lead to the exception.

It's clear that Rich is a worthwhile dependency to consider for development, let's take a look at how we can integrate its logging and traceback behavior into FastAPI.

TLDR: Skip to the end for the finale code snippet

Step by Step

First, we're going to create a dataclass that will hold our logger config. While not necessary, I find having an object that contains configurations value to be a useful pattern.

@dataclass
class LoggerConfig:
    handlers: list
    format: str
    date_format: str
    logger_file: str
    level: str = logging.INFO
Here we've specified all the information we'll need to pass to the logger.basicConfig

Next, we'll need to create a function that will either install rich as the handler or use the production log configuration.

@lru_cache
def get_logger_config():
    if not settings.PRODUCTION:
        from rich.logging import RichHandler

        return LoggerConfig(
            handlers=[RichHandler(rich_tracebacks=True)],
            format=None,
            date_format=None,
            logger_file=None,
        )

    # Add File Loggin
    output_file_handler = logging.FileHandler(LOGGER_FILE)
    handler_format = logging.Formatter(LOGGER_FORMAT, datefmt=DATE_FORMAT)
    output_file_handler.setFormatter(handler_format)

    # Stdout
    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setFormatter(handler_format)

    return LoggerConfig(
        handlers=[output_file_handler, stdout_handler],
        format="%(levelname)s: %(asctime)s \t%(message)s",
        date_format="%d-%b-%y %H:%M:%S",
        logger_file=LOGGER_FILE,
    )
This requires a settings.PRODUCTION variable that will pickup an env variable to determine if you're in production mode. If not in production it will install rich and return the dataclass with all the correct values.
From the man himself, add tracebacks_show_locals=True and cause "Rich to display the value of local variables for each frame of the traceback." - Docs

Finally, you'll need to call the function and pass those values into the logger configuration.

logger_config = get_logger_config()

logging.basicConfig(
    level=logger_config.level,
    format=logger_config.format,
    datefmt=logger_config.date_format,
    handlers=logger_config.handlers,
)
FastAPI/Starlette uses the logging.basicConfig as their logger configuration. If you override those configuration values, they will be applied to FastAPI/Starlette

Disabling Uvicorn Logger

Updated Aug 10, 2021:

You may also need to override the logger for Uvicorn.

def main():
    uvicorn.run(
        "app:app",
        host="0.0.0.0",
        log_level="debug",
        use_colors=True,
        log_config=None, # See Here
    )

All Together

import logging
import sys
from dataclasses import dataclass
from functools import lru_cache

from .config import settings, DATA_DIR

LOGGER_FILE = DATA_DIR.joinpath("mealie.log")
DATE_FORMAT = "%d-%b-%y %H:%M:%S"
LOGGER_FORMAT = "%(levelname)s: %(asctime)s \t%(message)s"
LOGGER_HANDLER = None


@dataclass
class LoggerConfig:
    handlers: list
    format: str
    date_format: str
    logger_file: str
    level: str = logging.INFO


@lru_cache
def get_logger_config():
    if not settings.PRODUCTION:
        from rich.logging import RichHandler

        return LoggerConfig(
            handlers=[RichHandler(rich_tracebacks=True)],
            format=None,
            date_format=None,
            logger_file=None,
        )

    output_file_handler = logging.FileHandler(LOGGER_FILE)
    handler_format = logging.Formatter(LOGGER_FORMAT, datefmt=DATE_FORMAT)
    output_file_handler.setFormatter(handler_format)

    # Stdout
    stdout_handler = logging.StreamHandler(sys.stdout)
    stdout_handler.setFormatter(handler_format)

    return LoggerConfig(
        handlers=[output_file_handler, stdout_handler],
        format="%(levelname)s: %(asctime)s \t%(message)s",
        date_format="%d-%b-%y %H:%M:%S",
        logger_file=LOGGER_FILE,
    )


logger_config = get_logger_config()

logging.basicConfig(
    level=logger_config.level,
    format=logger_config.format,
    datefmt=logger_config.date_format,
    handlers=logger_config.handlers,
)

Examples

Logger
Tracebacks