slog: структурированное логирование в стандартной библиотеке Go

log/slog (Go 1.21) — структурированное логирование в стандартной библиотеке: уровни, атрибуты, группы, JSON и text-хендлеры, With для контекста и связка с context. Как устроены Handler/Record, как писать свой хендлер и почему slog вытесняет самописные обёртки над log — на живых примерах вывода

Логирование в Go долго жило в двух режимах: либо пакет log из stdlib, печатающий строки, либо один из десятка сторонних «структурных» логгеров (zap, zerolog, logrus) со своими API. Первый удобен человеку, но бесполезен машине: чтобы найти в 2026/07/11 12:00:00 request GET /users/42 status=200 поле status, парсеру придётся угадывать формат. Вторые машиночитаемы, но каждый тянул свою зависимость и свой стиль — и обёртки над log в каждом проекте писались заново. В Go 1.21 в стандартную библиотеку добавили log/slog — структурированное логирование, где запись это не строка, а сообщение плюс набор пар ключ-значение, и хендлер сам решает, как это сериализовать: в JSON для Loki/ELK или в key=value для глаз разработчика.

Эта статья — про то, как slog устроен и почему он вытесняет самописные обёртки: уровни и фильтрация, типизированные атрибуты против «сырых» пар, JSON- и text-хендлеры, группы, With, связка с context и собственный Handler. Всё заземлено на живой стенд digital-cookbook/go-slog/: каждый фрагмент вывода снят с go run ./cmd/output, каждое число — прогнанный бенчмарк (go1.26.3 windows/amd64, вывод в io.Discard). Отдельно разберём миф про «LogAttrs экономит аллокации» — на этом стенде он не подтверждается, и это честнее, чем повторять его вслед за туториалами. Модуль объявляет go 1.25.0 как минимум, но сам slog доступен с Go 1.21.

Схема «Полдень»: структурированное логирование log/slog — неструктурированная строка против машиночитаемой записи (level/msg/path/status/группа db/request_id/time), конвейер Logger → Handler → Record с выходами JSON и text, уровни с фильтрацией (Debug закрыт — выключенные логи ~50× дешевле), With для констант, декоратор ContextHandler с request_id из контекста

В статье

Зачем структурированное логирование

Разница между log и slog — это разница между строкой и записью. Классический log.Printf склеивает всё в одну строку, форматирование остаётся на совести автора, и два вызова в разных местах кода легко разъезжаются по формату. Машине потом приходится разбирать эту строку регулярками — хрупко и медленно.

log.Printf("http request method=%s path=%s status=%d", method, path, status)
// 2026/07/11 12:00:00 http request method=GET path=/users/42 status=200

slog печатает то же самое, но как структуру: сообщение msg отдельно, поля отдельно, каждое со своим ключом. Тот же вызов через JSON-хендлер даёт машиночитаемую запись, где status — это поле, а не подстрока:

logger.Info("http request",
    slog.String("method", method),
    slog.String("path", path),
    slog.Int("status", status),
)
{"time":"...","level":"INFO","msg":"http request","method":"GET","path":"/users/42","status":200}

Такую запись система сбора логов (Loki, Elasticsearch, любой JSON-агрегатор) индексирует по полям без разбора строк: можно фильтровать по status>=500, группировать по path, строить графики по method. Именно поэтому slog вытесняет самописные обёртки над log: раньше каждый проект городил свой «структурный» вывод поверх log.Printf или тянул стороннюю библиотеку, теперь для 90% случаев хватает stdlib — единый API, ноль зависимостей, и совместимость с экосистемой через интерфейс Handler.

Уровни и атрибуты

У каждой записи есть уровеньDebug, Info, Warn, Error (внутри это просто числа: −4, 0, 4, 8, так что между ними можно вставлять кастомные). Хендлер отбрасывает всё, что ниже настроенного порога HandlerOptions.Level: при уровне Info записи Debug не пишутся вовсе. Это не только фильтр шума — как увидим в разделе про производительность, выключенная запись почти ничего не стоит.

func JSONLogger(w io.Writer, level slog.Level) *slog.Logger {
    return slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{Level: level}))
}

Поля записи — атрибуты. У slog два способа их передать, и выбор между ними не косметический.

Первый — «сырые» вариативные пары: logger.Info("msg", "key1", val1, "key2", val2, ...). Коротко, но опасно: аргументы — это ...any, и компилятор не проверит ни типы, ни то, что их чётное число. Забыли значение — и slog в рантайме подставит !BADKEY, а не упадёт на компиляции. Такую ошибку легко пропустить в редко исполняемой ветке.

