The Ultimate Guide To Python Logging

Python has the wonderful package for recording logs called logging. You can find it in the standard library. Many developers think that it is overcomplicated and not pythonic. In this post I will try to convince you that it is not true. We are going to explore how this package is structured, discover its main concepts and see the code examples. At the end of this post you can find the code which sends log records to the Telegram. Let's get started.

Why Logs

Logs are X-Ray of your program runtime. The more detailed your logs are the easier is to debug non-standard cases. One of the most popular log example is webserver's access log. Take a look at the nginx's log output from this blog:

92.63.107.227 - - [04/Nov/2020:06:30:48 +0000] "GET /ru/hosted-open-vpn-server/ HTTP/1.1" 301 169 "-" "python-requests/2.11.1" "-"
92.63.107.227 - - [04/Nov/2020:06:30:49 +0000] "GET /ru/data-engineering-course/ HTTP/1.1" 301 169 "-" "python-requests/2.11.1" "-"
213.180.203.50 - - [04/Nov/2020:06:36:07 +0000] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (compatible; YandexMetrika/2.0; +http://yandex.com/bots yabs01)" "-"
114.119.160.75 - - [04/Nov/2020:06:36:41 +0000] "GET /robots.txt HTTP/1.1" 301 169 "-" "(compatible;PetalBot;+https://aspiegel.com/petalbot)" "10.179.80.67"
90.180.35.207 - - [04/Nov/2020:06:47:11 +0000] "GET / HTTP/1.0" 301 169 "-" "-" "-"
46.246.122.77 - - [04/Nov/2020:06:53:22 +0000] "GET / HTTP/1.1" 301 169 "<http://khashtamov.com>" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36" "-"
66.249.76.16 - - [04/Nov/2020:06:53:30 +0000] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" "-"
66.102.9.118 - - [04/Nov/2020:07:11:19 +0000] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.75 Safari/537.36 Google Favicon" "46.159.204.234"
71.6.167.142 - - [04/Nov/2020:07:11:55 +0000] "GET / HTTP/1.1" 301 169 "-" "Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/41.0.2228.0 Safari/537.36" "-" 

Nginx also has an error log which contains errors while processing HTTP requests. Your program's logs can also be divided into normal logs, debug logs or error logs.

Python logging

Python has a logging package perform log recordings. At first you may think that this package is complicated and not pythonic. But if you invest some time to explore its functionality you may change your mind. For me the logging package is the example of object oriented programming where composition dominates over inheritance, that is why there are lots of functional classes. The main idea of this post is to introduce you to those classes to make everything clear. Let's dive.

Logger

To start with logs in Python we have to import logging module and execute the getLogger function. This function returns the instance of Logger class which is the entrypoint into the world of log records.

import logging

logger = logging.getLogger('logger_name')

getLogger accepts a single argument, logger's name. You can provide whatever you want but I recommend using __name__. If you execute getLogger multiple times with the same name, function will return the same instance of Logger class.

>>> import logging
>>> x = logging.getLogger('same_name')
>>> y = logging.getLogger('same_name')
>>> y == x
0: True
>>> id(y) == id(x)
1: True
>>>

If you are gonna use the __name__ then you should not worry about name collisions between your packages & modules.

The Logger class provides methods which you can use to write logs with different message levels. Levels are useful because they provide additional information about the runtime's context. Logging has the following levels:

  1. DEBUG — useful while developing an application, usually provides information for developers (variable values, function names etc)
  2. INFO — additional information which can help you understand how program executes
  3. WARNING — useful for additional information like deprecation warnings, backwards compatibility issues
  4. ERROR — used when writing errors, e.g. a host is unavailable or file has wrong format or does not exist
  5. CRITICAL — the highest level of information, usually means that a script will be terminated

By default logging has a WARNING level which means that DEBUG & INFO will be ignored (because they have lower priority in comparison to WARNING). Developer can change the level by using setLevel method:

logger.setLevel(logging.DEBUG)

Log recording methods are named according to the corresponding level they represent:

logger.debug('debug info')
logger.info('info')
logger.warning('warning')
logger.error('debug info')
logger.critical('debug info')

Also Logger has the exception method which should be used in the except block in order to catch an exception and create a log record:

try:
    1/0
except :
    logger.exception('exception')

Handler

Handlers are responsible for writing log messages, they send logs to the appropriate destination (socket, file, stdout etc). The logging module exposes predefined handlers:

  • SteamHandler — sends logs to stdout, stderr
  • FileHandler — sends logs to the file (see also RotatingFileHandler, TimedRotatingFileHandler)
  • SocketHandler — sends logs via TCP socket
  • DatagramHandler — sends logs via UDP socket
  • SysLogHandler — sends logs to the syslog
  • HTTPHandler — sends logs via HTTP

If you want to explore more, please follow the link. In order to add a handler to a Logger instance you have to call addHandler method:

import sys
import logging
from logging import StreamHandler

logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
handler = StreamHandler(stream=sys.stdout)
logger.addHandler(handler)

logger.debug('debug information')

If you run this code the output will be:

debug information

Just a simple text message in a terminal is not enough, we need to add some context. Ask me how 😎

Formatter

Formatter specifies the layout of log messages in the final destination (stdout, files etc). By default if you do not specify a custom Formatter class, your log records will contain only the text you provided to the corresponding log methods (debug, info etc) as you have already seen in the example above. In order to specify a Formatter instance you have to call setFormatter method of Handler instance:

import sys
import logging
from logging import StreamHandler, Formatter

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

handler = StreamHandler(stream=sys.stdout)
handler.setFormatter(Formatter(fmt='[%(asctime)s: %(levelname)s] %(message)s'))
logger.addHandler(handler)

logger.debug('debug information')

The output is

[2020-11-22 13:00:08,751: DEBUG] debug information

Pay your attention to the placeholders:

[%(asctime)s: %(levelname)s] %(message)s

Logging has a number of predefined placeholders (most of which are derived while constructing the LogRecord instance).

Filter

The class is responsible for filtering log records according to a specific condition. Let's assume that I want to exclude log records which do not contain the word python. If you want to add a filter, you have to call the method addFilter of Logger instance. This method accepts an instance of a class which defines filter method or any python callable:

import sys
import logging
from logging import StreamHandler, Formatter, LogRecord

def filter_python(record: LogRecord) -> bool:
    return record.getMessage().find('python') != -1

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

handler = StreamHandler(stream=sys.stdout)
handler.setFormatter(Formatter(fmt='[%(asctime)s: %(levelname)s] %(message)s'))
logger.addHandler(handler)
logger.addFilter(filter_python)

logger.debug('python debug information')

LoggerAdapter

If you want to add some extra contextual information to your log records but do not want to provide it everytime you call a log method, then LoggerAdapter is a way to go. Let's assume you are building a web app and want to log a username everytime you make a record:

class CustomLoggerAdapter(LoggerAdapter):
    def process(self, msg, kwargs):
        return f'{msg} from {self.extra["username"]}', kwargs

logger = logging.getLogger('adapter')
logger.setLevel(logging.DEBUG)

handler = StreamHandler(stream=sys.stdout)
handler.setFormatter(Formatter(fmt='[%(asctime)s: %(levelname)s] %(message)s'))

adapter = CustomLoggerAdapter(logger, {'username': 'adilkhash'})

logger.addHandler(handler)
adapter.error('failed to save')

LoggerAdapter has the same interface as Logger instance, feel free to call the same log methods.

extra

You are building a cool app which sends requests to the remote API and want to log every response that you receive. See how it is done using the extra argument:

logger.debug('debug info', extra={"response": response.text})

In order to see it in the log record you have to update your formatter class:

Formatter(fmt='[%(asctime)s: %(levelname)s] %(message)s, response: %(response)s')

Quite simple and the code looks clean.

Logging Configuration

Official documentation recommends configuring logging via python dictionary. In order to do this you have to call logging.config.dictConfig which accepts a single argument — a dictionary. Its schema is described here:

  • version — should be 1 for backwards compatibility
  • disable_existing_loggers — disables the configuration for existing loggers (3rd party libararies etc), by default is True
  • formatters — settings for formatters
  • handlers — settings for handlers
  • loggers — settings for loggers

Previously we were able to configure logging settings via python code, it is okay if you have a single module or 2 but in case of an application (e.g. Django app) it is better to have a separate (centralized) place where we can define the config. See the example:

import logging.config

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

    'formatters': {
        'default_formatter': {
            'format': '[%(levelname)s:%(asctime)s] %(message)s'
        },
    },

    'handlers': {
        'stream_handler': {
            'class': 'logging.StreamHandler',
            'formatter': 'default_formatter',
        },
    },

    'loggers': {
        'my_logger': {
            'handlers': ['stream_handler'],
            'level': 'DEBUG',
            'propagate': True
        }
    }
}

logging.config.dictConfig(LOGGING_CONFIG)
logger = logging.getLogger('my_logger')
logger.debug('debug log')

In a real-world application developers tend to separate logging configuration from the application's code by creating new module, e.g. settings.py or conf.py.

Inheritance in Logging

Loggers can inherit logging settings from their ancestors. The inheritance is defined by the . in logger's name. If you have the logger called my_package.logger1 it will inherit the settings defined for my_package.

If you have a specific logging setting for a particular logger and you don't want it to be handled by the parent's setting you have to set propagate attribute to False.

Implementing a Telegram Handler

In case if you did not work with telegram bots go ahead and read my post about Telegram bots. We are going to use pyTelegramBotAPI package. I assume that you are prepared:

  1. Created a telegram bot via BotFather
  2. You know the token
  3. You know your user id (you can get it via @userinfobot)

In order to create a custom handler you have to inherit your class from Handler and implement the emit method:

import telebot
from logging import Handler, LogRecord

class TelegramBotHandler(Handler):
    def __init__(self, token: str, chat_id: str):
        super().__init__()
        self.token = token
        self.chat_id = chat_id

    def emit(self, record: LogRecord):
        bot = telebot.TeleBot(self.token)
        bot.send_message(
            self.chat_id,
            self.format(record)
        )

Sensitive data like tokens & chat id can be provided via logging configuration, you should not store it in the code (e.g. use environment variables instead):

'handlers': {
        'telegram_handler': {
            'class': 'handlers.TelegramBotHandler',
            'chat_id': '<CHAT_ID>',
            'token': '<BOT_TOKEN>',
            'formatter': 'default_formatter',
        }
    },

TelegramBotHandler is located in the handlers.py module. To activate it you have to update the corresponding setting in the config:

LOGGING_CONFIG['loggers'].update({
    'my_package': {
        'handlers': ['stream_handler', 'telegram_handler'],
        'level': 'DEBUG',
        'propagate': False
    }
})

That's it 😎