Содержание урока · 10 разделов
- Logger.With: контекстные поля логов
- zerolog.Context: методы и порядок в JSON
- Правила контекстных полей одним списком
- Логгер на модуль и на запрос
- Value-семантика: копия логгера — снимок
- Цена контекста: один append и один memcpy
- Контекстные поля против полей события
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Начнём с сервиса, в котором контекстных полей логов ещё нет. Каждый вызов пишет только то, что ему передали: log.Info().Str("user", id).Msg(...) — и чтобы идентификатор запроса оказался в записи, его надо не забыть дописать в каждом из двадцати мест, где обработчик логирует. Через месяц в коде тридцать вариантов одного и того же: где-то user_id, где-то uid, где-то поле пропущено вовсе, а на разборе инцидента половина записей блока обработки заказа осталась без order_id — и трек запроса не собирается даже руками. Вторая стадия этой болезни — копипаст-константы: строка "service":"billing" повторяется в каждом вызове, и при переименовании сервиса кто-то обязательно обновит половину вхождений, а вторая половина будет врать в логах молча. Корень проблемы в том, что общие для всего потока поля живут на уровне события, а задаются на уровне вызова. Логичнее объявить их один раз на логгере: вот этот логгер пишет от имени модуля auth, этот — от имени обработки запроса r-1024, и каждое событие уходит уже с нужными полями. Именно это делает With: общие поля переезжают из вызова в конфигурацию логгера, а в коде события остаётся только то, что уникально для конкретной записи.
Logger.With: контекстные поля логов#
В zerolog With() — метод логгера, который возвращает zerolog.Context, накопитель предзаданных полей. Вы цепочкой добавляете в него Str, Int, Bool, Timestamp — что угодно из того же набора, что и у события, — и финализируете вызовом Logger(): на выходе новый логгер, у которого в контексте лежат все выбранные поля. Ключевая деталь механики: контекст хранится уже сериализованным — готовыми JSON-фрагментами вроде "service":"billing" — поэтому на каждое событие zerolog просто дописывает этот готовый кусок в буфер записи: без перечитывания полей, без рефлексии и без аллокаций. Отсюда и порядок в итоговом JSON: уровень, ключ time, если он в контексте, затем поля контекста в порядке их добавления, затем поля самого события, и последним — message: он дописывается при финализации, после всех полей. Контекстное поле легко узнать в записи по позиции — оно стоит раньше всего, что добавлено в цепочке события. Метод работает и от глобального логгера пакета log — log.With().Str("service", "api").Logger() — хотя в приложениях чаще собирают собственный базовый логгер при старте и раздают его по зависимостям, как мы разбирали в уроке про первый лог.
Первый With: service и env на логгере
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).With().
Str("service", "billing").
Str("env", "prod").
Logger()
logger.Info().Str("user", "ann").Msg("signed in")
logger.Error().Str("op", "charge").Msg("charge failed")
}
// Output:
// {"level":"info","service":"billing","env":"prod","user":"ann","message":"signed in"}
// {"level":"error","service":"billing","env":"prod","op":"charge","message":"charge failed"}With() собирает zerolog.Context: два строковых поля, затем Logger() возвращает логгер, у которого эти байты уже лежат в контексте. Оба события выводят service и env автоматически, в JSON они стоят после уровня и раньше полей события — ровно в порядке добавления. Ни один вызов Info или Error не повторяет общие поля: они заданы один раз на логгере и попадают в каждую запись, пока живёт эта копия.
zerolog.Context: методы и порядок в JSON#
Набор методов у zerolog.Context почти тот же, что у события: Str и Strs, Int и все целочисленные вплоть до Uint64, Float32 и Float64, Bool, Bytes, Hex, Dur, Time, Err — он, как и у события, молча пропускает nil, — AnErr, Errs, Interface и Any, RawJSON, Dict. Плюс служебные методы, которым в контексте самое место. Timestamp() добавляет ко всем событиям ключ time — так собирают базовый логгер с самого первого урока. Caller() добавляет файл и строку вызова, Stack() — стек при ошибках. Fields принимает map или слайс пар ключ-значение и добавляет их все разом; деталь, о которой мало кто читает в документации: ключи карты zerolog сортирует по алфавиту, чтобы вывод не зависел от случайного порядка обхода в Go, — так что для критичных к порядку полей пишите отдельные Str. Reset() очищает контекст. Всё это задаётся один раз на логгер, поэтому сюда переезжают и Timestamp, и общие Err — то, что раньше повторялось в каждом вызове из урока про поля события. Отдельно упомяну экзотику, которая прячется в конце списка методов: IPAddr и MACAddr для сетевых адресов, Stringer для любого значения с методом String() — удобно для типизированных ID из enum-подобных типов, Type для имени типа значения. Всё это — одни и те же байты контекста, и все они доступны ровно там, где есть zerolog.Context: и в With, и в цепочке события.
Правила контекстных полей одним списком#
- общие для потока поля — на логгер через With; уникальные для записи — в цепочку события
- ключи контекста и события не дедуплицируются: одноимённое поле события даст в JSON второй ключ — следите за уникальностью имён
- Timestamp и Caller — тоже контекстные поля: добавляйте их в базовый логгер, а не в каждый вызов
- имена ключей выносите в константы одного пакета: контекст обрастает дашбордами и менять его потом больно
- Context.Fields(map) сортирует ключи по алфавиту — порядок предсказуем, но не ваш
- логгер на запрос создавайте после чтения заголовков, когда request_id уже известен
- не складывайте в контекст большие дампы: они поедут в каждое событие этого логгера
Логгер на модуль и на запрос#
Из базового логгера вырастает целое дерево. Первый этаж — логгеры на модуль: base.With().Str("module", "auth").Logger() и рядом billing, payments, worker — каждая подсистема получает свою копию со своим module. Второй этаж — логгер на запрос: в начале обработки reqLog := base.With().Str("request_id", id).Logger(), и все записи обработчика, включая вложенные вызовы, куда reqLog передали аргументом, уже размечены идентификатором. Дочерние логгеры расширяются так же: auth.With().Str("component", "oauth").Logger() даст события с module=auth и component=oauth сразу. Такая разметка — это не украшение, а формат данных для системы сбора: графики по модулям и треки запросов строятся фильтром по одному ключу. Имена модулей держите в константах и придумывайте один раз — они станут координатами в каждой записи, и переименование после того, как на module настроены дашборды и алерты, обойдётся дороже, чем кажется. Полный разбор дерева сублоггеров с реестром и тестами — в отдельном уроке про сублоггеры, а middleware, который размечает запросы request_id автоматически, ждёт в уроке про HTTP-логи.
Логгер на запрос: request_id в каждой записи
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
func handle(base zerolog.Logger, requestID string) {
reqLog := base.With().Str("request_id", requestID).Logger()
reqLog.Info().Msg("request received")
reqLog.Info().Str("route", "/orders").Msg("request handled")
}
func main() {
base := zerolog.New(os.Stdout).With().
Str("service", "api").
Logger()
handle(base, "r-1024")
}
// Output:
// {"level":"info","service":"api","request_id":"r-1024","message":"request received"}
// {"level":"info","service":"api","request_id":"r-1024","route":"/orders","message":"request handled"}reqLog — копия base с готовым request_id в контексте; в handle он передаётся обычным значением, без указателей и ссылок. Обе записи внутри обработчика размечены идентификатором автоматически: ни один вызов Info не упоминает request_id. Событие со Str("route") показывает порядок: контекст base, затем контекст reqLog, затем поля события и последним message.
Value-семантика: копия логгера — снимок#
zerolog.Logger — тип-значение: структура с writer-ом, уровнем и байтовым контекстом. Все методы модификации — With, Level, Output, Sample, Hook — возвращают копию, а не меняют receiver. Из этого следуют три практических правила. Первое: дочерний логгер — это снимок родителя на момент создания; если позже пересобрать base с новым полем, уже созданные дети этого поля не увидят. Второе: копию можно свободно передавать в функции и горутины — никакой общей мутируемой нагрузки, кроме writer-а, у копии нет, а writer в zerolog рассчитан на параллельные вызовы. Третье: раз логгеры — значения, поля контекста лежат в каждой копии своими байтами, и на длинных цепочках With контекст растёт для всех потомков — ещё одна причина не складывать туда лишнее. Экономнее всего разметка живёт на длинных связках: одна и та же копия логгера прокидывается через конструкторы репозиториев и сервисов, и каждая запись на выходе самодостаточна. Глобальный порог zerolog.SetGlobalLevel при этом общий для всех копий — он проверяется на каждом событии поверх локального уровня. Есть и обратная операция: GetLevel() читает порог копии, что удобно в тестах конфигурации — собрали логгер для шума на Warn, проверили GetLevel, не разбирая JSON. Отличать поля контекста от полей события и выбирать, что куда, помогает навык из урока про поля события: там же и таблица типизированных методов.
Реальный кейс: хендлер создал reqLog, но в go-горутину отправки писем по привычке передал base — и все записи отправки уехали без request_id. Инцидент случился ночью: собрать трек запроса не удалось, потому что полстроки лога размечены, а полстроки — нет. Лечится дисциплиной: в горутины и вложенные вызовы передаём самый размеченный логгер из доступных, а на ревью ищем base в замыканиях горутин. Проверка одна: каждое поле, по которому вы собираетесь искать, должно быть в контексте самого глубокого логгера.
Цена контекста: один append и один memcpy#
Стоимость With раскладывается на две части. При создании логгера каждое поле контекста один раз сериализуется в байты и дописывается в буфер контекста — это единственная работа на всю жизнь копии. При каждом событии zerolog копирует готовые байты контекста в буфер события — по сути memcpy куска на десятки-сотни байт; аллокаций нет, рефлексии нет. Отключённое событие не копирует ничего: nil-событие получает не буфер, а отказ, и контекст остаётся не у дел. Сравните с альтернативой без With: те же поля дописываются в каждом вызове — больше кода, шанс пропустить и та же цена сериализации, только на каждом событии вместо одного раза. Так что With выгоднее и короче одновременно — редкий случай, когда лучший по читаемости вариант и самый быстрый совпадают. Замеры логгера в бенчмарках с контекстными полями и честные оговорки — в уроке про производительность. Проверить всё руками проще локально: песочница сайта — тематическая программа на чистом Go, а настоящий With с живым JSON запускается через go run за минуту.
Если предзаданных полей больше пяти — например, разметка окружения из переменных среды — соберите map и передайте в Fields: ctx := base.With().Fields(map[string]interface{}{"service": "api", "region": "eu", "build": ver}). Ключи отсортируются по алфавиту, значения сериализуются по типу. Для пары полей читаемее отдельные Str, для конфига из десяти значений — Fields в цикле по секциям.
Контекстные поля против полей события#
| Аспект | Контекст (With) | Событие (цепочка вызова) |
|---|---|---|
| Задаётся | один раз при создании логгера | в каждом вызове логирования |
| Известны | заранее: сервис, модуль, request_id | в момент записи: op, code, err |
| Позиция в JSON | после уровня и time | после контекста, перед message |
| Цена | сериализация один раз, далее memcpy | сериализация на каждом событии |
| Методы | тот же набор + Timestamp, Caller, Stack | тот же набор полей |
| Типичный источник | main и конструкторы | аргументы конкретной функции |
Практический порядок внедрения простой. Пройдитесь по коду и выпишите поля, которые повторяются в разных вызовах: обычно это service, env, version, module и идентификаторы потоков. Соберите базовый логгер при старте и раздайте модулям их копии через With — конструкторы хендлеров и воркеров принимают zerolog.Logger параметром. Оставьте в вызовах только уникальные поля. Через неделю посмотрите в системе сбора, как изменился поиск: записи одного запроса собираются фильтром по request_id, графики по модулям — фильтром по module. Это и есть главный дивиденд контекстных полей: не скорость записи, а предсказуемость каждой строки, которую вы будете читать глазами в три часа ночи. Перед закрытием темы проверьте два места, где With ошибается незаметно. Первое — порядок сборки: поле, добавленное в base позже, чем создана копия, в этой копии не появится, и это не баг, а снимок. Второе — имена ключей: два разных модуля, размечающие одно и то же поле с разными именами вроде user_id и userId, с гарантией дадут два графика вместо одного. Сведите имена в константы одного пакета ещё на этапе внедрения — дешевле, чем миграция после того, как на ключи опираются алерты.
Проверка: что возвращает With
Что возвращает вызов logger.With()?
Проверка: порядок в JSON
Где в JSON-записи окажется поле service, добавленное через With()?
Какой JSON выйдет из дочернего логгера?
Прочитайте код и выберите, что он выведет:
base := zerolog.New(os.Stdout).With().Str("service", "api").Logger()
auth := base.With().Str("module", "auth").Logger()
auth.Info().Str("user", "ann").Msg("ready")Увидит ли дочерний логгер новое поле родителя?
Прочитайте код и выберите, что он выведет:
base := zerolog.New(os.Stdout).With().Str("service", "api").Logger()
reqLog := base.With().Str("request_id", "r-1").Logger()
base = base.With().Str("extra", "late").Logger()
reqLog.Info().Msg("work")Соберите цепочку With
Восстановите программу: пакет, поля контекста, финализация, дочерний логгер и событие.
Программа с базовым и дочерним логгером
Расставьте строки в правильном порядке: от объявления пакета до записи дочернего логгера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: With и контекст
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- With() возвращает zerolog.Context; Logger() финализирует цепочку и создаёт логгер с контекстными полями.
- Контекст хранится готовыми JSON-байтами: на событие — один memcpy, без сериализации и аллокаций.
- Порядок в JSON: уровень, time, поля контекста, поля события, последним — message.
- Цепочка base — сублоггер на модуль — логгер на запрос размечает записи без повторов в коде.
- Logger — значение: дочерний логгер — снимок родителя, позже добавленные поля в него не приезжают.
- Context.Fields(map) сортирует ключи по алфавиту — порядок внутри контекста предсказуем.
- Timestamp и Caller — тоже контекстные поля: им место в базовом логгере, а не в каждом вызове.
Домашнее задание
Соберите дерево логгеров на локальном Go: base с service и Timestamp, два сублоггера на вымышленные модули и логгер на запрос с request_id. Запустите и проверьте по выводу позицию каждого поля в JSON. Затем пересоберите base с дополнительным полем после создания сублоггеров и убедитесь, что записи сублоггеров не изменились. Наконец, передайте reqLog в функцию с горутиной и проверьте, что все её записи размечены.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем Logger.With отличается от event-полей?
With задаёт поля один раз на логгере — они попадают в каждое событие этой копии. Поля события добавляются в конкретную запись и живут один вызов. Правило: общее для потока — в контекст, уникальное для записи — в цепочку вызова.
Что произойдёт, если событие добавит поле с тем же ключом, что в контексте?
Zerolog не дедуплицирует ключи: в JSON окажутся два поля с одним именем, а парсеры возьмут последнее. Не рассчитывайте на перезапись — следите за уникальностью имён ключей контекста и события на уровне констант в коде.
Сколько стоит With на горячем пути?
Почти ничего: поля сериализуются один раз при создании логгера, а на каждом событии готовые байты контекста копируются в буфер записи. Отключённое событие контекст не копирует вовсе. Замеры с бенчмарками — в уроке про производительность.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила