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

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

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

Урок 19 из 25 ~16 мин +50 XP

Логирование запросов

Логи — единственный свидетель, который остаётся у сервера после того, как ответ уехал клиенту, и тема «fiber логирование» отвечает на вопрос, как сделать этого свидетеля полезным. Журнал запросов в Fiber v3 пишет middleware/logger из комплекта ядра: один HTTP-запрос — одна строка, собранная из формата-строки с тегами вроде ${status} и ${latency}. Прошлый урок обещал мост: [X-Cache из кэша](/courses/fiber/lessons/18-kechirovanie/) — готовый маркер для лога, и сегодня мы встроим его в каждую строку журнала рядом с корреляцией по RequestID. Запрос «логи запросов go» на практике распадается на четыре навыка: придумать формат, который переживёт рост сервиса; вытащить в строку свои поля; завести структурированный лог на slog для событий приложения; склеить записи разных сервисов по идентификатору запроса. Разберём всё по живым исходникам ветки main ядра fiber v3.5.0 — файлы middleware/logger (logger.go, config.go, tags.go, format.go, default_logger.go, context_tag.go, timestamp.go) и пакет log, — сравним подход с Echo, чей [девятый урок логирования](/courses/echo/lessons/09-logirovanie/) уже на счету, и честно скажем, где Fiber требует ручной работы.

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

Мост из прошлого урока: X-Cache — готовый маркер лога#

Восемнадцатый урок закончился точной формулировкой: X-Cache: hit и miss — готовый маркер для лога, а Age и Date — поля, по которым по логам видно, сколько жизни у записи; кэш, который никто не измеряет, превращается в чёрный ящик. Сегодня мы этот мост проходим: решение кэша живёт в заголовке ответа, а заголовок ответа — это то, что логгер умеет печатать без единой строчки своего кода. Тема «fiber логирование» складывается из двух независимых механизмов, которые новички часто сливают в один. Первый — журнал запросов, access log: по одной строке на каждый HTTP-запрос, его пишет middleware/logger из комплекта ядра. Второй — события приложения: «заказ сохранён», «подключение к базе провалилось», «задача взята в работу»; за них отвечает пакет fiberlog из того же ядра. Запрос «логи запросов go» почти всегда ведёт именно к access-логу: что пришло, какой статус, сколько заняло, чем ответило.

У пары есть первая честная асимметрия: access-лог по умолчанию течёт в os.Stdout, а fiberlog — в os.Stderr, причём через стандартный пакет log. Потоки разделили не случайно: журнал запросов собирают целиком и агрегируют, а события приложения фильтруют по уровням — у них разная судьба. Вторая асимметрия — уровни: у fiberlog они есть (от Trace до Panic), у access-лога их нет принципиально — журнал либо пишется, либо нет, а разнообразие внутри обеспечивают формат и Skip. Главная цель урока — чтобы каждая строка журнала отвечала на вопрос «что случилось с этим запросом»: идентификатор, статус, латентность, решение кэша, ошибка.

fiber логирование глубже шестого урока: строка как маленький DSL#

В шестом уроке вы уже подключали logger.New(), видели DefaultFormat и правило порядка requestid → logger → recover. Сегодня — изнанка того же слоя, и она заслуживает отдельного урока, потому что формат-строка в Fiber — это не printf и не шаблон в привычном смысле, а маленький предметно-ориентированный язык. Каждый тег ${status} или ${latency} — это зарегистрированная функция, компилятор формата превращает строку в цепочку из фиксированных кусков и вызовов ещё на этапе logger.New, а опечатка в имени тега роняет приложение паникой на старте — не в момент первого запроса. Дальше по уроку: полный словарь тегов v3 с параметрическими формами, три крючка управления — Skip, Done и LoggerFunc, механизм цветов с isatty, горутина обновления времени, корреляция по RequestID вплоть до регистрации тегов, адаптер на slog и честное сравнение с Echo. Ничего из этого в шестом уроке не было — там была механика слоя, здесь — экономика строки.

Канонический минимум: журнал с RequestID и X-Cache

GO
package main

import (
        "time"

        "github.com/gofiber/fiber/v3"
        "github.com/gofiber/fiber/v3/middleware/cache"
        "github.com/gofiber/fiber/v3/middleware/logger"
        "github.com/gofiber/fiber/v3/middleware/requestid"
)

// Task — форма данных из девятого урока
type Task struct {
        ID    int    `json:"id"`
        Title string `json:"title"`
}

func main() {
        app := fiber.New()

        // первым — RequestID: логгер захочет печатать его значение
        app.Use(requestid.New())

        // access-log: корреляция по RequestID и решение кэша из прошлого урока
        app.Use(logger.New(logger.Config{
                Format: "${time} ${requestid} ${status} ${latency} ${method} ${path} cache=${respHeader:X-Cache} ${error}\n",
        }))

        api := app.Group("/api")

        // кэш из восемнадцатого урока: запись живёт тридцать секунд
        tasksCache := cache.New(cache.Config{
                Expiration: 30 * time.Second,
        })

        api.Get("/tasks", tasksCache, func(c fiber.Ctx) error {
                return c.JSON(fiber.Map{"items": []Task{
                        {ID: 1, Title: "починить шлюз"},
                }})
        })

        _ = app.Listen(":8080") // http://localhost:8080/api/tasks
}

Пакеты logger и requestid живут в ядре github.com/gofiber/fiber/v3/middleware/... — отдельные go get не нужны, cache из прошлого урока тоже из ядра. Порядок не вкусовщина: requestid первым, потому что логгер компилирует формат при создании и хочет увидеть в реестре настоящий тег ${requestid}, а не заглушку. Новый тег в нашем формате — ${respHeader:X-Cache}: кэш-слой ставит свой заголовок до раскрутки цепочки, а логгер рендерит строку после c.Next(), так что к моменту печати решение уже известно. Два curl подряд дают две строки: cache=miss у первого и cache=hit у второго, идентификаторы RequestID в строках разные — у каждого запроса свой. А ${error} в хвосте при успехе печатает прочерк: отсутствие ошибки — тоже значение, об этом отдельный интерактив ниже. В реальной строке тег ${latency} выровнен на 13 колонок вправо, а ${status} в цветном терминале — на 3: ширины колонок зашиты в рендереры тегов, не в ваш формат.

Проверьте себя: reqHeader против respHeader

В каноне стоит cache=${respHeader:X-Cache}. Коллега предлагает заменить на ${reqHeader:X-Cache} — «так симметричнее». Что на это скажет tags.go?

Компиляция формата: одна сборка на инстанс и паника на опечатке#

Главное, что стоит знать о производительности формат-строки: она компилируется. Внутри logger.New вызывается logtemplate.Build[fiber.Ctx, Data](cfg.Format, createTagMap(&cfg)) — шаблон разбирается один раз и превращается в пару цепочек: фиксированные куски строки и функции-рендереры. Комментарий в logger.go объясняет замену старого подхода прямо: instead of analyzing the template inside(handler) each time, this is done once before — вместо анализа шаблона на каждый запрос, это делается один раз заранее. На запросе остаётся только прогнать буфер по готовым цепочкам — переиспользуемый буфер из bytebufferpool, как data из sync.Pool.

Ссылка на несуществующий тег — это не пустая подстановка, а паника: фабрика logger.New(config ...Config) собирает конфиг и передаёт формат в Build, тот возвращает ошибку, logger.New переводит её в типизированную UnknownTagError с полями Tag, Param и Hint — подсказкой вроде «возможно, вы имели в виду параметрическую форму» — и паникует. Константа ErrUnknownTag формулирует диагноз без анестезии: logger: unknown template tag. Ошибка конфигурации стреляет при подключении, а не под нагрузкой — тот же принцип, что у KeyAuth в восьмом уроке Echo. Свои теги регистрируются парой RegisterTag / MustRegisterTag: первая возвращает ошибку ErrTagInvalid — logger: tag name and function are required, вторая паникует; реестр глобальный, и новые инстансы логгера видят свежую регистрацию, но существующие инстансы сохраняют скомпилированные цепочки — пере-регистрация тега старые строки не меняет. А на уровне одного инстанса всё можно перекрыть картой CustomTags — комментарий в config.go честен: CustomTags override built-in and globally registered tags with the same name.

Полный словарь тегов v3 (tags.go)#

