Опирается на правила: R-OBS-LOG-1R-OBS-LOG-6 и R-OBS-LOG-X1R-OBS-LOG-X6 из Observability Style Guide → раздел 1. Logging.

Важно знать

  • JSON в проде через slog.NewJSONHandler(os.Stdout, ...) — один вызов при старте по APP_ENV; Loki/ELK читают без regex.
  • Логгер через конструктор (DI), не slog.Default() и не глобальная переменная-пакет.
  • Структурные поля через slog.String / slog.Int64 / slog.Any — не fmt.Sprintf в сообщение.
  • Уровни осмысленные: ERROR — неожиданный сбой, WARN — ожидаемая деградация, INFO — значимое доменное событие, DEBUG — детали отладки.
  • traceId/spanId автоматически через OTel-slog bridge (go.opentelemetry.io/contrib/bridges/otelslog); добавлять руками не надо.
  • requestId и userId кладутся в context.Context middleware-ами и явно извлекаются на edge перед логированием.
  • PII в логах запрещены (R-OBS-LOG-X1): email, телефон, ФИО, токены, полный payload карты — маскировать или убирать совсем.
  • fmt.Println / fmt.Fprintf(os.Stderr, ...) не попадают в structured pipeline — только slog.

Логи — первое, что смотрят при инциденте. Если они не структурированы и не содержат traceId, расследование начинается с нуля. Go log/slog даёт стандартный structured API без сторонних зависимостей: JSONHandler в проде, TextHandler локально, и OTel-bridge, который подшивает трейс автоматически.

JSON в проде, текст локально

R-OBS-LOG-1: единственный *slog.Logger создаётся при старте сервиса по переменной окружения APP_ENV и пробрасывается через конструкторы.

// 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,
    }))
}

В проде каждая запись — JSON-строка с полями time, level, msg, source (опционально), плюс всё, что добавлено через With(...) или key-value аргументы. Loki и ELK индексируют поля без regex.

Локально — читаемый текст: 2026-06-18T10:02:00.123Z INFO order_created order_id=ord-99 customer_id=cust-7.

Логгер через конструктор

R-OBS-LOG-2: логгер — поле структуры, не slog.Default() и не log.Printf из stdlib.

// 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 без повторения в каждом вызове.

// internal/product/handler.go
type ProductHandler struct {
    log      *slog.Logger
    products ProductService
}

func NewProductHandler(log *slog.Logger, products ProductService) *ProductHandler {
    return &ProductHandler{
        log:      log.With("component", "product_handler"),
        products: products,
    }
}

Инициализация в main или wire-графе:

// cmd/server/main.go
logger := platform_log.New(cfg.AppEnv)
orderHandler := order.NewOrderHandler(logger, orderService)
productHandler := product.NewProductHandler(logger, productService)

Структурные поля

R-OBS-LOG-3: все переменные — через key-value аргументы или slog.*-атрибуты, не в строку сообщения.

// internal/order/handler.go
func (h *OrderHandler) Create(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
    order, err := h.orders.Create(ctx, cmd)
    if err != nil {
        return nil, err
    }
    h.log.InfoContext(ctx, "order_created",
        slog.String("order_id", order.ID),
        slog.String("customer_id", order.CustomerID),
        slog.String("payment_method", string(cmd.PaymentMethod)),
    )
    return order, nil
}

Правило: имя события — snake_case глаголом-существительным (order_created, product_lookup_failed), поля — в той же форме (order_id, customer_id). Это делает запись фильтруемой: {msg="order_created"} | json | order_id="ord-99".

Всегда используй InfoContext / WarnContext / ErrorContextctx), не Info / Warn / ErrorContext-варианты передают active OTel-span в bridge, который добавляет trace_id/span_id автоматически.

Уровни логов

R-OBS-LOG-4: семантика по типам ошибок.

УровеньКогда
ERRORНеожиданный сбой: panic, Technical-ошибка, Integration-ошибка при открытом Circuit Breaker. Всегда с полем error.
WARNОжидаемая деградация: Domain/Validation-ошибка во входящем запросе, retry attempt, деградация внешнего сервиса.
INFOЗначимое доменное событие: заказ создан, статус сменился, пользователь зарегистрирован, scheduler завершил работу.
DEBUGДетали для отладки: промежуточные состояния, ответы из кеша, внутренние шаги агрегата. В проде выключено по умолчанию.
// internal/order/handler.go

// ERROR — неожиданный технический сбой
h.log.ErrorContext(ctx, "order_repository_failed",
    slog.String("order_id", cmd.OrderID),
    slog.String("error", err.Error()),
)

