Структурированное логирование в Go: чек-лист перед продом
Пакет log/slog в стандартной библиотеке Go закрывает большинство задач структурированного логирования. Разбираем, что проверить перед выкатом: формат, уровни, контекст, PII и производительность.

С Go 1.21 в стандартной библиотеке появился log/slog — структурированное логирование без сторонних зависимостей. Но факт наличия пакета не значит, что логи в вашем сервисе готовы к проду. Типичная картина: локально всё читаемо, а в проде — смесь текстового и JSON-вывода, пароли в теле запроса попадают в лог, а под нагрузкой логгер сам становится узким местом. Ниже — чек-лист, который проходят перед выкатом сервиса на Go 1.21+.
Что должно быть решено до прода
Логирование — не «допишем потом». Формат и уровни попадают в дашборды, алерты и в конфиг агрегатора (Loki, ELK, Datadog). Поменять их после того, как накопились гигабайты логов и настроены парсеры, дороже, чем сделать сразу. Разбор ниже применим к slog из стандартной библиотеки; для zap и zerolog принципы те же, отличается синтаксис.
Хорошая проверка перед выкатом: сможет ли дежурный в три часа ночи найти по логам все события одного запроса, не открывая код. Если для этого нужен grep по свободному тексту — лог не структурирован.
1. Один формат вывода, и это JSON в проде
Смешивать текстовый и JSON-вывод в одном сервисе — источник битых записей в агрегаторе. Договоритесь: в проде JSONHandler, локально можно TextHandler для читаемости. Выбор делайте по переменной окружения, а не хардкодом.
package logging
import (
"log/slog"
"os"
)
// New возвращает логгер: JSON в проде, текст локально.
func New(env string) *slog.Logger {
opts := &slog.HandlerOptions{
Level: slog.LevelInfo,
}
var handler slog.Handler
if env == "production" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(handler)
}
Пишите в os.Stdout, а не в файл. Ротацию, буферизацию и доставку берёт на себя runtime (systemd, Docker, Kubernetes). Сервис, который сам управляет файлами логов, дублирует работу инфраструктуры и ломается при нескольких репликах.
2. Уровни используются по назначению
У slog четыре встроенных уровня: Debug, Info, Warn, Error. Частая ошибка — писать всё в Info или, наоборот, объявлять Error на каждую пойманную ошибку, которую сервис успешно обработал.
- Error — событие требует реакции человека или роняет запрос. По нему настраивают алерт.
- Warn — сервис справился сам (сработал ретрай, включился фолбэк), но это отклонение от нормы.
- Info — бизнес-события: запрос принят, задача выполнена. Не в цикле.
- Debug — детали для отладки, в проде выключены уровнем.
Уровень задаётся один раз через HandlerOptions.Level. Чтобы менять его без перезапуска, используйте slog.LevelVar — его значение можно переключать в рантайме, например по сигналу или HTTP-эндпоинту.
var levelVar = new(slog.LevelVar) // по умолчанию LevelInfo
func NewWithDynamicLevel() *slog.Logger {
opts := &slog.HandlerOptions{Level: levelVar}
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
// Где-то в обработчике админ-эндпоинта:
func setDebug() {
levelVar.Set(slog.LevelDebug)
}
3. Контекст запроса проходит сквозь все логи
Без сквозного идентификатора запроса (trace_id, request_id) логи из разных горутин невозможно связать. slog умеет доставать атрибуты из context.Context — для этого нужен обработчик, который читает нужные ключи. В стандартной библиотеке готового такого хендлера нет, поэтому либо оборачивайте вручную, либо используйте logger.With в начале обработки запроса.
// В middleware после генерации request_id:
func handler(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-Id")
if reqID == "" {
reqID = generateID() // ваша реализация
}
// Логгер с привязанным контекстом передаём дальше по цепочке.
log := slog.Default().With(
slog.String("request_id", reqID),
slog.String("path", r.URL.Path),
)
log.Info("request received")
// ... вся дальнейшая обработка пишет через этот log
}
Передавайте логгер с контекстом через context.Context или явным аргументом. Прокидывание через context — отдельная тема, здесь важно одно: не логируйте из глубины кода через slog.Default() без request_id.
4. В логи не утекают секреты и персональные данные
Самая опасная строка. Если вы логируете весь запрос или структуру пользователя целиком, туда попадут пароли, токены, номера карт и телефоны. Агрегатор хранит это годами, доступ к нему шире, чем к базе, — и вы получаете утечку без взлома.
У slog есть механизм LogValuer: тип сам решает, как он выглядит в логе. Определите метод LogValue() на структурах с чувствительными полями.
type User struct {
ID int64
Email string
Token string // не должен попасть в лог
}
// LogValue задаёт безопасное представление для slog.
func (u User) LogValue() slog.Value {
return slog.GroupValue(
slog.Int64("id", u.ID),
// email маскируем, token не выводим вовсе
slog.String("email", maskEmail(u.Email)),
)
}
Правило на ревью: никаких slog.Any("request", r) и log.Info("body", "data", string(body)) без явного разбора полей. Логируйте перечень конкретных безопасных атрибутов, а не объект целиком.
5. Логирование не роняет производительность
Под нагрузкой логгер способен стать узким местом двумя путями: дорогая сериализация в горячем пути и лишние аллокации. slog предоставляет типизированные конструкторы (slog.String, slog.Int) — они дешевле, чем slog.Any, потому что не гоняют значение через рефлексию.
Если сообщение уровня Debug дорого собирать, проверяйте уровень заранее, чтобы не тратить CPU на форматирование того, что всё равно отброшено:
if logger.Enabled(ctx, slog.LevelDebug) {
logger.Debug("dump", slog.String("payload", expensiveDump()))
}
Не логируйте в цикле по каждой итерации на Info — это заваливает и диск, и агрегатор. Агрегируйте: одна запись с количеством вместо тысячи записей.
Как измерить, а не угадать
Замеряйте на своей нагрузке, а не по чужим числам. Запустите бенчмарк с реальным набором атрибутов через go test -bench и посмотрите ns/op и allocs/op для JSONHandler. Сравните вариант с slog.Any и с типизированными атрибутами — разница в аллокациях на горячем пути обычно заметна. Отдельно проверьте поведение под параллельностью: -benchmem и рост числа горутин, которые пишут в один os.Stdout.
Быстрый чек-лист перед выкатом
| Пункт | Что проверить |
|---|---|
| Формат | JSON в проде, вывод в stdout, ротацией управляет инфраструктура |
| Уровни | Error только на то, что требует реакции; уровень задан через LevelVar |
| Контекст | request_id/trace_id проходит через все записи запроса |
| Секреты | LogValue на структурах с PII; нет логирования объектов целиком |
| Производительность | типизированные атрибуты, Enabled перед дорогим Debug, нет логов в цикле |
| Единый логгер | нет параллельного fmt.Println и log.Printf в обход slog |
Что сделать дальше
После выката настройте в агрегаторе парсинг по ключам, которые вы фиксировали (level, request_id, msg), и один алерт на рост записей уровня Error. Проверьте, что LevelVar действительно переключается без перезапуска на стейджинге. Если сервис работает под ощутимой нагрузкой, свяжите логи с трейсингом (OpenTelemetry) через общий trace_id — это следующий уровень наблюдаемости, но начинать стоит с чистых структурированных логов.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: log/slog — официальная документация пакета, Structured Logging with slog — блог Go
Частые вопросы
У меня Go 1.20, slog недоступен. Что делать?
slog медленнее, чем zap?
Куда писать логи — в файл или в stdout?
Как в slog добавить request_id ко всем логам запроса?
А если в логи уже утекли пароли — что делать?
Стоит ли логировать тело каждого HTTP-запроса?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.