Опирается на правила:
R-OBS-LOG-1…R-OBS-LOG-6иR-OBS-LOG-X1…R-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.Contextmiddleware-ами и явно извлекаются на 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 / ErrorContext (с ctx), не Info / Warn / Error — Context-варианты передают 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-X2 | slog.InfoContext / slog.ErrorContext |
fmt.Sprintf("order: %v", order) как аргумент msg | R-OBS-LOG-X3 | slog.Any("order", order) — slog ленив |
log.ErrorContext(ctx, err.Error()) без поля error | R-OBS-LOG-X4 | slog.String("error", err.Error()) отдельным атрибутом |
| Полный request body платёжного / PII-эндпоинта в логах | R-OBS-LOG-X5 | только order_id, amount — не card_token, не address |
| INFO на каждый HTTP-запрос внутри handler-а | R-OBS-LOG-X6 | chi middleware.Logger ведёт access-log отдельно |
slog.Default() / глобальный пакетный логгер | R-OBS-LOG-2 | логгер через конструктор, поле структуры |
Info / Warn / Error без Context | R-OBS-LOG-5 | InfoContext(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.