ТегЧто рендерит
${time}метка времени: TimeFormat и TimeZone, обновляется раз в TimeInterval
${ip}IP клиента (c.IP())
${ips}цепочка X-Forwarded-For через c.IPs(), через запятую
${host}имя хоста из запроса
${port}порт сервера
${scheme}схема с учётом доверенного прокси (X-Forwarded-Proto)
${protocol}протокол запроса (HTTP/1.1, HTTP/2)
${method}метод запроса, в цветном терминале раскрашен по методу
${path}путь запроса
${url}полный OriginalURL, включая query
${route}шаблон зарегистрированного маршрута c.Route().Path
${ua}заголовок User-Agent
${referer}заголовок Referer
${status}статус ответа, в цветном терминале раскрашен по классу
${latency}Stop − Start, выровнен на 13 колонок
${bytesSent}ContentLength ответа
${bytesReceived}ContentLength запроса
${body}тело запроса, санитизировано от управляющих байтов
${resBody}тело ответа; для стримов — ничего, из него тело не осушается
${reqHeaders}все заголовки запроса в виде key=value, склеенные амперсандом
${queryParams}строка query-параметров
${error}ошибка цепочки или прочерк, если её нет
${pid}PID процесса, вычисляется один раз при создании слоя
${reqHeader:X}значение заголовка запроса X
${respHeader:X}значение заголовка ответа X — так в лог попадает X-Cache
${locals:X}c.Locals(X): строка или []byte как есть, остальное через %v
${query:X}значение query-параметра X
${form:X}значение поля формы X
${cookie:X}значение куки X
${black}…${reset}девять цветовых тегов: подставляют ColorScheme приложения

Параметрические теги и Locals-поля: свои данные в чужой строке#

Половина словаря — теги с параметром после двоеточия, и именно они превращают журнал из «как у всех» в ваш собственный. ${query:page}, ${cookie:session_id}, ${form:email} — всё это доступно без единой строчки кода поверх формата. Но рабочий конь структурирования — ${locals:X}. Хендлер или промежуточный слой кладёт значение через c.Locals("user", user), и формат печатает ${locals:user} в каждой строке этого запроса: не надо протаскивать поле через отдельную переменную и не надо переписывать LoggerFunc ради одного поля. Рендерер в tags.go разобран по типам честно: строка и []byte пишутся как есть, nil — ничего, всё остальное печатается через %v — и любой из этих путей проходит санитизацию, чтобы значение из запроса не могло склеить вам лишнюю строку журнала.

Пара правил гигиены. Первое: Locals-ключи — это договор внутри вашего кода, компилятор их не проверяет; опечатались в имени — тег молча напечатает пустоту, ведь nil-ветка рендерит ничего. Второе: не кладите в Locals то, что не хотите видеть в журнале, — пароли из форм, сырые токены: ${locals:} не знает, что для вас секретно. Третье: если поле нужно в каждой строке и вычисляется дёшево, ${locals:} — самый короткий путь; если нужна типизированная структура — это работа для своего LoggerFunc, о нём ниже.

Соберите путь запроса через logger-слой

Расставьте шаги от проверки Next до хука Done в правильном порядке.

Строки

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

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

Skip, Next и Done: три крючка одного слоя#

У logger-слоя три ручки управления, и путать их дорого. Next — стандартное для всех middleware Fiber поле: вернул true — слой пропускается целиком, запрос проходит сквозь него как через стекло: ни строки лога, ни ручного вызова ErrorHandler — ошибку обработает кто-то выше или fiber на самом верху. Skip — ручка тоньше: запрос обрабатывается слоем до конца, включая фирменную деталь logger.go — логгер сам вызывает ErrorHandler приложения, если цепочка вернула ошибку, — но строка не пишется. Документ конфига: Skip is a function to determine if logging is skipped or written to Stream. Тонкость, о которой стоит знать заранее: проверка Skip живёт внутри defaultLoggerInstance — если вы подмените LoggerFunc своим кодом, cfg.Skip придётся проверять самому, дефолтный фильтр за вас никто не вызовет.

Done — третий крючок и главный подарок для измеримости. Документ: Done is a function that is called after the log string for a request is written to Output — функция получает logString []byte уже после записи строки в Stream. В этот момент хендлер давно отработал, ответ уехал, клиент ничего не ждёт — идеальное место, чтобы посчитать метрики, отправить строку в агрегатор или распарсить собственный же формат. Заметьте: Skip гасит и Done — ранний выход из defaultLoggerInstance происходит до записи и до хука, у пропущенного запроса строки нет нигде, в том числе в ваших счётчиках.

Практика: Skip для health-проб и Done для метрик кэша

