Як (не) варто налаштовувати логування в Python

Привіт!

Мене звати Павло Шушков, я — Software Engineer у компанії Yalantis. Протягом останніх трьох років я працюю над проєктом американського необанку, де моя роль охоплює повний цикл розробки. Зокрема йдеться про співпрацю з бізнес-аналітиками та дизайнерами, розробку та підтримку проєкту, а також спілкування з фінансовими партнерами та платіжними мережами.

Моя робота дозволила мені глибше зрозуміти значення якісного логування, яке може полегшити роботу розробника, зменшити часові витрати й створити більш структуровані та зрозумілі дані для всієї команди.

У статті я розповідаю про роботу модуля logging в Python, а також розгляну ключові компоненти, які слід інтегрувати в логи. Матеріал буде корисним тим, хто бажає оптимізувати вже наявні методи й способи логування або ж налаштувати їх з нуля.

Досвід на моєму проєкті та чому я зіштовхнувся з логами

Проєкт тісно пов’язаний з операціями з платіжними картками, що зобов’язує нас дотримуватися стандарту PCI DSS (Payment Card Industry Data Security Standard). Стандарт встановлює вимоги до безпеки обробки платіжних даних, включаючи зберігання логів протягом певного періоду часу, захист даних картки, моніторинг і виявлення шахрайства, контроль доступу, захист від зловживань, а також вимоги до аудиту та звітності.

Крім того, обробка персональних даних (PII) та співпраця з великою кількістю сторонніх сервісів, робить логування невіддільною частиною нашої роботи.

Для наочності можна згадати приклад, коли до нас звернувся сапорт з тікетом, що клієнт замовив кредит скор-репорт, але не отримав його протягом п’яти годин — потрібно було якомога швидше впоратись із цією ситуацією. Пробігши очима кодом та проаналізувавши логи, ми ідентифікували причину: кредитне бюро змінило поведінку вебхуків, а саме: перенесло деякі поля та не попередило нас.

Виправлення було проведено оперативно, а також були виправлені всі зачеплені користувачі, висновки що цей кейс потрібно додати в моніторинг (це, до речі, також цікава тема для статті).

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

Зберігання великої кількості логів дозволяє нашій команді оперативно виявляти, аналізувати та усувати будь-яку неправильну поведінку в системі. Крім того, це надає можливість переглядати записи у минулому для переконання в тому, що з нашими клієнтами не відбулося нічого підозрілого, що підвищує рівень довіри до продукту.

Що таке логи та що використовують для створення логів в Python

Логи (логування) — це процес ведення та фіксації подій, дій або стану програми(або ж комп’ютерної системи) під час їх роботи.

У далекому липні 2003 році була випущена остаточна та стабільна версія Python 2.3. Поруч із такими нововведеннями, як функції sum та enumerate, були додані модулі для логування повідомлень:

Він є частиною PEP-282 та описує запропонований пакет логування для стандартної бібліотеки Python, де основною мотивацією виступає створення єдиного механізму логування з різними рівнями важливості повідомлень та різні логічні потоки логування (або «канали»).

Починаючи із цього моменту, можна стверджувати, що модуль logging стає основним для логування в python.

Чи можна замінити або покращити logging

Хоча теоретично можна замінити модуль logging на інші інструменти, я особисто ніколи не стикався з такою потребою та вважаю, що це не варто робити. Наявний модуль надає широкий спектр функціональних можливостей для логування, що зазвичай задовольняє потреби більшості проєктів.

Однак, давайте також подивимось на альтернативи:

  1. Loguru — *17.7k github stars github.com/Delgan/loguru
  2. Logbook — *1.5k github stars github.com/getlogbook/logbook
  3. Structlog — *3.1k github stars github.com/hynek/structlog

* на день написання матеріалу.

Ці бібліотеки мають одну загальну мету: спростити та зробити логування більш елегантним. Вони пропонують різні шаблони та додаткові можливості, такі як асинхронне логування. У версії 3.2 Python було додано QueueHandler, що мав розв’язати проблеми з операціями введення/виведення для асинхронного логування, але не забезпечує повного вирішення. Тому існують різні підходи до розв’язання цієї проблеми.

Отже, додаткові бібліотеки для логування використовуються лише у випадках, коли вони необхідні або просто на власний розсуд розробника. У більшості проєктів вистачає використання модуля logging.

Базові знання по Python logging

Коротко пригадаймо, як працює модуль логування і який механізм його роботи.

Рівні логування

Якщо ми розглянемо файл logging/__init__.py, ми зможемо побачити, що існують наступні рівні логування:

CRITICAL = 50  
FATAL = CRITICAL  
ERROR = 40  
WARNING = 30  
WARN = WARNING  
INFO = 20  
DEBUG = 10  
NOTSET = 0 

Рівні логування в модулі logging визначають, які повідомлення будуть зафіксовані, а які проігноровані. Наприклад, якщо логер налаштований на рівень WARNING, це означає, що повідомлення з рівнем INFO будуть проігноровані, а повідомлення з рівнем ERROR будуть зафіксовані.

  1. Loggers — відповідають за створення та запис логів та кожен з них має унікальне ім’я. Логери можуть утворювати ієрархію за допомогою крапки у їхніх іменах, наприклад, parent та parent.child відповідно. root логер використовується за замовчуванням та завжди є батьківським для всіх інших логерів.
  2. Handlers — обробники ж отримують записи від логера та надсилають їх до визначених вихідних точок, таких як консоль, файли, сокети або інші системи логування. Вони відповідають за забезпечення того, що повідомлення логування справді потраплять у вказані місця.
  3. Formatters — призначені для форматування записів логування перед їхнім надсиланням обробникам. Вони дозволяють налаштувати вигляд кожного повідомлення логування, включаючи додавання додаткової інформації, такої як час, рівень логування, ім’я логера тощо.

Під час записування логів, Logger передає LogRecord обробникам (Handlers), які своєю чергою використовують форматувальники (Formatters) для створення остаточного запису логу у вказаному форматі та направленні його до відповідного місця призначення, такого як консоль або файл.

Після цього короткого пояснення, якщо ви відчуваєте, що щось не зрозуміли, раджу прочитати та власноруч практикувати, використовуючи посилання.

Python Logging Best Practices OR Logs Must Have

Варто замислитись над логуванням в ідеалі на самому початку роботи над проєктом. І так, на це варто знайти час та умовно запровадити певну етику логування. Таке рішення полегшить роботу команді, забезпечить ефективне та зручне відстеження роботи коду та зробить роботу з проєктом зручною та прозорою.

Виникає необхідність мати можливість відновлення та аналізу конкретного кейсу у випадку інцидентів. Ділюся власним досвідом та набитими шишками, а також прикладами.

1. Перейти до форматування логів у формат JSON

Перше з чого потрібно почати — конвертування текстових логів у формат JSON. Це стандартна практика, і вона полегшує подальшу обробку та аналіз даних. Зазвичай, на більшості проєктах ці JSON-логи можуть бути легко інтегровані з різними системами моніторингу, такими як ELK, Datadog etc., що забезпечує зручний доступ до них, або хоча б архівувати їх у файли з ротацією, наприклад, AWS S3.

import json
import logging
from datetime import datetime

class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        return json.dumps(
            {
                "timestamp": datetime.now().isoformat(),
                "message": record.getMessage(),
                "level": record.levelname,
                "logger_name": record.name,
            },
        )

LOGGING = {
    "formatters": {
        "json": {
            "()": "utils.logging.utils.JSONFormatter",
        }
    },
    "handlers": {
        "default": {
            "level": "INFO",
            "class": "logging.StreamHandler",
            "formatter": "json",
        }
    },
    "loggers": {
        "default": {
            "handlers": ["default"],
            "level": "INFO",
            "propagate": False,
        },
    }
}

2. Використовувати кастомну функцію для логування

Використання власної функції для логування дозволяє централізувати всі налаштування щодо логування — рівень логування, формат запису та обробники логів в одному місці. До того ж це надає можливість легко додавати нові поля та додаткову інформацію у логи за необхідності.

def log(
    msg: str,
    level: int = logging.INFO,
    logger_name: str = settings.DEFAULT_LOGGER_NAME,
    **kwargs,
):
    logger = logging.getLogger(name=logger_name)
    logger.log(level=level, msg=msg, extra=kwargs)

3. Використовувати __name__ для getLogger()

Рекомендується передавати __name__ в параметр name, що дозволить автоматично використовувати ім’я поточного модуля. Це спростить процес розуміння та аналізу логів.

logger = logging.getLogger(__name__)

4. Обирайте осмисленні меседжі для логів

Під час створення повідомлень логування, слід надавати перевагу стислості та зрозумілості. Уникайте включення зайвих змінних у повідомленнях, оскільки це може ускладнити пошук та розуміння даних, як, наприклад {... "msg": "The user #{number} created an account #{acc_num}"} > {"msg": "Account has been created", "data": {"user_id": 1, "account_number": 1}

5. Уніфіковане поле для додаткової інформації

Для полегшення майбутнього пошуку та доступу до додаткової інформації рекомендується зберігати її у спеціальному полі під назвою data.

def log(
    msg: str,
    level: int = logging.INFO,
    logger_name: str = settings.DEFAULT_LOGGER_NAME,
    data: dict | None = None,
    **kwargs
):
    extra = kwargs.copy()
    logger = logging.getLogger(name=logger_name)
    if data:
        extra.update(data=data)

    logger.log(level=level, msg=msg, extra=extra)

6. Timestamp в форматі ISO-8601

Лог без таймштампу — не лог :) Використовуйте один загальновживаний формат дати й часу, ISO-8601, щоб забезпечити сумісність та однаковий підхід до роботи з логами у всьому проєкті. Це дозволить уникнути проблем при обробці та аналізі даних. Також варто одразу після зміни перевірити коректність та додатково покрити тестом.

class JSONFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        return json.dumps(
            {
                "timestamp": datetime.now().isoformat()
            }
        )

7. Додаємо та використовуємо Tags

Додавання тегів в логи є досить корисними, оскільки вони надають додаткового контексту.

def log(
    msg: str,
    level: int = logging.INFO,
    logger_name: str = settings.DEFAULT_LOGGER_NAME,
  tags: Sequence[str] | None = None,
    **kwargs,
):
    logger = logging.getLogger(name=logger_name)
  extra = kwargs.copy()
  if tags:
    extra.update(tags=tags)

    logger.log(level=level, msg=msg, extra=extra)

8. instance, application — прокидаємо через os env

Додавайте інформацію про назву застосунка (application), а також ідентифікатори контейнера (pod) та контейнера (container id) у логи для полегшення налагодження та аналізу.

Це особливо важливо для сучасних бекенд-застосунків, які можуть складатися з різних мікросервісів та бути розгорнутими на платформі k8s, де інформація про конкретний контейнер може бути корисною. Проте важливо враховувати, що такі дії можуть здійснюватися як з боку розробників, так і з боку DevOps.

9. Ідентифікатор користувача

Зберігайте ідентифікатор користувача (user_id) в запиті та додайте його до кожного логу. Це спрощує виявлення останніх дій користувача та можливих складнощів, з якими він міг зіштовхнутись. Якщо ви отримуєте веб хуки від інших сторонніх сервісів, рекомендується також додавати ідентифікатор користувача до запиту та логів.

10. trace_id — унікальний ідентифікатор запиту

Він допомагає у рідких кейсах розуміти, що насправді зі сторони клієнту було декілька запитів з різних девайсів. Можливо додавати навіть просто UUID для того, щоб можна було зібрати різні логи в рамках одного запиту від користувача. Можна використовувати open telemetry або data dog, проте я б обрав open telemetry.

11. Вимикач логів

Рекомендується використовувати вимикачі логів для конкретних частин програми, що може значно знизити кількість збережених логів та призводить до зменшення потреби у ресурсах для їхнього зберігання.

12. Правильно використовувати рівні логування

Домовитися на рівні команди про використання різних рівнів логування та уникнення зловживання рівнями ERROR та CRITICAL.

13. Використовувати додаткові дані

Прокидувати додаткові дані через параметр extra для збереження всіх необхідних та додаткових полів.

def log(
    msg: str,
    level: int = logging.INFO,
    **kwargs,
):
    logger = logging.getLogger(name=logger_name)
    logger.log(level=level, msg=msg, extra=kwargs)

14. Вхідні та вихідні запити до third party

Рекомендується забезпечувати зберігання логів при надсиланні та отриманні запитів до сторонніх сервісів з можливістю їхнього вимкнення. Це не лише сприяє полегшенню усунення неполадок, але й дозволяє ефективно відповідати на запити сторонніх служб щодо надання журналів з запитами та відповідями.

15. Створити загальний список логів за повідомленнями

Варто створити таблицю в Confluence зі стовпцями Message та Description, що містять детальні пояснення логів та дані, які надсилаються. Це дозволить усім швидко знаходити необхідну інформацію.

16. Анонімізувати чутливі дані та особисту ідентифікаційну інформацію (PII)

Важливо анонімізувати дані, які ми надсилаємо в логах, оскільки вони можуть містити чутливу інформацію або особисто ідентифіковані дані (PII). Зважаючи на те, як компанії звертають увагу на безпеку та кількість випадків зливання даних або неправомірного доступу до інформації з логів, стає очевидним, що для забезпечення безпеки необхідно уважно стежити за даними, які ми надсилаємо.

Існують багато різних методів анонімізації. Для початку можна використовувати метод маскування, щоб приховати всі дані та залишити лише останні чотири символи. У разі потреби варто витратити трохи часу на створення власних правил маскування для різних типів даних.

Наприклад, для електронної пошти можна застосувати такий підхід: замінити перші символи на «no****y@example.com». А для номерів телефонів: залишити перші три й останні одиниці, приховавши проміжні числа.

Підсумок

Варто підкреслити важливість приділення більшої уваги логуванню. Дотримуючись рекомендацій, ви отримаєте логи з більш чіткою структурою та зручним форматом, що значно спростить їхнє використання, пошук та аналіз.


P.S Це моя перша стаття на DOU. Якщо ви помітили помилку або маєте зауваження або рекомендацію, які не були враховані в статті, будь ласка, залиште коментар, і я з радістю внесу відповідні зміни.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось35
До обраногоВ обраному7
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Не зрозумів щодо рекомендації використання окремої функції — виходить, що її треба буде в кожному файлі визначати окремо. Або ж для кожного виклику передавати аргументами (але це псує загальну читабельність коду).
Можливо процесу вибір того, що логується, а що ні — теж мав би бути описаний.

Для прикладу в ./utils.py створити «враппер» для logger.log (як вказано в пункт 2) та у разі використання імпортувати та викликати utils.log(...).

Можливо процесу вибір того, що логується, а що ні — теж мав би бути описаний.

Цікаве питання. Насправді як і всюди існує дуже тонка межа, що варто логувати, а що ні(залежить від багатьох факторів як бюджет, період ротації, дотримання певних сертифікацій та тп).

Просто з utils.log ми втрачаємо можливість іменування логера на рівні файла використовуючи `__name__`, а потім за потреби його вмикати чи вимикати.
Але мабуть це не всім потрібно, і достатньо лише на рівні модуля знати.

Ще питання- оцю кастомну функцію перетворити на декоратор і всюди робити @log це ще бест пректіс чи вже ні?

Так, декорування логування, наприклад, для запитів до сторонніх сервісів (14 пункт), є чудовою практикою.

PS Я починав писати статтю з «python logging, monitoring, alerting та налаштування і які продукти краще використовувати» та після розуміння який обєм вийде — скоротив до logging

Дякую за статтю
В прикладі нумер 5 код сніппет наче неповний, якщо хотілося додати поле data
І все ж таки для додаткових даних використовуємо data чи extra 🤔?

Дякую, виправив!
Рекомендую використовувати data(dict) та зберігати всю додаткову інформацію там. Це допоможе уникнути безладу в «структурі» лога.

Для прикладу, що ми отримуємо на фіналі

{
  "msg": "Subscription was deactivated",
  ...
  "data": {
    "previous_subscription_id": 11,
  }
}

Дякую за статтю. В основному, пишу не на Python, але принципи всі знайомі, і з ними погоджуюсь.

З мого досвіду, багато розробників не розуміють цінності якісного логування. Зазвичай, в такому випадку допомагає один раз заасайнити на них тікет з критичним продакшен інцидентом.

Власне якщо прибрати python то принципи залишаться ті самі не дарма ж є ліби як log4*language*

Непогана стаття, я б ще додав про string formatting vs log arguments.

На мою субєктивну думку, уникаю використання «string format» логів, оскільки потім їх важко знаходити, незалежно від того, як вони зберігаються (ELK, log file, Cloud watch)

Дякую за коментар :)

Так, згоден. Та ще було б непогано добавити ремарку що серіалізується в жсон не завжди все що потрібно залогати(умовно, в extra запихнули якийсь numpy float32, чи Decimal), так що з умовним return json.dumps(...) треба бути обережним. Це досить дефолтні речі, але про це не всі пам’ятають доки не виліз ексепшн. Ну і бонусом, щоб швидше усьо працювало, orjson ще згадати можна)

На рахунок json.dumps люто плюсую. Можливо це потрібно прямо першим пунктом :)
Дякую за згадування цієї важливої особливості

PS Використовую завжди ujson

Підписатись на коментарі