Структурные логи в Go на slog: рабочие приёмы
log/slog в стандартной библиотеке Go 1.21 закрыл вопрос, ради которого раньше тянули zap и zerolog. Разбираем, как писать структурные логи так, чтобы их было удобно искать в проде, и где легко выстрелить себе в ногу.

Задача простая: вы пишете HTTP-сервис на Go, в проде что-то отдаёт 500, и вам нужно быстро найти все логи одного запроса. С fmt.Sprintf("user %d failed: %v", id, err) вы будете гонять grep по подстрокам и молиться, чтобы формат сообщения нигде не разъехался. Со структурными логами вы фильтруете по user_id=42 в Loki или Elasticsearch за секунды. Начиная с Go 1.21 для этого не нужны внешние библиотеки — в стандартной библиотеке появился пакет log/slog.
Разберём рабочие приёмы: как настроить хендлер, как прокидывать общий контекст через все логи запроса, чем платить за производительность и где slog ведёт себя не так, как ожидаешь.
Почему привычный log не годится
Стандартный log.Printf отдаёт строку. Строку нельзя надёжно распарсить обратно в поля: сообщение "payment 100 rub declined for user 42" и "user 42: payment declined, amount 100" несут одно и то же, но ваш агрегатор логов их не свяжет. Структурный лог пишет пары ключ-значение, и user_id всегда лежит в поле user_id, а не где-то в тексте.
slog отделяет что писать от как писать. Логгер собирает записи, а хендлер решает, во что их превратить: в JSON для машины или в подсвеченный текст для локальной разработки.
Правило, которое экономит часы разбора инцидентов: сообщение (первый аргумент) — константа, всё переменное уходит в атрибуты. Тогда по сообщению можно сгруппировать события, а по атрибутам — отфильтровать.
Базовая настройка хендлера
Для прода нужен JSON, для локальной разработки — читаемый текст. Оба хендлера входят в стандартную библиотеку: slog.NewJSONHandler и slog.NewTextHandler.
// Go 1.22+
package main
import (
"log/slog"
"os"
)
func setupLogger(production bool) *slog.Logger {
opts := &slog.HandlerOptions{
Level: slog.LevelInfo, // в проде обычно Info, ниже — под флаг
}
var handler slog.Handler
if production {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(handler)
}
func main() {
logger := setupLogger(true)
// Правильно: сообщение — константа, переменное — атрибуты
logger.Info("payment declined",
"user_id", 42,
"amount", 100,
"currency", "RUB",
)
}На выходе получите строку JSON с полями time, level, msg и вашими атрибутами. В агрегаторе она разложится по колонкам автоматически.
Пары ключ-значение против slog.Attr
Вариант выше принимает пары ...any: удобно, но компилятор не поймает, если вы забудете значение — получите ключ без пары, и запись уедет с атрибутом !BADKEY. Типизированные конструкторы slog.String, slog.Int, slog.Any строже и чуть быстрее, потому что не гоняют аргументы через any:
logger.LogAttrs(context.Background(), slog.LevelInfo,
"payment declined",
slog.Int("user_id", 42),
slog.Int("amount", 100),
slog.String("currency", "RUB"),
)LogAttrs — единственный метод, который принимает только slog.Attr и не аллоцирует под интерфейсы. На горячем пути логирования это заметно, о цифрах ниже.
Общий контекст запроса через With
Главная боль без структурных логов — привязка всех сообщений к одному запросу. В slog для этого есть logger.With: он возвращает новый логгер с уже приклеенными атрибутами.
func handleRequest(baseLogger *slog.Logger, requestID string, userID int) {
// Один раз приклеили — дальше пишем без повторов
log := baseLogger.With(
"request_id", requestID,
"user_id", userID,
)
log.Info("request started")
// ... обработка ...
log.Warn("cache miss", "key", "user:profile")
log.Info("request completed", "status", 200)
}Все три записи получат request_id и user_id без повторного перечисления. Один request_id — и вы вытащили всю историю запроса одним фильтром.
Прокидывание логгера через context
Передавать логгер параметром в каждую функцию неудобно. Практичнее положить его в context.Context на входе в запрос и доставать в глубине стека:
type ctxKey struct{}
func withLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok {
return l
}
return slog.Default() // фоллбэк, чтобы не паниковать на nil
}Ключ контекста — пустая структура собственного типа, а не строка: так исключены коллизии с чужими значениями в том же контексте.
Цена вопроса: производительность
Логирование на горячем пути стоит аллокаций. Три момента, где slog тратит больше, чем кажется:
- Пары
...anyбоксируют значения. Каждыйint, переданный какany, может уйти в кучу.LogAttrsс типизированнымиAttrэтого избегает. - Атрибуты вычисляются всегда, даже для отключённого уровня. Если вы пишете
log.Debug("dump", "payload", heavyMarshal()), тоheavyMarshal()выполнится, даже когда уровень Debug выключен. Оборачивайте дорогое вslog.Anyс типом, реализующимslog.LogValuer, или проверяйтеlog.Enabled(ctx, slog.LevelDebug)перед вызовом. - JSON-сериализация не бесплатна. На тысячах записей в секунду разница между JSON и текстовым хендлером измерима.
Точные цифры зависят от вашего железа и размера записи, поэтому не берите чужие бенчмарки на веру. Померяйте свой путь: go test -bench=. -benchmem с b.ReportAllocs() покажет аллокации на операцию. Сравните logger.Info с парами против logger.LogAttrs с Attr на вашей типичной записи — разница в allocs/op будет видна сразу.
Ленивое вычисление через LogValuer
Если атрибут дорого собирать, отложите вычисление до момента, когда хендлер решит, что запись действительно пишется:
type sessionInfo struct {
userID int
}
// LogValue вызовется только если запись реально попадёт в вывод
func (s sessionInfo) LogValue() slog.Value {
return slog.GroupValue(
slog.Int("user_id", s.userID),
slog.String("tier", lookupTier(s.userID)), // дорогой вызов
)
}Передавайте объект как slog.Any("session", sess) — при выключенном уровне lookupTier не выполнится.
Сравнение подходов
| Приём | Что выигрываете | Чем платите |
|---|---|---|
logger.Info(msg, "k", v) | Короткий синтаксис, читается легко | Боксинг в any, риск !BADKEY |
LogAttrs + типизированные Attr | Меньше аллокаций, проверка типов | Многословнее, нужен ctx |
slog.Default() глобально | Ничего не прокидывать | Нет контекста запроса, трудно тестировать |
Логгер в context | Контекст запроса везде без параметров | Прячет зависимость, легко забыть положить |
Что ломается на практике
- Нечётное число аргументов.
log.Info("msg", "user_id")без значения даст!BADKEYвместо ошибки компиляции. Линтерsloglintловит это статически — поставьте его в CI. - Разъезжающиеся имена ключей.
user_idв одном месте иuserIdв другом — и фильтр не соберёт все записи. Заведите константы для ключей или общий пакет с хелперами. - Логирование секретов.
slog.Any("user", user)сериализует структуру целиком, включая поле с токеном. РеализуйтеLogValueна чувствительных типах, чтобы отдавать маскированное значение. - Уровни ниже Info в проде. Оставленный Debug раздувает объём логов и счёт за хранилище. Уровень должен переключаться флагом или переменной окружения, а не пересборкой.
Что настроить дальше
После базовой настройки посмотрите в сторону трёх вещей. Первое — slog.HandlerOptions.ReplaceAttr: функция, которой можно переименовать или удалить поля на лету, например замаскировать всё, что похоже на токен, или переименовать msg под формат вашего агрегатора. Второе — связка с трассировкой: если вы используете OpenTelemetry, вытаскивайте trace_id из контекста и добавляйте в каждый лог, тогда логи и трейсы сойдутся по одному идентификатору. Третье — измерьте объём логов в проде за сутки и прикиньте стоимость хранения: структурные логи легко разрастаются, и уровень Info на болтливом сервисе может стоить ощутимых денег в облачном логгере.
Проверка рекламы на риски: Что в макете вызовет вопросы ФАС: превосходные степени, сравнения, обещания результата, маркировка.
Спросить за 40 ₽Источники: Пакет log/slog — официальная документация Go, Structured Logging with slog — The Go Blog
Частые вопросы
У меня Go 1.20, slog в стандартной библиотеке нет. Что делать?
Логи пишутся, но в агрегаторе не разбиваются на поля. Почему?
Насколько slog медленнее zap на горячем пути?
Стоит ли класть логгер в context или лучше передавать параметром?
Как не залогировать пароль или токен вместе со структурой?
Дорогой атрибут вычисляется, даже когда уровень Debug выключен. Как отложить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.