Когда использовать принты и как переходить на нормальное логирование. Постарался объяснить необходимость этого, описать сам переход и уровни логгирования простым языком. Не затронул конфигурирование и работу с сервером логов, это темы отдельных статей.

Зачем логировать

Логирование не нужно. Вообще бесполезная штука. С одной оговоркой: пока всё идет как надо.

Когда что-то сломалось, надо смотреть логи. Чтобы было на что смотреть, надо писать логи. Если вы, как и я, иногда допускаете ошибки, надо писать логи. Иначе будет больно.

Принты

Если возникает какая-то проблема-баг, для обнаружения причины в простейшем случае можно использовать десяток print(). И в маленьких проектах, на 100 строк или до 1000 это вполне адекватное решение. Ничего сложнее там как правило не нужно.

Если вы воспользуетесь принтом для дебага в бОльшем проекте, после исправления бага у вас будет выбор из трех вариантов. Для каждого варианта я указал вероятность, что читающий этот код назовет вас мудаком.

  1. оставить принты как есть. 90%. Потому-что это некрасиво и мешает работе и дебагу в другом месте.
  2. закомментировать принты, чтобы можно потом быстро раскомментировать и продебажить еще разок. 50%. Потому-что это некрасиво, а еще коменты устаревают. Будете краснеть на код ревью. "Вы разве не знаете, что оставлять закомментированный код - это плохой тон?"
  3. удалить принты во славу код ревью. 10%. Без принтов с понятными комментариями, код может показать не таким понятным. А чтобы его продебажить, надо будет эти принты опять добавлять.

Еще одной проблемой принта является его назначение. Принт печатает в консоль (стандартный вывод). И если вы пишете скрипт, который например печатает имена файлов или что-то подобное для записи в файл или подобной обработки, то принты там используются по назначению. Дебажные принты там будут лишними.

Решение всех перечисленных проблем - система логирования.

Простые логи на коленке, DEBUG и INFO

Итак, задача состоит в том, чтобы иметь аналог принтов для дебага, но он не должен мешать и раздражать.

В логировании это решается разделением на уровни. И раз мы начали с ловли багов, для нее используются уровни DEBUG и INFO. По-хорошему, надо воспользоваться стандартным питоновским модулем logging, но сначала проиллюстрирую более понятной самоделкой. Пишем в начале нужного файла такой класс:

class logger:
    level = 10

    @staticmethod
    def log(level, text, *args):
        print(level, (text % args) if args else text)

    @classmethod
    def debug(cls, text, *args):
        if cls.level <= 10:
            cls.log("DEBUG", text, *args)

    @classmethod
    def info(cls, text, *args):
        if cls.level <= 20:
            cls.log("INFO", text, *args)

Использовать его просто. Вместо print("var:", var) надо писать logger.debug("var: %s", var) или logger.info("start operation"). Плюсы:

  1. Их можно быстро отключить для всего файла. Не комментируя или удаляя кучу строчек, а увеличивая logger.level. Значение "10" будет показывать всё, "20" только INFO, а "21" и больше - не будет показывать ничего.
  2. Их не обязательно удалять из кода. И никто не будет против, так как их можно скрыть.
  3. А еще у нас есть не один принт, а DEBUG и INFO.

INFO можно повесить на более важные операции, которые что констатируют. Например, старт и стоп операций (особенно тех, которые запускаются по времени и что-то долго делают). Можно вывести время выполнения функции или какую-нибудь важную айдишку. Можно повесить на редкие важные события: регистрацию и удаление пользователя, денежные транзакции, создание каких-то полезных объектов, например, статей. Главное, чтобы их не было сильно много. Если статьи создаются тысячами в день, это скорее не info, а debug.

DEBUG же можно использовать для ... дебага. Можно показывать любые переменные, структуры, джейсоны, dir(obj). Втыкать в каждый if и else, чтобы видеть, как отрабатывает алгоритм. В том числе внутри цикла.

DEBUG обычно нет смысла использовать просто так. Только для отладки программы и ловли багов. Информации в этом режиме выводится гораздо больше, поэтому пока багов нет, её лучше выключать.

ERROR, WARNING, CRITICAL, EXCEPTION

ERROR

Следующая тройка уровней. Главный из них - ERROR. Дополним наш класс:

    @classmethod
    def warning(cls, text, *args):
        if cls.level <= 30:
            cls.log("WARNING", text, *args)

    @classmethod
    def error(cls, text, *args):
        if cls.level <= 40:
            cls.log("ERROR", text, *args)

    @classmethod
    def critical(cls, text, *args):
        if cls.level <= 50:
            cls.log("CRITICAL", text, *args)

Зачем же нужен ERROR, если ошибка - это плохо? Если мы встречаем ошибку в коде, то надо её не логировать, а исправлять. Ведь так? Не совсем. Бывают ситуации, когда это бывает полезно. Приведу примеры.

Внешний API. Характерной особенностью любого сервиса, который мы можем использовать в нашем коде является возможность отказа. Если от нашей БД мы ждем безотказной работы, то вызывая google для поиска, или сервис погоды, или создание твита, или список чатов в Slack, мы можем получить не то, что ожидаем.

Например, есть код получения каких-то каналов:

def get_channels():
    response = requests.get("http://example.com/api/channels/")
    if response.status_code != 200:
        logger.error("wrong status code %s", response.status_code)
        return None

    result = response.json()
    # TODO здесь нужна проверка на ratelimit

    if not result.get("ok") or "channels" not in result:
        logger.error("response not success. Service error: %s", result.get('error'))
        return None

    return result["channels"]

Можно еще добавить проверок. Обернуть json() в try или сделать проверку типа channels. Пока это неважно, это всего лишь пример.

Этот код может работать 100 раз подряд хорошо и правильно, выдавая какой-то массив с данными. Но на 101 результат может отличаться. Может быть 502 или 403 ошибка. Или ratelimit, если мы вызываем сервис слишком часто, а он отслеживает количество запросов в секунду. Эти данные мы можешь потом увидеть в логах и добавить в код правильное реагирование на теперь уже штатные ситуации. Например, при ratelimit можно добавить после response.json() такую проверку:

    if not result.get("ok") and result.get('error') == "ratelimit":
        logger.info("ratelimit")
        time.sleep(1)
        return get_channels()

Это уже штатная ситуация, мы о ней знаем и маркируем её не ERROR, а INFO.

Нештатная ситуация. Например, нужны какие-то данные, но нам приходит пустой словарь. Или нам нужен объект, но его почему-то нет в БД. В таком случае логируем и прерываем алгоритм.

WARNING

WARNING - это как ERROR, только не так страшно. Используется, когда потенциально проблема есть, но сейчас мы можем её обойти. В предыдущем примере можно заменить вызов INFO на logger.warning("ratelimit"), если для нас это хоть и штатная, но неприятная ситуация, которая возникать не должна. По моему опыту, используется реже, чем ERROR.

CRITICAL

CRITICAL - это как ERROR, но смертельно. Для процесса. ERROR мы можем залогировать для потомков и продолжить работу. В случае, если при возникновении ситуации мы продолжить работу не можем, используется CRITICAL. Например, нельзя открыть сокет или нет доступа к БД или нет файла, из которого скрипт должен взять данные. Используется еще реже, чем WARNING. В обычной жизни вообще можете про него забыть. Когда он понадобится, вы про него вспомните.

EXCEPTION

EXCEPTION - это как ERROR, но с трейсбэком. Обычно в него оборачивается большой блок кода, в котором нет ошибок (ха-ха), но может и есть немного, а где именно - неизвестно.

    @classmethod
    def exception(cls, text, *args):
        if cls.level <= 40:
            cls.log("ERROR", text, *args)
            import traceback
            print(traceback.format_exc())

Пример: чат-бот получает команду от пользователя. Он должен что-то сделать, неважно что. А после прислать в пользователю в ответ "Успешно" и счастливый смайлик. Но если не получилось, ответ должен быть "Сорян, произошла ошибка", а смайлик грустный.

def handle_action(command):
    try:
        many_many_work(command)
    except Exception:
        logger.exception("error in handle action %s", command)
        return "Sorry, an error occurred =("
    else:
        return "Success =)"

Проверяя потом логи на предмет перехваченных ошибок, можно увидеть подобную запись:

ERROR error in handle action send_sms
Traceback (most recent call last):
  File "/src/chatbot.py", line 214, in handle_action
    many_many_work(command)
  File "/src/chatbot.py", line 281, in many_many_work
    raise ValueError('asdf')
ValueError: asdf

Или что-то более длинное и реалистичное.

Еще несколько улучшений

Это не финальная версия. К этому и так уже полезному классу можно накрутить еще фишек и будет еще удобней.

Можно вынести класс в отдельный файл, переименовать в BaseLogger и наследовать в каждом файле маленький логгер от него, с указанием персонального уровня логирования:

class logger(BaseLogger):
    level = 30

Хорошо бы при этом еще отличать логи из одного файла от другого, для этого нам поможет встроенная переменная __name__:

class BaseLogger:
    level = 30
    name = __name__

    @classmethod
    def log(cls, level, text, *args):
        print(cls.name, level, (text % args) if args else text)


class logger(BaseLogger):
    name = __name__
    level = 10

Есть еще набор улучшений, который можно сделать, чтобы пользоваться было еще удобнее:

  1. Научить наш логгер печатать не только в консоль через print, но и в файл. И еще куда угодно.
  2. Конфигурировать логгер не из файла с кодом, в котором расположен логгер, а из конфига.
  3. Возможность писать логи из разных файлов (логгеров) в разные места (логфайлы).
  4. Возможность фильтрации.
  5. Возможность из того же конфига настраивать вывод. Чтобы можно было дату и номер строки. Или не добавлять. Или в разных местах выдавать разные по виду логи.

Делать мы это конечно же не будем. Ведь уже написан стандартный модуль logging и внешний loguru, в которых всё это уже сделано и сделано хорошо.

Стандартный модуль logging

Описывать полностью я его не буду. Многие это уже сделали, а еще к нему есть нормальная документация.

Вкратце, в начале каждого файла с кодом надо написать

import logging

logger = logging.getLogger(__name__)

и дальше использовать logger.debug и прочие методы, точно так же, как мой самописный логгер.

А вот настройка - дело уже более сложное и муторное.

Еще можно настроить логгирование не в консоль или файлы, а в сервис наподобие ElasticSearch. Это имеет смысл, если у вас сложная система из кучи компонентов и нескольких серверов. Уследить за ней через файлы будет сложно.