---
title: "Контекст логгера: With и Named"
description: "zap with named: контекстный логгер go — logger.With и logger.Named, дешёвые дочерние логгеры, один логгер на модуль, DI через структуру. С примерами кода."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/12-with-named/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Контекст логгера: With и Named

*Курс «Zap: структурное логирование на Go», урок 12 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zap/lessons/12-with-named/*

zap with named — это пара методов контекстного логгера go: With прицепляет поля ко всем записям потомка, Named даёт модулю имя в каждой строке. Дочерние логгеры в zap копеечны, и на них держится вся дисциплина больших проектов: один логгер на модуль, поля контекста без повторов и чистые сигнатуры вместо глобальных переменных.

Алерт приводит вас в логи в 2 часа ночи. Записи двух видов: «ошибка списания» и «ошибка списания» — поле error разное, всё остальное одинаковое. Grep по тексту выдаёт триста строк за сутки из billing, payments и отчётного воркера, и отличить их друг от друга нечем: ни имени модуля, ни контекста. Корень проблемы старый: логгер один на всё приложение, а модулей много, и каждый пихает в записи то, что вспомнил. Лечение — контекст: логгеру сообщают, кто он и что у него вокруг, один раз при создании, и дальше каждая запись уносит это с собой. В zap для этого два метода: With(fields...) добавляет поля, Named(name) добавляет имя. Оба возвращают новый логгер, оба копеечны по ресурсам, и оба меняют стиль проекта сильнее, чем кажется: пропадают повторяющиеся поля, появляются модули, а передача логгера в зависимости становится осмысленной. Дальше в уроке: механика обоих методов, цена потомков, конвенция одного логгера на модуль и вопрос «глобальный логгер или поле структуры», который в каждом втором ревью вызывает спор.

### Контекстный логгер go: Named

logger.Named("billing") возвращает логгер, у которого имя «billing» попадает в каждую запись — в JSON его пишет ключ из EncoderConfig: NameKey, у production-энкодера это «logger». Имя — это иерархия через точку: Named у именованного логгера добавляет подскоп, billing.Named("repo") даст «billing.repo». Если логгер ещё не имел имени, первый Named становится корнем; два Named подряд складываются, не перезаписывают. Связка zap With Named — это то, что в англоязычных источниках зовут child loggers: контекст накапливается слоями, и каждый слой добавляет только своё. Зачем имя, если есть поле? Фильтрация в системах сбора: в Loki и Kibana выборка по имени логгера — операция первого класса, а поле service вам всё равно придётся ставить отдельным With, потому что имя логгера и имя сервиса — вещи разные: сервис один, модулей внутри много. На практике работают так: корневой логгер собирается при старте из конфига, и каждый пакет при инициализации создаёт себе именованного потомка — auth, billing, api, worker. В записи это выглядит как поле «logger» со значением «billing.repo» — и ночные расследования перестают быть археологией.

### Named: логгер с именем модуля

```go
logger, _ := zap.NewProduction()
defer logger.Sync()

base := logger.Named("billing")
repo := base.Named("repo")

base.Info("charge started", zap.Int("amount", 990))
repo.Warn("slow query", zap.Duration("took", 480*time.Millisecond))
```

Две записи с полем logger: первая — «billing», вторая — «billing.repo». Создание потомков ничего не стоит: имя хранится строкой в контексте логгера. В консольном формате дев-энкодера имя выводится колонкой, в JSON — ключом NameKey; фильтруйте в системе сбора по нему как по обычному полю. Запуск примера — локально, как и весь курс.

### With: поля, которые повторять не хочется

logger.With(fields...) — вторая половина контекста: возвращает логгер, который на каждую запись приклеивает переданные поля. Экономия видна сразу: вместо logger.Info("charge started", zap.String("service", "billing"), zap.String("module", "repo")) в каждом вызове — один раз base := logger.With(zap.String("service", "billing"), zap.String("module", "repo")) и дальше короткие Info и Warn. Но главное не удобство, а корректность: повторяющиеся поля, набитые руками в каждом вызове, рано или поздно расходятся — в одном месте забыли version, в другом опечатались в ключе, и одинаковые события перестали группироваться в системе сбора. With решает это структурно: контекст задаётся в одном месте. Иммутабельность при этом полная: With не меняет исходный логгер, а возвращает нового потомка; поля потомка не просачиваются к родителю и наоборот. Наследование накопительное: потомок получает всё от родителя плюс своё — с этим мы уже сталкивались в уроке про SugaredLogger, у которого есть собственные With и Named с парами ключ-значение вместо конструкторов полей.

### With: контекст запроса без повторов

```go
type Handler struct {
        log *zap.Logger
        db  *sql.DB
}

func (h *Handler) handleOrder(order *Order) {
        log := h.log.With(
                zap.String("order_id", order.ID),
                zap.String("user_id", order.UserID),
        )

        log.Info("order accepted")

        if err := h.db.Save(order); err != nil {
                log.Error("order save failed", zap.Error(err))
                return
        }

        log.Info("order saved")
}
```

Логгер контекстуализируется один раз в начале обработки — обе записи и ошибка несут order_id и user_id без повторов в каждом вызове. zap.Error внутри if добавляет ошибку поверх контекста: поля складываются в порядке добавления. Именно этот приём станет основой per-request логгера в уроке про request ID.

### Стек контекста: слои от сервиса до операции

Контекст накапливается слоями, и полезно видеть типовую схему целиком. Первый слой — приложение: метки app, version, env из InitialFields или опции zap.Fields живут в каждой записи с первого байта. Второй — модуль: Named-потомок при инициализации пакета, он же корень для всего его кода. Третий — операция: With-потомок в обработчике запроса, в обработчике сообщения из очереди, в вызове одного отчёта; у него общий идентификатор операции и, возможно, идентификатор пользователя. Четвёртый — точечные поля в самих вызовах Info: то, что относится к конкретной строке. Слои не мешают друг другу, потому что складываются: имя от Named сохранится через все With, поля операции дополнят метки приложения, а точечные поля лягут последними. Ошибка начинается тогда, когда слой пропускают: операцию логируют от корневого логгера — и записи теряются в общем потоке; или, наоборот, приложение пихает в каждый вызов поля второго слоя — и код обрастает повторами. Дисциплина слоёв — и есть та выгода, ради которой дешёвые потомки вообще придуманы.

### Named, With, InitialFields: что выбрать

| Инструмент | Что добавляет | Когда уместен |
| --- | --- | --- |
| logger.Named("m") | имя логгера в поле logger | границы модулей и пакетов |
| logger.With(fields...) | типизированные поля каждой записи | общий контекст: сервис, запрос, пользователь |
| cfg.InitialFields | поля карты any всем записям | метки уровня приложения: app, version, env |
| zap.Fields опцией | поля при сборке логгера | то же, но без карты any |

### Дочерние логгеры дёшевы

На этом месте у бдительного читателя вспыхивает рефлекс: создание объекта на каждый запрос — дорого? В zap нет: With и Named не открывают файлов, не берут замков и не копируют энкодеры. Потомок — это копия маленькой структуры логгера: указатель на общее ядро плюс слайс полей контекста, который растёт на ваши несколько полей. Всё тяжёлое — ядро, энкодер, приёмники, сэмплер — общее и создаётся один раз. Поэтому здесь разрешено то, что в других библиотеках запрещено: заводить логгера на каждый HTTP-запрос, на каждую горутину-обработчик, на каждое сообщение из очереди. Что действительно дорого — создавать ЛОГГЕРОВ заново: zap.NewProduction или Build открывают приёмники и строят ядро; в двух местах программы — два набора открытых файлов и рассинхрон настроек. Правило простое: ядро и корневой логгер — один раз при старте, всё дальше — With и Named от него. С этим согласуется и Sync: вызов у потомка пробрасывается в общее ядро, поэтому дожидаться закрытия каждого дочернего логгера не нужно — достаточно корневого.

### Проверка: что станет с исходным логгером

**Вопрос:** base := logger.With(zap.String("module", "billing")). Что произойдёт с logger после этого?

- К logger тоже добавится поле module при следующих записях
- ✅ logger не изменится: With вернул независимого потомка
- logger сломается: поля нельзя добавлять после создания
- Поле module появится у logger, но не у его будущих потомков

With не мутирует приёмник — он возвращает нового потомка со слайсом полей поверх родительского контекста. Родитель продолжает писать без module, потомки base унаследуют поля от base, а не от logger напрямую.

### Конвенция: один логгер на модуль

Дешевизна потомков подсказывает организацию, которая работает в проектах любого размера. Корневой логгер создаётся один раз при старте — из конфига, с метками приложения из урока про zap.Config. Каждый модуль при инициализации берёт себе именованного потомка и хранит его: пакет billing живёт с logger.Named("billing"), внутренние слои уточняют — Named("billing").Named("repo"). Дальше два варианта, и оба честные. Пакетная переменная: var log = базовый.Named(...) — удобно, но глобально: в тестах подменить труднее, а порядок инициализации пакетов становится незримой зависимостью. Структурный подход: логгер — поле структуры, которое прокладывается при конструировании, — стоит дороже в набивке кода, но продаёт себя в тестах и многомодульных сервисах. Третьего не дано: создавать логгер внутри каждой функции — потеря контекста и путаница с конфигурацией. Спорное мнение, к которому я пришёл: пакетная переменная допустима в прикладном коде конечного приложения, но библиотеку, которую читают чужие проекты, логгер обязан принимать параметром — навязывать свой logger чужому приложению невежливо.

### Логгер через структуру: DI против глобального

Передача логгера через поле структуры — это dependency injection в его простейшей форме, и у неё три практических плюса. Первый — явность: Handler с полем log *zap.Logger видно в сигнатуре конструктора, зависимости не прячутся в глобальных переменных. Второй — тесты: в конструктор подсовывается наблюдатель из zaptest, и тест читает записи обработчика, не трогая stdout; тестируемость логов — тема отдельного урока курса. Третий — изоляция конфигурации: структура хранит контекстуализированного потомка, и никто внутри не знает про глобальные состояния. Глобальный zap.L — законная альтернатива для кода, куда логгер не протянешь: глубокие легаси-слои, сторонние обёртки, отладочный вывод в библиотечных хелперах; ReplaceGlobals делает его безопаснее, но глобальность остаётся глобальностью: тесты, параллельность и скрытые зависимости никуда не деваются. Мой рабочий баланс: прикладной код — поле структуры, межпакетные границы — Named-потомки, глобальный логгер — аварийный люк, а не архитектура.

### Прокладка логгера через конструктор

```go
func NewBillingService(root *zap.Logger, db *sql.DB) *BillingService {
        return &BillingService{
                log: root.Named("billing"),
                db:  db,
        }
}

func (s *BillingService) ProcessOrder(o *Order) {
        log := s.log.With(zap.String("order_id", o.ID))
        log.Info("processing")
        // ...
}
```

Корневой логгер приходит в конструктор извне, сервис берёт себе именованного потомка и хранит его в поле. Внутри методы контекстуализируют его под конкретную операцию — With на order_id живёт только в рамках ProcessOrder. Тест подставит вместо root своего наблюдателя и прочитает записи сервиса.

### Какое имя окажется в записи?

```go
base := logger.Named("api")
auth := base.Named("auth")
log := auth.With(zap.String("user", "u42"))

log.Info("token refreshed")
```

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

- logger: "auth" — второй Named перезаписал имя
- ✅ logger: "api.auth" — имена склеиваются через точку
- Поля logger не будет: With сбрасывает имя
- logger: "api" — только первый Named учитывается

Named добавляет подскоп к существующему имени: api, затем api.auth. With поля не трогает и имя не сбрасывает — потомок унаследует имя auth вместе с полями user. В записи к «logger»: «api.auth» добавится user со значением u42.

### Проверка: логгер модуля

**Вопрос:** Корневой логгер собран при старте. Как правильно получить логгер модуля billing внутри пакета?

- ✅ logger.Named("billing") — именованный потомок корневого
- zap.NewProduction() второй раз — со своим уровнем
- logger.Info("billing: charge started") — модуль в тексте сообщения
- zap.AddCaller() — имя модуля возьмётся из caller

Named — механика модулей: поле logger в каждой записи, фильтрация в системе сбора, нулевая цена. Второй NewProduction откроет приёмники заново; модуль в тексте не фильтруется; AddCaller даёт файл:строку, а не модуль.

> **SugaredLogger умеет то же.** sugar.With("service", "billing") прицепляет пары ключ-значение без конструкторов полей, sugar.Named("billing") даёт имя. Обе операции возвращают нового потомка и не мутируют обёртку. Если модуль живёт на сахаре из урока про SugaredLogger — контекстуйте его, не десугаря ради одного With.

### Соберите контекстный логгер

Восстановите цепочку: модуль, контекст запроса, запись.

```go
base := logger.___("billing")

req := base.___(zap.String("request_id", id))

req.___("charge completed")
```

Подсказка: Имя модуля добавляет Named, поля контекста — With, запись делает Info.

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

### Соберите работу с дочерними логгерами

Расставьте строки: от корневого логгера до записей запроса.

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

1. `logger, _ := zap.NewProduction()`
2. `defer logger.Sync()`
3. `base := logger.Named("billing")`
4. `req := base.With(zap.String("request_id", "a1b2c3"))`
5. `req.Info("charge started", zap.Int("amount", 990))`
6. `req.Info("charge completed")`

### Карточки: контекст логгера

- **logger.With(fields...)** — Возвращает потомка, который добавляет поля к каждой записи. Не мутирует исходный логгер; поля наследуются накопительно.
- **logger.Named(name)** — Возвращает потомка с именем: поле logger (NameKey). Имена склеиваются через точку: billing.repo. Первый Named — корень.
- **NameKey** — Ключ EncoderConfig, куда энкодер кладёт имя логгера; у production-конфига — "logger". Пустой ключ — имя не пишется.
- **Цена потомка** — Копия структуры логгера: указатель на общее ядро плюс слайс полей. Файлов не открывает — можно на каждый запрос.
- **DI через структуру** — log *zap.Logger как поле, логгер прокладывается конструктором: явные зависимости и подмена наблюдателем в тестах.
- **sugar.With / sugar.Named** — Контекст на SugaredLogger: пары ключ-значение вместо конструкторов полей, имя — тем же Named. Тоже возвращают потомков.

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

Следующий уровень контекста — сквозной: [request ID в логах](/courses/zap/lessons/14-request-id/) порождает дочерний логгер на каждый HTTP-запрос, и приём With из этого урока превращается в middleware по образцу [middleware Gin](/courses/gin/lessons/06-middleware/). Ошибки внутри модулей стоит прикладывать целиком, со стеком при необходимости, — этому посвящён урок про [zap.Error и стек-трейсы](/courses/zap/lessons/18-oshibki-steki/). Карта курса — на [странице курса](/courses/zap/).

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

- With прицепляет поля, Named даёт имя: оба возвращают нового потомка, не трогая исходный логгер.
- Имена склеиваются через точку: billing.repo; имя пишется в поле из NameKey — у прода это logger.
- Дочерний логгер — копия структуры с общим ядром: дешевле заводить на каждый запрос, чем повторять поля.
- Конвенция: корневой логгер один при старте, у модуля — свой Named-потомок, у операции — свой With.
- Логгер прокладывается полем структуры: явные зависимости, подмена в тестах; глобальный zap.L — аварийный люк.
- Повторяющиеся поля в каждом вызове — источник расползания ключей: контекст задаётся один раз.
- SugaredLogger имеет собственные With и Named — сахар и контекст не конфликтуют.

## Домашнее задание

Соберите локально приложение из трёх структур — Service, Repository, Notifier — с полем log *zap.Logger у каждой, прокладываемым из корневого логгера через Named с именами billing, billing.repo и billing.notifier. Добавьте метод-обработчик, который создаёт With-потомка с полем request_id и пишет две записи. Проверьте по JSON-выводу, что поле logger принимает три значения, а request_id есть только в записях обработчика.

## Вопросы и ответы по уроку

### With или Named — что использовать?

Это разные оси: Named отвечает на вопрос «кто пишет» и задаётся раз на модуль, With отвечает на вопрос «в каком контексте» и задаётся на операцию или запрос. В реальном коде они соседствуют: потомок billing.Named(...).With(...).

### Действительно ли можно создавать логгер на каждый запрос?

Да: With и Named копируют маленькую структуру и добавляют слайс полей, ни файлов, ни замков, ни энкодеров не трогая. Дорогое — создание нового логгера через zap.New или Build: оно открывает приёмники и строит ядро, его делают один раз.

### Почему не глобальный zap.L везде?

Глобальность скрывает зависимость, мешает параллельным тестам и фиксирует конфигурацию до старта тестов. Для кода, куда логгер не протянуть, zap.L с ReplaceGlobals существует, но в прикладном коде поле структуры и Named-потомки дают тот же результат с явной передачей.
