Содержание урока · 10 разделов
Сервис вырос, и теперь в нём десяток подсистем: аутентификация, биллинг, воркеры очередей, интеграции. Пишут они в один лог одним логгером, и каждая запись — вопрос «а кто это сказал?». По тексту сообщения отвечать не выходит: три модуля логируют ret, два — done, а сообщение об ошибке вообще скопировано из соседнего пакета. Разбор инцидента превращается в археологию: дежурный собирает вокруг записи соседние строки, угадывает границы запроса, и если два модуля писали вперемешку — восстанавливает последовательность по времени с точностью до недоразумения. Grep по строке не масштабируется ещё и потому, что сообщения меняются при каждом рефакторинге, а потребители логов — дашборды и алерты — должны работать годами. Решение известно из урока про контекстные поля: общая разметка живёт на логгере, а не в вызовах. Доведём идею до конца: каждой подсистеме — собственный логгер с подписью module, собранный от одного базового, и сервис в логе обретает структуру, которую читают фильтрами, а не глазами. Обещание то же, что давала механика With из прошлого урока: записью управляет конфигурация, а не память разработчика.
Zerolog сублоггеры: With().Str("module")#
Сублоггер в zerolog — это логгер модуля go, собранный от базового одной строкой: auth := base.With().Str("module", "auth").Logger(). Механика та же, что у логгера на запрос из урока про Logger.With: With возвращает накопитель контекста, Str дописывает подпись, Logger финализирует — и на выходе тип-значение zerolog.Logger со всеми свойствами родителя: тем же writer-ом, уровнем и временем, плюс собственный байтовый кусок контекста с module. Каждый вызов auth.Info() подписывает запись модулем автоматически, и код событий не упоминает module ни разу. Дерево продолжается вниз: auth.With().Str("component", "oauth").Logger() даст события с двумя полями сразу — модуль и компонент, — и на любом этаже дерево можно разветвить ещё раз. Важно, что base никуда не девается: он остаётся точкой сборки, от которой отрастают новые ветки, — конструкторы подсистем принимают base параметром и строят себе сублоггер на входе. Логгер модуля — не отдельная сущность, а та же копия логгера с готовой разметкой, и вся сила подхода в том, что разметка задаётся один раз в месте, которое легко найти и проверить.
Два сублоггера от одного base
GOpackage main
import (
"os"
"github.com/rs/zerolog"
)
const (
moduleAuth = "auth"
moduleBilling = "billing"
)
func main() {
base := zerolog.New(os.Stdout).With().Timestamp().Logger()
auth := base.With().Str("module", moduleAuth).Logger()
billing := base.With().Str("module", moduleBilling).Logger()
auth.Info().Str("user", "ann").Msg("signed in")
billing.Warn().Int("retries", 3).Msg("charge retry")
}
// Output:
// {"level":"info","time":"...","module":"auth","user":"ann","message":"signed in"}
// {"level":"warn","time":"...","module":"billing","retries":3,"message":"charge retry"}Имена модулей вынесены в константы — литералов в коде логирования нет. Оба сублоггера собраны от одного base: writer и Timestamp унаследованы, а module у каждого свой. В JSON подпись стоит после time и раньше полей события — контекст копии дописывается раньше полей вызова.
Именование: module, константы и реестр#
Ключ module станет координатой в каждой записи сервиса, поэтому конвенцию фиксируют письменно. Имя — существительное из словаря домена: auth, billing, worker, mailer; без версий и имён файлов, потому что переименованный модуль сломает дашборды, настроенные на старое значение. Вложенность раскладывают на соседние ключи — module и component, — а не в один составной auth.oauth: по каждому ключу фильтруется независимо, и графики строятся на любом уровне. Литералы в вызовах запрещают правилом линтера и ревью: имя живёт в константе пакета, а лучше — в одном месте. Когда модулей больше пяти, вместо набора констант заводят реестр: пакет logger с функцией Get(name), которая при первом обращении собирает и кеширует сублоггер от base — подсистемы просят логгер по имени и не знают, как он собран. base в реестре прячут за атомарной ссылкой или sync.Once, а до инициализации Get возвращает запасной логгер с полем module=uninitialized — такие записи в системе сбора видны сразу, и пропущенную инициализацию ловят на первом же запуске, а не по пустому каталогу через неделю. Смысловая нагрузка одна: zerolog сублоггеры работают только при дисциплине имён, потому что их продуктом является не запись, а предсказуемый ключ, по которому записи одного модуля собираются в систему сбора. Сломанный ключ — это тишина на дашборде, и её заметят позже, чем падение теста, поэтому конфигурацию сублоггеров проверяют тестом, как ниже в этом уроке.
Правила именования одним списком#
- имя модуля — существительное из словаря домена: auth, billing, worker; без версий и имён файлов
- один ключ на уровень разметки: module для подсистемы, component для вложенности — фильтры по ним независимы
- литералы в вызовах запрещены: имя живёт константой, а лучше — в реестре логгеров
- sublogger собирается от base в конструкторе подсистемы: подсистема получает логгер параметром или просит из реестра
- переименование модуля — миграция: старое значение остаётся в дашбордах и алертах
- не подписывайте модуль строкой в message — строка не фильтруется, ключ фильтруется
- новый ключ разметки — это новая схема данных: обсудите его так же серьёзно, как поле API
Уровень и writer на сублоггер#
Раз сублоггер — полноценный логгер, у него можно переопределить и уровень, и приёмник. Метод Level(lvl) возвращает копию со своим порогом: шумный модуль отладочных трасс переводится на Warn одной строкой — auth := base.With().Str("module", moduleAuth).Logger().Level(zerolog.WarnLevel), — и это не глушит соседей: базовый уровень и глобальный SetGlobalLevel из урока про уровни логов работают как раньше, порог копии проверяется своим полем. Метод Output(w) аналогично меняет writer копии: модуль аудита получает отдельный журнал, модуль отладки — файл с темами. Оба метода возвращают новые копии и не трогают родителя — value-семантика, разобранная в уроке про контекст, — поэтому эксперименты с уровнем безопасны: не понравилось, пересобрали. Порог копии без записи проверяет GetLevel(): собрали ветку с Warn — GetLevel вернёт WarnLevel, что дешевле разбора JSON в тестах конфигурации. Практический порядок такой: общий base задаёт writer и время, сублоггеры добавляют разметку, а точечные отклонения уровня или приёмника — уже поверх, на конкретных ветках. Глобальный SetGlobalLevel остаётся общим фильтром поверх всех локальных порогов, и это стоит держать в голове, когда ищете, почему модуль молчит. Если отклонений становится много, это сигнал не разветвлять дерево дальше, а возвращаться к единому приёмнику и фильтрации на стороне системы сбора: чем больше индивидуальных конфигураций, тем труднее ответить, куда вообще попадёт запись.
Типовая авария переходного периода: в пакете объявили var authLog = log.With().Str("module", "auth").Logger() — пакетная переменная инициализируется до того, как main собрал base с нужным writer-ом и уровнем, и сублоггер уехал с дефолтами в stderr. Сервис работает, логи пишутся, но мимо файла и без общей разметки; замечают через неделю по пустому каталогу логов. Правило: сублоггеры собираются не на пакетной инициализации, а после сборки base — в конструкторе подсистемы или в реестре с ленивым Get(name). Если нужен глобальный доступ, храните замыкание или атомарную ссылку, но не готовый логгер до инициализации.
Горутины и передача по значению#
Сублоггер — тип-значение, и в горутины он уезжает копией: go func(l zerolog.Logger) {...}(authLog). Копия безопасна: writer общий, но писатели zerolog — os.File и stdout — рассчитаны на параллельные записи, а буфер события у каждой записи свой из sync.Pool. Копия логгера дешёвая — структура примерно на сотню байт плюс ссылка на общий writer и уже собранные байты контекста, — так что передача по значению в воркеры и горутины норма, а не расточительство. Никаких мьютексов на логгере держать не нужно, и это осознанный контракт zerolog: один writer на сервис, логгеров сколько угодно. Гонка возможна там, где её создаёт автор кода: перезапись глобальной переменной-логгера во время работы, о чём честно предупреждал урок про первый лог, — сублоггеры эту проблему не создают, если собираются один раз и дальше только читаются. Передавайте в горутину самый размеченный логгер из доступных: воркер очереди получает логгер модуля плюс идентификатор задачи в контексте, и все его записи самодостаточны, сколько бы задач ни крутилось параллельно. Глубокая проверка потокобезопасности — pprof и гонки — отдельная тема, ей посвящён урок про логи в горутинах, а здесь достаточно правила: собрал — раздал — не меняй.
Тест конфигурации сублоггера
GOfunc TestAuthSublogger(t *testing.T) {
var buf bytes.Buffer
base := zerolog.New(&buf).With().Timestamp().Logger()
auth := base.With().Str("module", "auth").Logger()
auth.Info().Msg("signed in")
var rec map[string]any
if err := json.Unmarshal(buf.Bytes(), &rec); err != nil {
t.Fatalf("bad json: %v", err)
}
if rec["module"] != "auth" {
t.Fatalf("module = %v, want auth", rec["module"])
}
}Тест собирает тот же сублоггер, но поверх буфера: одна запись, один JSON, разбор в карту и проверка ключа module. Контракт фиксирован: подпись модуля обязана быть в каждой записи, и сломанная сборка контекста падает в тестах, а не тишиной на дашборде. Буфер вместо writer-а — приём из урока про тесты логов.
Тест выше — минимальная дисциплина, которая окупается в первый же рефакторинг: кто-то перепутает порядок With и Level, вынесет Str в переменную с другим значением или случайно соберёт сублоггер от глобального логгера — и тест поймает разницу в JSON раньше прода. Расширяйте его по мере дерева: проверяйте, что поля base на месте, что Level копии не затронула соседей, что реестр возвращает один и тот же сублоггер на повторный Get. Добавьте и обратный тест: собрать соседний сублоггер billing и убедиться, что в его записях module=billing даже когда auth живёт рядом — так ловится класс ошибок, где одна копия логгера переиспользована между двумя модулями. Здесь же уместен вопрос вкуса: не плодите сублоггер на каждый файл. Модуль — единица домена, а не единица компиляции: логгер на пакет ради пакета раздробит разметку, и фильтр по module превратится в угадывание. Мне достаточно трёх-семи веток на сервис; когда счёт идёт на десятки, чаще всего это признак, что разметка замещает нормальную структуру полей события. Различие простое: module отвечает на вопрос «кто пишет», а подробности движения — на вопрос «что происходит» — и это поля события, а не ключи разметки.
Четыре этажа дерева логгеров#
| Логгер | Что добавляет | Кому живёт | Типичный контекст |
|---|---|---|---|
| Глобальный zerolog/log | ничего: stderr и время | скрипты и первые дни | level, time |
| base | writer, уровень, service, env | точка сборки сервиса | service, env, version |
| Сублоггер модуля | подпись подсистемы | конструкторам подсистем | module |
| Логгер на запрос/задачу | идентификатор потока | обработчикам и горутинам | request_id, task_id |
Песочница сайта — тематическая программа на чистом Go: она собирает JSON-события из нескольких «модулей» вручную и показывает, как подпись модуля попадает в каждую запись. Настоящее дерево логгеров собирайте локально: go run, пара сублоггеров из урока — и фильтр grep module=auth в терминале, чтобы увидеть модульную разметку в действии.
Что дальше по курсу#
Сублоггер — прикладной итог темы контекста: в уроке про Logger.With лежит механика With и порядок полей, здесь — дисциплина именования и конфигурация. Дальше по курсу: логирование HTTP покажет сублоггер-промежуточник, который размечает запросы request_id автоматически, а урок про тесты логов развернёт буферный приёмник из этого урока в целую методику проверки записей. Справочная задача на закрепление: соберите сервис с тремя модулями, один из них переведите на Warn и напишите тест, который ловит поломку подписи module. Если тест проходит на правильной конфигурации и падает на сломанной — вы освоили главный навык урока: разметка модулей проверяется машиной, а не глазами дежурного.
Проверка: сборка сублоггера
Как создать сублоггер на модуль auth от базового логгера base?
Проверка: Level на копии
Что сделает auth.Level(zerolog.WarnLevel), где auth — сублоггер от base?
Какой JSON выйдет из сублоггера?
Прочитайте код и выберите, что он выведет:
base := zerolog.New(os.Stdout).With().Timestamp().Logger()
auth := base.With().Str("module", "auth").Logger()
auth.Info().Str("user", "ann").Msg("signed in")Что напечатает Debug у сублоггера на Warn?
Прочитайте код и выберите, что он выведет:
base := zerolog.New(os.Stdout).With().Timestamp().Logger()
auth := base.With().Str("module", "auth").Logger().Level(zerolog.WarnLevel)
auth.Debug().Str("trace", "step-1").Msg("details")Соберите сублоггер
Восстановите программу: вывод, контекст, финализация и событие модуля.
Программа с логгером модуля
Расставьте строки в правильном порядке: константа, base, сублоггер и запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: сублоггеры
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Сублоггер — копия base с подписью: base.With().Str("module", "auth").Logger().
- Разметка модуля живёт в контексте: в JSON она стоит после time и раньше полей события.
- Имена модулей — константы или реестр с Get(name); литералы в вызовах запрещены правилом ревью.
- Level() и Output() на сублоггере возвращают копию: свой порог и writer не трогают base.
- Сублоггеры собирают после base: пакетная переменная до инициализации уедет с дефолтами в stderr.
- В горутины логгер передают по значению: writer потокобезопасен, буфер события у записи свой.
- Тест с bytes.Buffer фиксирует контракт: подпись module есть в каждой записи модуля.
- Логгер — на модуль домена, а не на каждый файл: разметка «кто пишет», поля события — «что происходит».
Домашнее задание
Соберите сервис с тремя модулями — auth, billing, worker — от одного base с Timestamp. Выведите по событию из каждого модуля и убедитесь по JSON, что подписи разные, а writer общий. Переведите billing на Warn и проверьте, что его debug исчез, а у auth остались. Напишите тест из урока: буфер, одна запись, проверка module — и временно сломайте подпись, чтобы тест упал. Наконец, соберите сублоггер пакетной переменной до main и запишите своими словами, куда уехали его записи.
Личные заметкиПусто
Вопросы и ответы по уроку
Что такое сублоггер в zerolog?
Копия базового логгера с предзаданными полями контекста, чаще всего с подписью модуля: base.With().Str("module", "auth").Logger(). Каждая запись сублоггера подписана автоматически, а уровень и writer можно переопределить на копии методами Level и Output, не трогая base.
Сколько сублоггеров заводить?
По числу подсистем домена: обычно три-семь веток — auth, billing, worker, интеграции. Логгер на каждый файл раздробит разметку, и фильтры станут угадыванием. Если веток оказывается больше десяти, сначала проверьте, не дублирует ли module обычные поля события: чаще всего половина веток нужна лишь для фильтрации по значению, которое логгер мог бы класть в событие. Вложенность раскладывайте соседними ключами — module и component — и не создавайте ключи, по которым никто не ищет.
Как убедиться, что конфигурация сублоггеров не сломается?
Тестом: соберите сублоггер поверх bytes.Buffer, сделайте одну запись, разберите JSON в карту и проверьте ключ module и прочие обязательные поля. Такой тест фиксирует контракт разметки: рефакторинг, который уронит подпись, падает в CI, а не тишины на дашборде через неделю.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила