Когда в продакшне что-то идёт не так, первое, что открывают — логи. Если там написано 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 можно написать {msg="order_created"} | order_id="ord-99".
Обратите внимание на InfoContext вместо Info: контекстные варианты (InfoContext, WarnContext, ErrorContext) передают активный OpenTelemetry-span в bridge, который автоматически добавляет 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))
})
}
На краю обработчика — вспомогательная функция обогащает логгер полями из контекста:
// internal/platform/log/ctx.go
func FromCtx(ctx context.Context, log *slog.Logger) *slog.Logger {
var args []any
if id, ok := ctx.Value(ctxRequestID).(string); ok {
args = append(args, slog.String("request_id", id))
}
if uid, ok := ctx.Value(ctxUserID).(string); ok {
args = append(args, slog.String("user_id", uid))
}
return log.With(args...)
}
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 важен: OTel-middleware регистрируется первым (чтобы span был открыт), затем RequestID и Auth:
r := chi.NewRouter()
r.Use(otelhttp.NewMiddleware("order-service"))
r.Use(RequestID)
r.Use(Auth(tokenVerifier))
Автоматический traceId через OTel-bridge
Если в проекте используется OpenTelemetry, trace_id и span_id можно добавлять в логи автоматически — через otelslog bridge. Тогда не нужно руками тянуть идентификатор трейса из контекста.
// internal/platform/log/setup.go
import (
"go.opentelemetry.io/contrib/bridges/otelslog"
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(otel.GetLoggerProvider()),
)
return slog.New(slogmulti.Fanout(base, otelHandler))
}
slogmulti.Fanout направляет каждую запись одновременно в оба обработчика: в stdout для оператора и в OTel-pipeline для трейс-коллектора. Достаточно везде использовать InfoContext(ctx, ...) — bridge сам достанет span из контекста.
Логи на границах с внешним миром
Хорошее место для логирования — точки, где сервис пересекает границу: 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(...)— тогда OTel-bridge подшиваетtrace_idиspan_idавтоматически. 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.