// WARN — деградация: retry
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 — детали состояния (dev/staging)
h.log.DebugContext(ctx, "order_aggregate_snapshot",
    slog.Any("order", order),
)

traceId и requestId через context.Context

R-OBS-LOG-5: в Go нет MDC/thread-local — весь propagation через context.Context.

traceId и spanId подставляются автоматически через OTel-slog bridge. Достаточно создать логгер через otelslog.NewHandler:

// internal/platform/log/setup.go
import "go.opentelemetry.io/contrib/bridges/otelslog"

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})
    }
    return slog.New(otelslog.NewHandler("order-service", otelslog.WithLoggerProvider(otel.GetLoggerProvider()), otelslog.WithSource(true)))
}

Для requestId и userId — middleware кладёт значения в контекст, edge-слой (handler) явно читает и добавляет к логгеру:

// internal/platform/middleware/reqid.go
type ctxKey string

const (
    ctxRequestID ctxKey = "request_id"
    ctxUserID    ctxKey = "user_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))
    })
}

Вспомогательная функция на edge для обогащения логгера полями из контекста:

// 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-handler на edge:

// internal/customer/http_handler.go
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 и Auth идут до бизнес-логики, OTel-middleware — перед ними (чтобы span был открыт к моменту логирования):

// cmd/server/main.go
r := chi.NewRouter()
r.Use(RequestID)
r.Use(Auth(tokenVerifier))
r.Use(middleware.Logger) // chi access-log, не бизнес-логи

Логи на границах

R-OBS-LOG-6: логируем там, где сервис пересекает границу с внешним миром.

Outbound-адаптер (HTTP-клиент к payment-provider):

// 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)
    }
    if resp.StatusCode >= 500 {
        a.log.ErrorContext(ctx, "payment_charge_provider_error",
            slog.String("order_id", cmd.OrderID),
            slog.Int("status", resp.StatusCode),
        )
        return nil, &apperr.IntegrationError{Provider: "payment-provider"}
    }
    a.log.InfoContext(ctx, "payment_charge_completed",
        slog.String("order_id", cmd.OrderID),
    )
    return result, nil
}

Outbound-адаптер (sqlc/pgx запрос к БД):

// 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
}

Scheduler (outbox relay):

// internal/platform/outbox/relay.go
func (rel *OutboxRelay) Run(ctx context.Context) {
    rel.log.InfoContext(ctx, "outbox_relay_started")
    n, err := rel.publish(ctx)
    if err != nil {
        rel.log.ErrorContext(ctx, "outbox_relay_failed", slog.String("error", err.Error()))
        return
    }
    rel.log.InfoContext(ctx, "outbox_relay_finished", slog.Int("published", n))
}

Внутри UseCase Handler — только события принятых решений. Не «вошли в метод», не «загрузили N строк» — это шум.

Что запрещено

АнтипаттернПравилоЧто взамен
PII в логах (email, телефон, ФИО, токены, полный payload карты)R-OBS-LOG-X1маскировать или логировать только id
fmt.Println / fmt.Fprintf(os.Stderr, ...)R-OBS-LOG-X2slog.InfoContext / slog.ErrorContext
fmt.Sprintf("order: %v", order) как аргумент msgR-OBS-LOG-X3slog.Any("order", order) — slog ленив
log.ErrorContext(ctx, err.Error()) без поля errorR-OBS-LOG-X4slog.String("error", err.Error()) отдельным атрибутом
Полный request body платёжного / PII-эндпоинта в логахR-OBS-LOG-X5только order_id, amount — не card_token, не address
INFO на каждый HTTP-запрос внутри handler-аR-OBS-LOG-X6chi middleware.Logger ведёт access-log отдельно
slog.Default() / глобальный пакетный логгерR-OBS-LOG-2логгер через конструктор, поле структуры
Info / Warn / Error без ContextR-OBS-LOG-5InfoContext(ctx, ...) — bridge берёт span из ctx
context.WithValue в UseCase Handler для observability-полейR-OBS-CTX-X2только в middleware; handler только читает

Куда дальше

  • Context propagation — как requestId и userId путешествуют через context.Context; горутины без разрыва трейса.
  • Tracing — OTel-slog bridge, defer span.End(), ручные span для UseCase handlers.
  • Metrics — promauto, RED-middleware на chi, бизнес-Counter/Histogram.
  • Конфигурация — management-сервер на отдельном порту, slog.LevelVar для runtime-изменения уровня.
  • Health checks — /health/live и /health/ready, TTL-кеш для pgx-ping.
  • SLO и алерты — multi-window burn-rate alerts, error budget.