Когда в продакшне что-то идёт не так, первое, что открывают — логи. Если там написано fmt.Println("error:", err), расследование начинается с нуля: нет времени события, нет структуры, нет связи с запросом. Если же лог — JSON с полями order_id, trace_id и понятным уровнем — причину находят за минуты.
Go с версии 1.21 поставляется с log/slog: стандартная библиотека структурированного логирования без сторонних зависимостей.
Два формата: JSON в продакшне, текст при разработке
При разработке удобно читать логи как строки. В продакшне сборщики логов (Loki, ELK, Datadog) ожидают JSON — тогда они могут индексировать поля и делать сложные фильтры без регулярных выражений.
Создаём один логгер при старте сервиса по переменной окружения:
// internal/platform/log/setup.go
func New(env string) *slog.Logger {
if env == "production" {
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
}
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
}
В продакшне каждая запись выглядит так:
{"time":"2026-06-18T10:02:00Z","level":"INFO","msg":"order_created","order_id":"ord-99","customer_id":"cust-7"}
При разработке — читаемая строка:
2026-06-18T10:02:00.123Z INFO order_created order_id=ord-99 customer_id=cust-7
Логгер создаётся один раз в main и передаётся в компоненты через конструкторы — не через глобальную переменную и не через slog.Default().
Логгер через конструктор
Частая ошибка — хранить логгер в глобальной переменной пакета или вызывать slog.Default() прямо внутри методов. Проблема: логгер нельзя подменить в тестах, нельзя добавить к нему постоянные поля.
Правильно: логгер — поле структуры, передаётся через конструктор.
// internal/order/handler.go
type OrderHandler struct {
log *slog.Logger
orders OrderService
}
func NewOrderHandler(log *slog.Logger, orders OrderService) *OrderHandler {
return &OrderHandler{
log: log.With("component", "order_handler"),
orders: orders,
}
}
log.With("component", "order_handler") создаёт дочерний логгер — все записи этого компонента автоматически получат поле component=order_handler без повторения в каждом вызове.
Инициализация в main:
// cmd/server/main.go
logger := platform_log.New(cfg.AppEnv)
orderHandler := order.NewOrderHandler(logger, orderService)
productHandler := product.NewProductHandler(logger, productService)
Структурные поля вместо форматирования строкспросят на собеседовании
Интуитивно хочется писать так:
log.Info(fmt.Sprintf("order created: %v", order.ID)) // плохо
Но тогда значение уходит в строку msg и теряется как отдельное поле — его нельзя отфильтровать.
Правильно — отдельные ключ-значение аргументы:
h.log.InfoContext(ctx, "order_created",
slog.String("order_id", order.ID),
slog.String("customer_id", order.CustomerID),
)
Имя события — snake_case существительное с глаголом (order_created, payment_failed). Поля — в той же форме (order_id, customer_id). Это делает запись фильтруемой: в Loki можно написать {app="order-service"} | json | msg="order_created" | order_id="ord-99".
Обратите внимание на InfoContext вместо Info: контекстные варианты (InfoContext, WarnContext, ErrorContext) передают ctx в обработчик, и тот достаёт из него активный OpenTelemetry-span — так trace_id и span_id попадают в запись. Как это подключить, ниже.
Уровни логовспросят на собеседовании
Четыре уровня — и у каждого конкретный смысл:
ERROR — что-то пошло не так и мы этого не ожидали: паника, сбой базы данных, недоступность платёжного провайдера. Всегда с полем error.
h.log.ErrorContext(ctx, "order_repository_failed",
slog.String("order_id", cmd.OrderID),
slog.String("error", err.Error()),
)
WARN — ожидаемая деградация: запрос не прошёл валидацию, повторная попытка подключения, сторонний сервис работает медленно.
h.log.WarnContext(ctx, "payment_provider_retry",
slog.String("order_id", cmd.OrderID),
slog.Int("attempt", attempt),
)
INFO — значимое событие в домене: заказ создан, пользователь зарегистрирован, задача завершена.
h.log.InfoContext(ctx, "order_confirmed",
slog.String("order_id", order.ID),
slog.String("customer_id", order.CustomerID),
)
DEBUG — детали для отладки. В продакшне выключены по умолчанию.
h.log.DebugContext(ctx, "order_aggregate_snapshot",
slog.Any("order", order),
)
Частая ошибка: писать INFO на каждый входящий HTTP-запрос прямо в обработчике. Это шум — access-лог ведёт chi-middleware отдельно.
requestId и userId через context.Contextспросят на собеседовании
В Java есть MDC — хранилище полей, привязанное к потоку. В Go у горутин нет локального хранилища. Вместо этого поля путешествуют через context.Context.
Middleware создаёт идентификатор запроса и кладёт его в контекст:
// internal/platform/middleware/reqid.go
type ctxKey string
const ctxRequestID ctxKey = "request_id"
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-Id")
if id == "" {
id = uuid.NewString()
}
ctx := context.WithValue(r.Context(), ctxRequestID, id)
w.Header().Set("X-Request-Id", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func RequestIDFrom(ctx context.Context) (string, bool) {
id, ok := ctx.Value(ctxRequestID).(string)
return id, ok
}
На краю обработчика — вспомогательная функция обогащает логгер полями из контекста:
// internal/platform/log/ctx.go
func FromCtx(ctx context.Context, log *slog.Logger) *slog.Logger {
var args []any
if id, ok := middleware.RequestIDFrom(ctx); ok {
args = append(args, slog.String("request_id", id))
}
if uid, ok := middleware.UserIDFrom(ctx); ok {
args = append(args, slog.String("user_id", uid))
}
return log.With(args...)
}
Ключи контекста и функции чтения держат в одном пакете: platform/middleware экспортирует RequestIDFrom(ctx) и UserIDFrom(ctx), а сам ключ наружу не выходит — неэкспортируемый ключ из другого пакета не виден, а свой одноимённый ключ был бы уже другим значением, и чтение вернуло бы пустоту.
HTTP-обработчик вызывает её один раз в начале:
func (h *CustomerHTTPHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
log := platform_log.FromCtx(r.Context(), h.log)
customerID := chi.URLParam(r, "id")
profile, err := h.customers.GetProfile(r.Context(), customerID)
if err != nil {
log.WarnContext(r.Context(), "customer_profile_not_found",
slog.String("customer_id", customerID),
slog.String("error", err.Error()),
)
httperr.Write(w, r, err)
return
}
log.InfoContext(r.Context(), "customer_profile_fetched",
slog.String("customer_id", customerID),
)
render.JSON(w, r, profile)
}
Порядок middleware в chi: RequestID первым — чтобы идентификатор попал и в access-log, и в ответ 401 от Auth, — затем OTel и Auth, а логирующий middleware после всех (подробнее в context propagation):
r := chi.NewRouter()
r.Use(RequestID)
r.Use(otelhttp.NewMiddleware("order-service"))
r.Use(Auth(tokenVerifier))
Автоматический traceId через OTel-bridge
Если в проекте используется OpenTelemetry, trace_id и span_id можно добавлять в логи автоматически — без ручного ctx.Value. Важно понимать, где именно это происходит. Мост otelslog передаёт записи в OTel SDK, тот сам подшивает к записи трассу из ctx и отправляет её по OTLP в коллектор. Обычный JSONHandler, который пишет в stdout, про span ничего не знает: если логи собирают со stdout, trace_id в них сам не появится. Поэтому для stdout ставят маленькую обёртку над обработчиком:
// internal/platform/log/trace.go
type traceHandler struct{ slog.Handler }
func (h traceHandler) Handle(ctx context.Context, r slog.Record) error {
if sc := trace.SpanContextFromContext(ctx); sc.IsValid() {
r.AddAttrs(
slog.String("trace_id", sc.TraceID().String()),
slog.String("span_id", sc.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
// без этих двух методов log.With(...) вернёт внутренний обработчик, и обёртка потеряется
func (h traceHandler) WithAttrs(a []slog.Attr) slog.Handler { return traceHandler{h.Handler.WithAttrs(a)} }
func (h traceHandler) WithGroup(n string) slog.Handler { return traceHandler{h.Handler.WithGroup(n)} }
А мост подключают вторым получателем, если логи должны уезжать ещё и по OTLP:
// internal/platform/log/setup.go
import (
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel/log/global"
slogmulti "github.com/samber/slog-multi"
)
func NewWithOTel(env string) *slog.Logger {
var base slog.Handler
if env == "production" {
base = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})
} else {
base = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})
}
otelHandler := otelslog.NewHandler("order-service",
otelslog.WithLoggerProvider(global.GetLoggerProvider()),
)
return slog.New(slogmulti.Fanout(traceHandler{base}, otelHandler))
}
slogmulti.Fanout направляет каждую запись в оба обработчика: в stdout для оператора и в OTel-конвейер для коллектора. В обоих случаях источник трассы один — ctx, поэтому везде нужен InfoContext(ctx, ...): запись через Info(...) без контекста уедет без trace_id и там, и там.
Логи на границах с внешним миром
Хорошее место для логирования — точки, где сервис пересекает границу: HTTP-вызов во внешний сервис, запрос к базе данных, работа планировщика.
Адаптер к платёжному провайдеру:
// internal/payment/http_adapter.go
func (a *PaymentHTTPAdapter) Charge(ctx context.Context, cmd ChargeCommand) (*ChargeResult, error) {
a.log.InfoContext(ctx, "payment_charge_started",
slog.String("order_id", cmd.OrderID),
)
resp, err := a.client.Do(req.WithContext(ctx))
if err != nil {
a.log.ErrorContext(ctx, "payment_charge_network_failed",
slog.String("order_id", cmd.OrderID),
slog.String("error", err.Error()),
)
return nil, fmt.Errorf("charge: %w", err)
}
a.log.InfoContext(ctx, "payment_charge_completed",
slog.String("order_id", cmd.OrderID),
)
return result, nil
}
Запрос к базе данных:
// internal/order/postgres_repository.go
func (r *OrderPostgresRepository) Load(ctx context.Context, id string) (*Order, error) {
r.log.DebugContext(ctx, "order_load_started", slog.String("order_id", id))
row, err := r.queries.GetOrder(ctx, id)
if err != nil {
r.log.WarnContext(ctx, "order_not_found", slog.String("order_id", id))
return nil, &apperr.NotFoundError{Entity: "Order", ID: id}
}
return mapOrderRow(row), nil
}
Внутри бизнес-логики — только события принятых решений. «Вошли в метод» или «загрузили N строк» — это шум, не информация.
Персональные данные в логах
Персональные данные (ФИО, email, номер телефона, токены, данные карты) нельзя писать в логи. Причина простая: логи хранятся дольше, чем нужно, доступны большему числу людей, чем нужно, и их сложно вычистить постфактум.
Правило: в лог идут только идентификаторы (order_id, customer_id, amount), не значения полей (card_token, address, email).
Частые ошибки и как их избежать:
fmt.Sprintf("user: %+v", user)— попадут все поля структуры, включая email. Логировать толькоuser.ID.slog.Any("request_body", body)для платёжного эндпоинта — может попасть номер карты. Логировать только нужные поля явно.log.ErrorContext(ctx, err.Error())— сообщение об ошибке иногда содержит введённые пользователем данные. Использоватьslog.String("error", err.Error())как отдельный атрибут и проверять, что туда попадает.
Коротко
- Два обработчика:
slog.NewJSONHandlerв продакшне (для Loki/ELK),slog.NewTextHandlerпри разработке — выбор поAPP_ENVпри старте. - Логгер — поле структуры, передаётся через конструктор.
slog.Default()и глобальные переменные не использовать. - Все переменные — через
slog.String/slog.Int/slog.Any, не черезfmt.Sprintfв строку сообщения. - Всегда
InfoContext(ctx, ...), неInfo(...)—trace_idиspan_idберутся изctx; для stdout это делает обёртка над обработчиком, для OTLP — мостotelslog. requestIdиuserIdхранятся вcontext.Context, middleware кладёт их туда, обработчик читает через вспомогательную функцию.- Уровни: ERROR — неожиданный сбой, WARN — ожидаемая деградация, INFO — доменное событие, DEBUG — детали отладки.
- Персональные данные в логах запрещены: только идентификаторы, не значения.
Что почитать дальше
- Tracing в Go — OTel-slog bridge, ручные span для обработчиков.
- Metrics в Go — promauto, RED-middleware на chi, бизнес-метрики.
- Context propagation в Go — как requestId и userId путешествуют через context.Context.