Перейти к содержимому

Логирование в Go: slog и production-практики

Зачем знать: логи — основной инструмент диагностики в production. Middle 1 Go-разработчик в 2026 должен уметь писать структурированные логи, понимать различия между slog/zap/zerolog, корректно прокидывать контекст (trace ID, request ID) и не сливать в логи sensitive-данные. Без этого debugging в кластере превращается в гадание.

  1. Базовая концепция
  2. Как в Go (с примерами)
  3. Gotchas
  4. Best practices в production
  5. Вопросы на собесе
  6. Practice
  7. Источники

Текстовые логи (log.Printf("user %s logged in", id)) — это плоские строки, которые сложно парсить, искать и агрегировать. В production у вас тысячи сервисов и миллионы событий — без структуры (key-value) вы утонете.

Структурированный лог — это запись с явными полями:

{"time":"2026-05-21T10:00:00Z","level":"INFO","msg":"user logged in","user_id":"u-123","ip":"10.0.0.1","trace_id":"abc-def"}

Такие записи легко индексировать (ELK, Loki, CloudWatch), искать (level=ERROR AND service=payments) и агрегировать (count by user_id).

  • log (stdlib, исторический) — только plain text, без уровней, без структуры. Для middle 1 в 2026 — не использовать в новых проектах.
  • logrus (sirupsen) — первый популярный structured logger. Сейчас в maintenance mode, низкая производительность. Не использовать в новых проектах.
  • zap (uber-go) — высокопроизводительный, типизированные поля, две версии API (sugared/structured).
  • zerolog (rs/zerolog) — zero-allocation, chain API, JSON only.
  • slog (Go 1.21+, stdlib) — официальный stdlib structured logger. Дефолт в 2026 для нового кода, если нет требований к экстремальной производительности.

log/slog появился в Go 1.21 (август 2023). Это официальный structured logger в стандартной библиотеке. Ключевые компоненты:

  • Logger — точка входа (slog.Info, slog.Error и т.д.).
  • Handler — отвечает за форматирование и вывод (TextHandler, JSONHandler, custom).
  • Record — одна запись (time, level, message, attrs).
  • Attr — пара key-value (slog.String("user_id", id)).

В slog четыре основных уровня:

  • Debug — детальная отладка (отключается в prod).
  • Info — нормальные события (запросы, события бизнес-логики).
  • Warn — нештатные ситуации, recoverable.
  • Error — ошибки, требующие внимания.

Можно определить кастомные (slog.Level(-2) для Trace). Дефолтный уровень — Info.


package main
import (
"log/slog"
"os"
)
func main() {
// Простейший вариант — использовать дефолтный logger
slog.Info("server started", "port", 8080, "env", "production")
slog.Error("failed to connect db", "err", "timeout", "host", "db-1")
// Вывод в формате текста по умолчанию:
// time=... level=INFO msg="server started" port=8080 env=production
}
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("request handled",
"method", "GET",
"path", "/api/users",
"status", 200,
"duration_ms", 42)
}

Вывод (одна строка):

{"time":"2026-05-21T10:00:00Z","level":"INFO","msg":"request handled","method":"GET","path":"/api/users","status":200,"duration_ms":42}

2.3 Типизированные Attr — быстрее и безопаснее

Заголовок раздела «2.3 Типизированные Attr — быстрее и безопаснее»
slog.Info("payment processed",
slog.String("user_id", "u-123"),
slog.Int64("amount_cents", 9999),
slog.Duration("duration", 245*time.Millisecond),
slog.Bool("retry", false),
)

Типизированные Attr избегают reflection и аллокаций по сравнению с any-аргументами.

// Логгер с префикс-полями для одного запроса
reqLogger := slog.With(
slog.String("request_id", "req-abc"),
slog.String("user_id", "u-123"),
)
reqLogger.Info("start handling")
reqLogger.Info("db query", "table", "users")
reqLogger.Info("done", "status", 200)

Все три записи получат request_id и user_id автоматически.

slog.Info("http request",
slog.Group("request",
slog.String("method", "POST"),
slog.String("path", "/login"),
),
slog.Group("response",
slog.Int("status", 401),
slog.String("error", "invalid credentials"),
),
)

В JSON получится:

