Логирование¶
Одинаковый формат логов во всех сервисах: одна команда в 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,
)
Ошибки¶
- Сообщение — статическое:
"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 — логировать каждый успешный запрос не нужно, это только греет диск. Для таких маршрутов sample 10% на уровне middleware:
func SampledLog(base *slog.Logger, rate float64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if rand.Float64() > rate {
next.ServeHTTP(w, r)
return
}
// ...логируем полностью...
next.ServeHTTP(w, r)
})
}
}
Ошибки (WARN/ERROR) сэмплировать не надо — они важны каждая.
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.md— как логи соединяются с метриками и трейсами.security.md— правила маскирования PII, запрет логировать секреты и Authorization header.