Структурированное логирование: что, как и куда складывать
Текстовый лог в файле — это удобно, пока сервисов не больше двух. Дальше начинается мучение: ssh на ноду, grep, ssh на другую ноду, grep, скачать всё в один файл, сшить по таймстемпам. Структурированное логирование решает эту проблему — превращает логи из текста в данные, которые можно искать, фильтровать, агрегировать.
За последние десять лет я видел как минимум три волны переходов команд на JSON-логи. Каждый раз сначала «зачем это надо, у нас и так grep работает», потом «как мы раньше без этого жили». Разберу, что класть в структурированный лог, как избежать типичных проблем и куда отправлять, чтобы это реально работало.
Что значит «структурированное»
Текстовая запись:
2026-03-15 15:42:01.123 INFO OrderService - Order created for user 42, total 1500 RUBJSON-запись:
{"ts":"2026-03-15T15:42:01.123Z","level":"INFO","logger":"OrderService","msg":"order created","user_id":42,"total":1500,"currency":"RUB","trace_id":"abc123"}Главное отличие — поля доступны как поля, не как часть текста. Можно искать user_id:42, фильтровать level:ERROR, агрегировать «топ user_id с количеством ошибок» — без regexp'ов и парсинга.
Минимальный набор полей
В каждой записи должно быть.
- ts — timestamp в ISO 8601 с TZ. Никакого «локального времени», только UTC.
- level — DEBUG, INFO, WARN, ERROR.
- service — имя сервиса. Когда логи от десятка сервисов смешиваются в одном агрегаторе, без этого не разобраться.
- logger — обычно класс/модуль, который пишет. Полезно для фильтрации.
- msg — короткое описание события.
- trace_id / correlation_id — для сшивания через сервисы.
- level_value — числовое представление level'а, удобно для сортировки.
Дополнительные поля по контексту — user_id, order_id, request_path, что угодно конкретное.
Как писать сообщение
Главный антипаттерн — динамическое сообщение, в которое подставляются значения.
// ПЛОХО: значения вшиты в текст
logger.info("Order $orderId for user $userId created with amount $amount")
// ХОРОШО: сообщение константное, значения — отдельные поля
logger.info("order created",
"order_id", orderId,
"user_id", userId,
"amount", amount
)Почему: с константным сообщением можно агрегировать «сколько раз случилось 'order created'». С динамическим — каждое сообщение уникально, ничего не агрегируется.
В Java/Kotlin удобный API даёт SLF4J с argument substitution или structured logging библиотеки (Logstash-encoder, Logback-JSON). В Go — zap, slog. В Python — structlog.
Уровни логов
Договорённости команды важнее любых стандартов, но базовая логика:
- ERROR — что-то сломалось, требует внимания. Если в день ваш сервис пишет 1000 ERROR — что-то не так, либо вы не видите реальные ошибки за шумом, либо у вас 1000 проблем.
- WARN — что-то странное, но обработали. Например, retry сработал, fallback произошёл.
- INFO — важные события: запрос обработан, заказ создан, конфиг загружен. Не каждое действие, а ключевые точки.
- DEBUG — детали для отладки. По умолчанию выключен в production.
- TRACE — самый подробный. Используется редко, для специфической диагностики.
Регулирование уровней — runtime: dynamic logger config даёт возможность включить DEBUG на прод-инстансе временно для расследования, не передеплоивая.
Что класть, чего не класть
Несколько правил, которые я выработал.
Класть
- Идентификаторы (user_id, order_id, request_id) — для поиска и связи.
- Бизнес-метрики (amount, status, items_count) — для аналитики через логи.
- Имена внешних сервисов и эндпоинтов при вызовах.
- Длительности операций (duration_ms).
- Версия сервиса (service_version) — критично при rolling deploys.
Не класть
- Пароли, токены, ключи — никогда. Даже хешированные. Один раз случайно прокинуть в лог — и они навсегда в архивах.
- Полные тела запросов с PII — email, паспорта, телефоны.
- Внутренние состояния, которые меняются без логики — кеши, счётчики.
- Stack trace на каждый чих. Только при ERROR.
- Большие payload'ы — если очень нужно, обрезайте до 1 КБ.
Sensitive data
PII в логах — большая проблема, и часто никто о ней не думает до первого аудита.
Стратегии:
Маскирование на уровне сериализатора
JSON-сериализатор знает, какие поля чувствительные, и заменяет их на ***:
@JsonSerialize(using = MaskingSerializer::class)
data class User(
val id: UUID,
@Sensitive val email: String,
@Sensitive val phone: String
)Любая запись в лог проходит через сериализатор, sensitive поля маскируются автоматически.
Allowlist полей
В логах разрешены только поля из заранее определённого списка. Всё остальное — отбрасывается. Это жёстче, но надёжнее: новое поле в DTO не появится в логе случайно.
Hashing
Email хешируется в SHA-256 для логирования. По хешу нельзя восстановить email, но можно сравнить «два события — это один user». Полезно для cross-service correlation без утечки PII.
На проектах с GDPR-/PCI-DSS-требованиями я обычно делаю allowlist + hashing для тех немногих случаев, где нужно correlation.
Куда отправлять
Вариантов много, выбор зависит от объёмов и бюджета.
Loki + Grafana
Лёгкий, дешёвый, индексирует только метаданные (labels), сами логи хранит сжатыми. Запросы LogQL похожи на PromQL: {service="orders"} |= "error" | json | line_format "...".
Минус — не годится для full-text search в больших объёмах. Хорош, когда есть структура.
Elasticsearch + Kibana
Полнотекстовый поиск, визуализация, алерты. Дорогая инфраструктура, но мощная.
Минус — стоимость хранения и индексации. Я переходил с ELK на Loki на одном проекте именно из-за расходов: 80% запросов было «найди по labels», ELK было overkill.
Cloud-native
CloudWatch, Datadog Logs, New Relic — managed, ничего настраивать не надо, платите за объём. Подходит, когда команда маленькая и не хочет администрировать инфраструктуру логов.
Vector / Fluentbit как агент
Программа, которая стоит на каждой ноде, читает логи stdout контейнеров, парсит, обогащает (добавляет k8s metadata), отправляет в backend. Любой из перечисленных backend'ов поддерживается.
Эта прослойка оказывается полезной: можно делать фильтрацию, sampling, маршрутизацию на уровне агента, не меняя сервисы.
Подводные камни
Несколько вещей, которые я ловил.
Лог-storm. В цикле кода случайно стоит logger.info, в проде идёт 100k iterations/sec. Логи генерируются гигабайтами, агрегатор не справляется. Лекарство — sampling на уровне сервиса для «горячих путей» и метрики на rate логирования.
Поломка JSON. Один поток пишет логи в stdout, другой пишет stack trace через System.err. Контейнер видит две перемешанные строки. JSON parser ломается. Решение — писать всё через единый logger, никогда не смешивать.
Schema-drift. Поле user_id раньше было String, потом стало Long. Elasticsearch падает с mapping conflict. Решение — заранее задавать tipping в индексе, или использовать строки для всех ID.
Неэкранированные данные. В msg попал кусок текста с двойными кавычками — JSON ломается. Логгер должен правильно сериализовать всё.
Time skew. Логи разных нод имеют разные timestamps из-за NTP-проблем. При сшивании сервисов получается «event B произошёл до event A», хотя B вызвал A. Используйте agent-side timestamping (когда лог получает агент) и nanosecond-precision где возможно.
Логи в production без retention policy. Логи копятся месяцами, диск/storage заканчивается. Должна быть политика: 7 дней — горячие, 30 — тёплые, 90 — архив, дальше — удаление. Зависит от compliance.
Production debug logging. Включили DEBUG для расследования, забыли выключить. Через неделю билинг за CloudWatch — внезапные большие цифры. Лимиты по объёму логов на сервис — обязательны.
Лог-driven debugging
Несколько практик, как использовать логи для отладки эффективно.
Сэмпл логов на ошибки. При ERROR в логе автоматически создаётся ссылка на trace в Jaeger или поиск в Loki. Cliсkable links в Slack-алертах.
Дашборды по логам. Не только по метрикам. Loki/ELK умеют визуализировать частоту событий: «количество ORDER_CREATED по часам», «топ пользователей по error rate».
Алерты по логам. «Если 'OutOfMemoryError' встретился 5 раз за минуту» — это полезный алерт, который ловит проблемы до того, как сервис упал.
Логи как audit trail. Чувствительные операции — логируются как business events с фиксированной schema. Их можно использовать для аудита, не отдельной системой.
Что запомнить
Структурированное логирование — это про превращение логов из текста в данные. Минимум — JSON с ts/level/service/msg/correlation_id, плюс контекстные поля.
Сообщение константное, значения — отдельными полями. Без этого нельзя агрегировать.
PII всегда маскируется, никаких исключений «временно». Один раз случайно прокинуть — и они в архивах навсегда.
Backend выбирается по объёмам и бюджету: Loki для labels-driven поиска, ELK для full-text, managed для маленьких команд. Vector/Fluentbit как агент собирает логи унифицированно.
Главное — структурированные логи без агрегатора бесполезны. JSON в файле на ноде — это не лучше текста. Платите налог инфраструктурой, чтобы получить отдачу. Без неё структурированное логирование — это просто более многословный формат текста.