GO
var cacheHits int64 // счётчик попаданий в кэш из прошлого урока

app.Use(logger.New(logger.Config{
        // health-пробы балансировщика не логируем: дёргаются каждую секунду
        Skip: func(c fiber.Ctx) bool {
                return c.Path() == "/healthz"
        },
        // Done вызывается после записи строки: logString — готовая строка лога
        Done: func(c fiber.Ctx, logString []byte) {
                if bytes.Contains(logString, []byte("cache=hit")) {
                        atomic.AddInt64(&cacheHits, 1)
                }
        },
}))

Импорты для фрагмента — bytes и sync/atomic. Skip замыкается на путь /healthz: пробы не должны заполнять журнал, но запрос по-прежнему проходит слой целиком — если когда-нибудь проба упадёт с ошибкой, ErrorHandler отработает, просто строка не попадёт в журнал. Done — мост к прошлому уроку в чистом виде: мы ищем маркер cache=hit в готовой строке и копим счётчик попаданий; за минуту на его основе считается hit rate кэша без отдельной библиотеки метрик. Честная граница приёма: парсить собственный формат — приём рабочий, но хрупкий; поменяли слово cache= в Format — счётчик молча обнулился. Для длинной жизни типизируйте это своим LoggerFunc, где решение кэша читается прямо из заголовка ответа, а не из строки.

Проверьте себя: что делает Skip

Skip вернул true для запроса. Что произойдёт с этим запросом?

Что напечатает ${error} без ошибки

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

app.Use(logger.New(logger.Config{
        Format: "${time} ${status} ${method} ${path} err=${error}\n",
}))

api.Get("/tasks", func(c fiber.Ctx) error {
        // хендлер завершается без ошибки
        return c.JSON(fiber.Map{"items": []Task{
                {ID: 1, Title: "починить шлюз"},
        }})
})

Цвета: раскраска по статусу и методу, isatty и NO_COLOR#

Цветной вывод в терминале — не украшение, а навигация, и он тоже разобран в исходниках до отображений. statusColor в utils.go: 2xx — зелёный, 3xx — синий, 4xx — жёлтый, всё прочее — красный. methodColor: GET — cyan, POST — зелёный, PUT — жёлтый, DELETE — красный, PATCH — белый, HEAD — пурпурный, OPTIONS — синий. Решение о цветах принимает configDefault: они включены, когда Stream — дефолтный os.Stdout и DisableColors не поднят, либо когда выставлен ForceColors. Уже с новым Stream цвета молча гаснут: вы уводите журнал в файл — и Escape-коды не мусорят его.

Сама проверка терминала живёт в beforeHandlerFunc и собрана честно: Stream заменяется на colorable.NewColorableStdout из пакета mattn/go-colorable, а если ForceColors не поднят и TERM равен dumb, или NO_COLOR равен 1, или стандартный вывод не терминал по isatty.IsTerminal и IsCygwinTerminal — на colorable.NewNonColorable(os.Stdout). Это та самая связка isatty: цвета включаются сами, когда stdout — настоящий терминал, и гаснут сами, когда журнал пайпом уходит в файл или коллектор. Для ручной раскраски своего формата есть девять цветовых тегов ${black}…${reset} — они подставляют ColorScheme из конфигурации приложения, так что и самодельная разметка подчиняется одной палитре.

Время: TimeFormat, TimeZone и одна горутина на все инстансы#

Тег ${time} дёшев на рендере, потому что дорогая часть вынесена из запроса. TimeFormat по умолчанию — «15:04:05», тот самый Go-layout; TimeZone — «Local» (в исходнике "Local"), и logger.New грузит зону через time.LoadLocation, а при ошибке молча откатывается на time.Local. Дальше начинается инженерия: отформатированная строка времени хранится в atomic.Value и обновляется горутиной раз в TimeInterval — дефолт 500 * time.Millisecond. Никто не форматирует время на каждый запрос: рендерер тега просто читает готовую строку.

Интереснее то, сколько горутин у вас заведётся. Пакетная функция sharedTimestamp держит кэш обновляльщиков, и ключ — тройка формат/зона/интервал: два logger-инстанса с одинаковыми настройками времени делят одну горутину на двоих. Комментарий в logger.go формулирует это в лоб: Process-lifetime shared updater, deduplicated by format/zone/interval — общий на процесс, дедуплицирован по формату, зоне и интервалу. Плата за общий лайфтайм — горутина, живущая до конца процесса, и для тестов она неудобна; для этого в конфиге есть TimeDone: канал, чьё закрытие останавливает выделенный обновляльщик, — комментарий зовёт сценарий useful for tests and short-lived apps. В обычном сервере поле не нужно, в утилите командной строки с Fiber внутри — обязательная гигиена.

Порядок регистрации и судьба ${requestid}

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

// порядок перепутан: логгер создан раньше requestid
app.Use(logger.New(logger.Config{
        Format: "${time} ${requestid} ${status} ${method} ${path}\n",
}))
app.Use(requestid.New())

Корреляция по RequestID: контекст, реестр и порядок#

Механику requestid.New — переиспользование входящего заголовка X-Request-ID, генерацию SecureToken с тремя попытками — вы разбирали в шестом уроке; здесь нас интересует только путь идентификатора в строку журнала, и он в v3 поучительный. requestid хранит готовый ID в контексте запроса через fiber.StoreInContext, а не в Locals, и при первом создании слоя выполняет sync.Once-регистрацию двух тегов сразу: ${requestid} и ${request-id}. Регистрация идёт через logger.RegisterContextTag, и её документ точен: so that the same name can be used in a logger.Config.Format and in fiberlog.SetContextTemplate — двойной реестр, один вызов.

Практические следствия три. Первое: порядок — requestid.New() строго раньше logger.New(), иначе заглушка из прошлого интерактива. Второе: альтернативы без контекста. ${respHeader:X-Request-ID} надёжен всегда — requestid ставит заголовок ответа до раскрутки, и к рендеру строки он уже стоит; ${reqHeader:X-Request-ID} печатает только то, что прислал клиент, и годится разве что для отладки входящих трасс. Третье: ценность корреляции за пределами одного процесса — клиент или балансировщик присылает свой ID, requestid его валидирует и переиспользует, и строки двух сервисов склеиваются по одному значению. Требование валидности — видимые ASCII — не бюрократия: идентификатор уезжает в заголовок ответа и в журнал, а управляющие байты там не гости.

Соберите конфиг: формат, поток и фильтр

Восстановите конфиг журнала: корреляция, статус, латентность, маркер кэша, стандартный поток и молчание на пробах.

