Содержание урока · 12 разделов
- Зачем go-kit начал с логгера
- Интерфейс Logger: одна строка на весь контракт
- log.With и Valuer: слоёные значения, вычисляемые на каждом событии
- Почему логи живут в middleware, а не в сервисе
- X-Request-ID и ServerFinalizer: лог HTTP-запроса
- Уровни: пакет level поверх kitlog
- Какой лог — из какого места
- Чего в логи не класть
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Зачем go-kit начал с логгера#
Из всех сквозных функций логирование появилось в курсе первым не случайно: пока у сервиса нет метрик и трейсинга, лог — единственный свидетель о жизни. Но пакет kitlog решает более глубокую задачу, чем вывод строк: он делает событие структурой. Вызов logger.Log("method", "uppercase", "err", err) — это не строка, а набор пар ключ-значение, который форматтер рендерит на выходе как угодно: logfmt для консоли разработчика, JSON для сборщика логов платформы. Причина проста: в микросервисах логи читает не человек, а конвейер — grep по ключу, группировка, алерт; строка «failed to process» для него мусор, а пара "method", "uppercase" — индексируемое поле. Поэтому go kit логирование начинается с вопроса «какие ключи будут у события», а не «что напечатать». Ты уже видел kitlog в уроке 04: kitlog.NewJSONLogger(os.Stdout) и logger.Log("msg", "listening", "addr", ":8080") — сегодня разберём каждую букву этого заклинания и соберём из него настоящий наблюдательный слой.
Интерфейс Logger: одна строка на весь контракт#
Откроем исходники пакета github.com/go-kit/log — весь интерфейс умещается в одну сигнатуру: type Logger interface { Log(keyvals ...interface{}) error }. Это осознанный минимализм в духе endpoint: никаких уровней, имён полей, форматтеров в контракте — только пары. Три следствия, которые надо понять сразу. Первое: пары идут чётно — ключ, значение, ключ, значение; если передать нечётное количество, логгер не упадёт, а подставит в хвост значение ErrMissingValue — константу с текстом (MISSING), и в логе появится громкий маркер ошибки программиста. Второе: Log возвращает error, и по общему соглашению курса его игнорируют: в defer-обёртках пишут _ = logger.Log(...). Падать из-за невозможности записать лог — путь к каскадной аварии. Третье: реализация обязана быть безопасной для конкурентного доступа и не менять переданные keyvals — контракт записан в доке пакета, и на него опирается вся композиция. Один интерфейс, а в результате любой компонент — middleware, транспорт, клиент — принимает log.Logger и не знает, куда именно едет вывод: stdout, файл, syslog или сетевой коллектор.
Базовый логгер: JSON, logfmt, Nop
GOimport (
"log"
"net/http"
"os"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
)
func main() {
// Форматтер №1: JSON для прод-коллектора.
var logger log.Logger
logger = log.NewJSONLogger(os.Stdout)
// Форматтер №2: logfmt для локальной разработки.
// logger = log.NewLogfmtLogger(os.Stderr)
logger = log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller)
logger.Log("msg", "listening", "addr", ":8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}Два штатных форматтера kitlog: NewJSONLogger пишет каждое событие объектом, NewLogfmtLogger — пары ключ=значение через пробел, читаемые глазами. Оба НЕ безопасны для конкурентного использования без обёртки log.NewSyncLogger — оберни их, если логгер разделён между горутинами. log.With добавляет ко всем событиям пары ts и caller: DefaultTimestampUTC — это Valuer, который на каждый вызов Log вычисляет свежий RFC3339Nano в UTC, а DefaultCaller — файл и строку вызова.
log.With и Valuer: слоёные значения, вычисляемые на каждом событии#
Главный инструмент kitlog — функция log.With(logger, keyvals...), возвращающая новый Logger с добавленными парами. Слои складываются: базовый With в main задаёт ts и caller, middleware поверх добавляет request_id, и каждая строка лога собирает их все — без передачи логгера по аргументам через весь стек. Пара слов о направлении: With добавляет пары в конец (ближе к твоим данным), WithPrefix — в начало (ближе к ts), и именно WithPrefix используют хелперы пакета level. Значения в With могут быть не константами, а Valuer — функциями func() interface{}; они перевычисляются на каждый вызов Log. Из коробки есть log.Timestamp(t func() time.Time), готовые DefaultTimestamp и DefaultTimestampUTC (обе — RFC3339Nano, вторая в UTC) и DefaultCaller. Свой Valuer пишется за минуту — например, генератор request id, если тот не пришёл в заголовке. Важная граница: слой With — место для контекста вызова (какой сервис, какой компонент пишет), а не для данных конкретного запроса; тело запроса в слой With не кладут никогда, для него есть поля события.
Endpoint-middleware логирования
GOfunc loggingMiddleware(logger log.Logger) endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, request interface{}) (response interface{}, err error) {
defer func(begin time.Time) {
_ = logger.Log(
"method", "uppercase",
"err", err,
"took", time.Since(begin),
)
}(time.Now())
response, err = next(ctx, request)
return response, err
}
}
}
// Сборка в main: middleware надевается один раз.
upper := makeUppercaseEndpoint(svc)
upper = loggingMiddleware(logger)(upper)Канон go-kit логирования: лог — это обёртка endpoint'а, а не строка в хендлере. Замыкание помнит logger и next; именованные результаты response и err видны defer'у, который фиксирует время от входа до выхода и пишет событие с err и took. Проверка type assertion на response даст доступ к Err-полю Failer, если хочешь логировать бизнес-ошибки отдельно от err. Middleware надевается в main один раз — на горячем пути запроса только вызов готовой функции.
Почему логи живут в middleware, а не в сервисе#
Вопрос, который тебе зададут на ревью: почему не написать logger.Log прямо в методе сервиса? Ответ — тот же принцип, что и весь курс. Сервис из урока 03 не знает ни о транспорте, ни о форматтерах: суй ему logger — и появится зависимость от инфраструктуры в самом чистом слое, а тесты сервиса потащат моки логгера. Endpoint-middleware — слой между транспортом и endpoint'ом: он видит и request, и response, и err, но остаётся транспорт-агностичным. Повесь один и тот же loggingMiddleware на HTTP-endpoint сегодня и на gRPC-endpoint из урока 13 завтра — логи поедут в обоих случаях, сервис не узнает об этом. Сравни уровни, куда можно вставить лог: декодер/энкодер знают только транспорт, endpoint-middleware знает операцию целиком, service middleware из урока 15 — даже бизнес-контекст, но требует интерфейса сервиса. Правило выбора: лог операции с временем и err — endpoint-middleware; лог бизнес-события («заказ создан») — service middleware; лог транспортных деталей — ServerFinalizer, о котором ниже.
X-Request-ID и ServerFinalizer: лог HTTP-запроса#
В уроке 04 мы обещали: идентификатор запроса появится в уроке про логи. Схема из двух частей. Часть первая — ServerBefore: RequestFunc читает заголовок X-Request-ID (или генерирует новый) и кладёт его в контекст с приватным ключом; теперь значение едет через endpoint в сервис и доступно любому слою. Часть вторая — ServerFinalizer: опция NewServer принимает ServerFinalizerFunc func(ctx context.Context, code int, r *http.Request) и вызывается после того, как ответ уже записан клиенту; это официальное место для лога запроса: method, uri, статус код, длительность, размер ответа. Именно поэтому finalizer'а есть доступ к response headers и размеру: когда опция установлена, Server оборачивает ResponseWriter в interceptingWriter и складывает их в контекст под ключами ContextKeyResponseHeaders и ContextKeyResponseSize. Корреляция собирается в цепочку: клиент шлёт X-Request-ID → ServerBefore кладёт в ctx → endpoint-middleware логирует операцию → finalizer логирует запрос; в платформе логов по одному request_id разворачивается весь путь вызова через все сервисы.
ServerBefore и ServerFinalizer рядом
GOtype requestIDKey struct{}
func toContext() kithttp.RequestFunc {
return func(ctx context.Context, r *http.Request) context.Context {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = fmt.Sprint(rand.Int63()) // учебный генератор
}
return context.WithValue(ctx, requestIDKey{}, id)
}
}
mux.Handle("/uppercase", kithttp.NewServer(
upper,
decodeUppercaseRequest,
encodeResponse,
kithttp.ServerBefore(toContext()),
kithttp.ServerFinalizer(func(ctx context.Context, code int, r *http.Request) {
id, _ := ctx.Value(requestIDKey{}).(string)
_ = logger.Log("method", r.Method, "uri", r.RequestURI,
"status", code, "request_id", id)
}),
))Слева — RequestFunc из урока 04: условия вызова, а не данные запроса, кладутся в ctx с приватным структурным ключом. Справа — finalizer: он выполнится после записи ответа, поэтому знает финальный статус код. Приватный ключ-структура вместо строки — канон работы с context.Value из урока 02: типобезопасно и без коллизий.
Уровни: пакет level поверх kitlog#
В интерфейсе Logger нет уровней — и это не упущение, а точка расширения. Уровень в go-kit логировании — просто ещё одна пара в событии, по умолчанию ключ level со значениями debug, info, warn, error. Пакет github.com/go-kit/log/level даёт удобные хелперы: level.Error(logger) возвращает логгер с префиксом level=error, и далее level.Info, level.Warn, level.Debug — все они построены на log.WithPrefix над твоим логгером. Зачем нужен ключ, если можно писать "level", "error" руками: чтобы работала вторая половина пакета — фильтр. level.NewFilter(logger, level.AllowInfo()) оборачивает базовый логгер и пропускает только события с уровнем info и выше: debug-записи пишутся в коде всегда, но молчат в проде, где фильтр AllowInfo, и шумят локально, где AllowDebug. Важные дефолты фильтра: без опций он давит ВСЕ leveled-события (собери фильтр с Allow... — иначе ничего не увидишь), а события без уровня проходят мимо фильтра, пока ты не включил опцию SquelchNoLevel. Уровень — не замена тексту, а измеримая размерность: алерт платформы смотрит на rate error-событий, а не парсит слова.
Уровни в сборке main
GOvar logger log.Logger
logger = log.NewLogfmtLogger(os.Stderr)
logger = level.NewFilter(logger, level.AllowInfo())
logger = log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller)
// Ошибка инфраструктуры:
level.Error(logger).Log("msg", "decode failed", "err", err)
// Обычное событие:
level.Info(logger).Log("msg", "listening", "addr", ":8080")
// Debug-записи пишутся в коде сразу, но молчат под AllowInfo:
level.Debug(logger).Log("msg", "request decoded", "req", req)Порядок слоёв важен: фильтр ставится над форматтером, а With — над фильтром, чтобы ts и caller не вычислялись зря на отфильтрованных событиях. level.Error добавляет pair level=error через WithPrefix, дальше событие идёт сквозь фильтр (error пропущен) и форматтер. Уровень придёт в JSON/logfmt полем — платформа группирует по нему без регулярок.
Какой лог — из какого места#
| Тип события | Где пишется | Что содержит | Уровень |
|---|---|---|---|
| Жизненный цикл процесса | main до ListenAndServe | msg, addr, версия сборки | info |
| HTTP-запрос целиком | ServerFinalizer | method, uri, status, request_id | info / error по коду |
| Операция сервиса | endpoint-middleware | method, err, took | warn при err != nil |
| Бизнес-событие | service middleware (урок 15) | заказ создан, id, сумма | info |
| Инфраструктурный сбой | ServerErrorHandler / код | err, transport | error |
| Шум отладки | level.Debug где угодно | req, response | debug — фильтруется |
Чего в логи не класть#
Логи — данные, которые ты отправляешь неизвестно куда на годы вперёд, поэтому гигиена важнее многословия. Пароли, токены, заголовок Authorization — никогда, ни в debug, ни при ошибке; запрос с JWT в логе — это слитый доступ. Персональные данные — с осторожностью: имя и почта в логе делают его субъектом GDPR и удорожают хранение; часто достаточно хешированного идентификатора. Тела запросов целиком — только в debug и только на низовых окружениях: строка с телом в error-логе на проде быстро превращается в хранилище личных переписок. Про объём тоже думай: лог на каждый запрос — норма, лог на каждый внутренний цикл — инцидент бюджета; при всплесках помогает семплирование. И последнее — длина ключей: короткие имена (ts, err, took) — конвенция kitlog, они экономят и место, и глаза; главное — договориться об одном словаре ключей на все сервисы платформы, иначе фильтры коллектора будут враждовать с твоими названиями.
Два грабли на первой неделе с kitlog. Первая: logger.Log("method") — нечётное число аргументов; логгер не упадёт, а подставит (MISSING), и ты найдёшь эту строку в проде через месяц. Вторая: Log возвращает error, и в defer его нельзя оставить без _ = — компилятор потребует объяснений, а необработанный error внутри defer паникой не станет, но линтер и ревью будут недовольны. Пиши _ = logger.Log(...) как идиому курса.
Урок требует установленного Go и модуля github.com/go-kit/kit — в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода. Локально собери stringsvc, замени логгер на LogfmtLogger с фильтром AllowInfo и уровневыми вызовами, дёрни сервис curl'ом и найди в выводе пары level=, ts= и caller= — три маркера того, что слои собраны правильно.
logger.Log("method", "uppercase", "err") — что окажется в логе?
Почему loggingMiddleware — это endpoint-middleware, а не строки в методе сервиса?
logger = level.NewFilter(log.NewLogfmtLogger(os.Stderr), level.AllowInfo()). Что попадёт в вывод?
Порядок пар в слоёном логгере
Прочитайте код и выберите, что он выведет:
logger := log.NewJSONLogger(os.Stdout)
logger = log.With(logger, "a", 1)
logger = log.With(logger, "b", 2)
_ = logger.Log("c", 3)Valuer: сколько раз вычислится ts?
Прочитайте код и выберите, что он выведет:
logger := log.NewJSONLogger(os.Stdout)
logger = log.With(logger, "ts", log.DefaultTimestampUTC)
_ = logger.Log("msg", "first")
time.Sleep(time.Second)
_ = logger.Log("msg", "second")Собери базовый логгер stringsvc
Восстанови сборку логгера в main. Пропуски — по порядку: форматтер kitlog, слоёная функция, Valuer времени UTC.
Путь события лога одного запроса
Расставь этапы генерации логов для POST /uppercase в правильном порядке: от прихода запроса до последней записи.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Сервис обрёл голос: структура события вместо строк, слои With вместо передачи логгера по стеку, уровни через фильтр, request id через ServerBefore и финальный лог запроса через ServerFinalizer. Вернуть контекст обёрток можно в уроке 02-endpoint, к опциям транспорта — в уроке 04-http-transport, к кодеку ошибок — в уроке 05-codeki; полная программа — на странице курса. Логирование — первый наблюдательный слой; второй — урок 07-metriki: Counter, Gauge и Histogram пакета metrics, prometheus-реализации, латентность в гистограмме и instrumenting middleware рядом с тем loggingMiddleware, который ты только что написал. Третье измерение — трейсинг — ждёт в уроке 08: там запросы получат сквозные спаны, и request id из этого урока окажется их предком.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- kitlog — структурный лог: Logger.Log(keyvals ...interface{}) error; чётные пары, нечётные добираются ErrMissingValue.
- Два штатных форматтера: NewJSONLogger для коллектора и NewLogfmtLogger для консоли; NewNopLogger — тихий заглушка.
- log.With наслаивает пары, Valuer вычисляется на каждый Log: ts и caller — конвенция log.With(logger, "ts", log.DefaultTimestampUTC, "caller", log.DefaultCaller).
- Лог операции — endpoint-middleware: defer + time.Since пишет method/err/took, работает на любом транспорте, сервис не знает про логгер.
- X-Request-ID кладёт ServerBefore в ctx, финальный лог запроса пишет ServerFinalizer со статусом и request_id.
- Уровни — пара level=key из пакета level: level.Error(logger) для события, level.NewFilter(logger, level.AllowInfo()) для прод-фильтра.
- В логи не едут секреты и персональные данные; словарь ключей — единый на платформу.
Домашнее задание
Возьми stringsvc из урока 05 и надень на него полный лог-контур. Первое: собери базовый логгер — LogfmtLogger для локали, фильтр level.AllowInfo, слой With с ts и caller, и убедись, что каждое событие несёт level, ts и caller. Второе: напиши loggingMiddleware(logger) endpoint.Middleware и повесь его на оба endpoint'а; в логе должны появиться method, err и took — проверь на успешном запросе и на ErrEmpty. Третье: добавь ServerBefore с X-Request-ID (чужой заголовок или генерация) и ServerFinalizer с method, uri, status, request_id; двумя curl-вызовами с одним заголовком добейся, чтобы обе записи — middleware и finalizer — срослись по одному id. Четвёртое: уровняй события — ошибки декодера как level.Error, слушание порта как level.Info, декодирование тела как level.Debug; переключи фильтр между AllowInfo и AllowDebug и опиши разницу одной фразой. Пятое: почисти вывод — ни одного секрета, ни одного тела запроса в error-логе; для этого перепиши место, где err выводится целиком, на err.Error().
Личные заметкиПусто
Вопросы и ответы по уроку
JSON или logfmt — что выбрать для продакшена?
Смотри на потребителя. Коллекторы вроде Elasticsearch и Loki любят JSON: поля парсятся без правил. logfmt удобнее человеку в консоли и дешевле на диске; платформы вроде Heroku проглотят и его. В kitlog смена формата — одна строка в main: NewJSONLogger вместо NewLogfmtLogger, весь остальной код не меняется.
Почему Log возвращает error, если его принято игнорировать?
Контракт честный: запись может не удаться — диск полон, коллектор недоступен. kitlog не решает за тебя, что делать с этим сбоем, и по умолчанию курс рекомендует _ = logger.Log(...): падать из-за невозможности записать лог хуже, чем остаться без записи. Если важность выше — оберни логгер своим middleware с политикой ретраев или метрикой потерь.
Логировать на уровне endpoint или сервиса?
Endpoint-middleware — для операций: время, err, request id; он транспорт-агностичен и не требует менять интерфейс сервиса. Service middleware (урок 15) — для бизнес-событий с доменным контекстом. Строки лога в самом сервисе — только когда без них никак; тогда логгер передаётся через конструктор вместе с зависимостями, а не создается на месте.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации