log.Println("user logged in:", userID) работает ровно до того момента, когда сервис уезжает в прод. Логов становится миллионы строк в день, и начинается боль. Вы хотите найти все события одного пользователя или отфильтровать по уровню ошибки, а у вас плоский текст, который приходится разбирать регулярками. Структурированное логирование решает эту проблему: каждая запись не строка, а набор пар ключ-значение, который машина читает без угадывания. В этой статье разберу три способа логировать структурно в Go: стандартный log/slog, zerolog и zap. Покажу рабочий код каждого, сравню по аллокациям и удобству, дам ориентир, что брать под вашу задачу.
Если вы только осваиваете язык, сначала имеет смысл пройти базовый маршрут из «Go для начинающих: дорожная карта». Здесь я считаю, что вы уже писали на Go и упирались в логи.
Почему структурное, а не строковое
Разница между строковым и структурированным логом видна сразу, как только вы пытаетесь что-то с логом сделать, кроме как прочитать глазами.
Вот типичная строковая запись:
2026/06/21 09:25:37 order 4821 for user 137 failed: payment declined
Прочитать её человеку легко. А теперь попробуйте машинно достать все заказы пользователя 137. Придётся писать регулярку, которая ломается, как только кто-то поменяет формат сообщения. Идентификатор пользователя здесь не данные, а кусок текста.
Структурная запись та же по смыслу, но устроена иначе:
{"time":"2026-06-21T09:25:37+03:00","level":"ERROR","msg":"order failed","order_id":4821,"user_id":137,"reason":"payment declined"}
Теперь user_id это поле. Loki, Elasticsearch, CloudWatch и любой другой сборщик логов индексируют его и дают фильтровать запросом user_id=137 AND level=ERROR. Сообщение msg стало стабильным («order failed»), а переменные части уехали в отдельные поля. Это и есть суть подхода: текст для человека отделён от данных для машины.
Из этого вытекают три практических свойства, которые дают все структурные логгеры:
- Уровни (
Debug,Info,Warn,Error). В проде включёнInfoи выше, в отладке опускаете порог доDebugбез перекомпиляции. - Контекстные поля. К логгеру можно один раз прицепить
request_idилиuser_id, и они появятся во всех последующих записях этого запроса. - Машинный формат вывода. Обычно JSON в проде (для сборщика) и человекочитаемый текст локально.
Дальше посмотрим, как это выглядит в каждой из трёх библиотек.
log/slog: стандартная библиотека
С Go 1.21 структурное логирование въехало в стандартную библиотеку под именем log/slog. Это важная веха: до неё каждый проект тащил стороннюю зависимость, а теперь для большинства случаев ничего ставить не нужно.
Минимальный пример:
package main
import (
"log/slog"
"os"
)
func main() {
// JSONHandler пишет записи построчным JSON в stdout.
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("order failed",
"order_id", 4821,
"user_id", 137,
"reason", "payment declined",
)
}
$ go run main.go
{"time":"2026-06-21T09:25:37.12+03:00","level":"INFO","msg":"order failed","order_id":4821,"user_id":137,"reason":"payment declined"}
Атрибуты передаются как чередующиеся пары ключ-значение прямо в метод. Удобно, но у такого API есть ловушка: легко сбить чётность и передать ключ без значения. Поэтому для горячего кода есть строго типизированный вариант через slog.Int, slog.String и так далее, который заодно быстрее, потому что не гоняет значения через any:
logger.Info("order failed",
slog.Int("order_id", 4821),
slog.Int("user_id", 137),
slog.String("reason", "payment declined"),
)
Handler решает, как и куда писать
Ключевая абстракция slog это Handler. Логгер только собирает запись (Record), а форматирование и вывод полностью на хендлере. В стандартной библиотеке их два: TextHandler (пары key=value, удобно читать локально) и JSONHandler (для прода). Типовая практика это выбирать хендлер по окружению:
func newLogger() *slog.Logger {
opts := &slog.HandlerOptions{Level: slog.LevelDebug}
var h slog.Handler
if os.Getenv("ENV") == "production" {
h = slog.NewJSONHandler(os.Stdout, opts)
} else {
h = slog.NewTextHandler(os.Stdout, opts)
}
return slog.New(h)
}
Интерфейс Handler открытый, поэтому экосистема быстро обросла сторонними хендлерами: цветной вывод для разработки, отправка в разные бэкенды, обёртки над zap и zerolog. Вы пишете код против стандартного *slog.Logger, а бэкенд меняете подменой хендлера. Это сильная сторона: API в стандартной библиотеке, реализация сменная.
Контекстные поля и группы
Метод With возвращает логгер с уже прицепленными атрибутами. Это основной способ протащить request_id через всю обработку запроса, не повторяя его в каждом вызове:
// Один раз на запрос — дальше поля едут сами.
reqLogger := logger.With(
slog.String("request_id", reqID),
slog.String("method", r.Method),
)
reqLogger.Info("request started")
// ... обработка ...
reqLogger.Info("request finished", slog.Int("status", 200))
Обе записи получат request_id и method. Связанные поля можно сгруппировать через slog.Group, тогда в JSON они лягут вложенным объектом:
logger.Info("request finished",
slog.Group("http",
slog.Int("status", 200),
slog.Duration("latency", 42*time.Millisecond),
),
)
// → "http":{"status":200,"latency":42000000}
Связь с context.Context
У методов есть варианты с суффиксом Context: InfoContext, ErrorContext и так далее. Они принимают context.Context первым аргументом и передают его хендлеру. Это нужно, чтобы хендлер мог достать значения из контекста, например тот же request_id или трейс-идентификатор, и добавить их в каждую запись автоматически:
logger.InfoContext(ctx, "user authenticated", slog.Int("user_id", 137))
Сам по себе slog поля из контекста не достаёт, для этого нужен хендлер, который это умеет, или своя обёртка. Но точка интеграции в API заложена. Про устройство контекста, передачу значений и типичные ошибки я подробно писал в отдельной статье про context.Context.
Логирование ошибок
Отдельная привычка, которая окупается: класть ошибку в поле, а не вклеивать в текст сообщения. Тогда msg остаётся стабильным, а ошибку видно отдельным полем.
if err != nil {
logger.Error("failed to save order", slog.Any("error", err))
return err
}
Здесь хорошо ложится связка с обёрнутыми ошибками через %w. О том, как правильно строить цепочки ошибок, чтобы в логе была видна вся история, я писал в разборе идиоматичной обработки ошибок в Go.
zerolog: ставка на ноль аллокаций
github.com/rs/zerolog появился задолго до slog и сделал ставку на одну идею: писать JSON-лог вообще без лишних аллокаций. Достигается это цепочечным API, где каждое поле сразу дописывается в байтовый буфер, а не складывается в промежуточный слайс.
package main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
logger.Error().
Int("order_id", 4821).
Int("user_id", 137).
Str("reason", "payment declined").
Msg("order failed")
}
$ go run main.go
{"level":"error","order_id":4821,"user_id":137,"reason":"payment declined","time":"2026-06-21T09:25:37+03:00","message":"order failed"}
Логика чтения такая: logger.Error() открывает событие нужного уровня, дальше цепочкой добавляются типизированные поля, а Msg(...) завершает запись и отправляет её в вывод. Пока не вызван Msg или Send, запись не материализуется, поэтому отключённый уровень почти ничего не стоит.
Контекстные поля задаются через With(), который возвращает новый логгер:
reqLogger := logger.With().
Str("request_id", reqID).
Logger()
reqLogger.Info().Msg("request started")
Сильные стороны zerolog это скорость, минимум аллокаций и аккуратный читаемый JSON из коробки. Цена видна в API. Типизированная цепочка с обязательным Msg в конце непривычна. Легко забыть Msg — и запись просто не появится в логе. Линтеры это ловят, но осадок остаётся. Ещё zerolog ориентирован на JSON, человекочитаемый цветной вывод есть через ConsoleWriter, но он заметно медленнее и предназначен для локальной разработки.
zap: производительность и экосистема от Uber
go.uber.org/zap это логгер от Uber, который годами был стандартом де-факто для нагруженных Go-сервисов. Он предлагает два API: быстрый строго типизированный Logger и удобный, но чуть более медленный SugaredLogger.
Типизированный вариант похож на slog с типами полей:
package main
import "go.uber.org/zap"
func main() {
logger, _ := zap.NewProduction()
defer logger.Sync() // сбросить буфер перед выходом
logger.Error("order failed",
zap.Int("order_id", 4821),
zap.Int("user_id", 137),
zap.String("reason", "payment declined"),
)
}
zap.NewProduction() сразу даёт JSON-вывод в stderr, уровень Info и набор разумных дефолтов. Для разработки есть zap.NewDevelopment() с человекочитаемым форматом. Конструктор Field (zap.Int, zap.String и прочие) это аналог типизированных атрибутов slog, и работает он так же быстро, без прохода значений через any.
SugaredLogger жертвует частью скорости ради эргономики и принимает пары ключ-значение как slog по умолчанию:
sugar := logger.Sugar()
sugar.Errorw("order failed",
"order_id", 4821,
"user_id", 137,
"reason", "payment declined",
)
Один важный момент, про который забывают: defer logger.Sync(). Zap буферизует вывод, и без Sync последние записи могут не успеть записаться при завершении программы. Это особенно критично при остановке сервиса, поэтому Sync логгера логично встроить в процедуру корректного завершения. Как организовать остановку сервиса аккуратно, с дренажом и flush, я разбирал в статье про graceful shutdown в Go.
Главный аргумент за zap сегодня это не столько скорость (slog с типизированными атрибутами уже близок), сколько зрелая экосистема: интеграции с веб-фреймворками, ротация через lumberjack, готовые конфиги, годами выверенные в проде. Если ваш стек уже стоит на zap, мигрировать никуда не нужно.
Что с производительностью
Производительность это первый аргумент в спорах про логгеры. И обычно он переоценён. Дам ориентиры, а не точные цифры: числа зависят от версий, железа и набора полей. Мерить надо на своей нагрузке.
Расклад по аллокациям примерно такой. zerolog и типизированный zap.Logger спроектированы под ноль аллокаций на запись в горячем пути и в синтетических бенчмарках идут вровень в лидерах. slog с типизированными атрибутами (slog.Int, slog.String) тоже очень быстр и аллоцирует мало. slog и zap.SugaredLogger с нетипизированными парами ключ-значение медленнее, потому что значения проходят через any и могут вызывать упаковку (boxing) в кучу. Порядок предпочтения по скорости: типизированный API всегда быстрее парного, в любой из библиотек.
Но вот что важно держать в голове. Если ваш сервис пишет тысячи логов в секунду в горячем цикле, разница между логгерами может стать заметной, и тогда есть смысл в zero-alloc решении. Если же логирование это сотни записей в секунду (а так у абсолютного большинства сервисов), разница в наносекундах на запись теряется на фоне любого сетевого вызова или запроса к базе. Оптимизировать логгер ради синтетического бенчмарка, который не отражает вашу нагрузку, это классическая преждевременная оптимизация.
Поэтому правильный путь, как всегда, измерить на реальном профиле. Как ставить бенчмарки аккуратно, со статистикой, а не на глаз, я писал в статье про бенчмарки и оптимизацию в Go.
Куда писать логи
Отдельный вопрос, не зависящий от выбора библиотеки: куда направлять вывод. Частая ошибка новичков это открывать файл прямо из приложения и писать в него, заодно прикручивая ротацию по размеру. В контейнерах и под systemd так делать не нужно.
Современный подход (его формулирует методология 12-factor) это писать логи в stdout/stderr и не думать о хранении. Дальше за вас всё делает среда: Docker и Kubernetes подхватывают поток, systemd складывает его в journald, а сборщик логов отправляет в Loki или Elasticsearch. Приложение не знает и не должно знать, где в итоге окажется запись. Поэтому во всех примерах выше вывод идёт в os.Stdout или os.Stderr, и это не упрощение для статьи, а рабочий дефолт.
Ротация файлов через lumberjack остаётся актуальной только там, где сервис пишет прямо в файл на хосте без оркестратора. Если у вас Kubernetes, ротацией занимается он, а не ваш Go-код.
Сравнение
| Критерий | slog | zerolog | zap |
|---|---|---|---|
| Зависимость | Стандартная библиотека (Go 1.21+) | Внешняя (rs/zerolog) | Внешняя (uber-go/zap) |
| API | Пары и типизированные атрибуты | Цепочка с Msg() в конце | Типизированный Logger + SugaredLogger |
| Аллокации | Мало (типизированно), больше с парами | ~ноль | ~ноль (Logger), больше у Sugared |
| Формат из коробки | Text и JSON | JSON (+ ConsoleWriter) | JSON (+ Development-режим) |
| Сменный бэкенд | Да, через Handler | Нет | Нет |
| Экосистема | Растёт, стандарт по умолчанию | Зрелая | Самая богатая |
Что выбирать
Мой ориентир на 2026 год простой.
Берите slog по умолчанию для новых проектов. Это стандартная библиотека, не тащит зависимость, по скорости достаточен для абсолютного большинства сервисов, а абстракция Handler даёт редкую возможность сменить бэкенд логирования, не переписывая код приложения. Когда сомневаетесь, это правильный выбор по умолчанию.
Берите zap, если вы уже в его экосистеме или вам нужна самая богатая обвязка из коробки: проверенные интеграции, ротация, годами обкатанные в проде конфиги. Мигрировать рабочий сервис с zap на slog ради чистоты обычно не стоит усилий.
Берите zerolog, если вам нравится его цепочечный API и важны минимальные аллокации в действительно горячем пути логирования. Это нишевый, но обоснованный выбор для сервисов, которые пишут очень много логов и где вы померили, что это узкое место.
Чего точно не стоит делать, это смешивать несколько логгеров в одном сервисе или выбирать библиотеку по строчке в синтетическом бенчмарке. Согласованность важнее микросекунд: один логгер, один формат, одни соглашения по именам полей по всему сервису.
Итог
Структурированное логирование это переход от строк для человека к полям для машины, и в современном Go он почти бесплатен. Стандартный log/slog закрывает потребности большинства проектов: типизированные атрибуты для скорости, With для контекстных полей, Handler для сменного бэкенда и точка интеграции с context.Context. zerolog и zap остаются сильными альтернативами там, где важны нулевые аллокации или зрелая экосистема, но точкой отсчёта для нового кода я теперь беру slog.
Главное в логировании это не выбор библиотеки, а дисциплина: стабильные сообщения, переменные части в полях, ошибки отдельным полем, единые имена ключей по сервису. Логгер только инструмент, а пользу приносит то, как вы им пользуетесь.
Дальше по теме стоит посмотреть на обработку ошибок, чтобы в логах была видна вся цепочка сбоя, и на graceful shutdown, чтобы при остановке сервиса не потерять последние записи.
Теги: