Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~20 мин +50 XP

Логирование HTTP-запросов: middleware

Запрос zap middleware gin — частый не потому, что решение хорошо спрятано, а потому, что готового нет: zap ничего не знает про HTTP, и логирующий посредник для Gin каждый пишет сам. Соберём его по шагам — время, статус, метод, путь, клиент, уровень по коду ответа — и добавим recovery, который пишет панику со стеком в структурный лог.

Содержание урока · 12 разделов

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.

Логирующий посредник целиком

GO
func 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 с настоящим роутером.

Уровень по статусу: 500 — Error, 404 — Warn#

Механическая привязка уровня к классу ответа экономит десятки решений: пятисотые — Error, потому что это авария сервера и под них заводят алерты; четырёхсотые — Warn, потому что это поведение клиента, но шумное или подозрительное; остальное — Info, потому что по ним считают трафик. Спорные места всё равно появятся, и их лучше решить заранее командным правилом. 401 и 403: Warn наполняет дашборд брутфорса, но на публичном API с чужими сканерами — это половина потока. 404: Warn полезен после релиза, когда ищут битые ссылки, и превращается в мусор от сканеров путей. Моё рабочее правило: всё, что вы собираетесь алертить, — Error; всё, что вы собираетесь смотреть раз в день, — Warn; остальное — Info, и алерты на 4xx держать, только если умеете их глушить.

Карта статус — уровень#

Класс ответаУровеньПримеры
2xxInfo200 создан заказ, 204 пустой ответ
4xxWarn404 нет маршрута, 422 не прошла валидация, 429 превышен лимит
5xxError500 паника, 502 упал upstream, 504 таймаут
4xx по решению командыInfo401/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

GO
func 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-логе».

Сборка роутера

GO
r := 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 латентность доступна и встроенной метрикой, но поле в своей записи всё равно надёжнее.

Какая запись уйдёт в лог?

Прочитайте код и выберите, что он выведет:

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-лога.

start := path := c.Request.URL.Path c.() status := c.Writer.()

Соберите пост-обработку запроса

Расставьте строки после c.Next() в логирующем посреднике.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: логирование HTTP

Термин

Каркас посредника Gin

нажмите, чтобы перевернуть
Определение

Функция, возвращающая gin.HandlerFunc: фиксишь start, c.Next(), всё после Next — пост-обработка с готовым ответом.

1 / 6

Что дальше по курсу#

Следующий источник логов стоит в том же конвейере ниже: урок про 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 или паника — пропала и почему.

Личные заметкиПусто
Хранятся только в вашем браузере (Zap · Логирование HTTP-запросов: middleware)

Вопросы и ответы по уроку

Стоит ли логировать body запроса?

Для отладки форм полезно, но body — это пароли, токены и персональные данные. Если без него нельзя, логируйте его на Debug, обрезайте по размеру и маскируйте известные поля; в Info-поток body не попадает вовсе.

Почему не взять готовый пакет-посредник для zap?

Их есть несколько, и они работают, но каждое поле и уровень всё равно настраиваете вы. Собственный посредник в шестьдесят строк снимает зависимость и делает поведение очевидным при ревью — готовый берите, если у него уже нужная вам карта уровней.

Куда девать ошибки, которые хендлер собрал через c.Error()?

Gin копит их в c.Errors, и пост-код посредника может приложить их к access-записи одним полем. Практика спорная: ошибка, влияющая на ответ, обычно и так логируется в месте возникновения, дублировать её стоит только для связи с латентностью и статусом.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Colly25 уроков~9 чпрактика25 уроков уже доступны

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси