Содержание урока · 12 разделов
- grpc interceptor logging: контракт UnaryServerInterceptor
- Разбор полей записи
- Код статуса: codes.Code
- Ошибки по коду: Warn или Error
- Коды и уровни: шпаргалка
- Что будет, если обработчик паникует
- Request id из metadata
- StreamServerInterceptor: чем отличается
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gRPC-сервис без access-лога — чёрный ящик: трафик идёт, ошибки иногда приходят на клиент, а что происходило внутри, не знает никто. Разбрасывать zap.Error по каждому методу — путь к сотне разных форматов записей, из которых дашборд не соберёшь. У gRPC есть своё место для сквозной логики: перехватчики, аналог HTTP-middleware. Одна функция на весь сервер пишет единый лог вызовов: метод, код статуса, адрес клиента, длительность. Дальше — только политика: какой код каким уровнем логировать и как прокинуть request id внутрь.
grpc interceptor logging: контракт UnaryServerInterceptor#
Связку zap и grpc для unary-вызовов описывает тип grpc.UnaryServerInterceptor: grpc interceptor logging — это функция, принимающая контекст, запрос, info с описанием вызова и handler — сам вызов обработчика. Между «до» и «после» handler у перехватчика полная свобода: засечь время, достать metadata, построить дочерний логгер. info.FullMethod даёт стабильное имя вида /user.UserService/GetUser — оно же ключ для дашбордов и алертов, в отличие от путей HTTP-роутера, меняющихся от версии API. Возвращённые ответ и ошибка проходят наружу как есть: перехватчик — наблюдатель, а не фильтр. Регистрация — через grpc.NewServer(grpc.ChainUnaryInterceptor(...)): цепочка выполняется по порядку, и логирующий перехватчик обычно ставят первым, чтобы он видел полное время остальных — recovery, валидацию, аутентификацию.
Логирующий unary-перехватчик
GOfunc UnaryLogging(base *zap.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any,
info *grpc.UnaryServerInfo, handler grpc.UnaryHandler,
) (any, error) {
start := time.Now()
resp, err := handler(ctx, req)
fields := []zap.Field{
zap.String("method", info.FullMethod),
zap.String("code", status.Code(err).String()),
zap.Duration("latency", time.Since(start)),
zap.Error(err),
}
if p, ok := peer.FromContext(ctx); ok {
fields = append(fields, zap.String("peer", p.Addr.String()))
}
base.Log(levelByCode(status.Code(err)), "grpc request", fields...)
return resp, err
}
}Логгер приходит снаружи — уже собранный через With и Named, с request_id из HTTP-конвейера или metadata, что разобраны в соседних уроках. Метод base.Log — редкий зверь в zap: пишет на произвольном zapcore.Level, который вернула функция levelByCode по коду ошибки. status.Code(nil) возвращает codes.OK, поэтому успешные вызовы получают поле code со значением OK — отдельная ветка для успеха не нужна. Peer добавляется в запись, только когда он есть в контексте: сервер кладёт его туда сам. status.Code(err) вызывается дважды — в слайсе полей и в base.Log; на горячих путях код вычисляют один раз в переменную, здесь имена нагляднее. zap.Error с nil-ошибкой поля не создаст: успешные вызовы не получат пустого error в записи.
Разбор полей записи#
Каждое поле отвечает на конкретный вопрос. method с FullMethod — какие вызовы приходят и куда смотреть в коде; для access-аналитики этого мало, но для поиска по логам его хватает. code — строка из codes.Code через String(): в JSON уходит DEADLINE_EXCEEDED, а не магическое число 4 — грепается и строится в дашборды. latency считается вокруг handler: вместе с перехватчиками выше по цепочке цифра вырастет, поэтому меряющий перехватчик ставят первым — иначе в латентность затесаются чужие задержки. peer — адрес клиента: для расследований «кто долбит сервис» это первое поле, куда смотрят. А вот сам req в лог перехватчик не пишет: там почты, суммы и идентификаторы, и сериализация тела запроса — отдельное осознанное решение с маскированием, как с SQL в уроке про GORM.
Код статуса: codes.Code#
Ошибки в gRPC — не произвольные объекты: их конструируют через status.Error(codes.NotFound, "user not found"), и код путешествует по сети внутри status. На сервере err из handler — это *status.Status, спрятанная за интерфейсом error, и status.Code(err) достаёт код обратно. Два граничных случая стоит запомнить. err == nil даёт codes.OK. А ошибка, созданная без status — errors.New, fmt.Errorf — даёт codes.Unknown: gRPC честно не знает, что с ней делать. Unknown — самый дорогой код в логах: за ним прячется всё, что вы забыли обернуть в статус, и именно его стоит сначала перевести в осмысленные коды, а потом уже настраивать уровни логирования. Иначе Error-лог покажет «что-то упало», но не скажет, что именно.
Проверка: чужая ошибка
Обработчик вернул ошибку, созданную через errors.New("boom"), без status. Что вернёт status.Code(err)?
Ошибки по коду: Warn или Error#
Код статуса — машинное поле, уровень — ваше решение, и сводить их в правило «все ошибки — Error» значит утопить алерты. Моя рабочая политика простая. Internal, DataLoss и Unknown — Error: за ними баг в коде или потеря данных. DeadlineExceeded, Unavailable и ResourceExhausted — Warn: таймауты и недоступность живут в ретраях, и алерт на каждого клиента с плохой сетью отключают через неделю. Спорное место — codes.Canceled: я пишу его на Info и не согласен с командами, поднимающими его в Warn. Клиент сам закрыл соединение — аварии нет, а Warning на каждый закрытый контекст создаёт шум, маскирующий настоящие проблемы. Если ваша система реагирует на canceled иначе — политика у вас своя, но она должна быть записана кодом, а не в голове дежурного.
Политика уровней: levelByCode
GOfunc levelByCode(code codes.Code) zapcore.Level {
switch code {
case codes.Internal, codes.DataLoss, codes.Unknown:
return zapcore.ErrorLevel
case codes.DeadlineExceeded, codes.Unavailable,
codes.ResourceExhausted:
return zapcore.WarnLevel
default:
return zapcore.InfoLevel
}
}Политика уровней — обычная Go-функция: её видно в ревью, её покрывает табличный тест, её правят одной строкой. Дефолтная ветка отдаёт Info: NotFound, InvalidArgument и Canceled проходят как обычный access-лог. Проверка не требует сервера: таблица кодов и ожидаемых уровней запускается обычным go test на локальном Go. Это редкий случай, когда логику логирования проверяют так же строго, как бизнес-код.
Коды и уровни: шпаргалка#
| Код | Уровень | Почему |
|---|---|---|
| Internal | Error | баг сервера: паника, инвариант, деление на ноль |
| DataLoss | Error | потеря данных — сигнализировать всегда |
| Unknown | Error | необработанная ошибка: сначала обернуть в статус |
| DeadlineExceeded | Warn | таймаут; ретрай — штатная реакция клиента |
| Unavailable | Warn | сервис временно недоступен; клиент повторит |
| ResourceExhausted | Warn | квота или rate limit: счётчик, а не алерт |
| NotFound, InvalidArgument | Info | ожидаемый поток приложения |
Что будет, если обработчик паникует#
grpc-go не ставит recover вокруг вызовов обработчиков: паника внутри handler раскручивает стек через все перехватчики и убивает процесс — пост-код логирующего перехватчика не выполнится, записи не будет, клиент увидит обрыв соединения вместо кода. Лечение — перехватчик recovery: defer с recover переводит панику в status.Error(codes.Internal), пишет zap.Error со стеком debug.Stack() и возвращает клиенту честную ошибку. Порядок в ChainUnaryInterceptor зеркальный Gin: логирующий первым, recovery вторым — паника гасится глубже, и логирующий после возврата handler видит обычную ошибку Internal с латентностью и пишет access-запись. Recovery последним в цепочке панику тоже поймает, но записи лога она не оставит — тот же симптом, что и в уроке про HTTP-посредников.
ChainUnaryInterceptor выполняет перехватчики по порядку списка: первый — внешний слой. Если логирующий стоит после аутентификации, отказ в доступе происходит до входа в лог — вызовы идут, записей нет, и дашборд молчит, пока клиент стучится. Логирующий перехватчик — первым в списке: только он видит весь трафик, включая отбракованный.
Request id из metadata#
HTTP-конвейер из урока про request id живёт в заголовках, gRPC — в metadata: та же идея, другой транспорт. На сервере metadata.FromIncomingContext(ctx) достаёт map из входящего контекста, и ключи в нём всегда в нижнем регистре: X-Request-Id с клиента превратится в x-request-id. Достали значение — собрали дочерний логгер base.With(zap.String("request_id", id)), и весь лог перехватчика, и всё, что обработчик нафлогирует через переданный дальше логгер, окажется с одним идентификатором. Две оговорки. Клиент может прислать какой угодно request_id: длину и алфавит стоит ограничить, иначе поле станет вектором для раздувания записей. И md.Get возвращает слайс: повторяющийся ключ легален в metadata, берите первый элемент, остальные игнорируйте.
Дочерний логгер на вызов
GOfunc loggerFromContext(ctx context.Context, base *zap.Logger) *zap.Logger {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return base
}
ids := md.Get("x-request-id")
if len(ids) == 0 || len(ids[0]) > 64 {
return base
}
return base.With(zap.String("request_id", ids[0]))
}Дочерний логгер строится один раз на вызов и прокидывается дальше — цена With после урока про контекст логгера копеечная. metadata.FromIncomingContext возвращает false для контекста без входящих метаданных: в unit-тесте, где контекст построен руками, перехватчик просто работает с базовым логгером. md.Get возвращает слайс строк: повторяющийся ключ в metadata легален, берём первый. Проверка длины — минимальная гигиена: значение пришло по сети, и доверять ему на слово не стоит.
Проверка: регистр ключа
Клиент отправил metadata-ключ X-Request-Id. По какому ключу значение вернёт md.Get(...)?
StreamServerInterceptor: чем отличается#
Стримы ломают картину «один запрос — один ответ». Перехватчик типа grpc.StreamServerInterceptor получает не запрос, а grpc.ServerStream — канал, через который сообщения бегут в обе стороны, и handler возвращает одну ошибку на весь стрим. Асимметрия важная: сообщений много, записей лога лучше одна, иначе десятистрочный чат заспамит access-лог. А вот ошибок в стриме несколько: SendMsg и RecvMsg возвращают их по ходу, и финальная ошибка handler — агрегат. Особый случай — io.EOF из RecvMsg: это штатный сигнал «клиент перестал слать», а не авария; без фильтра он превратит каждый обычный стрим в Error-запись. StreamServerInfo дополнительно к FullMethod даёт IsClientStream и IsServerStream — по ним различают виды стримов в логе, если трафик смешанный.
Логирующий stream-перехватчик
GOfunc StreamLogging(base *zap.Logger) grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream,
info *grpc.StreamServerInfo, handler grpc.StreamHandler,
) error {
start := time.Now()
err := handler(srv, ss)
if errors.Is(err, io.EOF) {
return err
}
base.Log(levelByCode(status.Code(err)),
"grpc stream",
zap.String("method", info.FullMethod),
zap.Duration("latency", time.Since(start)),
zap.Error(err),
)
return err
}
}Скелет тот же, что у unary: засечь время, отдать handler, разложить ошибку по коду. Разница — фильтр io.EOF: штатное завершение чтения уходит наружу без записи, а errors.Is распознаёт и обёрнутые варианты. Если сервис в основном стримовый, здесь же логируют счётчики сообщений: обёртка над ServerStream инкрементирует recv и send, цифры попадают в финальную запись. Для начала хватит и этой версии.
Первая версия stream-перехватчика обычно логирует zap.Error(err) на всё — и получает Error-запись на каждый нормально завершённый клиентом стрим: io.EOF вернулся, логгер честно его зафиксировал, алерт-движок посчитал аварии. Фильтр EOF ставьте до levelByCode, а не после: статус для io.EOF — Unknown, и без фильтра уровень будет Error.
Что запишет перехватчик?
Прочитайте код и выберите, что он выведет:
// UnaryLogging установлен на сервере.
// handler возвращает:
return nil, status.Error(codes.DeadlineExceeded, "handler took too long")Соберите политику уровней
Восстановите levelByCode: три уровня по трём группам кодов.
Соберите unary-перехватчик
Расставьте строки от замера времени до возврата результата.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: gRPC и zap
Что дальше по курсу#
Перехватчики устроены так же, как middleware Gin: идея обёртки одна, тип другой. Без логирования их разбирает урок про перехватчики курса gRPC, семантику кодов статусов — урок про статусы. Request id из HTTP-конвейера — в уроке про request id; связь логов с метриками и трейсами — в материале про наблюдаемость. Связка zap grpc собирается в один файл: перехватчик, политика уровней, извлечение request id.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Перехватчик — одно место для access-лога: метод, код, peer, latency, request id.
- status.Code(nil) — это OK, а чужая ошибка без status — Unknown.
- Политику уровней пишите функцией: Internal — Error, таймауты — Warn, canceled — Info.
- Stream-перехватчик пишет одну запись на стрим; io.EOF фильтруется до levelByCode.
- Request id лежит в metadata с ключом в нижнем регистре.
- ChainUnaryInterceptor выполняется по порядку: логирующий — первым, чтобы видеть чужие задержки.
- Тело запроса в лог не пишем без маскирования: там персональные данные.
Домашнее задание
Реализуйте UnaryLogging и levelByCode, подключите их через grpc.ChainUnaryInterceptor и запустите сервер с парой методов на локальном Go: один возвращает OK, второй — DeadlineExceeded. Проверьте по JSON-выводу, что поля method, code и latency на месте, а уровни различаются. Затем добавьте клиент, отправляющий metadata x-request-id, и найдите идентификатор в поле request_id. Затем добавьте обработчик, паникующий на одном методе, recovery-перехватчик в цепочку и проверьте: процесс жив, запись со статусом Internal родилась.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему не логировать req через zap.Any?
В req — персональные данные: почты, суммы, идентификаторы. zap.Any уйдёт в рефлексию и вывалит всё содержимое в JSON. Если тело нужно для отладки, маскируйте поля и логируйте на Debug по флагу, а не на постоянной основе.
Отличается ли клиентский перехватчик?
Сигнатуры зеркальные: UnaryClientInterceptor и StreamClientInterceptor. Меняется контекст: peer — адрес сервера, metadata отправляется через outgoing-контекст, и request id туда ставит клиент перед вызовом.
Что раньше — логирующий перехватчик или recovery?
Оба, через ChainUnaryInterceptor: логирующий первым, recovery вторым. Панику, пойманную recovery, логирующий как ошибку не увидит — он уже отработал до handler. Запись о панике пишет сам recovery своим уровнем.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси