Содержание урока · 12 разделов
- Zap middleware gin: каркас на замыкании
- Уровень по статусу: 500 — Error, 404 — Warn
- Карта статус — уровень
- Per-request логгер и связка с request id
- Recovery: паника со стеком
- Что будет, если паника в чужой горутине
- Порядок: Logger снаружи, Recovery внутри
- Echo и Fiber: тот же приём
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
gin.Default() выглядит как забота о разработчике: одним вызовом вы получаете логи и восстановление после паник. Но откройте его вывод: цветной текст в один столбец, без request_id, без полей, мимо вашей системы сбора — такой access-лог годится для ноутбука и не годится для прода, где из потока нужно выбирать по статусу, клиенту и времени ответа. Логирование http-запросов в go-сервисе на zap собирается руками, и это честнее, чем кажется: вы решаете, какие поля нужны, какой уровень за каким кодом следует и куда пишется паника. Ниже — два посредника: логирующий access-лог и recovery со стеком. Вместе они заменяют встроенную пару Gin, а заодно показывают общий каркас, который переносится на Echo, Fiber и голый net/http без переделки.
Zap middleware gin: каркас на замыкании#
Любой посредник Gin — это функция, возвращающая gin.HandlerFunc: внешняя принимает зависимости, внутренняя обрабатывает каждый запрос. Скелет всегда один и тот же: зафиксировать время до c.Next(), дать обработчикам отработать, а всё после c.Next() выполнить уже с готовым ответом. Именно поэтому статус берут из c.Writer.Status() после Next — до Next там ноль, ответа ещё нет. Трассировка запросов через zap начинается с каркаса: логгер, метод, путь, query-строка, статус, латентность и клиент собираются в один With-потомок — по образцу per-request логгера из урока про request id. Дальше один вызов Info или Error пишет всё разом: поля в JSON лягут рядом с request_id, если тот уже стоит в base.
Логирующий посредник целиком
GOfunc GinLogger(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
c.Next()
status := c.Writer.Status()
log := logger.With(
zap.String("method", c.Request.Method),
zap.String("path", path),
zap.String("query", query),
zap.Int("status", status),
zap.Duration("latency", time.Since(start)),
zap.String("client", c.ClientIP()),
)
switch {
case status >= 500:
log.Error("request failed")
case status >= 400:
log.Warn("request rejected")
default:
log.Info("request served")
}
}
}path и query сохранены до Next: обработчик мог бы изменить URL, а access-лог должен показывать, что пришло на вход. c.ClientIP() разбирает X-Forwarded-For с учётом доверенных прокси — за этим стоит следить отдельно. Пороги статуса — одна строка switch: правило уровня меняется в одном месте, а не по всему проекту. Разбор по строкам: start фиксируется до Next и служит нулём латентности; path и query снимаются до обработчиков, потому что роутинг и хендлеры могут переписать URL. With строит потомка один раз на запрос, и switch ниже зовёт уже готовые методы Info, Warn, Error — поля собираются единожды, а не трижды. Запускать связку, как и весь курс, нужно на локальном Go с настоящим роутером.
Пост-обработка, написанная выше c.Next(), выглядит рабочей и молчит в тестах на пустой роутер: записи идут, но с латентностью около нуля и статусом по умолчанию. До Next c.Writer.Status() возвращает 200 — статус присваивается при записи ответа, а не при чтении поля. Весь блок после Next — уже с готовым ответом; всё до — лишняя запись с неверными данными, которую сложно поймать, пока обработчики не стали отвечать по-настоящему.
Уровень по статусу: 500 — Error, 404 — Warn#
Механическая привязка уровня к классу ответа экономит десятки решений: пятисотые — Error, потому что это авария сервера и под них заводят алерты; четырёхсотые — Warn, потому что это поведение клиента, но шумное или подозрительное; остальное — Info, потому что по ним считают трафик. Спорные места всё равно появятся, и их лучше решить заранее командным правилом. 401 и 403: Warn наполняет дашборд брутфорса, но на публичном API с чужими сканерами — это половина потока. 404: Warn полезен после релиза, когда ищут битые ссылки, и превращается в мусор от сканеров путей. Моё рабочее правило: всё, что вы собираетесь алертить, — Error; всё, что вы собираетесь смотреть раз в день, — Warn; остальное — Info, и алерты на 4xx держать, только если умеете их глушить.
Карта статус — уровень#
| Класс ответа | Уровень | Примеры |
|---|---|---|
| 2xx | Info | 200 создан заказ, 204 пустой ответ |
| 4xx | Warn | 404 нет маршрута, 422 не прошла валидация, 429 превышен лимит |
| 5xx | Error | 500 паника, 502 упал upstream, 504 таймаут |
| 4xx по решению команды | Info | 401/403 на публичном API: чужие сканеры |
Проверка: уровень для 404
Запрос на несуществующий путь вернул 404. Какой уровень запишет посредник из урока и почему это разумно?
Per-request логгер и связка с request id#
Access-запись без request_id — половина пользы потеряна: вы видите, что GET /orders был медленным, но не можете найти внутренние записи этого запроса. Поэтому base для access-лога собирают тем же приёмом, что и в уроке про request id: посредник идентификатора стоит первым, кладёт потомка в контекст, и логирующий посредник либо берёт готового из контекста, либо строит своего от base с тем же ID. В Gin прокинуть можно и через c.Set("logger", log) с достаёт c.Get — способ грубее контекста, но живёт в одной структуре запроса. Порядок посредников: RequestLogger, затем GinLogger — тогда access-запись и внутренние записи уносят один request_id, и grep по нему соединяет обе истории в одну хронологию.
Recovery: паника со стеком#
Встроенный gin.Recovery() делает ровно два дела: ловит панику и отдаёт клиенту 500 — молча. В access-логе появится пятисотый статус без причины, а текст паники уйдёт в stderr штатным логом net/http — текстовым, без полей и без привязки к вашему потоку. Свой recovery пишет структуру: значение паники как поле, стек через debug.Stack() из runtime/debug, путь запроса — чтобы связать с access-записью. Стек в JSON выглядит громоздко, но это единственный артефакт, по которому ночной дежурный находит строку кода без пересборки и отладчика: его место в Error-записи, а не в отдельном файле.
Посредник recovery
GOfunc GinRecovery(logger *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if err := recover(); err != nil {
logger.Error("panic recovered",
zap.Any("panic", err),
zap.ByteString("stack", debug.Stack()),
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
)
c.AbortWithStatus(http.StatusInternalServerError)
}
}()
c.Next()
}
}recover() возвращает любое значение — панику могли вызвать строкой, поэтому zap.Any, а не zap.Error. debug.Stack() снимает стек в месте recover: это стек обработчика, который и уронил запрос. AbortWithStatus отдаёт честный 500 и прекращает цепочку обработчиков — без него клиент получил бы обрыв соединения.
Что будет, если паника в чужой горутине#
Recovery ловит панику только в горутине запроса: defer с recover работает в той функции, где зарегистрирован, и в чужую горутину не тянет. Обработчик, запустивший фоновую горутину — отправка письма, прогрев кеша, — ответственности за её падение посреднику не передаёт: паника в новой горутине проходит мимо всех defer'ов запроса и убивает процесс, печатая стек текстом в stderr мимо zap. Лечение одно: каждая спавненная горутина оборачивается тем же паттерном руками — defer, recover, logger.Error с zap.Any("panic", err) и debug.Stack(). В крупных проектах для этого заводят helper вида WithRecovery(logger, fn) и правило код-ревью: «горутина — значит с обёрткой».
Порядок: Logger снаружи, Recovery внутри#
Здесь спотыкаются чаще всего. Паника раскручивает стек вверх, и посредник, у которого c.Next() ещё не вернулся, свой пост-код не выполняет. Если Recovery стоит снаружи, а Logger внутри, то access-запись умирает вместе с паникой: Recovery напишет стек, а строки с латентностью и статусом не будет вовсе. Правильный порядок обратный: Logger первым, Recovery вторым — Recovery гасит панику глубже, отдаёт 500, и Logger после Next видит обычный ответ со статусом 500 и честной латентностью. Регистрация в сборке — отдельный маленький код, и его стоит один раз прочитать вслух: Use перечисляет посредников снаружи внутрь в порядке вызова. Тонкость стоит минуты внимания, а экономит час поисков «почему паникующие запросы не в access-логе».
Сборка роутера
GOr := gin.New()
r.Use(RequestLogger(logger)) // request_id из урока 14
r.Use(GinLogger(logger)) // access-лог: снаружи Recovery
r.Use(GinRecovery(logger)) // паника: стек и 500
r.POST("/orders", createOrder)gin.New() вместо gin.Default(): встроенные Logger и Recovery не подключаются, иначе записи пойдут дважды в двух форматах. Порядок Use — порядок луковичных слоёв: RequestLogger самый внешний, Recovery самый внутренний. Паникующий обработчик получит access-запись со статусом 500, потому что Recovery стоит глубже GinLogger.
Проверка: порядок посредников
Почему GinLogger ставят раньше GinRecovery при Use, а не после?
Echo и Fiber: тот же приём#
Каркас переносится один в один, меняется только сигнатура. В Echo посредник — e.Use(func(next echo.HandlerFunc) echo.HandlerFunc), статус берут из c.Response().Status после next(c), клиент — из c.RealIP(). В Fiber — app.Use(func(c *fiber.Ctx) error) с вызовом c.Next() посередине, статус — c.Response().StatusCode(), время — time.Since(start) вокруг того же Next. Уровень по статусу, стек паники и порядок «лог снаружи, recovery внутри» повторяются без изменений: различия не в идее, а в именах методов. Единственное, что стоит проверить при переносе, — как конкретный фреймворк считает время начала запроса: у Fiber латентность доступна и встроенной метрикой, но поле в своей записи всё равно надёжнее.
gin.Default() плюс собственный посредник — это два access-лога на каждый запрос: текстовый и JSON. Собирайте роутер через gin.New() и подключайте только своих. Заодно выставьте gin.SetMode(gin.ReleaseMode): в debug-режиме Gin пишет свои предупреждения в stderr мимо вашего конвейера.
Какая запись уйдёт в лог?
Прочитайте код и выберите, что он выведет:
r.Use(GinLogger(logger))
r.Use(GinRecovery(logger))
r.GET("/promo", func(c *gin.Context) {
c.JSON(http.StatusUnprocessableEntity, gin.H{
"error": "coupon expired",
})
})Соберите каркас посредника
Восстановите три строки каркаса access-лога.
Соберите пост-обработку запроса
Расставьте строки после c.Next() в логирующем посреднике.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: логирование HTTP
Что дальше по курсу#
Следующий источник логов стоит в том же конвейере ниже: урок про GORM и zap покажет, как SQL-события попадают в общий поток с тем же request_id. Внутренние записи запроса — поле request_id из урока про сквозную трассировку, а тот же каркас на другом транспорте разобран в материале про перехватчики gRPC. Встроенные посредники Gin и их семантика — в уроке про middleware Gin; карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Посредник — замыкание: start до c.Next(), всё после Next — пост-обработка с готовым ответом.
- Поля access-записи: метод, путь, query, статус, латентность, клиент — один With-потомок.
- Уровень по статусу: 5xx — Error, 4xx — Warn, остальное — Info; спорные 401/403 решает правило команды.
- Свой recovery пишет панику со стеком и отдаёт 500: встроенный Recovery молчит.
- Порядок Use: Logger снаружи, Recovery внутри — access-запись переживает панику.
- gin.New() вместо gin.Default(): без дублей и без чужого формата.
- Каркас переносится на Echo и Fiber сменой сигнатуры и имён методов.
Домашнее задание
Соберите на локальном Go роутер из трёх посредников: RequestLogger с request_id, GinLogger с полями и GinRecovery со стеком. Добавьте обработчик, паникующий на пути /boom, и проверьте по JSON-выводу три вещи: паника записана Error со стеком, за ней родилась access-запись со статусом 500, и обе несут один request_id. Затем поменяйте порядок Use и опишите, что исчезло из лога. После этого уберите GinLogger из Use и повторите: какая из двух записей — access или паника — пропала и почему.
Личные заметкиПусто
Вопросы и ответы по уроку
Стоит ли логировать body запроса?
Для отладки форм полезно, но body — это пароли, токены и персональные данные. Если без него нельзя, логируйте его на Debug, обрезайте по размеру и маскируйте известные поля; в Info-поток body не попадает вовсе.
Почему не взять готовый пакет-посредник для zap?
Их есть несколько, и они работают, но каждое поле и уровень всё равно настраиваете вы. Собственный посредник в шестьдесят строк снимает зависимость и делает поведение очевидным при ревью — готовый берите, если у него уже нужная вам карта уровней.
Куда девать ошибки, которые хендлер собрал через c.Error()?
Gin копит их в c.Errors, и пост-код посредника может приложить их к access-записи одним полем. Практика спорная: ошибка, влияющая на ответ, обычно и так логируется в месте возникновения, дублировать её стоит только для связи с латентностью и статусом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси