---
title: "Сублоггеры: логгер на модуль"
description: "Сублоггеры zerolog: With().Str(\"module\") на модуль, свой уровень и writer на логгер, передача в горутины и тест конфигурации через bytes.Buffer."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/10-sublogery/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Сублоггеры: логгер на модуль

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 10 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/10-sublogery/*

Zerolog сублоггеры — это копии базового логгера со своей разметкой: With().Str("module", "auth") даёт логгер модуля, чьи записи подписаны автоматически. Разберём именование модулей, отдельный уровень и writer на сублоггер, передачу логгера в горутины и тест, который не даст конфигурации сломаться молча. Сразу договоримся о слове: сублоггер — не отдельный тип из пакета zerolog, а приём — тот же zerolog.Logger, собранный через With с подписью модуля.

Сервис вырос, и теперь в нём десяток подсистем: аутентификация, биллинг, воркеры очередей, интеграции. Пишут они в один лог одним логгером, и каждая запись — вопрос «а кто это сказал?». По тексту сообщения отвечать не выходит: три модуля логируют ret, два — done, а сообщение об ошибке вообще скопировано из соседнего пакета. Разбор инцидента превращается в археологию: дежурный собирает вокруг записи соседние строки, угадывает границы запроса, и если два модуля писали вперемешку — восстанавливает последовательность по времени с точностью до недоразумения. Grep по строке не масштабируется ещё и потому, что сообщения меняются при каждом рефакторинге, а потребители логов — дашборды и алерты — должны работать годами. Решение известно из урока про контекстные поля: общая разметка живёт на логгере, а не в вызовах. Доведём идею до конца: каждой подсистеме — собственный логгер с подписью module, собранный от одного базового, и сервис в логе обретает структуру, которую читают фильтрами, а не глазами. Обещание то же, что давала механика With из прошлого урока: записью управляет конфигурация, а не память разработчика.

### Zerolog сублоггеры: With().Str("module")

Сублоггер в zerolog — это логгер модуля go, собранный от базового одной строкой: auth := base.With().Str("module", "auth").Logger(). Механика та же, что у логгера на запрос из [урока про Logger.With](/courses/zerolog/lessons/06-log-context/): With возвращает накопитель контекста, Str дописывает подпись, Logger финализирует — и на выходе тип-значение zerolog.Logger со всеми свойствами родителя: тем же writer-ом, уровнем и временем, плюс собственный байтовый кусок контекста с module. Каждый вызов auth.Info() подписывает запись модулем автоматически, и код событий не упоминает module ни разу. Дерево продолжается вниз: auth.With().Str("component", "oauth").Logger() даст события с двумя полями сразу — модуль и компонент, — и на любом этаже дерево можно разветвить ещё раз. Важно, что base никуда не девается: он остаётся точкой сборки, от которой отрастают новые ветки, — конструкторы подсистем принимают base параметром и строят себе сублоггер на входе. Логгер модуля — не отдельная сущность, а та же копия логгера с готовой разметкой, и вся сила подхода в том, что разметка задаётся один раз в месте, которое легко найти и проверить.

### Два сублоггера от одного base

```go
package 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 остаётся общим фильтром поверх всех локальных порогов, и это стоит держать в голове, когда ищете, почему модуль молчит. Если отклонений становится много, это сигнал не разветвлять дерево дальше, а возвращаться к единому приёмнику и фильтрации на стороне системы сбора: чем больше индивидуальных конфигураций, тем труднее ответить, куда вообще попадёт запись.

> **Живое место: сублоггер, собранный раньше base.** Типовая авария переходного периода: в пакете объявили 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 и гонки — отдельная тема, ей посвящён [урок про логи в горутинах](/courses/zerolog/lessons/15-log-v-gorutinah/), а здесь достаточно правила: собрал — раздал — не меняй.

### Тест конфигурации сублоггера

```go
func 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 |

> **Проверить дерево без zerolog.** Песочница сайта — тематическая программа на чистом Go: она собирает JSON-события из нескольких «модулей» вручную и показывает, как подпись модуля попадает в каждую запись. Настоящее дерево логгеров собирайте локально: go run, пара сублоггеров из урока — и фильтр grep module=auth в терминале, чтобы увидеть модульную разметку в действии.

### Что дальше по курсу

Сублоггер — прикладной итог темы контекста: в уроке про Logger.With лежит механика With и порядок полей, здесь — дисциплина именования и конфигурация. Дальше по курсу: логирование HTTP покажет сублоггер-промежуточник, который размечает запросы request_id автоматически, а [урок про тесты логов](/courses/zerolog/lessons/20-testy-logov/) развернёт буферный приёмник из этого урока в целую методику проверки записей. Справочная задача на закрепление: соберите сервис с тремя модулями, один из них переведите на Warn и напишите тест, который ловит поломку подписи module. Если тест проходит на правильной конфигурации и падает на сломанной — вы освоили главный навык урока: разметка модулей проверяется машиной, а не глазами дежурного.

### Проверка: сборка сублоггера

**Вопрос:** Как создать сублоггер на модуль auth от базового логгера base?

- ✅ base.With().Str("module", "auth").Logger()
- zerolog.New(base).Str("module", "auth")
- base.Info().Str("module", "auth").Msg("")
- zerolog.Sub(base, "auth")

Сублоггер — копия base, собранная через контекст: With возвращает накопитель, Str добавляет подпись, Logger финализирует. Конструктора zerolog.Sub не существует, а разметка на событии жила бы один вызов.

### Проверка: Level на копии

**Вопрос:** Что сделает auth.Level(zerolog.WarnLevel), где auth — сублоггер от base?

- Сменит порог у base и всех его копий
- ✅ Вернёт копию auth с порогом Warn, не трогая base
- Выключит логирование auth полностью
- Вернёт ошибку: уровень меняют только глобально

Logger — тип-значение: Level возвращает новую копию со своим порогом. base и соседние сублоггеры продолжают писать как раньше; глобальный SetGlobalLevel при этом остаётся общим фильтром поверх локальных порогов.

### Какой JSON выйдет из сублоггера?

```go
base := zerolog.New(os.Stdout).With().Timestamp().Logger()

auth := base.With().Str("module", "auth").Logger()

auth.Info().Str("user", "ann").Msg("signed in")
```

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

- ✅ {"level":"info","time":"...","module":"auth","user":"ann","message":"signed in"}
- {"level":"info","module":"auth","time":"...","user":"ann","message":"signed in"}
- {"level":"info","time":"...","user":"ann","module":"auth","message":"signed in"}
- {"level":"info","time":"...","message":"signed in","module":"auth","user":"ann"}

Порядок контекста — порядок сборки: time добавлен в base, module — в сублоггере, поле user — в вызове, message — последним при финализации. Именно поэтому подпись модуля всегда стоит раньше полей события.

### Что напечатает Debug у сублоггера на Warn?

```go
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")
```

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

- ✅ Ничего: Debug ниже порога Warn копии
- Запись с module=auth: контекст важнее уровня
- Запись без поля trace: отключённые поля выкидываются
- Панику: Debug на копии с порогом запрещён

Порог копии проверяется в should: событие ниже Warn отсекается, Debug возвращает nil-событие, цепочка становится no-op. base и другие модули продолжают писать debug — порог локальный для этой копии.

### Соберите сублоггер

Восстановите программу: вывод, контекст, финализация и событие модуля.

```go
package ___

import (
        "os"

        "github.com/rs/zerolog"
)

const moduleAuth = "auth"

func main() {
        base := zerolog.New(os.___).With().Timestamp().Logger()

        auth := base.___().Str("module", moduleAuth).___()

        auth.___().Str("user", "ann").Msg("signed in")
}
```

Подсказка: Пакет — main; вывод — os.Stdout; сублоггер собирает With, финализирует Logger; штатное событие начинает Info.

Правильные ответы в пропусках: `main`, `Stdout`, `With`, `Logger`, `Info`

### Программа с логгером модуля

Расставьте строки в правильном порядке: константа, base, сублоггер и запись.

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

1. `package main`
2. `import (`
3. `  "os"`
4. `  "github.com/rs/zerolog"`
5. `)`
6. `const moduleBilling = "billing"`
7. `func main() {`
8. `  base := zerolog.New(os.Stdout).With().Timestamp().Logger()`
9. `  billing := base.With().Str("module", moduleBilling).Logger()`
10. `  billing.Warn().Msg("charge retry")`
11. `}`

### Карточки: сублоггеры

- **Сублоггер** — Копия базового логгера с готовой разметкой: base.With().Str("module", "auth").Logger(). Подписывает каждую запись модулем без повторов в коде.
- **base** — Точка сборки дерева: writer, уровень и общие поля (service, env, Timestamp). От него отрастают сублоггеры модулей и логгеры на запрос.
- **module** — Ключ разметки подсистемы: фильтр по нему собирает записи модуля в системе сбора. Имена — в константах; переименование — миграция.
- **Level() и Output() на копии** — Возвращают новую копию со своим порогом или writer-ом, не трогая родителя. Шумный модуль — на Warn; аудит — в отдельный журнал.
- **Тест конфигурации** — Сублоггер поверх bytes.Buffer: одна запись, json.Unmarshal в карту, проверка ключа. Ломается в тестах, а не тишиной на дашборде.
- **Реестр логгеров** — Пакет logger с Get(name): сублоггеры собираются лениво от base и кешируются. Подсистемы просят логгер по имени и не знают деталей сборки.

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

- Сублоггер — копия 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, а не тишины на дашборде через неделю.