Второй — типизированные конструкторы slog.String, slog.Int, slog.Duration, slog.Bool и т. д. Каждый возвращает slog.Attr — готовую пару «ключ + типизированное значение». Компилятор следит за типами, «нечётное число аргументов» становится невозможным по построению:

func LogRequest(l *slog.Logger, method, path string, status int) {
    l.Info("http request",
        slog.String("method", method),
        slog.String("path", path),
        slog.Int("status", status),
    )
}

На горячем пути или в коде, который правят многие, типизированные атрибуты стоят своей многословности: они снимают целый класс ошибок «забыл значение / перепутал местами ключ и значение».

Хендлеры, группы и With

Logger сам ничего не форматирует — он собирает запись и отдаёт её хендлеру. В stdlib два готовых: JSONHandler (одна JSON-строка на запись — для прода) и TextHandler (key=value — для глаз разработчика). Один и тот же вызов через разные хендлеры даёт разный вывод.

{"time":"...","level":"INFO","msg":"http request","method":"GET","path":"/users/42","status":200}
time=... level=INFO msg="http request" method=GET status=200

Группы: вложенный объект

Связанные поля можно собрать в группу через slog.Group. В JSON-хендлере группа становится вложенным объектом — так поля запроса к БД не смешиваются с полями верхнего уровня:

func LogInGroup(l *slog.Logger, query string, rows int) {
    l.Info("db query",
        slog.Group("db",
            slog.String("query", query),
            slog.Int("rows", rows),
        ),
    )
}
{"time":"...","level":"WARN","msg":"slow query","db":{"query":"SELECT ...","took":0}}

With: постоянный атрибут во всех записях

Метод With возвращает дочерний логгер, у которого заданные атрибуты автоматически добавляются в каждую последующую запись. Это «запомнить контекст один раз»: завели логгер с service и request_id — и не таскаете их в каждый вызов.

func WithRequestID(l *slog.Logger, id string) *slog.Logger {
    return l.With(slog.String("request_id", id))
}
{"time":"...","level":"INFO","msg":"started","service":"api","request_id":"req-7"}

Важная деталь: With не мутирует исходный логгер, а возвращает новый — под капотом хендлер получает эти атрибуты через WithAttrs и держит их «предвычисленными». Поэтому дочерний логгер, созданный один раз на входе в обработку запроса, дешевле, чем добавлять те же поля в каждый вызов вручную.

Устройство: Logger, Handler, Record

Под капотом slog — три сущности. Logger — тонкая обёртка с методами Info/Warn/...; он собирает Record (время, уровень, сообщение, атрибуты) и передаёт хендлеру. Handler — интерфейс из четырёх методов, и всё поведение (формат, фильтрация, обогащение) живёт именно в нём:

  • Enabled(ctx, level) bool — пропускать ли запись этого уровня (тут отсекаются выключенные логи, ещё до сборки Record);
  • Handle(ctx, Record) error — отформатировать и записать;
  • WithAttrs([]Attr) Handler — вернуть хендлер с добавленными постоянными атрибутами (сюда уходит logger.With);
  • WithGroup(name) Handler — вернуть хендлер, открывший группу.

Чтобы написать свой хендлер, не нужно реализовывать все четыре с нуля: достаточно встроить существующий и переопределить то, что нужно. На стенде — ContextHandler: декоратор, который на каждом Handle достаёт request_id из context.Context и подставляет в запись. Так идентификатор запроса не передают в каждый вызов лога — он приезжает через контекст.

type ContextHandler struct {
    slog.Handler
}

func New(h slog.Handler) *ContextHandler {
    return &ContextHandler{Handler: h}
}

Встраивание slog.Handler даёт Enabled, WithAttrs и WithGroup даром — они делегируются обёрнутому хендлеру. Собственная логика только в Handle:

func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
    if id, ok := ctx.Value(requestIDKey).(string); ok {
        r.AddAttrs(slog.String("request_id", id))
    }
    return h.Handler.Handle(ctx, r)
}

А вот тонкость, на которой спотыкаются: WithAttrs и WithGroup обязаны вернуть хендлер того же типа. Если оставить встроенные версии как есть, они вернут голый обёрнутый хендлер — и при первом же logger.With(...) декоратор «потеряется», request_id из контекста перестанет подставляться. Поэтому оба метода переопределяют, заново заворачивая результат в ContextHandler:

func (h *ContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
    return &ContextHandler{Handler: h.Handler.WithAttrs(attrs)}
}