app.Use(logger.New(logger.Config{ Format: , // время, RequestID, статус, латентность, метод, путь и кэш из ответа Stream: , // журнал — в стандартный поток вывода Skip: func(c fiber.Ctx) bool { return // health-пробы балансировщика не логируем }, }))

fiberlog: уровни, интерфейсы и глобальный логгер событий#

Второй поток fiber логирования — события приложения — живёт в пакете github.com/gofiber/fiber/v3/log (привычное имя fiberlog). По умолчанию это обёртка над стандартным пакетом log: writer — os.Stderr, флаги LstdFlags с Lshortfile и Lmicroseconds, к каждой записи приклеивается префикс уровня вроде «[Info] ». Уровней семь и объявлены они честным iota: LevelTrace, LevelDebug, LevelInfo, LevelWarn, LevelError, LevelFatal, LevelPanic. Фильтр формулирует документ уровня: When a logger is configured with a level, any log message with a lower log level (smaller by integer comparison) will not be output — всё, что ниже порога числом, не печатается; дефолтный логгер порог не поднимает, печатается всё. Одна деталь с характером: Fatal завершает процесс даже тогда, когда его строка ниже порога и не была написана.

Интерфейсная семья: Logger — плоские методы Trace…Panic, FormatLogger — семейство Tracef…Panicf, WithLogger — Tracew…Panicw с парами ключ-значение; комбинация плюс управление — AllLogger. Пакетные функции fiberlog.Info, fiberlog.Errorf и компания пишут через глобальный экземпляр, а fiberlog.SetLogger подменяет его целиком, SetOutput и SetLevel — ручки поточнее. Отдельный слой — контекст: fiberlog.WithContext(c) возвращает логгера, приклеенного к запросу, а SetContextTemplate задаёт, какие контекстные теги печатать в каждой строке — константа RequestIDFormat из пакета собирает формат [${requestid}], а ${value:KEY} — зарезервированный параметрический тег для любых значений из контекста. Журнал запросов уровней не имеет и не должен: он либо пишется, либо нет — уровни живут в событийном потоке.

slog-адаптер: LoggerFunc со структурированными полями

GO
// свой LoggerFunc: вместо строки — запись slog с типизированными полями
logFunc := func(c fiber.Ctx, data *logger.Data, cfg *logger.Config) error {
        // у своего LoggerFunc Skip проверяем сами — дефолтный фильтр не работает
        if cfg.Skip != nil && cfg.Skip(c) {
                return nil
        }
        level, msg := slog.LevelInfo, "request"
        if data.ChainErr != nil {
                level, msg = slog.LevelError, "request_error"
        }
        slog.LogAttrs(c, level, msg,
                slog.String("request_id", requestid.FromContext(c)),
                slog.String("method", c.Method()),
                slog.String("path", c.Path()),
                slog.Int("status", c.Res().StatusCode()),
                slog.Duration("latency", data.Stop.Sub(data.Start)),
                slog.String("cache", c.GetRespHeader("X-Cache")),
        )
        return nil
}

app.Use(logger.New(logger.Config{
        Format:     logger.DefaultFormat, // ${latency} в нём включает замеры Start/Stop
        Skip:       func(c fiber.Ctx) bool { return c.Path() == "/healthz" },
        LoggerFunc: logFunc,
}))

Импорты: log/slog и middleware/requestid в дополнение к logger. Структура logger.Data — контракт своего LoggerFunc: Start и Stop, ChainErr, Timestamp, Pid, ErrPaddingStr; цепочки TemplateChain и LogFuncChain общие на все запросы — их можно читать, но нельзя дополнять и перемешивать, это прямо написано в комментарии logger.go. Ловушка, ради которой в конфиге оставлен DefaultFormat: logger включает замер латентности проверкой strings.Contains(Format, "${latency}") — уберёте тег из формата, data.Start и data.Stop останутся нулями, и Duration в вашей slog-записи будет 0s при любой нагрузке. Подмена LoggerFunc — самый контролируемый из трёх путей slog; другие два — LoggerToWriter (обёртка io.Writer над fiberlog.AllLogger: журнал целиком уходит уровнем в ваш логгер — документ обещает интеграцию zerolog, logrus и подобных) и fiberlog.SetLogger для событийного потока. Готового slog-адаптера в ядре Fiber нет — клей из пары десятков строк пишется руками, и это честная цена свободы формата.

Проверьте себя: уровни fiberlog

Вызван fiberlog.SetLevel(fiberlog.LevelWarn). Сколько строк напечатают fiberlog.Info("заказ сохранён") и fiberlog.Error("база недоступна")?

Сравнение с Echo: два потока, два языка конфигурации#

Ставить Fiber и Echo рядом честно именно на этом уроке: их девятый урок Echo уже разобрал логирование Echo, и архитектурно пары похожи — у обоих два потока. У Echo события приложения идут через e.Logger с уровнями gommon, а журнал запросов — через middleware.RequestLoggerWithConfig с полтора десятком флагов LogStatus, LogURI, LogRequestID и обязательной LogValuesFunc. У Fiber события — fiberlog, журнал — middleware/logger. Разница в языке конфигурации: Echo описывает колонку флагом-булевым, Fiber — тегом в строке формата. Флаг надёжнее против опечаток, строка гибче: добавить в лог решение кэша — это ${respHeader:X-Cache} в Format, а не новый флаг и не правка LogValuesFunc.

Честные отличия по трём пунктам. Уровни: у gommon и у fiberlog они есть, у access-логов у обоих нет — тут фреймворки совпадают. RequestID: Echo читает идентификатор из заголовков запроса и ответа флагом LogRequestID; Fiber v3 хранит его в контексте и печатает контекстным тегом ${requestid}, с оговоркой о порядке регистрации. slog: Echo документирует три готовых пути присадки, а в ядре Fiber адаптера нет — LoggerFunc, LoggerToWriter или fiberlog.SetLogger, клей за вами. Ни тот ни другой не конвертирует автоматически свой встроенный логгер в slog — SetDefault из Echo и SetLogger из Fiber меняют только свой поток, чужие writer'ы живут своей жизнью.

Санитизация: клиент не пишет ваши строки журнала#

Журнал запросов — парсер читает его автоматически, — и строка, склеенная из пользовательского ввода, опасна вдвойне: путь ?q=foo%0A200 OK — это попытка подсунуть парсеру вторую строку. logger v3 защищается системно: все теги, печатающие значения из запроса, проходят writeSanitized — ASCII-управляющие байты заменяются пробелами, горизонтальный таб сохраняется. Быстрый путь DefaultFormat чистит те же значения руками: IP, путь и сообщение ошибки, — и комментарий в default_logger.go называет причину по номеру issues: raw CR/LF lets a client forge additional access-log lines. See #4341. Метод при этом не чистится — fasthttp отвергает запрос, чей метод-токен содержит управляющие байты, так что в ${method} мусора не бывает по построению.

Для тех, кто пишет свои теги, пакет экспортирует SanitizeValue — ту же функцию с тем же контрактом; комментарий прямо перечисляет дыры, через которые сырой ввод обычно попадает в лог: RegisterTag, CustomTags, LoggerFunc. Отдельная честность про ${resBody}: рендерер берёт c.Res().Body(), но для стриминговых ответов тело равно nil — комментарий в tags.go: Res.Body answers nil for a streamed response rather than draining it — логгер не осушает SSE-поток и не буферизует SendFile, которые обязан был только наблюдать. И практическое правило поверх всего: ${body}, ${resBody} и ${reqHeaders} — инструменты отладки, не продакшена; пароли в формах и токены в заголовках попадут в журнал точно так же, как и всё остальное.

Чек-лист: журнал запросов на проде#

  • Порядок регистрации: requestid.New() строго раньше logger.New() — теги компилируются при создании инстанса
  • Под коллектор берите JSONFormat или ECSFormat — не заставляйте парсер разбирать текстовый столбик DefaultFormat; CommonFormat и CombinedFormat из шестого урока — форма для парсеров Apache
  • Skip на /healthz и внутренние пробы: журнал — не место для пульса балансировщика
  • Свои поля — через c.Locals и ${locals:user}: типизированнее только свой LoggerFunc
  • Не стройте логику на ${reqHeader:}: клиентский заголовок подделывается, для маркеров берите ${respHeader:}
  • Метрики и форк строк — в Done: он получает готовую строку после записи и ничего не задерживает
  • Свой LoggerFunc — проверьте Skip сами и держите ${latency} в Format, иначе латентность обнулится
  • Stream в файл — цвета гаснут сами; ForceColors нужен только когда журнальный файл реально терминал
  • Тестам и CLI-утилитам — TimeDone: закройте канал и остановите горутину обновления времени
  • События приложения — fiberlog с уровнями и SetLevel, access-лог — без уровней: не смешивайте потоки в один writer без причины

Прогон: curl-сценарии и границы урока#

Соберите канон: инстанс на :8080, requestid, логгер с форматом из канона, кэш и /api/tasks. Клиент — curl, и сценарии короткие. Первый: два GET /api/tasks подряд с curl -i — в первом ответе X-Cache: miss, во втором X-Cache: hit, а в журнале две строки с одинаковым форматом, разными RequestID и разными значениями cache=: так прошлогодний маркер становится полем журнала без правки кэша. Второй: curl -i /healthz при Skip — ответ есть, строки в журнале нет. Третий: curl -i /api/missing — строка с 404 и прочерк в ${error}, ведь fiber сам отработал ошибку маршрута; а хендлер, вернувший fiber.NewError, даст в хвосте строки своё сообщение. Четвёртый: заголовок X-Request-ID в ответе — то же значение, что в строке журнала: корреляция проверяется глазами за секунду.

Честное слово про инструментарий: песочница сайта сетевые сценарии не исполняет — runner-ов в уроках Fiber нет с первого урока, интерактивы здесь quiz, predict, fill, order и флешкарты; журнал проверяется локально, где ваш curl видит и заголовки, и строки целиком. Материал держится на исходниках ветки main ядра fiber v3.5.0: logger.go, config.go, tags.go, format.go, default_logger.go, context_tag.go, timestamp.go плюс пакет log. Полный план курса — на странице курса Fiber.

Что дальше: конфигурация сервера#

Следующий, двадцатый урок — конфигурация и настройка сервера: Settings, Prefork, graceful shutdown, сигналы. Связь с сегодняшней темой прямая и практическая. Во-первых, тег ${pid} из таблицы выше перестанет быть диковинкой: с Prefork родительский процесс плодит воркеров, и в общем журнале строки легко разнести по процессам именно по PID. Во-вторых, graceful shutdown — это в том числе судьба последних строк журнала: слушатель закрыт, но запросы в полёте должны дописать свои записи, а буферы — дойти до Stream; сигнал SIGTERM и аккуратное завершение — то, чем заканчивается каждый честный сервер. В-третьих, поток ошибок fiberlog на stderr и журнал на stdout обретают при настройке сервиса systemd буквальные пути: Stderr и Stdout — два файла, две судьбы. До встречи в двадцатом уроке.

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

Термин

Компиляция формата в logger.New

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

logtemplate.Build разбирает строку один раз в цепочки функций; неизвестный тег — паника UnknownTagError («logger: unknown template tag») на старте. Существующие инстансы не перечитывают реестр тегов.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • logger.New(logger.Config{Format: ...}) компилирует формат один раз при создании: неизвестный тег — паника UnknownTagError на старте, а не ошибка под нагрузкой.
  • Теги — функции из tags.go: ${status}, ${latency}, ${method}, ${path}, ${error} плюс параметрические ${reqHeader:}, ${respHeader:}, ${locals:}, ${query:}, ${form:}, ${cookie:}; свои — RegisterTag глобально и CustomTags на инстанс.
  • Skip гасит только строку (ErrorHandler вызывается, Done — нет), Next выключает слой целиком; Done получает готовый logString после записи — точка для метрик.
  • Корреляция: requestid хранит ID в контексте и регистрирует теги ${requestid}/${request-id}; порядок requestid.New() → logger.New() обязателен, а ${respHeader:X-Request-ID} — надёжный запасной вариант.
  • slog подключается своим LoggerFunc со slog.LogAttrs, LoggerToWriter или fiberlog.SetLogger; в ядре готового slog-адаптера нет — в этом честное отличие от Echo.
  • Два потока: access-лог в Stream без уровней, события приложения — fiberlog на stderr с LevelTrace…LevelPanic и порогом через SetLevel.

Домашнее задание

Возьмите канон урока и доработайте его в три шага. Первый: добавьте в хендлер c.Locals("user", "alice") и включите ${locals:user} в Format — убедитесь curl-ом, что поле печатается, а опечатка в имени ключа даёт пустоту. Второй: поднимите второй инстанс логгера на группу /api с Format из logger.JSONFormat и Stream в файл — сравните одну и ту же пару запросов в текстовом и JSON-журнале. Третий: через Done посчитайте cache=hit и cache=miss за десять запросов и выведите hit rate кэша из прошлого урока. Затем поставьте fiberlog.SetLevel(fiberlog.LevelWarn), добавьте в хендлер fiberlog.Info и fiberlog.Error и проверьте curl-ом, что в stderr доживает только Error, а строка access-лога печатается по-прежнему.

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

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

Как получить журнал в JSON?

Два пути: готовый JSONFormat или ECSFormat в поле Format — тогда строку собирает дефолтный рендер; либо свой LoggerFunc со slog.NewJSONHandler — тогда поля типизированы и есть уровни. Второй путь дороже по коду, но честнее для коллектора.

Почему ${latency} показывает 0s?

Замер латентности включается только если строка Format содержит ${latency} — logger проверяет это strings.Contains и только тогда заполняет data.Start и data.Stop. Уберёте тег из формата — Stop − Start станет нулевым, это осознанная оптимизация, о которой нужно помнить со своим LoggerFunc.

Куда направлять журнал в продакшене?

Stream в stdout, а сбор — снаружи: docker, journald или сайдкар заберут поток сами. fiberlog по умолчанию пишет в stderr — собирайте оба потока раздельно или осознанно слейте их в один writer через SetOutput и Stream.

Можно ли одному запросу дать несколько строк журнала?

Один logger-инстанс пишет одну строку после c.Next(). Несколько строк — это либо события fiberlog внутри хендлера, либо отдельные logger.New с разными Format, повешенные на разные группы маршрутов.

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

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

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

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

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

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

Gin: самый популярный веб-фреймворк Go

Курс Gin для Go: маршрутизация, binding, валидация, middleware, JWT, WebSocket, тесты и деплой. 25 уроков с кодом и итоговым REST API.

  • Уверенно владеть gin.Engine: маршруты, группы, версии
  • Привязывать запросы к структурам (ShouldBindJSON и др.)
  • Писать middleware и использовать встроенные
  • Строить аутентификацию на JWT