Структурированные логи: как писать логи, по которым легко искать
Практика3 минРедакция Innovialabs
Коротко
Пишите логи в JSON с постоянным набором полей — время, уровень, сервис, сообщение и request ID; тогда любую ошибку можно найти фильтром, а не чтением тысяч строк.
Что такое структурированный лог
Обычный лог — это строка текста: User 42 failed to pay order 981. Человеку понятно, но машине нужно её разбирать регулярками, которые ломаются при любой правке формулировки.
Структурированный лог — это запись с именованными полями, чаще всего в JSON:
{"ts":"2026-03-14T10:22:05Z","level":"error","service":"billing","msg":"payment failed","user_id":42,"order_id":981,"request_id":"a1b2c3"}
Такую запись система сбора логов понимает сразу: можно отфильтровать все ошибки сервиса billing, посчитать их по часам или найти все события одного запроса.
Обязательные поля
Договоритесь о едином наборе полей для всех сервисов:
- ts — время в UTC в формате ISO 8601.
- level — уровень записи.
- service и env — какой сервис и в каком окружении.
- msg — короткое постоянное описание события, без подстановки переменных.
- request_id / trace_id — идентификатор, связывающий записи одного запроса.
- error — тип и текст ошибки, stack trace — отдельным полем.
Ключевая привычка: переменные — в поля, а не в текст сообщения. msg: "payment failed" одинаков для всех случаев, поэтому его легко сгруппировать.
Уровни логирования
| Уровень | Когда использовать |
|---|---|
| debug | Детали для разработки; в продакшене обычно выключен |
| info | Значимые штатные события: запуск, завершение задачи, вход пользователя |
| warn | Что-то пошло не так, но система справилась: повтор запроса, fallback |
| error | Операция не выполнена, нужна реакция |
| fatal | Процесс не может продолжать работу |
Если error-записи появляются при нормальной работе, на них перестают реагировать. Уровень должен означать действие, а не настроение разработчика.
Request ID и correlation ID
Когда запрос проходит через фронтенд, API, очередь и воркер, его нужно отследить целиком.
- На входе (балансировщик или первый сервис) генерируется уникальный ID, если его ещё нет в заголовке.
- ID кладётся в контекст логгера, чтобы попадать в каждую запись автоматически.
- При вызове других сервисов ID передаётся дальше в заголовке, например
X-Request-IDили стандартномtraceparentиз W3C Trace Context. - ID возвращается клиенту в ответе — пользователь может назвать его поддержке.
Если вы используете OpenTelemetry, берите trace_id оттуда: логи сразу свяжутся с трейсами.
Что никогда не писать в логи
- Пароли, токены, API-ключи, cookie сессий, заголовок
Authorization. - Номера карт, паспортные данные и другие чувствительные персональные данные.
- Полные тела запросов и ответов «на всякий случай».
- Содержимое файлов и большие бинарные данные.
Настройте маскирование (redaction) на уровне логгера, а не надейтесь на внимательность каждого разработчика.
Примеры
Node.js (pino):
import pino from "pino";
const logger = pino({
base: { service: "billing" },
redact: ["req.headers.authorization", "password"],
});
const log = logger.child({ request_id: "a1b2c3" });
log.error({ user_id: 42, order_id: 981 }, "payment failed");
Python (structlog):
import structlog
structlog.configure(processors=[
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.processors.JSONRenderer(),
])
log = structlog.get_logger().bind(service="billing", request_id="a1b2c3")
log.error("payment failed", user_id=42, order_id=981)
Go (log/slog):
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
With("service", "billing", "request_id", "a1b2c3")
logger.Error("payment failed", "user_id", 42, "order_id", 981)
Частые ошибки
- Разные имена одного поля в разных сервисах:
userId,user_id,uid. - Логирование в цикле на каждой итерации — шум и лишние расходы.
- Многострочные stack trace в обычном тексте, которые разрываются на отдельные записи.
- Запись в файлы внутри контейнера вместо stdout.