func (h *ContextHandler) WithGroup(name string) slog.Handler {
    return &ContextHandler{Handler: h.Handler.WithGroup(name)}
}

Чтобы контекст доехал до Handle, запись должна создаваться методом, принимающим ctx: InfoContext(ctx, ...), ErrorContext(ctx, ...) или LogAttrs(ctx, level, ...). Обычные Info(...) передают в хендлер context.Background(), и request_id там взять неоткуда.

{"time":"...","level":"INFO","msg":"обработка","path":"/checkout","request_id":"req-from-ctx"}

Механику самого context.Context — как значение кладётся и достаётся, почему ключ приватного типа — разбирает статья про context в Goготовится, с 20 августа. Там же — как request_id зарождается в HTTP-middleware и живёт по всей цепочке обработки.

Маскировка секретов: LogValuer и ReplaceAttr

Лог — самое удобное место случайно утечь секретом: записей много, живут они долго, уезжают в агрегатор, где их индексируют и хранят. Токен, пароль, номер карты, персональные данные не должны попадать в вывод как есть. У slog для этого два дополняющих механизма, и оба идиоматичны.

Первый — интерфейс LogValuer: тип сам решает, как выглядит в логе. Оборачиваем секрет в собственный тип с методом LogValue() — и slog печатает маску вместо значения везде, где этот тип попадает в запись как самостоятельный атрибут (slog.Any("token", secret) или отдельным полем записи; про границу — когда секрет спрятан внутри другой структуры — ниже):

type Secret string

func (Secret) LogValue() slog.Value { return slog.StringValue("REDACTED") }

// Card показывает только хвост из 4 цифр — типичный приём для PII.
type Card string

func (c Card) LogValue() slog.Value {
    s := string(c)
    if len(s) < 4 {
        return slog.StringValue("****")
    }
    return slog.StringValue("**** **** **** " + s[len(s)-4:])
}
{"...","msg":"login","user":"alice","token":"REDACTED","card":"**** **** **** 1234"}

token обёрнут в Secret, card — в Card, и slog подставил маски сам. Это лучший способ: секрет замаскирован в точке объявления типа, а не в каждом месте логирования — забыть нельзя, если поле хранится как Secret, а не как string.

Второй механизм — HandlerOptions.ReplaceAttr, страховочная сетка на уровне хендлера. Функция вызывается для каждого атрибута перед сериализацией и может заменить значение по имени ключа — ловит то, что залогировали обычной строкой, забыв обернуть в тип:

func RedactKeys(keys ...string) func([]string, slog.Attr) slog.Attr {
    set := make(map[string]struct{}, len(keys))
    for _, k := range keys {
        set[k] = struct{}{}
    }
    return func(_ []string, a slog.Attr) slog.Attr {
        if _, ok := set[a.Key]; ok {
            a.Value = slog.StringValue("***")
        }
        return a
    }
}

// slog.NewJSONHandler(w, &slog.HandlerOptions{
//     ReplaceAttr: redact.RedactKeys("password", "authorization"),
// })
{"...","msg":"request","user":"alice","password":"***","authorization":"***"}

И честная граница обоих способов, которую стенд закрепляет отдельным тестом. Если секрет спрятан полем внутри структуры, отданной в slog.Any, его не поймает ни один из механизмов — даже если само поле объявлено типом-секретом Secret. JSON-кодировщик хендлера сериализует структуру целиком и не зовёт LogValue её полей (это делает только сам slog для значений-атрибутов, а не кодировщик для полей при marshaling), а ReplaceAttr видит один атрибут creds с «чужим» ключом — вложенного Token в его поле зрения нет. В демо стенда поле Token имеет тип Secret, который как отдельный атрибут дал бы REDACTED, — и всё равно утекает сырым внутри структуры:

{"...","msg":"leak","creds":{"User":"alice","Token":"s3cr3t"}}

Практический вывод из этой границы, без ложных гарантий: тип-LogValuer безопасен, когда секрет логируют как самостоятельный атрибутslog.Any("token", secret) или отдельным полем записи; там маска сработает всегда. Но это не «замаскирован, куда бы ни попал»: если такой секрет оказался полем структуры, которую логируют целиком через slog.Any, его LogValue не вызовется — защитить его должен уже LogValue() или MarshalJSON самой структуры. ReplaceAttr держите сеткой для известных ключей верхнего уровня, но не рассчитывайте, что он заглянет внутрь произвольной структуры. Короче: LogValuer на тип секрета — обязательный минимум, а структурам с секретами внутри нужен собственный контроль сериализации. Тема шире одной статьи — как в принципе не давать чувствительным данным утекать через приложение, разбирает безопасная разработка на бэкендеСкоро.

