slog в Go 1.21+: переход со старого логгера на структурированный
В Go 1.21 появился log/slog — структурированный логгер в стандартной библиотеке. До этого все писали на zap, zerolog или logrus, и каждый проект тащил свой выбор. Сейчас можно жить на stdlib без сторонних зависимостей и не терять в фичах.
За последний год я перевёл два сервиса со связки log + zap на slog. Расскажу, что выиграл, что отдал, и как обычно делается такой переход без полной остановки.
Что даёт slog из коробки
- Структурированные логи в JSON или text формате.
- Уровни Debug/Info/Warn/Error.
- Атрибуты:
slog.Info("user created", "user_id", id, "role", role). - Группы атрибутов и наследование контекста через
With. - Хендлеры — расширяемая точка для своего форматирования или фильтрации.
- Интеграция с
context.Context:slog.InfoContext(ctx, ...).
То есть всё, ради чего раньше тащили zap. Производительность чуть ниже zap (~30% дольше на одной записи в моих микро-бенчмарках), но в разы быстрее старого log.Println.
Минимальная настройка
package main
import (
"log/slog"
"os"
)
func main() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
logger := slog.New(handler)
slog.SetDefault(logger)
slog.Info("service started", "port", 8080, "env", "prod")
}
На выходе:
{"time":"2026-05-23T12:00:00Z","level":"INFO","msg":"service started","port":8080,"env":"prod"}
Для дева удобнее текстовый формат:
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
With и контекст логгера
Когда у тебя есть переменные, которые сопровождают каждый лог в куске кода — request_id, user_id, trace_id — каждый раз их прописывать в аргументах больно. Используй With:
requestLogger := slog.With("request_id", reqID, "user_id", userID)
requestLogger.Info("processing", "step", 1)
requestLogger.Info("saving to db", "table", "orders")
Все три атрибута будут в каждой записи. Это аналог zap.With или zerolog.Ctx.
slog и context.Context
Шаблон, который применяю в каждом сервисе:
type ctxKey int
const loggerKey ctxKey = 1
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
В middleware прокидываю логгер с request_id:
func LoggerMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.NewString()
}
l := slog.With("request_id", reqID, "path", r.URL.Path)
ctx := WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
В хендлерах:
func handler(w http.ResponseWriter, r *http.Request) {
log := FromContext(r.Context())
log.Info("got request", "method", r.Method)
}
Группы атрибутов
Иногда хочется все поля под одним префиксом:
slog.Info("http response",
slog.Group("req", "method", "GET", "path", "/api/users"),
slog.Group("resp", "status", 200, "duration_ms", 12),
)
В JSON это дает вложенные объекты req и resp. На стороне Loki/ELK так удобнее парсить и фильтровать.
Кастомный handler
Стандартные хендлеры покрывают 90% случаев. Своё пишу, когда нужны редкие фичи: сэмплирование, отправка в несколько мест, обогащение каждой записи статикой.
type SamplingHandler struct {
slog.Handler
rate float64
}
func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error {
if r.Level < slog.LevelError && rand.Float64() > h.rate {
return nil
}
return h.Handler.Handle(ctx, r)
}
Этот хендлер пропускает только часть Info/Warn-логов, а Error прокидывает все. Полезно для high-traffic сервисов, где info-логов очень много.
Уровни кроме стандартных
Если нужен Trace или Fatal:
const (
LevelTrace = slog.Level(-8)
LevelFatal = slog.Level(12)
)
func Trace(msg string, args ...any) {
slog.Log(context.Background(), LevelTrace, msg, args...)
}
Уровни — это просто int. Ниже стандартного Debug (-4) ставь свои отрицательные значения, выше Error (8) — свои положительные. В HandlerOptions.Level укажи, какие пускать.
Типизация атрибутов через slog.Attr
Если в команде ругаются на ...any-атрибуты, есть типизированные хелперы:
slog.Info("order placed",
slog.String("symbol", "BTC-USD"),
slog.Int64("amount", 100),
slog.Float64("price", 50000.5),
slog.Time("created_at", time.Now()),
slog.Bool("is_market", true),
)
Длиннее, но компилятор ловит ошибки типов. На горячих путях это ещё чуть быстрее, чем any-вариант, потому что не нужна reflection.
Реальные грабли при переходе
Двойное логирование
Если ты вызываешь slog.SetDefault(...), но в каком-то месте остался log.Println, обе записи окажутся в выводе, причём в разных форматах. Сделай slog.SetDefault и одновременно перенаправь старый log через slog.NewLogLogger:
log.SetFlags(0)
log.SetOutput(io.Discard) // или
log.SetOutput(slog.NewLogLogger(handler, slog.LevelInfo).Writer())
Нечитаемый JSON в дев-режиме
На локалке JSON-логи в консоли чудовищны. Сделай переключатель:
var handler slog.Handler
if os.Getenv("ENV") == "prod" {
handler = slog.NewJSONHandler(os.Stdout, opts)
} else {
handler = slog.NewTextHandler(os.Stdout, opts)
}
Или используй tint от lmittmann/tint для цветных text-логов в дев-режиме. Это не stdlib, но удобно.
Не сериализуемые типы
Если в атрибут попадает не примитив, slog вызывает fmt.Sprintf("%+v", v). На больших структурах это даёт громадный JSON. Решение — LogValuer:
func (u User) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", u.ID),
slog.String("email", maskEmail(u.Email)),
)
}
Теперь slog.Info("login", "user", user) логирует только id и замаскированный email. Полезно для PII-данных.
Производительность на горячем пути
Если в hot path вызываешь логгер 100 тысяч раз в секунду — следи за ...any: каждый аргумент аллоцирует. Используй типизированные slog.Attr и LogAttrs:
slog.LogAttrs(ctx, slog.LevelInfo, "order processed",
slog.String("symbol", sym),
slog.Int64("qty", qty),
)
Эта форма не делает упаковку в any и в pprof видна как почти нулевая по аллокациям.
Что я отдал, перейдя со zap
Не идеально. Чего не хватает:
- Sampling из коробки. Пишется руками за 20 строк.
- Caller-info (filename:line). Включается через
HandlerOptions.AddSource: true, но дороже по производительности, чем zap. - Готовых интеграций с разными бекендами. Тут zap впереди, но slog быстро догоняет.
Что выиграл:
- Минус одна сторонняя зависимость.
- Стандартный API, который знает каждый Go-разработчик.
- Проще вводить новых людей в проект.
Когда оставаться на zap
Если у тебя сервис с очень высоким logging-load (десятки тысяч записей в секунду на одном поде) и каждая микросекунда на счету — zap всё ещё чуть быстрее. Если 95% твоего времени логгер не упирается в bottleneck, разница не имеет значения.
На моих новых сервисах беру slog по умолчанию. На старых, где zap уже работает и вокруг него обвязка — оставляю как есть. Бессмысленно перепиливать ради того, чтобы сменить логгер.