Логирование
Одинаковый формат логов во всех сервисах: одна команда в Grafana должна
одинаково работать для user, review, media и любого будущего сервиса.
log/slog — стандарт
Используй log/slog из stdlib. Никаких logrus, zap, zerolog,
log.Printf в прод-коде.
Handler
JSON → stdout. Никогда в файл. Никогда plain text в prod (plain-текст
допустим только в локальной отладке через SERVICE_LOG_FORMAT=text, если
сервис поддерживает такой флаг).
Настройка в cmd/server/main.go:
func newLogger(level string) *slog.Logger {
var lv slog.Level
_ = lv.UnmarshalText([]byte(level))
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: lv,
AddSource: false,
})
return slog.New(h).With(
"service", "review",
)
}service навешивается один раз на корневой логгер — дальше везде
присутствует как структурное поле. AddSource=false в prod: путь к файлу
и номер строки съедает cardinality в логах; включай в dev.
Уровни
| Уровень | Когда использовать | Пример |
|---|---|---|
| DEBUG | Локальная отладка. В prod выключен (INFO-порог). | log.Debug("cache lookup", "key", key, "hit", hit) |
| INFO | Бизнес-события: «что произошло». | log.Info("review created", "review_id", r.ID, "user_id", userID) |
| WARN | Retryable ошибки, degraded path, таймаут одного из соседей. | log.Warn("redis timeout, falling back to db", "err", err) |
| ERROR | Unhandled ошибка, требует внимания. | log.Error("publish outbox", "err", err, "topic", topic) |
DEBUG в prod всегда выключен. INFO — базовый уровень прод-логгера.
Не пиши INFO на каждый HTTP-запрос — access-лог пишет middleware. INFO на уровне service — только значимые события (создан/удалён/сменил состояние).
Обязательные структурные поля
На каждой записи (через middleware log.FromCtx(ctx)), как минимум:
| Поле | Значение |
|---|---|
request_id | Из X-Request-Id header’а. Генерируется RequestID middleware на входе. |
user_id | bigint пользователя, если запрос authenticated. Иначе отсутствует. |
route | Паттерн маршрута (/v1/reviews/{id}), не «фактический URL». |
service | Имя сервиса. Навешивается один раз в newLogger. |
correlation_id | Cross-service id, прокидывается из header’а и из Kafka metadata. |
Логгер прокидывается через ctx:
package log
type ctxKey int
const ctxKeyLogger ctxKey = iota
func Into(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKeyLogger, l)
}
func FromCtx(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKeyLogger).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}Middleware оборачивает базовый логгер per-request:
func WithLog(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := base.With(
"request_id", middleware.GetReqID(r.Context()),
"route", chi.RouteContext(r.Context()).RoutePattern(),
)
ctx := log.Into(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}Handler / service пишет так:
log.FromCtx(ctx).Info("review created",
"review_id", review.ID,
"place_id", review.PlaceID,
"user_id", userID,
)Ошибки
log.FromCtx(ctx).Error("publish outbox",
"err", err,
"topic", topic,
"aggregate_id", id,
)- Сообщение — статическое:
"publish outbox", а не"publish outbox failed: "+err.Error(). Это даёт возможность группировать ошибки в дашборде по сообщению. - Ошибка — поле:
"err", err.slogсам вызоветerr.Error()и положит как строку в JSON. - Никогда не используй
err.Error()как сообщение.
PII запрещено в логах
Категорически нельзя писать в лог в открытом виде:
email,phone,- полный JWT (access/refresh),
password,password_hash,- OAuth
access_token, OAuthrefresh_token, device_id(может быть идентификатором устройства),- IP-адрес (в ряде юрисдикций — PII).
Если нужно увидеть поле в логе — маскируй. Хелперы живут в
pkg/pii/:
package pii
import "strings"
// MaskEmail: "abdymaget@gmail.com" → "a***@gmail.com"
func MaskEmail(s string) string {
at := strings.Index(s, "@")
if at <= 0 {
return "***"
}
return s[:1] + "***" + s[at:]
}
// MaskPhone: "+77011234567" → "+7 701 *** ** 67"
func MaskPhone(s string) string {
if len(s) < 4 {
return "***"
}
return "*** " + s[len(s)-4:]
}
// MaskToken: возвращает короткий непривязанный хеш для поиска по логам.
func MaskToken(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])[:16]
}Использование:
log.FromCtx(ctx).Info("login attempt",
"user_id", userID,
"email_masked", pii.MaskEmail(req.Email),
)Что можно логировать
user_id(bigint) — это публичный технический идентификатор, он уже безопасен.review_id,place_id,session_id— внутренние ID.event_type,topic— метаданные Kafka.duration_ms,status_code.error_code(sentinel-код ошибки) — не полное сообщение, которое мог бы показать пользователь.error_code=not_found, а неerror_message="user 42 not found"(в последнем утекает существование конкретного id).
Sampling для hot path
Если endpoint обрабатывает > 1000 req/s — логировать каждый успешный
запрос не нужно, это только греет диск. Но sampling должен быть
детерминированным по trace_id: все записи одного trace либо
полностью попадают в sampled-набор, либо полностью выпадают.
Проблема недетерминированного rand.Float64() < p. Per-request
random означает, что в пределах одного trace одна запись лога попадёт
в sampled, а следующая — нет. Восстановить полную картину запроса по
trace невозможно: половина строк просто отсутствует.
Правило. Sampling выполняется детерминированно на основе
trace_id. Все логи одного trace либо все логируются, либо все — нет.
Реализация:
// sampleByTrace возвращает true для `rate`-доли trace'ов детерминированно.
// rate = 0.01 → 1% trace'ов полностью залогированы, остальные — нет.
//
// Политика при нестандартном trace_id: fail-OPEN (логируем). Лучше лишний
// лог, чем потерянная запись в цепочке инцидента. Все такие случаи
// считаем в метрику, чтобы поймать системные проблемы с trace_id.
func sampleByTrace(traceID string, rate float64) bool {
if rate <= 0 {
return false
}
if rate >= 1 {
return true
}
if len(traceID) < 8 {
sampleTraceMalformed.WithLabelValues("too_short").Inc()
return true
}
// Последние 8 hex-символов (32 бита) — наиболее энтропийная часть
// в W3C trace-id, безопасно использовать как хеш.
suffix := traceID[len(traceID)-8:]
v, err := strconv.ParseUint(suffix, 16, 32)
if err != nil {
sampleTraceMalformed.WithLabelValues("parse_error").Inc()
return true
}
// Округление через math.Round, чтобы rate=0.01 давал стабильно ~1%,
// а не 0.99% из-за truncation.
threshold := uint32(math.Round(rate * float64(math.MaxUint32)))
return uint32(v) < threshold
}sampleTraceMalformed — CounterVec{reason} в том же пакете. Alert:
rate(log_sample_trace_malformed_total[15m]) > 0 → ticket: в trace-
propagation сломалось, надо чинить отдельно (unwanted fail-open
означает, что часть логов идёт мимо sampling’а и может пересоздать
давление на Loki).
Использование в middleware / handler:
if sampleByTrace(traceIDFromCtx(ctx), 0.01) {
log.FromCtx(ctx).Info("request served", "status", status, "duration_ms", d)
}Rate-конфигурация по уровням
- Hot-path access-log (успешные 2xx) — sample 1% (
0.01). - ERROR (5xx, panic) — никогда не sample, логируй все без исключения.
- WARN — sample 10% (
0.10). - DEBUG в prod — off (
0.00); включается только по явному per-request debug-флагу в header.
Rate задаётся env-переменными, никаких hard-coded значений в коде:
LOG_SAMPLE_RATE_INFO=0.01
LOG_SAMPLE_RATE_WARN=0.10
LOG_SAMPLE_RATE_DEBUG=0.00Anti-pattern
rand.Float64() < rate без привязки к trace_id — запрещён. Trace
с частичными логами бесполезен для отладки: ты видишь, что запрос
стартовал, но не видишь, где именно он упал, потому что половина
промежуточных записей выпала в rand-lottery.
Correlation-Id vs Request-Id
Два разных ID, не путай:
Request-Id(X-Request-Id, полеrequest_idв логе) — идентификатор одного HTTP-запроса. Генерируется на входе в сервис (chimw.RequestIDили приходит от Gateway) и живёт только в пределах этого запроса.Correlation-Id(X-Correlation-Id, Kafka metadataCorrelation-Id, полеcorrelation_idв логе) — идентификатор цепочки, которая может пройти через несколько сервисов и Kafka. Может совпадать сRequest-Id(если Gateway положил то же значение), но не обязан.traceparent— W3C-стандарт OpenTelemetry (trace_id + span_id), отдельный канал для distributed tracing. Не путай сCorrelation-Id.
Correlation-Id прокидывается через всё:
- HTTP: header
X-Correlation-Id, читается middleware, кладётся в ctx и в логгер. - Kafka: metadata
Correlation-Id(envelope) → при consume ложится в ctx через Watermill middlewareCorrelationID. - Logger: поле
correlation_idприсутствует во всех записях одного запроса/сообщения.
Если header не пришёл — middleware генерирует ULID. Для исходящих
HTTP-вызовов в другой сервис — передавай X-Correlation-Id из ctx.
Запреты
fmt.Println/fmt.Printf/log.Printв прод-коде — запрещены. CI-lint (forbidigo) отлавливает.panic("...")ради «залогировать и упасть» — нет. Возвращай error, логируй на ERROR, решение об exit принимает вызывающий уровень.- Логирование в цикле на каждый элемент — убивает производительность и читаемость. Лог на батч — да, лог на каждую итерацию — нет.
log.FromCtx(context.Background())в handler/service — это значит, что ты где-то потерял per-request логгер. Чини цепочку ctx.
Пример полной цепочки
func (h *ReviewHandler) CreateReview(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req CreateReviewRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "bad json")
return
}
if err := validate(req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_input", "validation failed")
return
}
userID, _ := middleware.UserIDFrom(ctx)
review, err := h.svc.Create(ctx, service.CreateReviewCommand{
UserID: userID,
PlaceID: req.PlaceID,
Rating: req.Rating,
})
if err != nil {
log.FromCtx(ctx).Warn("create review failed", "err", err, "place_id", req.PlaceID)
mapServiceError(w, err)
return
}
log.FromCtx(ctx).Info("review created", "review_id", review.ID, "place_id", review.PlaceID)
writeJSON(w, http.StatusCreated, map[string]any{"data": review})
}Вывод в stdout:
{"time":"...","level":"INFO","msg":"review created","service":"review","request_id":"01HZ..","route":"/v1/reviews","user_id":42,"review_id":1001,"place_id":7}По этому JSON-у Grafana/Loki строит поиск за 5 секунд. По
fmt.Printf("review %d created\n", id) — никак.
См. также
observability— как логи соединяются с метриками и трейсами.security— правила маскирования PII, запрет логировать секреты и Authorization header.