Производительность и миф про LogAttrs

Вокруг slog ходит устойчивый совет: «используйте LogAttrs вместо Info — он экономит аллокации». LogAttrs действительно отличается — он принимает вариативные типизированные ...slog.Attr (а не «сырой» ...any), поэтому не строит слайс []any из пар ключ-значение:

func LogFast(ctx context.Context, l *slog.Logger, path string, status int) {
    l.LogAttrs(ctx, slog.LevelInfo, "http request",
        slog.String("path", path),
        slog.Int("status", status),
    )
}

Но бенчмарки стенда (go1.26.3 windows/amd64, вывод в io.Discard, чтобы мерить сборку записи, а не I/O) показывают, что на этом сценарии миф не подтверждается:

Бенчмарк ns/op allocs/op Что показывает
BenchmarkInfoKV ~1150–1250 0 Info("msg","k",v,...) — вариативные пары
BenchmarkLogAttrs ~1150–1200 0 LogAttrs(ctx, level, msg, slog.String(...))
BenchmarkDisabledLevel ~24 0 запись ниже порога уровня

InfoKV и LogAttrs дают обе по нулю аллокаций и близкое время. Причина в том, что в бенче аргументы константны, не «убегают» из вызова, и escape-анализ компилятора держит тот самый []any на стеке — аллокация упаковки не рождается. Почему константные аргументы не уходят в кучу, подробно разбирает статья про escape-анализ в Goготовится, с 28 августа: «указатель» и []any — ещё не повод для кучи, решает время жизни.

Значит ли это, что LogAttrs бесполезен? Нет — просто его ценность не в аллокациях на этом сценарии, а в двух других вещах. Предсказуемость: LogAttrs не полагается на то, оставит ли компилятор []any на стеке в вашем конкретном коде (стоит аргументам «убежать» — и у Info(...) появится аллокация упаковки, а у LogAttrs её нет по построению). Типобезопасность: slog.String/Int/... исключают «нечётное число аргументов». Это причины выбирать LogAttrs, но честная причина, а не выдуманная экономия.

А вот где экономия настоящая и крупная — это выключенные логи. BenchmarkDisabledLevel (запись Debug при уровне Info) — ~24 ns/op против ~1150 у активной, примерно в 50 раз дешевле. Работает Handler.Enabled: он отсекает запись ниже порога рано, ещё до сборки Record и вычисления атрибутов. Поэтому Debug-логи в горячем цикле можно держать в коде постоянно — в проде при уровне Info они почти ничего не стоят, а при отладке уровень опускают одним флагом.

Важная оговорка: дёшев именно выключенный вызов с дешёвыми аргументами — ровно то, что меряет бенч (константные slog.String/slog.Int). Аргументы в Go вычисляются до вызова, поэтому slog.Debug("dump", slog.Any("state", expensive())) выполнит expensive() даже при уровне Info: Enabled отсечёт запись уже после того, как дорогое значение построено. Если аргумент дорогой (сериализация, обход большой структуры), не полагайтесь на порог уровня — либо оберните лог явной проверкой if logger.Enabled(ctx, slog.LevelDebug) { logger.Debug(...) }, либо спрячьте вычисление внутрь LogValuer: его LogValue() хендлер вызывает только при обработке записи, а для отсечённой по уровню записи Handle не вызывается вовсе — и дорогая работа не выполняется.

Ещё одна практичная деталь — глобальный логгер по умолчанию. slog.SetDefault(logger) устанавливает логгер, который потом достаётся через slog.Default(), а пакетные функции slog.Info(...) пишут именно в него. Один раз настроили в main — и весь код может логировать без проброса логгера в каждую функцию (хотя явная передача через context или аргумент обычно чище для тестируемости).

Практика: прод, разработка, observability

Сложившийся минимум для сервиса выглядит так.

Формат по окружению. В проде — JSONHandler: агрегатор логов индексирует поля без разбора строк. В разработке — TextHandler: key=value в одну строку читается глазами, JSON в терминале неудобен. Выбор — одна ветка в main по переменной окружения.

var h slog.Handler
if os.Getenv("ENV") == "production" {
    h = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
    h = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
slog.SetDefault(slog.New(h))

Контекст запроса — через With и context. На входе в обработку HTTP-запроса заводят дочерний логгер с request_id (или, как на стенде, подставляют его кастомным ContextHandler из контекста) — дальше все записи в рамках запроса автоматически несут идентификатор. Как встроить это в цепочку обработки — в статье про HTTP-сервер и middleware в Goготовится, с 27 августа: логирующая middleware заводит логгер один раз и кладёт в контекст.

Связка с observability. slog — это буква L в «logs, metrics, traces». Через интерфейс Handler его подключают к OpenTelemetry: OTEL-бридж достаёт из context.Context активный span и дописывает в каждую запись trace_id и span_id. После этого лог и трейс связаны — по trace_id в агрегаторе логов прыгаешь в трассировку конкретного запроса и обратно. Механика ровно та же, что у ContextHandler выше: хендлер обогащает Record данными из контекста, только источник — не свой ключ, а OTEL span. Именно поэтому Handle принимает ctx, а записи в проде создают через InfoContext/ErrorContext, а не через Info.

А как же zap и zerolog? Вопрос закономерный — до slog они были стандартом де-факто для структурного логирования. Короткий ответ: slog — разумный дефолт (в stdlib, без зависимости, единый интерфейс на всю экосистему), а zap/zerolog берут там, где нужен максимальный throughput логирования или zero-alloc на по-настоящему горячем пути — в сырых бенчмарках их zero-allocation-режимы обычно быстрее slog. Но это не «или-или»: slog — это в первую очередь интерфейс (Handler), и быстрый логгер можно поставить за ним бэкендом. У zap для этого есть официальный адаптер go.uber.org/zap/exp/zapslog — он даёт slog.Handler поверх zap-ядра: код везде пишет через стандартный *slog.Logger, а под капотом быстрый zap-бэкенд; для zerolog аналогичные slog.Handler-адаптеры есть в сообществе. Поэтому современный выбор чаще не «slog против zap», а «стандартный slog-фасад в коде, а бэкенд — встроенный хендлер или zap/zerolog, если упёрлись в производительность логирования». Числа конкретно для вашего профиля стоит снять самим — они сильно зависят от формата, набора полей и того, аллоцируют ли ваши аргументы (см. раздел про производительность выше).

Демо и версии

  • Код: живой стенд digital-cookbook/go-slog/ — чистый Go, без docker, только stdlib. Подкаталог basic/ — уровни, атрибуты, группы, With, LogAttrs и бенчи способов записи; handler/ContextHandler со всеми четырьмя методами интерфейса; redact/ — маскировка секретов через LogValuer и ReplaceAttr с тестом на честную границу. Идея стенда: вывод логов проверяется тестами (пишем в bytes.Buffer, парсим JSON, сверяем поля), а «как это выглядит вживую» показывает демо cmd/output. Код в тексте — выжимки.
  • Как воспроизвести: go run ./cmd/output (реальный вывод JSON/text-хендлеров, групп, With и ContextHandler) и go test -bench=. -benchmem -run=^$ ./basic/ (числа способов записи). Тесты — go test ./.... Числа сверены на go1.26.3 windows/amd64, вывод бенчей в io.Discard.
  • Оговорка: числа бенчмарков машинозависимы и версионнозависимы и приведены ориентировочно — снимайте их на своей сборке. Ключевой честный вывод остаётся: Info и LogAttrs на константных аргументах дают 0 аллокаций и близкое время (миф про экономию LogAttrs тут не работает), а настоящая крупная экономия — выключенные логи (~50× дешевле активных).
  • Смежные статьи: context в Goготовится, с 20 августа (проброс request_id, ключ приватного типа), HTTP-сервер и middlewareготовится, с 27 августа (логирующая middleware), escape-анализ в Goготовится, с 28 августа (почему константные аргументы не аллоцируют).

Документация и первоисточники

  • pkg.go.dev/log/slog — референс пакета: Logger, Handler, Record, Attr, HandlerOptions, конструкторы атрибутов и оба встроенных хендлера.
  • go.dev/blog/slog — обзорная статья команды Go «Structured Logging with slog»: мотивация, устройство, как писать свой хендлер и работать с производительностью.
  • go.dev/wiki: Resources for slog — курируемый список практических руководств и рецептов сообщества (тестирование хендлеров, готовые адаптеры к zap/zerolog и другим бэкендам, частые ошибки).
  • Proposal: log/slog (Jonathan Amsterdam, #56345) и design doc — исходное предложение, из которого slog вырос: почему интерфейс Handler именно из этих методов, зачем разделены Attr и вариативные пары, откуда LogAttrs.

Обсуждение в Telegram

Присоединиться →

Комментарии