Анатомия записи фиксирована. Событие рождается с ключом level — порогом, который перед записью проходит через LevelFieldMarshalFunc. Затем ложится байтовый контекст логгера: то, что собрано через With — service, request_id, timestamp. Потом едут ваши поля события в порядке вызовов. Последним, при финализации, дописывается message — и только если оно непустое: Send с пустым сообщением даёт запись без ключа message вовсе. Порядок подтверждается исходниками: newEvent сначала пишет уровень, затем добавляет контекст логгера, поля цепочки следом. Парсеру порядок безразличен — JSON-объект неупорядочен по спецификации, — но людям и diff-ам порядок важен: стабильные первые ключи дают визуальные якоря при просмотре ленты, а одинаковый порядок полей между релизами делает дифы логов читаемыми. Фиксированный скелет — не косметика: первые ключи парсеры пайплайна разбирают с минимальными настройками, а хвост ленты глазами просматривает дежурный при tail — когда level и service всегда на своих местах, аномалия видна на первой сотне строк. Отсюда простое правило: контекст — для того, что есть у каждого события модуля или запроса, поля — для факта конкретной записи; это разделение из урока про поля события прямо диктует и структуру JSON: два источника, два участка строки, message всегда в хвосте.
Скелет записи целиком
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
func main() {
logger := zerolog.New(os.Stdout).With().
Str("service", "orders").Timestamp().Logger()
logger.Info().Str("op", "create").Int("items", 3).Msg("order created")
}
// {"level":"info","service":"orders","time":"2024-04-06T12:00:00+03:00",
// "op":"create","items":3,"message":"order created"}Порядок ключей здесь виден целиком: level дописан при создании события, затем контекст — service и time в том порядке, как вызывались в With, затем поля события и message последним. Контекст лежит в логгере байтами, поэтому на каждое событие не тратится ни одной пересборки — строка склеивается из готовых кусков.
Имена ключей: переменные zerolog#
Имена скелетных ключей — переменные пакета, а не константы: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте: чужая система сбора, которая ждёт ts вместо time, — одна строка до создания логгера. Это обычные переменные без защиты: присваивайте до запуска горутин, чтобы не устроить гонку на горячем пути. Переопределение живёт в инициализации пакета логирования — первая строка main или func init, до первого события и до старта воркеров: переменные читаются на каждой записи, и менять их в рантайме — значит гонять присваивание наперегонки с записью, безо всякой синхронизации вокруг них. Значение уровня тоже настраивается — LevelFieldMarshalFunc превращает info в INFO, если формат вашей ленты требует верхний регистр. Время — TimeFieldFormat: RFC3339 по умолчанию, а TimeFormatUnix и TimeFormatUnixMs дают целые числа, что экономит байты и упрощает сортировку. Что важно понимать честно: API для массового переименования собственных полей в zerolog нет — ключ из Str("user_id") навсегда ваш строковый литерал. Имена фиксируются константами вашего пакета логирования, и их смена — осознанная миграция дашбордов, а не правка одной строки. Поле error из Err — часть того же скелетного словаря: алерты и дежурные ищут именно его, поэтому переопределять ErrorFieldName без крайней необходимости не стоит.
Dict и Array: вложенность без рефлексии#
Два способа собрать вложенное. Dict: zerolog.Dict() создаёт безымянное событие, вы наполняете его обычными методами полей, а e.Dict(key, dict) закрывает его фигурной скобкой и вклеивает под вашим ключом; тот же метод есть у Context для контекста. Array: zerolog.Arr() создаёт массив с тем же словарём методов — Str, Int, Dur, Time, Object, Dict; e.Array(key, arr) записывает его значением ключа. Оба пути — типизированные методы, дописывающие байты: рефлексии нет, аллокаций почти нет, вложенность стоит примерно столько же, сколько плоские поля. Практический ориентир — группы, у которых есть смысл как целое: http с method, path и status, client с ip и версией приложения. Поля, которые фильтруются по отдельности, вложенности не просят: level, request_id и op живут наверху. Для структур с поведением есть Object: тип реализует LogObjectMarshaler с методом MarshalZerologObject(e *Event) и сам раскладывает себя по полям события; EmbedObject вклеивает те же поля без имени-обёртки. Третий путь — Interface: если значение не умеет MarshalZerologObject, оно уезжает через encoding/json с выключенным HTML-экранированием, и имена ключей определяют теги json структуры. Именно поэтому Object и Dict — инструменты горячего пути, а Interface — редких дампов: цена и предсказуемость различаются, экономику этого выбора разбирает урок про производительность.
Объект, словарь и массив в одной записи
GOpackage main
import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
type request struct {
Method string
Path string
}
func (r request) MarshalZerologObject(e *zerolog.Event) {
e.Str("method", r.Method).Str("path", r.Path)
}
func main() {
log.Info().
Object("request", request{"POST", "/api/orders"}).
Dict("client", zerolog.Dict().Str("ip", "203.0.113.7")).
Array("ids", zerolog.Arr().Int(101).Int(102)).
Msg("batch accepted")
}
// {"level":"info","time":"...","request":{"method":"POST","path":"/api/orders"},
// "client":{"ip":"203.0.113.7"},"ids":[101,102],"message":"batch accepted"}Object зовёт MarshalZerologObject и вклеивает объект под ключом request: метод сам решает, какие поля и в каком порядке уедут. Dict собирает подобъект на месте из методов полей, Array пишет массив чисел. Все три пути обходятся без рефлексии: байты дописываются в буфер теми же Append-методами, что и плоские поля события.
Вложенность чаще всего нужна в двух местах. Первое — контекст: поля, общие для всех событий запроса, собираются в With и могут быть группой — Context.Dict вклеивает объект прямо в байтовый контекст логгера, и каждое событие уносит его без повторных вызовов. Второе — событие-отчёт: дамп конфигурации при старте, сводка пакета задач, результат батча. Промежуточные варианты — Dict в каждой записи горячей цепочки «на всякий случай» — это байты, которые платят все события ради одного, кому они были нужны; правило из урока про условные поля применимо и здесь: собирайте вложенность там, где она объясняет запись, а не украшает её. Отдельный случай — слайс своих структур: вместо ручного перебора с Dict на каждый элемент определите тип-маршалер. У LogArrayMarshaler есть метод MarshalZerologArray(a *Array) — типизированный путь, который пишет массив целиком через те же методы, и e.Array принимает такой тип напрямую, без ручной сборки. Маршалер переиспользуется: один и тот же тип годится и для Object, и для EmbedObject, и для контекста, и для события — раскладка по полям описана один раз, рядом с самим типом.
Что окажется в JSON#
| Метод | Форма в JSON | Когда брать |
|---|---|---|
| Dict(key, zerolog.Dict()) | объект под ключом | группировка родственных полей без своих типов |
| Array(key, zerolog.Arr()) | массив значений | списки идентификаторов и тегов |
| Object(key, obj) | объект из MarshalZerologObject | свои типы на горячем пути |
| EmbedObject(obj) | поля объекта прямо в событии | плоская запись без имени-обёртки |
| Interface / Any | то, что выдаст encoding/json | редкие дампы структур с тегами json |
| RawJSON(key, b) | готовые байты как есть | значение уже сериализовано, например из кэша |
Если структура уехала в Interface, форму записи контролируют теги json: переименовали поле, добавили omitempty — и лог сменил форму без единой правки в вызовах. Непроэкспортированное поле вообще исчезнет из вывода: encoding/json его не видит. Если структура лога важна, закрывает её тест: буфер и сравнение ключей ловит смену формы раньше дежурного.
Плоская структура для Loki#
Loki собирает ленту JSON-объектов: каждая строка — самостоятельный объект, и запросы LogQL разбирают его ключи. Практические следствия для zerolog. Первое: ключи верхнего уровня — ваши главные фильтры; json-парсер LogQL достаёт их и даёт фильтровать ленту по значению, поэтому service, op, level и request_id должны лежать наверху, а не внутри объектов. Второе: вложенные объекты парсер разворачивает в плоские имена с подчёркиваниями — method внутри объекта http станет http_method; один уровень вложенности переживается спокойно, но каждый следующий — место, где имена перестают быть очевидными. Третье: message остаётся текстом для полнотекстового поиска, а level — стандартным ключом для распределения по уровням; оба имени заданы скелетом zerolog, и это совпадает с ожиданиями сборщика. Четвёртое — время: если пайплайн не настроен взять time как метку события, запись получит время приёма, и при задержках доставки графики будут врать. TimeFieldFormat в виде Unix-миллисекунд упрощает разбор: число парсится однозначно, а RFC3339 со смещением требует явного формата. Типовой разбор глазами дежурного: селектор по service, затем json-парсер, затем фильтр по level — все три слова это ключи вашей записи, и ни одно не появилось бы там само. Плоская запись делает каждую строку самодостаточной для такого разбора: логи разных сервисов склеиваются в одну ленту, и фильтр отделяет чужое. Итог: глубина максимум один уровень, ключи наверху, message — факт, level — стандартный, время — машиночитаемое.
Правила структуры одним списком#
- скелет записи: level, контекст из With, поля события в порядке вызовов, message последним
- имена скелета — переменные zerolog: time, level, message, error; менять до старта горутин
- собственные ключи — константы вашего пакета: переименование поля это миграция дашбордов
- вложенность — Dict, Array и Object: методы полей, без рефлексии и без сюрпризов
- Interface — для редких дампов: форму записи диктуют теги json структуры
- для Loki: ключи верхнего уровня, глубина не больше одного уровня, level и message стандартные
- time — машиночитаемым: Unix-миллисекунды парсятся пайплайном однозначнее, чем RFC3339
- каждый уровень вложенности — плюс скобки и байты, минус очевидность имён в фильтрах
Цена вложенности и ключ error#
Вложенность не бесплатна до бесконечности. Каждая скобка — байты, каждый подобъект — вызовы методов и ветвления; на тысячах событий в секунду разница между плоским событием из шести полей и трёхуровневой конструкцией заметна в профиле — та же экономика, что и в производительности. Отдельно про значения-объекты в Interface: encoding/json упакует и слайсы, и вложенные структуры, но заплачено будет рефлексией на каждой записи; если такой дамп нужен раз в сутки при старте — цена незаметна, а если он переехал в обработчик запроса, профиль покажет рост аллокаций раньше, чем вы успеете удивиться. Читаемость страдает раньше скорости: вложенный объект занимает строку на каждый уровень и в консоли, и в дампе, и поиск глазами по ленте замедляется. Договоритесь в команде на одном правиле: два-три имени объектов максимум — например, http для запроса и client для источника, — внутри всё плоско. Err стоит отдельно: его ключ задаётся ErrorFieldName и по умолчанию равен error, значение — текст ошибки или пропуск при nil, как разобрано в уроке про поля. Этот ключ тоже часть словаря сборщика: алерты строятся по error, и переопределять имя стоит только вместе с ними. Поле Errs для слайса ошибок и AnErr для ошибки-объекта завершают семейство — оно целиком разобрано в уроке про логирование ошибок.
В песочнице библиотека не исполняется: программа урока разбирается построчно, а форма JSON видна в комментариях к коду. Локально достаточно go run main.go — настоящие строки ленты покажут и порядок ключей, и вложенность так, как их увидит Loki.
Проверка: хвост записи
Какой ключ в JSON-записи zerolog дописывается последним?
Проверка: Dict против Interface
Чем Dict выгоднее Interface для вложенного объекта?
Что окажется в JSON?
Прочитайте код и выберите, что он выведет:
log.Info().
Dict("http", zerolog.Dict().
Str("method", "GET").
Int("status", 200)).
Msg("done")Собрать вложенное событие
Восстановите вызовы: объект под ключом, сам объект и список идентификаторов.
Событие с вложенностью
Расставьте строки в правильном порядке: импорты, функция с вложенным событием из словаря и массива.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: структура JSON
Что дальше по курсу#
Следующий урок — тесты логов: bytes.Buffer вместо writer-а, разбор JSON в тесте и фиксация структуры из этого урока как контракта. После — логи и метрики Prometheus: ключи записи становятся колонками дашбордов, а счётчик рядом с событием превращает ленту в алерт. Структура, которую вы сегодня зафиксировали, — это и есть контракт, который будут защищать тесты.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Скелет записи: level, контекст из With, поля в порядке вызовов, message последним.
- Имена скелета — переменные пакета: time, level, message, error, caller; менять до старта горутин.
- Dict и Array собирают вложенность методами полей — без рефлексии и аллокаций.
- Object и EmbedObject — свои типы через MarshalZerologObject; Interface — encoding/json и теги.
- Форму Interface-поля меняют теги json: фиксируйте структуру тестом.
- Для Loki: ключи наверху, глубина до одного уровня, message — факт, level — стандарт.
- Время — машиночитаемое: TimeFormatUnixMs парсится пайплайном однозначнее RFC3339.
- RawJSON пишет готовые байты без проверок и экранирования — ответственность на вызывающем.
Домашнее задание
Соберите событие с тремя видами структуры: Dict для клиента, Array для идентификаторов и Object на собственном типе с MarshalZerologObject — и рассмотрите JSON: проверьте порядок ключей и то, что message в хвосте. Переопределите TimestampFieldName на ts при старте и убедитесь, что лента сменила имя ключа. Затем добавьте структуру с тегами json и передайте её через Interface: переименуйте поле, добавьте omitempty и посмотрите, как форма записи поменялась без правки вызова. Напишите тест, который пишет это событие в буфер и сверяет набор ключей верхнего уровня — он станет стражем контракта. Напоследок посчитайте байты: плоский вариант против варианта с тремя уровнями вложенности — разницу увидите на первой тысяче событий.
Личные заметкиПусто
Вопросы и ответы по уроку
Какие имена ключей использует zerolog в JSON?
Скелетные имена — переменные пакета: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте программы до запуска горутин — это обычные переменные без защиты от гонок. Собственные поля именуете вы: API массового переименования в библиотеке нет, ключи Str — ваши строковые литералы.
Как вложить объект или массив в запись zerolog?
Dict: zerolog.Dict() создаёт безымянное событие, методы полей наполняют его, а e.Dict(key, dict) вклеивает объект под ключом. Array: zerolog.Arr() с тем же словарём методов и e.Array(key, arr). Для своих типов — Object с методом MarshalZerologObject. Все пути пишут байты напрямую; Interface остаётся для значений, которые сериализует encoding/json по тегам.
Какая структура JSON нужна Loki?
Плоская: json-парсер LogQL берёт ключи верхнего уровня как поля фильтров, а вложенные объекты разворачивает в имена с подчёркиваниями. Держите service, level, request_id и op наверху и ограничьте глубину одним уровнем. Настройте пайплайн на разбор time как метки события, иначе записи получат время приёма, и графики соврут при задержках доставки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила