Почему выбор уровня логирования - не «поставить INFO и забыть»
Уровень логирования выбирают по трём признакам: стадия жизненного цикла приложения, тип данных в сообщении и действие, которое должен выполнить читатель лога. Отладочные детали нужны на dev-стенде, события жизненного цикла в staging, инциденты в production. Строка, после которой ничего делать не нужно, в логе лишняя, и её уровень завышен.
В 2026 году цена ошибки выше, чем пять лет назад. Объёмы логов растут вместе с трафиком, а хранение и индексация в облачных системах видны в счёте отдельной строкой. GDPR, его национальные аналоги и PCI DSS ограничивают и то, что попадает в лог, и срок хранения этих записей. Конкретные пункты стандартов сверяйте с действующей редакцией: требования обновляются чаще, чем внутренние инструкции команд.
Три сценария, которые регулярно всплывают при разборе инцидентов:
- DEBUG включили для диагностики и забыли выключить. В лог уходят тела HTTP-запросов, параметры SQL и структуры объектов, вместе с ними пароли, токены и email.
- INFO используют как универсальный уровень для всего, включая каждый запрос и каждую итерацию цикла. Сигнал тонет в шуме, и полезные сообщения никто не читает.
- ERROR выставляют для ожидаемых бизнес-ситуаций вроде «пользователь не найден». Дежурный привыкает к ложным алертам и перестаёт реагировать на настоящие.
Дальше разберём семантику уровней, дадим матрицу по стадиям, рабочие конфиги для Python, Java и Go, чек-лист ревью и команды проверки.
Уровни логирования INFO, WARN, ERROR и другие: что они значат на практике
Правило одинаково для всех библиотек: уровень описывает не важность сообщения в глазах автора кода, а реакцию читателя. Если строка не требует действия, её уровень завышен, и алертинг по этому уровню превратится в шум.
| Уровень | Числовое значение | Когда писать | Действие читателя |
|---|---|---|---|
| DEBUG | 10 | Отладка, разбор конкретного запроса, работа на dev-стенде | Никакого, это среда разработки |
| INFO | 20 | Старт и остановка сервиса, миграция, смена конфигурации, успешная транзакция | Понять контекст при расследовании |
| WARN | 30 | Повтор запроса, переход на резервный источник, деградация, истекающий сертификат | Проверить, если проблема повторится |
| ERROR | 40 | Операция не выполнена, исключение, недоступность внешней системы | Реагировать, при необходимости по алерту |
| FATAL, CRITICAL | 50 | Потеря консистентности данных, состояние, при котором сервис не может продолжать работу | Немедленное вмешательство и остановка |
Числовые значения совпадают не везде, порядок один и тот же. В Python logging: DEBUG=10, INFO=20, WARNING=30, ERROR=40, CRITICAL=50. В SLF4J (Java): TRACE=0, DEBUG=10, INFO=20, WARN=30, ERROR=40. В Go slog уровни идут с шагом 4: LevelDebug=-4, LevelInfo=0, LevelWarn=4, LevelError=8. Фильтрация работает одинаково: логгер пропускает сообщения с уровнем не ниже установленного порога.
DEBUG: почему он опасен в продакшене
DEBUG пишет всё, что нужно разработчику при отладке: тела запросов, фрагменты ответов внешних API, параметры SQL, значения переменных. Первый риск, утечка данных. ORM с включённым выводом параметров запроса сохранит в файл значения из WHERE и INSERT, включая email, телефон и номера документов. Второй риск, объём. Миллион запросов в сутки при теле около 1 КБ даёт порядка гигабайта логов в день на одном сервисе, и это без учёта ретраев и фоновых задач. Третий риск, скорость: синхронная запись на диск добавляет задержку в обработку каждого запроса.
Рабочее правило: DEBUG живёт на dev-стендах. В production его включают точечно и на время, обычно через динамическую конфигурацию: logback с параметром scan="true" и внешним файлом, Spring Boot Actuator с эндпоинтом /loggers, atomic level в Go. Факт включения DEBUG стоит логировать отдельным сообщением на уровне WARN, чтобы потом было понятно, откуда в логе появились подробности.
Проверка конфигов перед деплоем:
grep -rn 'DEBUG' /etc/app/ /opt/app/config/
grep -rn 'logging.level' /opt/app/application.yml
grep -rn 'LOG_LEVEL' /etc/systemd/system/app.service
INFO: когда он теряет ценность
INFO теряет смысл в двух случаях: сообщение пишется в цикле и сообщение дублирует то, что уже видно на другом уровне. Классический пример: логирование каждого HTTP-запроса на INFO в сервисе с сотнями запросов в секунду. За сутки набегают миллионы строк, а реальные проблемы в них не находятся поиском. Запросы лучше смотреть в access-логах reverse proxy (Nginx) или через sampling, а в приложении оставить значимые события: старт и остановку, применение миграций, смену конфигурации, завершение критичной транзакции.
Готовые критерии, какие события вообще заслуживают записи, разобраны в материале что логировать, а что нет.
В Go удобно опираться на slog.InfoContext и писать INFO только на входе и выходе значимых операций, а не внутри обработчика каждого запроса.
WARN и ERROR: где проходит граница
WARN описывает ситуацию, которая не сломала работу, но требует внимания: повтор после таймаута, переход на резервный источник, падение свободного места, сертификат с истекающим сроком. ERROR означает, что операция не выполнена и без вмешательства не выполнится: исключение, недоступность внешнего сервиса, ошибка записи в базу.
Антипаттерн, который ломает алертинг: бизнес-ошибки на уровне ERROR. Сообщение «пользователь не найден» при неверном логине это ожидаемый результат валидации, ему место на INFO или WARN в зависимости от того, интересует ли вас статистика. Проверка простая: если ошибка ожидаема и обработана кодом, это WARN или INFO; если нет, ERROR. В SLF4J это log.warn для ретраев и log.error для исключений, в Go slog.Warn для деградации и slog.Error для отказов.
Матрица выбора уровня логирования по стадиям: dev, staging, production
| Стадия | Уровень по умолчанию | Что логировать | Что не логировать |
|---|---|---|---|
| dev | DEBUG | Тела запросов, SQL с параметрами, работу кэша, ветвления логики | Реальные персональные данные, боевые ключи API, выгрузки из продакшена |
| staging | INFO плюс DEBUG точечно | Интеграции с внешними системами, миграции, старт и остановку, результаты нагрузочных прогонов | Секреты тестовых стендов в открытом виде, тела платёжных запросов |
| production | INFO | События жизненного цикла, WARN при деградации, ERROR при отказах, audit-события безопасности | Пароли, токены, тела запросов, параметры SQL, полные номера карт и документов |
Матрица задаёт отправную точку, а не догму. Для высоконагруженных сервисов уровень остаётся тем же, а объём снижается через sampling и rate limiting на стороне коллектора.
Как менять уровень логирования без перезапуска приложения
Диагностика в production редко совпадает с плановым деплоем, поэтому уровень меняют на лету. В Spring Boot для этого есть Actuator:
curl -X POST http://localhost:8080/actuator/loggers/com.example -H 'Content-Type: application/json' -d '{"configuredLevel":"DEBUG"}'
Сброс обратно: то же самое с configuredLevel: "INFO" или DELETE на тот же адрес. В не-Spring проектах на logback работает scan="true" с внешним файлом: правите XML, через 30 секунд конфигурация перечитывается. В Go уровень держат в slog.LevelVar и меняют его из внутреннего HTTP-эндпоинта или по сигналу. Логируйте каждое переключение и ограничивайте эндпоинт смены уровня локальной сетью или отдельным портом, иначе получите способ залить диск по внешнему запросу.
Производительность и стоимость: что меняет уровень
Стоимость логирования складывается из трёх частей: накладные расходы на форматирование и запись, объём хранения и цена индексации. Переход с DEBUG на INFO обычно сокращает объём в разы, а не на проценты, потому что отладочные записи многословны и часто пишутся в циклах. Приёмы, которые снижают накладные расходы без потери детальности: ленивое форматирование вместо конкатенации строк, асинхронные хэндлеры с буферизацией, sampling для однотипных событий. Практические сниппеты собраны в статье про оптимизацию логирования в Python.
Безопасность логирования: как не слить пароли, токены и PII
Список того, что не должно попадать в логи: пароли и их хеши, токены и ключи API, заголовок Authorization целиком, номера банковских карт, коды CVV, номера документов, полные email и телефоны там, где они не нужны для разбора инцидента, содержимое .env и переменных окружения. Отдельная категория, тела запросов к платёжным и медицинским API.
Маскирование делают на уровне приложения, а не только в коллекторе. Причина простая: до попадания в Logstash или Fluentd лог лежит в файле на диске и доступен каждому, у кого есть доступ к хосту или к томуе контейнера.
Проверка на утечки по уже собранным логам:
grep -iE 'password|passwd|secret|token|api[_-]?key|authorization' /var/log/app/*.log | head -50
grep -iE '[0-9]{13,19}' /var/log/app/*.log | head -20
Маскирование секретов в конфигах Python, Java и Go
В Python проще всего добавить logging.Filter, который чистит сообщение до форматирования. Регулярное выражение ищет пары вида key=value и заменяет значение:
class MaskFilter(logging.Filter):
PATTERN = re.compile(r'(?i)(password|token|secret|api_key)["\s:=]+([^\s"]+)')
def filter(self, record):
record.msg = self.PATTERN.sub(r'\1=***', str(record.msg))
return True
В Java есть два уровня. Для консольного паттерна подходит conversion word %replace, для JSON-вывода через logstash-logback-encoder настраивают ReplacingCompositeConverter, который правит поле message целиком. В Go пишут собственный slog.Handler: он оборачивает базовый JSON-хендлер и заменяет значения атрибутов по списку ключей (password, token, secret, authorization). Маскирование по ключу надёжнее маскирования по регулярке в тексте, потому что структурированные логи дают доступ к имени поля, а не к строке целиком.
Полный набор приёмов для маскировки паролей и PII, а также настройка разграничения доступа к логам описаны в руководстве по защите данных в логах.
Защита от log injection и CRLF-атак
Log injection возникает, когда пользовательские данные попадают в лог без обработки. Злоумышленник передаёт в поле имени символ перевода строки, и в файле появляется поддельная запись, например строка о входе под root. Меры защиты: экранировать управляющие символы, использовать структурированный формат JSON вместо конкатенации, валидировать входные данные по допустимым символам. В Python подойдёт json.dumps для полезной нагрузки, в Java logstash-logback-encoder, в Go slog.JSONHandler.
Проверка: отправьте запрос, в котором параметр содержит %0A и текст «ERROR: payment failed», затем поищите эту строку в логе. Если она нашлась как отдельная запись с вашим уровнем, экранирования нет.
Готовые конфиги логирования для Python, Java и Go
Каждый конфиг ниже читает уровень из переменной окружения, пишет JSON и маскирует секреты. Различия только в синтаксисе.
Python: dictConfig с JSON и маскированием
import logging, logging.config, os, re
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"filters": {"mask": {"()": "app.log_utils.MaskFilter"}},
"formatters": {"json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"format": "%(asctime)s %(levelname)s %(name)s %(request_id)s %(message)s"}},
"handlers": {"stdout": {"class": "logging.StreamHandler", "formatter": "json", "filters": ["mask"]}},
"root": {"level": os.environ.get("LOG_LEVEL", "INFO"), "handlers": ["stdout"]},
}logging.config.dictConfig(LOGGING)
Проверка: LOG_LEVEL=DEBUG python app.py и просмотр вывода через jq. Версии: Python 3.12 и выше, python-json-logger актуальной версии. Для Django и Flask схема та же, отличается только место инициализации; детали для этих фреймворков разобраны в материале про логирование Django и Flask. Базовые настройки модуля logging под промышленную эксплуатацию собраны в статье про настройку логирования в Python.
Java: logback-spring.xml с уровнями и маскированием
<configuration scan="true" scanPeriod="30 seconds">
<springProperty scope="context" name="LOG_LEVEL" source="logging.level.root" defaultValue="INFO"/>
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
<root level="${LOG_LEVEL}">
<appender-ref ref="JSON"/>
</root>
</configuration>
Для текстового вывода добавьте маскирование прямо в паттерн:
pattern="%d{yyyy-MM-dd HH:mm:ss} %-5level [%X{request_id}] %logger{36} - %replace(%msg){'(?i)(password|token|secret)=[^ ]+', '$1=***'}%n"
Проверка уровня на живом приложении: GET /actuator/loggers/com.example покажет configuredLevel и effectiveLevel, POST меняет значение. Версии: logback 1.5.x, Spring Boot 3.x. Для проектов без Spring тот же файл кладут в classpath как logback.xml, параметр scan="true" оставляют, а уровень задают через ${LOG_LEVEL} из переменной окружения.
Go: slog с JSONHandler и контекстом
level := slog.LevelInfo
switch os.Getenv("LOG_LEVEL") {
case "DEBUG":
level = slog.LevelDebug
case "WARN":
level = slog.LevelWarn
case "ERROR":
level = slog.LevelError
}logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
slog.SetDefault(logger)
Для смены уровня без перезапуска используют slog.LevelVar:
var lvl slog.LevelVar
lvl.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &lvl})
// включение диагностики из внутреннего эндпоинта
lvl.Set(slog.LevelDebug)
Проверка: go run main.go | jq и фильтр по полю level. Пакет slog появился в Go 1.21, для новых проектов он закрывает большинство задач. Если проект уже использует zap или zerolog, логика та же: уровень из окружения, JSON на stdout, атрибут request_id в каждом сообщении.
Контекст и структура логов: как не утонуть в шуме
Структурированный лог это JSON с фиксированными полями: timestamp, level, service, request_id, user_id, message. По таким полям работают фильтры в Loki, Elasticsearch и других коллекторах, а человек читает их без парсинга регулярками. Текстовые логи со склейкой строк ломают и поиск, и маскирование.
Минимальный набор полей, который стоит завести с первого дня: service, env, request_id, user_id (или внутренний идентификатор вместо email), duration_ms, status. Поля с высокой кардинальностью вроде полного URL с query-параметрами в индекс не выносят, иначе он разрастётся.
Correlation ID и trace ID в микросервисах
Схема, которая работает: входной прокси присваивает идентификатор запроса и прокидывает его в заголовке. В Nginx это proxy_set_header X-Request-ID $request_id. Приложение читает заголовок, кладёт значение в контекст логирования (MDC в Java, context в Go, contextvars в Python) и добавляет во все сообщения. Дальше идентификатор передаётся в HTTP-заголовках при вызовах соседних сервисов, поэтому цепочка собирается одним поиском.
Проверка: curl -H 'X-Request-ID: test-123' http://localhost:8080/api/health, затем поиск строки test-123 в логах всех сервисов, участвовавших в обработке. Если идентификатор нашёлся только в одном сервисе, передача заголовка дальше не настроена. Для распределённой трассировки применяют W3C Trace Context с заголовком traceparent и OpenTelemetry, но request_id полезно оставить даже при наличии трейсинга: он живёт в логах, доступных без отдельной инфраструктуры.
Типичные ошибки логирования и как их исправить
- Секреты в логе. Django REST Framework с телом запроса на DEBUG сохранит пароль из формы регистрации, Nginx с включённым логированием заголовков сохранит Authorization. Исправление: маскирование на уровне приложения плюс правка формата access-лога.
- Логирование в цикле. Обработка 50 000 записей с сообщением на каждой даёт 50 000 строк на один запуск. Исправление: агрегировать результат и писать одну итоговую строку.
- Log and throw. Исключение логируют в catch и пробрасывают выше, где логируют повторно. Исправление: логировать в одном месте, обычно на границе обработки запроса, остальным уровням оставить контекст.
- ERROR для бизнес-ошибок. Валидация, «не найдено», «недостаточно средств» это ожидаемые результаты. Исправление: перевести на INFO или WARN и не заводить по ним алерты.
- Отсутствие ротации. Файл растёт до заполнения раздела, и падает не логирование, а приложение. Исправление: logrotate для файлов, retention policy в Loki или Elasticsearch, лимиты на индекс.
- Отсутствие контекста. Сообщение «ошибка обработки» без идентификатора запроса и параметров бесполезно. Исправление: добавить request_id, имя операции и код ошибки.
Отдельно стоит следить за диагностическими выводами фреймворков. Отладочные режимы сторонних библиотек пишут в тот же поток, что и ваши логи, и часто содержат подключения и параметры запросов. Проверяйте корневые логгеры, а не только свои модули: параметр root level из примера выше как раз задаёт общий порог.
Чек-лист ревью кода и конфигов логирования
- Уровень берётся из переменной окружения, хардкода DEBUG в продакшн-конфиге нет.
- Пароли, токены, ключи API и номера карт не попадают в сообщения ни на одном уровне.
- Формат структурированный (JSON), поля фиксированные.
- request_id присутствует в каждом сообщении, включая ошибки.
- Исключение логируется в одном месте, дублирования нет.
- Бизнес-ошибки не пишутся на ERROR.
- Ротация файлов и retention в коллекторе настроены.
- Отладочные выводы сторонних библиотек ограничены.
- Внутри циклов нет логирования на каждой итерации.
- Алерты заведены на ERROR и на ключевые WARN, а не на весь поток.
Автоматизация проверки: статический анализатор с набором правил на логирование (например, semgrep с собственными паттернами на password, token, Authorization) плюс grep по конфигам в CI. Команды для локального прогона:
grep -rn 'password\|token\|secret' src/ --include='*.py' --include='*.java' --include='*.go'
grep -rn 'DEBUG' config/ /etc/app/
grep -rn 'LOG_LEVEL' deploy/
Как проверить, что логирование настроено правильно
Проверка занимает несколько минут и закрывает большинство сюрпризов до деплоя.
- Уровень в рантайме: journalctl -u app --since '10 min ago' | grep -c DEBUG. При production-конфиге счётчик должен быть равен нулю.
- Маскирование: отправьте запрос с полем password и убедитесь, что в логе стоит ***.
- Контекст: curl -H 'X-Request-ID: test-123' http://localhost:8080/api/health и поиск test-123 в выводе.
- Контейнеры: docker logs --tail 100 app, для Kubernetes kubectl logs deploy/app --tail=100 или stern.
- Ротация: ls -lh /var/log/app/ после принудительного запуска logrotate -f /etc/logrotate.d/app.
- Объём: du -sh /var/log/app/ раз в сутки, чтобы увидеть динамику до заполнения диска.
- Структура: вывод приложения через jq -c '. | select(.level=="ERROR")' покажет, парсится ли JSON.
Итог: алгоритм выбора уровня логирования за 5 шагов
- Определите стадию: dev, staging или production. От неё зависит уровень по умолчанию: DEBUG, INFO плюс точечный DEBUG, INFO.
- Составьте список событий для записи по каждому уровню, отталкиваясь от действия читателя, а не от важности события для автора кода.
- Настройте смену уровня без перезапуска: Actuator, scan="true", slog.LevelVar, и закройте эндпоинт смены уровня от внешней сети.
- Добавьте маскирование секретов и request_id, переведите вывод в JSON.
- Прогоните чек-лист и команды проверки из предыдущих разделов, настройте ротацию и алерты на ERROR.
Конфиги в статье даны как рабочие заготовки: подставьте свои имена модулей, пути и версии библиотек, проверьте поведение на staging и только потом переносите в production. Сохраните чек-лист рядом с инструкцией по деплою, чтобы возвращаться к нему при каждом изменении в логировании.