{
"msg": "http request",
"request": {"method":"POST","path":"/login"},
"response": {"status":401,"error":"invalid credentials"}
}

Чтобы прокидывать request_id/trace_id через context, есть slog.InfoContext:

package main
import (
"context"
"log/slog"
"os"
)
type ctxKey string
const requestIDKey ctxKey = "request_id"
// ContextHandler оборачивает базовый handler и подмешивает поля из ctx
type ContextHandler struct {
slog.Handler
}
func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if v, ok := ctx.Value(requestIDKey).(string); ok {
r.AddAttrs(slog.String("request_id", v))
}
return h.Handler.Handle(ctx, r)
}
func main() {
base := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(&ContextHandler{Handler: base})
slog.SetDefault(logger)
ctx := context.WithValue(context.Background(), requestIDKey, "req-42")
slog.InfoContext(ctx, "processing", "step", "validate")
}

Теперь любой вызов slog.InfoContext(ctx, ...) автоматически содержит request_id из контекста.

err := doSomething()
if err != nil {
// Просто как поле
slog.Error("operation failed", "err", err)
// Лучше — wrapped error с контекстом
wrapped := fmt.Errorf("user=%s: %w", userID, err)
slog.Error("operation failed",
slog.String("user_id", userID),
slog.Any("err", wrapped),
)
}

В JSON-выводе err сериализуется через Error() метод.

slog сам не пишет stack trace. Если нужен — добавляйте вручную:

import "runtime/debug"
slog.Error("panic recovered",
slog.Any("err", r),
slog.String("stack", string(debug.Stack())),
)
type PrettyHandler struct {
w io.Writer
level slog.Level
attrs []slog.Attr
}
func (h *PrettyHandler) Enabled(_ context.Context, l slog.Level) bool {
return l >= h.level
}
func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
buf := fmt.Sprintf("[%s] %s %s", r.Time.Format(time.RFC3339), r.Level, r.Message)
r.Attrs(func(a slog.Attr) bool {
buf += fmt.Sprintf(" %s=%v", a.Key, a.Value)
return true
})
_, err := fmt.Fprintln(h.w, buf)
return err
}
func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &PrettyHandler{w: h.w, level: h.level, attrs: append(h.attrs, attrs...)}
}
func (h *PrettyHandler) WithGroup(name string) slog.Handler { return h }

slog.HandlerOptions{AddSource: true} добавит source поле с файлом/строкой вызова. Полезно для debug, но дороже по производительности.

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
})

2.10 zap — для тех, кому нужна максимальная производительность

Заголовок раздела «2.10 zap — для тех, кому нужна максимальная производительность»
import "go.uber.org/zap"
logger, _ := zap.NewProduction() // structured JSON
defer logger.Sync()
logger.Info("request handled",
zap.String("method", "GET"),
zap.Int("status", 200),
zap.Duration("duration", 42*time.Millisecond),
)

zap.NewProduction() — JSON, INFO-level, sampling on. zap.NewDevelopment() — pretty text, DEBUG-level.

import "github.com/rs/zerolog"
import "github.com/rs/zerolog/log"
func main() {
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Info().
Str("user_id", "u-123").
Int("status", 200).
Msg("request handled")
}

zerolog строит JSON напрямую в []byte без interface{}, поэтому почти не аллоцирует.

Логгерns/opallocs/op
zerolog~500
zap (structured)~1000-1
slog (JSON)~150-2000-2
zap (sugared)~3002-3
logrus~300020+

Цифры зависят от количества полей и handler. slog ловит большую часть оптимизаций zap, но всё ещё медленнее на ~30-50%.

Можно настроить, как ваш тип будет логироваться:

type User struct {
ID string
Email string
Pass string // sensitive!
}
func (u User) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", u.ID),
slog.String("email", maskEmail(u.Email)),
// Pass — никогда!
)
}
slog.Info("user logged in", "user", user) // Pass не утечёт

Если в init() вы делаете slog.SetDefault(...), а другая горутина уже логирует через slog.Default() — будет race. Это редко, но: установите дефолтный logger в main() до запуска любых горутин.

slog.Info("oops", "key1", "value1", "key2") // "key2" без значения!

