---
title: "Логирование запросов"
description: "Урок fiber логирование: теги формата middleware/logger v3, slog-адаптер через LoggerFunc, хук Done, корреляция по RequestID и цвета терминала."
course: "Fiber: экспресс-подобный веб-фреймворк на Fasthttp"
url: "https://golangmaster.ru/courses/fiber/lessons/19-logirovanie/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

*Курс «Fiber: экспресс-подобный веб-фреймворк на Fasthttp», урок 19 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/fiber/lessons/19-logirovanie/*

Логи — единственный свидетель, который остаётся у сервера после того, как ответ уехал клиенту, и тема «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 требует ручной работы.

### Мост из прошлого урока: 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

В [шестом уроке](/courses/fiber/lessons/06-middleware/) вы уже подключали 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?

- Разницы нет: оба тега читают один и тот же заголовок X-Cache
- ✅ ${reqHeader:} читает заголовок ЗАПРОСА: клиент X-Cache не присылает, тег вернёт пустоту, и маркер кэша пропадёт из строк журнала
- ${reqHeader:} тоже сработает: middleware/cache копирует своё решение в заголовки запроса
- Оба тега упадут с паникой: X-Cache — зарезервированное имя Fiber

TagReqHeader в tags.go рендерит c.Get(extraParam) — заголовки запроса, а решение кэша живёт в заголовке ответа: middleware/cache ставит X-Cache до раскрутки, и к моменту рендера строки ${respHeader:X-Cache} заполнен и на miss, и на hit. Единственный случай, когда ${reqHeader:X-Cache} напечатает значение, — клиент сам прислал такой заголовок, а доверять клиенту в этом вопросе не стоит: он способен подделать ваш маркер. Симметрия тут обманчива: reqHeader — про вход, respHeader — про выход, и у каждого своё расписание жизни в цепочке.

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

Главное, что стоит знать о производительности формат-строки: она компилируется. Внутри 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 в правильном порядке.

Правильный порядок строк:

1. `Next вернул true — слой пропущен целиком, строки не будет`
2. `Data достаётся из sync.Pool: Pid, ErrPaddingStr, Timestamp`
3. `Format содержит ${latency} — data.Start = time.Now()`
4. `chainErr := c.Next() — запрос уходит по цепочке дальше`
5. `chainErr не nil — логгер сам вызывает ErrorHandler приложения`
6. `data.Stop = time.Now(): латентность зафиксирована`
7. `Управление уходит в cfg.LoggerFunc — дефолтно defaultLoggerInstance`
8. `Skip вернул true — ранний выход: буфер не собирается`
9. `Шаблон рендерится в буфер, writeLog пишет его в Stream`
10. `Done(c, buf.Bytes()) — хук получает готовую строку после записи`

### 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 для запроса. Что произойдёт с этим запросом?

- logger-слой не выполнится вовсе: ни строки, ни вызова ErrorHandler
- ✅ Строка не запишется, но запрос обработается слоем до конца: ErrorHandler при ошибке логгер вызовет, а Done — нет: ранний выход случается раньше записи
- Ни строки, ни ErrorHandler: ошибка запроса потеряется
- Skip работает только в паре с Next: без Next поле игнорируется

Skip проверяется в defaultLoggerInstance уже после обработки запроса: chainErr сохранён, ErrorHandler при ошибке вызван руками логгера, но дальше — ранний return nil: ни буфера, ни writeLog, ни Done. Это главное отличие от Next: тот срабатывает в начале хендлера слоя и пропускает вообще всё. Вариант «ошибка потеряется» тем более неверен: именно logger.go гарантирует ручной вызов errHandler до рендера строки. И напомним правило своих LoggerFunc: дефолтная проверка Skip — деталь defaultLoggerInstance, кастомный LoggerFunc обязан проверить cfg.Skip сам.

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

```go
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: "починить шлюз"},
        }})
})
```

**Что выведет программа?**

- err= — пустая подстановка: ошибки нет, тег рендерит ничего
- ✅ err=- — тег ${error} печатает прочерк, когда ошибки в цепочке нет
- err=nil — тег печатает Go-представление нулевого error
- Строки не будет вовсе: без ошибки логгер молчит

Рендерер TagError в tags.go устроен ветвлением: если data.ChainErr != nil — печатается сообщение ошибки (в цветном терминале — красным), иначе output.WriteString("-"). Прочерк — наследие Apache-форматов, где прочерк означает «поле отсутствует», и он позволяет парсерам не спотыкаться о пустые колонки. Так что строка появится всегда — logger пишет после c.Next() независимо от исхода, — но вместо ошибки будет дефис. Если в хендлере вернуть fiber.NewError(404, «нет такой задачи»), на месте дефиса окажется её сообщение, а статус 404 попадёт в ${status} — логгер видит финальное состояние ответа, потому что рендерит строку после раскрутки цепочки.

### Цвета: раскраска по статусу и методу, 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}

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

**Что выведет программа?**

- Валидный id со второго запроса: requestid.New зарегистрирует тег, и он заработает
- ✅ Пустота навсегда: на момент logger.New тег был заглушкой, а инстанс хранит скомпилированную цепочку и реестр не перечитывает
- Паника UnknownTagError: тег ещё не зарегистрирован
- Значение заголовка X-Request-ID из ответа: теги всегда читают заголовки

При инициализации пакета logger регистрирует заглушки-пустышки под имена requestid, request-id, api-key, csrf-token, session-id и username — чтобы формат, ссылающийся на ${requestid}, компилировался даже без соответствующего middleware. Паники не будет. Но функция рендера фиксируется при logger.New: template.Build собирает цепочки один раз, а комментарий context_tag.go предупреждает — Existing logger middleware instances keep the function chain they compiled at construction. Заглушка рендерит пустоту, и вы получите двойные пробелы в каждой строке этого инстанса — навсегда, сколько бы раз requestid потом ни регистрировался. Чинится только порядком: requestid.New() раньше logger.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 — не бюрократия: идентификатор уезжает в заголовок ответа и в журнал, а управляющие байты там не гости.

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

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

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

Подсказка: Формат — строка с тегами в ${...}; Stream — io.Writer из пакета os; Skip сравнивает путь с константой.

Правильные ответы в пропусках: `"${time} ${requestid} ${status} ${latency} ${method} ${path} ${respHeader:X-Cache}\n"`, `os.Stdout`, `c.Path() == "/healthz"`

### 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("база недоступна")?

- Две: SetLevel влияет только на middleware/logger
- ✅ Одну: Info ниже порога Warn молчит, Error проходит; access-лог это не затронет — у него своих уровней нет
- Ни одной: после SetLevel fiberlog замолкает целиком до перезапуска
- Две, но Info уйдёт в stdout, а Error — в stderr

Фильтр в defaultLogger сравнивает уровни числом: сообщение ниже порога не печатается — документ log.go говорит об этом буквально, LevelInfo меньше LevelWarn, строка пропадает. LevelError выше порога и напечатается с префиксом «[Error] ». Разделение потоков важно: SetLevel крутит ручку fiberlog, а middleware/logger не имеет уровней вовсе — его фильтры это Skip и сам формат, и его Stream никуда не переезжает. Вариант с разными потоками путает направления: fiberlog пишет в stderr всегда (если SetOutput не сменил), access-лог — в свой Stream, и это решение конфига, а не уровня.

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

Ставить Fiber и Echo рядом честно именно на этом уроке: их девятый урок Echo уже разобрал [логирование Echo](/courses/echo/lessons/09-logirovanie/), и архитектурно пары похожи — у обоих два потока. У 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](/courses/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») на старте. Существующие инстансы не перечитывают реестр тегов.
- **${respHeader:X-Cache}** — Тег заголовка ответа: кэш из прошлого урока ставит его до раскрутки, логгер рендерит после c.Next() — маркер hit/miss в каждой строке. ${reqHeader:} читал бы запрос клиента и был бы пуст.
- **Skip против Next** — Next пропускает слой целиком. Skip гасит только строку: ErrorHandler логгер вызывает, Done — нет. У своего LoggerFunc проверку Skip пишете сами.
- **Done** — Вызывается после записи строки в Stream и получает logString []byte. Точка для метрик (счётчик cache=hit) и форка в агрегатор; Skip гасит и его.
- **Корреляция ${requestid}** — requestid хранит ID в контексте (fiber.StoreInContext) и регистрирует теги requestid/request-id через RegisterContextTag; requestid.New() должен создаться раньше logger.New(). Запасной вариант — ${respHeader:X-Request-ID}.
- **fiberlog и slog** — fiberlog — событийный поток: уровни LevelTrace…LevelPanic, дефолт — std log на stderr. slog подключается своим LoggerFunc, LoggerToWriter или fiberlog.SetLogger; готового адаптера в ядре нет.

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

- 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-лога печатается по-прежнему.

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

### Как получить журнал в 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, повешенные на разные группы маршрутов.
