lenec ru

← все посты

slog в Go 1.21+: переход со старого логгера на структурированный

19K

В 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 уже работает и вокруг него обвязка — оставляю как есть. Бессмысленно перепиливать ради того, чтобы сменить логгер.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.