slog распознает это как !BADKEY или повесит ключу значение nil. Поведение зависит от handler. Используйте slog.String(...) если боитесь ошибиться.

defer logger.Sync() // zap
log.Fatalf("boom") // os.Exit(1), defer не вызовется!

Используйте os.Exit только когда логи уже flush’нуты или используйте handler с синхронной записью.

Логирование в горячей петле (миллионы вызовов в секунду) даже у zerolog всё ещё аллоцирует кое-что. Если профайлер показывает логи — добавьте sampling:

sampler := zerolog.BasicSampler{N: 100} // 1 из 100
logger := log.Sample(&sampler)

В slog встроенного sampling нет — пишите свой handler.

Никогда не логируйте:

  • Пароли, API keys, токены, secrets.
  • Полные номера карт (PAN), CVV.
  • PII (email, телефон) — маскируйте.
  • JWT целиком (там может быть PII).
slog.Info("login attempt", "user", user.Email) // BAD: email — PII
slog.Info("login attempt", "user_id", user.ID) // OK
slog.Info("got response", "body", string(body)) // body — 10 МБ JSON, ой

Логи попадут в Loki/ELK и съедят квоту. Логируйте размер, sample, hash — но не всё тело.

slog.Info("a", "x", 42) // any → reflection
slog.Info("a", slog.Int("x", 42)) // typed, быстрее

Разница ~2-3x. В hot path используйте типизированные Attr.

slog не имеет встроенного механизма для динамического изменения уровня. Решение — slog.LevelVar:

var levelVar slog.LevelVar
levelVar.Set(slog.LevelInfo)
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: &levelVar})
logger := slog.New(handler)
// В рантайме (например, через HTTP endpoint):
levelVar.Set(slog.LevelDebug) // включили debug на лету

По умолчанию slog использует time.RFC3339Nano. Если ваш сборщик логов ждёт другой формат — настройте HandlerOptions.ReplaceAttr:

handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Int64("ts", a.Value.Time().Unix())
}
return a
},
})

В Kubernetes сборщик логов читает оба, но конвенция:

  • stdout — нормальные логи.
  • stderr — критические ошибки, паники, fatal.

Если смешать в одном JSONHandler — всё уйдёт в один поток. Это нормально, главное не писать в файл.


func newLogger(env string) *slog.Logger {
level := slog.LevelInfo
if env == "dev" {
level = slog.LevelDebug
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
AddSource: env == "dev",
})
return slog.New(handler).With(
slog.String("service", "payments"),
slog.String("version", buildVersion),
slog.String("env", env),
)
}

В Kubernetes (и Docker в целом) контейнер пишет в stdout/stderr, сборщик (fluent-bit, vector) подбирает. Никогда не пишите логи в файл в контейнере — будет fight с rotation, full disk и т.д.

Если запускаете не в контейнере — используйте systemd journal или lumberjack (но это уже в 2026 редкость).

if os.Getenv("LOG_LEVEL") == "debug" {
levelVar.Set(slog.LevelDebug)
}

DEBUG в prod = квота кончится за час и счёт за хранилище взлетит.

Обязательно:

  • service — имя сервиса.
  • version — версия билда.
  • env — окружение (prod/staging/dev).
  • trace_id — OTel trace ID (для корреляции).
  • request_id — идентификатор запроса.

Опционально (по контексту):

  • user_id, tenant_id, account_id.
  • method, path, status, duration_ms для HTTP.
func LoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.NewString()
}
ctx := context.WithValue(r.Context(), requestIDKey, reqID)
rw := &statusRecorder{ResponseWriter: w, status: 200}
next.ServeHTTP(rw, r.WithContext(ctx))
slog.InfoContext(ctx, "http request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rw.status),
slog.Duration("duration", time.Since(start)),
slog.String("ip", r.RemoteAddr),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (sr *statusRecorder) WriteHeader(code int) {
sr.status = code
sr.ResponseWriter.WriteHeader(code)
}
// Простой sampler — каждую N-ую запись
type SamplingHandler struct {
slog.Handler
rate int64
cnt atomic.Int64
}
func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error {
if r.Level >= slog.LevelError {
return h.Handler.Handle(ctx, r) // ошибки всегда
}
if h.cnt.Add(1)%h.rate == 0 {
return h.Handler.Handle(ctx, r)
}
return nil
}

При использовании OpenTelemetry:

func traceCtxAttrs(ctx context.Context) []slog.Attr {
span := trace.SpanFromContext(ctx)
sc := span.SpanContext()
if !sc.IsValid() {
return nil
}
return []slog.Attr{
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
}
}

Подключите через custom Handler — и каждая запись будет иметь trace_id для корреляции в Grafana/Tempo.

// BAD
slog.Debug("entering function")
slog.Debug("step 1")
slog.Debug("step 2")
slog.Debug("exiting function")
// GOOD
ctx, span := tracer.Start(ctx, "doWork")
defer span.End()
// логи — только для важных событий

Трассы лучше логов для пошагового профилирования. Логи — для событий.

Для критичных ошибок дополнительно отправляйте в Sentry/Honeycomb/Datadog:

if err != nil {
slog.ErrorContext(ctx, "payment failed", "err", err)
sentry.CaptureException(err) // отправит stack + breadcrumbs
}
func maskEmail(s string) string {
at := strings.Index(s, "@")
if at <= 1 {
return "***"
}
return s[:1] + "***" + s[at:]
}

Заверните в slog.LogValuer (см. п. 2.13), чтобы PII никогда не сливалась случайно.

type AppError struct {
Code string
Msg string
Fields map[string]any
}
func (e *AppError) Error() string { return e.Msg }
func (e *AppError) LogValue() slog.Value {
attrs := []slog.Attr{
slog.String("code", e.Code),
slog.String("msg", e.Msg),
}
for k, v := range e.Fields {
attrs = append(attrs, slog.Any(k, v))
}
return slog.GroupValue(attrs...)
}

Финансовые/правовые события (списания, согласия, выдачи) — это аудит. Логи могут потеряться (sampling, retention), а аудит должен жить вечно. Делайте отдельный канал: запись в БД, отдельный Kafka-топик, иммутабельное хранилище.


  1. Почему log из stdlib больше не используется в production? Нет уровней, нет структуры, plain text, сложно парсить, нет context propagation.

  2. Чем slog отличается от zap и zerolog? slog — stdlib, кросс-вендорный handler API. zap и zerolog — быстрее (на 30-100%), но сторонние библиотеки. В 2026 для большинства проектов slog достаточен.

  3. Что такое handler в slog? Компонент, который форматирует и пишет Record. Встроены TextHandler и JSONHandler. Можно писать кастомные (sampling, context-aware, multi-target).

  4. Как добавить trace_id во все логи запроса? Custom Handler, который читает trace.SpanFromContext(ctx) и подмешивает trace_id/span_id в Record.

  5. Почему JSONHandler предпочтителен в production? Парсится сборщиками (Loki, ELK, CloudWatch), индексируется по полям, можно искать по конкретным значениям.

  6. Что такое slog.LogValuer и зачем он? Интерфейс для кастомной сериализации типа в slog.Value. Применение: маскирование PII, скрытие паролей, форматирование сложных структур.

  7. Как менять уровень логирования в рантайме? Через slog.LevelVar — указатель на уровень, который можно атомарно изменить (например, через HTTP endpoint /debug/loglevel).

  8. Что такое sampling в логах? Зачем? Запись только N% событий, чтобы не утопить систему сбора при peak load. Реализуется кастомным handler-ом или встроено в zap/zerolog.

  9. Куда писать логи в Docker/K8s контейнере? stdout/stderr — runtime/сборщик подберёт сам. Не писать в файл внутри контейнера.

  10. Чем опасно логирование внутри hot path? Аллокации, синхронизация (lock в handler), I/O. Может прибить latency. Решение: sampling, типизированные Attr, отдельный async-handler.

  11. Что такое cardinality в контексте логов? Сколько уникальных значений у поля. Высокая cardinality (user_id, request_id) — нормально для логов, но для метрик — плохо (см. файл про Prometheus).

  12. Как не залогировать пароль? Не передавать в Info/Error. Использовать LogValuer на типе. Code review. Pre-commit hooks с regex.

  13. Логи vs метрики vs трассы — когда что? Логи — что произошло (события). Метрики — сколько раз (агрегация). Трассы — где и сколько заняло (путь запроса).

  14. Что такое correlation ID? Уникальный ID запроса, который пробрасывается через все сервисы (HTTP header X-Request-ID или traceparent для OTel) — чтобы связать логи разных сервисов.

  15. Как slog сериализует ошибки? Через Error() метод (по умолчанию Any.Resolve() распознаёт error). Stack trace надо добавлять вручную (runtime/debug.Stack()).

  16. Что такое handler chain? Несколько handler-ов, обёрнутых друг в друга: например, SamplingHandler → ContextHandler → JSONHandler. Каждый делает свою работу.

  17. Чем slog.With отличается от slog.Group? With создаёт новый logger с предустановленными полями (для serie логов). Group — добавляет вложенный объект к одной записи.

  18. Можно ли использовать log/slog с zap под капотом? Да, через slog.NewHandler adapter (go.uber.org/zap/exp/zapslog). Получаете API slog, perf zap.

  19. Что такое log shipping pipeline? Контейнер → stdout → node agent (fluent-bit, vector) → centralized store (Loki, ELK) → UI (Grafana, Kibana).

  20. Чем logrus плох в 2026? Очень много аллокаций (reflection), maintenance mode, синхронные хуки. Для новых проектов — slog или zap.

  21. Зачем AddSource: true? Добавляет file:line в каждый лог. Полезно в dev, но дорого в prod (runtime caller information).

  22. Что такое structured panic? Recover + лог с полной структурой: error, stack, request_id. Без этого panic ничего не даст в Kibana.

  23. Как тестировать логирование? Пишите в bytes.Buffer через JSONHandler, парсите JSON, проверяйте поля. testing/slogtest пакет помогает проверить корректность кастомных handler-ов.

  24. Что такое log levels в Kubernetes? Конвенция: DEBUG/INFO/WARN/ERROR/FATAL. K8s сам не парсит уровень — это делает сборщик (например, Loki может фильтровать по level JSON-поле).

  25. Чем отличаются slog.Info и slog.InfoContext? InfoContext принимает context.Context и пробрасывает его в handler — handler может прочитать trace_id, deadline, request_id из ctx.


Напишите функцию NewLogger(env string) *slog.Logger, которая:

  • В prod — JSONHandler, level=Info, AddSource=false.
  • В dev — TextHandler, level=Debug, AddSource=true.
  • Всегда добавляет service и version как базовые поля.

Напишите handler, который из context.Context извлекает request_id и trace_id и добавляет их в каждую запись.

Создайте тип User с полями ID, Email, Phone. Реализуйте LogValuer, чтобы email маскировался (a***@example.com), а phone — последние 4 цифры (***1234).

Напишите middleware LoggingMiddleware, который логирует метод, путь, статус, длительность каждого запроса.

Реализуйте handler, который пропускает Info/Debug каждые 1 из 100, а Warn/Error — всегда.

Сделайте HTTP endpoint /debug/loglevel, который через GET возвращает текущий уровень, через POST устанавливает новый (debug/info/warn/error).

Напишите тест, который проверяет, что при вызове handlePayment(...) с ошибкой логируется запись с level=ERROR и полем err.


  1. Официальная документация log/sloghttps://pkg.go.dev/log/slog (2026, актуальная версия).
  2. Go blog: “Structured Logging with slog”https://go.dev/blog/slog (Jonathan Amsterdam, автор пакета).
  3. Uber zaphttps://github.com/uber-go/zap (если нужна максимальная производительность).
  4. zerologhttps://github.com/rs/zerolog (zero-allocation logging).
  5. The Twelve-Factor App: Logshttps://12factor.net/logs (stdout, log streams).
  6. Google SRE Book: Monitoring Distributed Systemshttps://sre.google/sre-book/monitoring-distributed-systems/ (логи vs метрики vs трассы).
  7. OpenTelemetry Logs spechttps://opentelemetry.io/docs/specs/otel/logs/ (как корректно коррелировать с trace_id).
  8. Loki best practiceshttps://grafana.com/docs/loki/latest/best-practices/ (cardinality, structured logging).
  9. Datadog blog: “Best Practices for Logging in Go” — поиск по году, 2024-2026.
  10. Kubernetes logging architecturehttps://kubernetes.io/docs/concepts/cluster-administration/logging/.