Типизированные поля: байты вместо рефлексии#
Поле в zerolog — это метод события, который дописывает в буфер готовые JSON-байты: ключ с кавычками, двоеточие и значение в правильном для типа виде. Тип известен на этапе компиляции — метод Int не может принять строку, — поэтому сериализация обходится без рефлексии и без упаковки в interface{}: число ложится в JSON числом, булево значение словами true или false, строка строкой. Порядок ключей в записи равен порядку вызовов: если вам важно, как поля выглядят глазами или в diff-ах, просто вызывайте методы в нужном порядке. Сообщение живёт отдельно от полей — оно дописывается при финализации и оказывается последним ключом. Для систем сбора типизированные поля — главный аргумент: Elasticsearch и ClickHouse получают числовые значения числами и умеют по ним агрегировать, тогда как строка «items: 3» внутри message остаётся мусором, из которого данные приходится доставать регуляркой. Проверить всё это проще всего локально: zerolog в песочнице сайта не исполняется, она показывает тематическую программу на чистом Go, а библиотека ждёт вас в терминале. Цепочка при этом не создаёт объектов: каждый метод поля дописывает байты и возвращает тот же указатель события, поэтому десять полей стоят структурно столько же, сколько одно. Из типизации следует и безопасность рефакторинга: переименование ключа компилятор не заметит — это просто строка, — а вот смену типа значения поймает сразу. И автодополнение работает за типизацию: редактор предлагает ровно те методы полей, что подходят значению, — опечатка в имени метода ловится до запуска, а не на ревью вывода.
Событие с набором полей
GOpackage main
import (
"time"
"github.com/rs/zerolog/log"
)
func main() {
log.Info().
Str("order_id", "A-1024").
Int("items", 3).
Int64("amount_cents", 149000).
Float64("weight", 1.75).
Bool("paid", true).
Dur("elapsed", 317*time.Millisecond).
Time("due", time.Date(2024, 4, 7, 12, 0, 0, 0, time.UTC)).
Msg("order updated")
}
// {"level":"info","time":"2024-04-06T12:00:00+03:00","order_id":"A-1024","items":3,
// "amount_cents":149000,"weight":1.75,"paid":true,"elapsed":317,
// "due":"2024-04-07T12:00:00Z","message":"order updated"}Каждый метод дописывает пару ключ-значение и возвращает событие дальше по цепочке. В JSON сохранены типы: items и amount_cents — числа без кавычек, paid — true, elapsed — число миллисекунд, due — строка RFC3339, как задал метод Time. Ключи идут в порядке вызовов, а message дописан последним — так zerolog финализирует каждое событие. Time здесь пишет строкой, а Dur — числом: единицы у каждого метода свои, и смешивать их в дашбордах не стоит.
Dur: длительности в миллисекундах#
Метод Dur принимает time.Duration и по умолчанию пишет значение числом миллисекунд: 317time.Millisecond превращается в "elapsed":317. За единицу отвечает глобальная переменная zerolog.DurationFieldUnit — по умолчанию time.Millisecond; если вашим дашбордам нужны секунды, поставьте её равной time.Second при старте, а DurationFieldInteger=true отрежет дробную часть у целых значений. Почему за дефолт взяты миллисекунды: это единица латентностей — HTTP-запросы, запросы к базе и кэшу живут в десятках и сотнях миллисекунд, и именно в этих единицах построены графики p50 и p99. Дробные доли миллисекунды Dur тоже пишет: 500time.Microsecond даст 0.5. Если дроби не нужны, DurationFieldInteger=true пишет целые миллисекунды без хвоста — для индексов это дешевле. Ошибка новичка — логировать длительность строкой через .Str("elapsed", d.String()): получившаяся «317ms» не сортируется и не агрегируется, а число из миллисекунд — сразу готовая метрика в системе сбора. Канонический замер живёт рядом с операцией: start := time.Now() на входе, дальше Dur("elapsed", time.Since(start)) в записи — поле показывает полную жизнь запроса, а замерять куски удобнее отдельными полями: db_ms и render_ms расскажут больше, чем один elapsed, который придётся объяснять.
Err: ошибка как данные#
Метод Err — самый частый гость в боевом коде. Вызов .Err(err) кладёт в запись ключ error со значением err.Error() — текстом ошибки. Ключевое поведение: если err равен nil, ключ не добавится вовсе — цепочка просто продолжится без поля, поэтому не надо строить if err != nil вокруг логов «на всякий случай». Ошибка в поле — это данные: система сбора строит по error частоты, группирует тексты, ищет всплеск одинаковых провалов, тогда как ошибка, вклеенная в message, остаётся текстом без индекса. Родственники Err закрывают остальные случаи: AnErr кладёт ошибку, умеющую сериализоваться объектом — метод MarshalZerologObject у ошибок с деталями; Errs пишет слайс ошибок одним полем; Stack() добавляет к ошибке стек, если настроен ErrorStackMarshaler — вся механика стека в уроке про логирование ошибок. Рабочее правило: каждый вызов Error несёт .Err(err), иначе запись объясняет, что провалилось, но не объясняет почему. Обёрнутые через %w ошибки Err печатает целиком: err.Error() склеивает тексты цепочки через двоеточие — читаемо, но не индексируемо по частям, поэтому детали для графиков лучше выводить отдельными полями. Если у ошибки есть данные — код ответа шлюза, идентификатор транзакции, — им место рядом, самостоятельными ключами.
Ошибка в логе: правильный вызов
GOpackage main
import (
"errors"
"github.com/rs/zerolog/log"
)
func chargeOrder() error {
return errors.New("card declined")
}
func main() {
if err := chargeOrder(); err != nil {
log.Error().
Err(err).
Str("op", "charge-order").
Int("attempt", 2).
Msg("payment failed")
}
}
// {"level":"error","time":"...","error":"card declined","op":"charge-order",
// "attempt":2,"message":"payment failed"}log.Error() задаёт уровень, Err(err) приносит ключ error с текстом провала, Str и Int добавляют контекст: какая операция и какая попытка. По такой записи система сбора ответит на вопросы «как часто падает charge-order» и «какие тексты ошибок всплывают» без единой регулярки. Автоматического стека здесь нет — он включается методом Stack() с настроенным ErrorStackMarshaler. if err != nil в примере ради уровня Error, а не ради Err: при nil поле бы просто не появилось.
Словарь методов-полей#
| Метод | Тип значения | Ключ в JSON | Заметка |
|---|---|---|---|
| Str | string | ваш ключ | идентификаторы, статусы, версии |
| Int / Int64 / Float64 | числа | ваш ключ | без кавычек — готовы к агрегации |
| Bool | bool | ваш ключ | true / false без кавычек |
| Dur | time.Duration | ваш ключ | число миллисекунд по умолчанию |
| Time | time.Time | ваш ключ | формат задаёт TimeFieldFormat |
| Err | error | error | nil не добавляет ключ вовсе |
| AnErr / Errs | error / []error | ваш ключ | ошибка-объект и слайс ошибок |
| Any | interface{} | ваш ключ | рефлексия: медленнее и с аллокациями |
| RawJSON | []byte | ваш ключ | готовые JSON-байты без экранирования |
Any в zerolog: универсальное поле и его цена#
Метод Any принимает interface{} и умеет закодировать почти всё — но делает это рефлексией в рантайме, а рефлексия стоит: аллокации и десятки раз более медленный путь, чем у типизированных полей. Это не повод запретить Any, а повод понимать, где он уместен: разовые записи со сложными структурами, редкие пути вроде отчётов о панике, прототипы, где сначала смотрят на данные, а потом размечают поля. На горячем пути — в обработчике HTTP или внутреннем цикле — Any заменяется типизированными методами, а вложенные структуры пишутся Dict и Array: они собирают подобъекты и списки без выхода в рефлексию. Средняя позиция — поля, которые пишутся только при условии: zerolog позволяет собирать событие по частям, и этот приём с bool-хелперами разбирается в уроке про условные поля. Каждая аллокация в логе на высоких RPS — аллокация, которая досталась логам, а не бизнесу: тема целиком раскрывается в уроке про производительность. Типичный примиряющий сценарий: Any живёт в обработчике паники и в дампе конфига при старте — два места на весь сервис, оба не на горячем пути. Как только такой вызов переезжает в хендлер запроса, он становится долгом: каждое событие платит рефлексией за данные, у которых были имя и тип ещё на этапе компиляции.
С числовыми методами спешка дороже всего. Int принимает int: на 64-битных платформах это 64 бита, на 32-битных — 32, и счётчик, уехавший за два миллиарда, на слабой машине запишется неверно; для больших значений существует Int64, для явной ширины — Int32 и Int8. Float64 — для весов, долей и коэффициентов, но не для денег: двоичная дробь не умеет 0.1 без хвоста, и сумма в 19.99 рано или поздно появится в логе как 19.990000000000002. Деньги пишите целыми: amount_cents через Int64 — центы, копейки, тиыны; если сумма приходит строкой из формы, конвертируйте до лога, а не после. Для адресов и бинарных значений в zerolog есть готовые методы: IPAddr и MACAddr пишут адрес в привычном виде, Hex и Bytes — сырые байты, Strs и Ints — слайсы. Они типизированы так же, как Str и Int, и стоят столько же — словарь методов стоит пробежать глазами один раз, чтобы потом не городить Any там, где хватало метода по типу. Сумма 19.990000000000002 — не теория: так выглядит сложение двоичных дробей в любом языке, и лог лишь фиксирует то, что уже поехало в платежах.
Ключи полей — контракт, который вы подписываете со всеми, кто будет читать логи после вас. Соглашения простые: snake_case — user_id, а не UserID и не userId; стабильные имена — ключ, однажды попавший в дашборд, переезжать не должен; единица в имени, если она не очевидна из типа — amount_cents вместо amount. Схема полей — входной билет для системы сбора: от неё зависит, что попадёт в колонки Loki и ELK, этому посвящён урок про структуру JSON. Держите в голове и цену переименования: ключ, уехавший в дашборд, переезжает только вместе с ней — новая версия поля означает либо миграцию графиков, либо переходный период с двумя ключами в параллель. Поэтому имена выбирают один раз и в одном месте: константы в пакете логирования вместо строк по всему коду избавят от опечаток, которые молча создадут новое поле. Два подводных камня из этой же темы. Первый: zerolog не дедуплицирует ключи — два вызова Str("user", ...) в одной цепочке дадут два ключа user в JSON, и парсеры возьмут последний, а поведение остальных не гарантировано. Второй: значения полей zerolog пишет как есть — маскируйте токены и персональные данные до вызова, а не после; теме секретов посвящён отдельный урок.
Правила полей одним списком#
- snake_case: user_id, а не UserID и не userId
- стабильные имена: ключ из дашборда не переименовывают — миграция графиков дороже
- единица в имени, если не видна из типа: amount_cents, elapsed_ms
- числа — Int и Int64, деньги — целыми копейками, Float64 — только для долей и весов
- каждый Error несёт .Err(err); сообщение — факт без данных внутри
- секреты маскируются до вызова: zerolog печатает значения как есть
Msgf("payment for order %s failed: %v", id, err) греет глаза, но фильтры бессильны: и идентификатор, и ошибка заперты в message. Что сломалось бы: дашборд по order_id, алерт по тексту ошибки, частоты провалов — всё становится grep-ом. Данные — в поля, в message — только факт: payment failed. Ловится это ревью одного вызова: посчитайте плейсхолдеры в Msgf — больше одного, значит данные просятся в поля.
Числа пишите Int и Int64, а не Str: числовое поле агрегируется в ClickHouse и Loki, строковое — нет. Статусы и идентификаторы — Str. Проверяйте типы на ревью: поле, однажды уехавшее строкой, будет строкой во всех дашбордах — переименовать его позже дороже, чем сразу выбрать Int. Исключение — идентификаторы с ведущими нулями и коды вроде 007: они числа только формально, пишите их Str.
Проверка: Err и nil
Что произойдёт при вызове log.Info().Err(nil).Msg("ok")?
Проверка: единицы Dur
В каких единицах метод Dur пишет time.Duration по умолчанию?
Какой порядок ключей получится?
Прочитайте код и выберите, что он выведет:
log.Info().Str("a", "1").Int("b", 2).Msg("hi")Что окажется в поле elapsed?
Прочитайте код и выберите, что он выведет:
log.Warn().Dur("elapsed", 250*time.Millisecond).Msg("slow query")Событие ошибки
Восстановите запись о провале: уровень ошибки, сама ошибка в поле, числовой контекст.
Программа с ошибкой в поле
Расставьте строки в правильном порядке: импорты, создание ошибки и запись с полями.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: поля события
Что дальше по курсу#
Следующий урок — Logger.With: поля, которые есть у каждого события, от имени модуля до request_id запроса. Затем — условные поля: собирать событие по частям и платить только за нужное. Для ошибок глубже — урок про логирование ошибок со стеком и обёртками, а про то, как поля едут в Loki и ELK, расскажет урок про структуру JSON.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Поля zerolog — типизированные методы цепочки: Str, Int, Int64, Float64, Bool, Dur, Time.
- Порядок ключей в JSON равен порядку вызовов; message дописывается последним, при финализации.
- .Err(err) кладёт ключ error; с nil ключ не появляется — if вокруг лога не нужен.
- Dur пишет миллисекунды числом; единица задаётся zerolog.DurationFieldUnit, целочисленность — DurationFieldInteger.
- Any кодирует всё через рефлексию — годится для редких путей, на горячем пути типизированные поля и Dict/Array.
- Int на 32-битных платформах — 32 бита: большие значения и деньги пишите Int64, Float64 — не для денег.
- Ключи — контракт для систем сбора: snake_case, стабильные имена, никакого Sprintf внутри Msg.
Домашнее задание
Соберите событие из шести полей разных типов — Str, Int, Float64, Bool, Dur и Time — и рассмотрите JSON: проверьте, что каждый тип сериализовался правильно. Потом замените Dur на Str с d.String() и посмотрите, как число превратилось в строку. Наконец, вызовите Err(nil) на событии Info и убедитесь, что ключа error в выводе нет. В конце сделайте два вызова Str("user", ...) в одной цепочке и посмотрите, как выглядит JSON с дубликатом ключа.
Личные заметкиПусто
Вопросы и ответы по уроку
Как добавить поле в лог zerolog?
Цепочкой после метода уровня: log.Info().Str("user", "ann").Int("age", 30).Msg("..."). Каждый метод дописывает типизированную пару в JSON: строковые — Str, числовые — Int/Int64/Float64, флаги — Bool, длительности — Dur, ошибки — Err. Для слайсов есть Strs и Ints, для адресов — IPAddr и MACAddr, для готовых JSON-байтов — RawJSON.
Что делает zerolog.Err(err) при err = nil?
Ничего: ключ error не добавляется, событие пишется как обычно. Это позволяет вызывать .Err(err) в общем пути кода без проверки на nil — поле появится только при настоящей ошибке, проверку делает сама библиотека.
Почему .Any медленнее других полей?
Any принимает interface{} и определяет тип через рефлексию в рантайме — это аллокации и заметно более медленный путь. На горячем пути используйте типизированные методы: они пишут байты напрямую в буфер события. Если без Any не обойтись, профилируйте: иногда хватает перехода на Dict без рефлексии.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила