---
title: "Logger.With: контекстные поля"
description: "Logger.With в zerolog: контекстные поля логов на модуль и на запрос, zerolog.Context, порядок полей в JSON, value-семантика копий и цена With."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/06-log-context/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Logger.With: контекстные поля

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

Контекстные поля логов — поля, которые zerolog дописывает к каждому событию автоматически: имя сервиса, модуля, request_id. Метод Logger.With собирает их один раз в zerolog.Context и возвращает дочерний логгер, чтобы не повторять Str() в каждой строке. Разберём цепочки базовый — сублоггер — запрос, порядок полей в JSON и цену With.

Начнём с сервиса, в котором контекстных полей логов ещё нет. Каждый вызов пишет только то, что ему передали: log.Info().Str("user", id).Msg(...) — и чтобы идентификатор запроса оказался в записи, его надо не забыть дописать в каждом из двадцати мест, где обработчик логирует. Через месяц в коде тридцать вариантов одного и того же: где-то user_id, где-то uid, где-то поле пропущено вовсе, а на разборе инцидента половина записей блока обработки заказа осталась без order_id — и трек запроса не собирается даже руками. Вторая стадия этой болезни — копипаст-константы: строка "service":"billing" повторяется в каждом вызове, и при переименовании сервиса кто-то обязательно обновит половину вхождений, а вторая половина будет врать в логах молча. Корень проблемы в том, что общие для всего потока поля живут на уровне события, а задаются на уровне вызова. Логичнее объявить их один раз на логгере: вот этот логгер пишет от имени модуля auth, этот — от имени обработки запроса r-1024, и каждое событие уходит уже с нужными полями. Именно это делает With: общие поля переезжают из вызова в конфигурацию логгера, а в коде события остаётся только то, что уникально для конкретной записи.

### Logger.With: контекстные поля логов

В zerolog With() — метод логгера, который возвращает zerolog.Context, накопитель предзаданных полей. Вы цепочкой добавляете в него Str, Int, Bool, Timestamp — что угодно из того же набора, что и у события, — и финализируете вызовом Logger(): на выходе новый логгер, у которого в контексте лежат все выбранные поля. Ключевая деталь механики: контекст хранится уже сериализованным — готовыми JSON-фрагментами вроде "service":"billing" — поэтому на каждое событие zerolog просто дописывает этот готовый кусок в буфер записи: без перечитывания полей, без рефлексии и без аллокаций. Отсюда и порядок в итоговом JSON: уровень, ключ time, если он в контексте, затем поля контекста в порядке их добавления, затем поля самого события, и последним — message: он дописывается при финализации, после всех полей. Контекстное поле легко узнать в записи по позиции — оно стоит раньше всего, что добавлено в цепочке события. Метод работает и от глобального логгера пакета log — log.With().Str("service", "api").Logger() — хотя в приложениях чаще собирают собственный базовый логгер при старте и раздают его по зависимостям, как мы разбирали в уроке про первый лог.

### Первый With: service и env на логгере

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        logger := zerolog.New(os.Stdout).With().
                Str("service", "billing").
                Str("env", "prod").
                Logger()

        logger.Info().Str("user", "ann").Msg("signed in")
        logger.Error().Str("op", "charge").Msg("charge failed")
}

// Output:
// {"level":"info","service":"billing","env":"prod","user":"ann","message":"signed in"}
// {"level":"error","service":"billing","env":"prod","op":"charge","message":"charge failed"}
```

With() собирает zerolog.Context: два строковых поля, затем Logger() возвращает логгер, у которого эти байты уже лежат в контексте. Оба события выводят service и env автоматически, в JSON они стоят после уровня и раньше полей события — ровно в порядке добавления. Ни один вызов Info или Error не повторяет общие поля: они заданы один раз на логгере и попадают в каждую запись, пока живёт эта копия.

### zerolog.Context: методы и порядок в JSON

Набор методов у zerolog.Context почти тот же, что у события: Str и Strs, Int и все целочисленные вплоть до Uint64, Float32 и Float64, Bool, Bytes, Hex, Dur, Time, Err — он, как и у события, молча пропускает nil, — AnErr, Errs, Interface и Any, RawJSON, Dict. Плюс служебные методы, которым в контексте самое место. Timestamp() добавляет ко всем событиям ключ time — так собирают базовый логгер с самого первого урока. Caller() добавляет файл и строку вызова, Stack() — стек при ошибках. Fields принимает map или слайс пар ключ-значение и добавляет их все разом; деталь, о которой мало кто читает в документации: ключи карты zerolog сортирует по алфавиту, чтобы вывод не зависел от случайного порядка обхода в Go, — так что для критичных к порядку полей пишите отдельные Str. Reset() очищает контекст. Всё это задаётся один раз на логгер, поэтому сюда переезжают и Timestamp, и общие Err — то, что раньше повторялось в каждом вызове из урока про поля события. Отдельно упомяну экзотику, которая прячется в конце списка методов: IPAddr и MACAddr для сетевых адресов, Stringer для любого значения с методом String() — удобно для типизированных ID из enum-подобных типов, Type для имени типа значения. Всё это — одни и те же байты контекста, и все они доступны ровно там, где есть zerolog.Context: и в With, и в цепочке события.

### Правила контекстных полей одним списком

- общие для потока поля — на логгер через With; уникальные для записи — в цепочку события
- ключи контекста и события не дедуплицируются: одноимённое поле события даст в JSON второй ключ — следите за уникальностью имён
- Timestamp и Caller — тоже контекстные поля: добавляйте их в базовый логгер, а не в каждый вызов
- имена ключей выносите в константы одного пакета: контекст обрастает дашбордами и менять его потом больно
- Context.Fields(map) сортирует ключи по алфавиту — порядок предсказуем, но не ваш
- логгер на запрос создавайте после чтения заголовков, когда request_id уже известен
- не складывайте в контекст большие дампы: они поедут в каждое событие этого логгера

### Логгер на модуль и на запрос

Из базового логгера вырастает целое дерево. Первый этаж — логгеры на модуль: base.With().Str("module", "auth").Logger() и рядом billing, payments, worker — каждая подсистема получает свою копию со своим module. Второй этаж — логгер на запрос: в начале обработки reqLog := base.With().Str("request_id", id).Logger(), и все записи обработчика, включая вложенные вызовы, куда reqLog передали аргументом, уже размечены идентификатором. Дочерние логгеры расширяются так же: auth.With().Str("component", "oauth").Logger() даст события с module=auth и component=oauth сразу. Такая разметка — это не украшение, а формат данных для системы сбора: графики по модулям и треки запросов строятся фильтром по одному ключу. Имена модулей держите в константах и придумывайте один раз — они станут координатами в каждой записи, и переименование после того, как на module настроены дашборды и алерты, обойдётся дороже, чем кажется. Полный разбор дерева сублоггеров с реестром и тестами — в [отдельном уроке про сублоггеры](/courses/zerolog/lessons/10-sublogery/), а middleware, который размечает запросы request_id автоматически, ждёт в [уроке про HTTP-логи](/courses/zerolog/lessons/11-log-http/).

### Логгер на запрос: request_id в каждой записи

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func handle(base zerolog.Logger, requestID string) {
        reqLog := base.With().Str("request_id", requestID).Logger()

        reqLog.Info().Msg("request received")
        reqLog.Info().Str("route", "/orders").Msg("request handled")
}

func main() {
        base := zerolog.New(os.Stdout).With().
                Str("service", "api").
                Logger()

        handle(base, "r-1024")
}

// Output:
// {"level":"info","service":"api","request_id":"r-1024","message":"request received"}
// {"level":"info","service":"api","request_id":"r-1024","route":"/orders","message":"request handled"}
```

reqLog — копия base с готовым request_id в контексте; в handle он передаётся обычным значением, без указателей и ссылок. Обе записи внутри обработчика размечены идентификатором автоматически: ни один вызов Info не упоминает request_id. Событие со Str("route") показывает порядок: контекст base, затем контекст reqLog, затем поля события и последним message.

### Value-семантика: копия логгера — снимок

zerolog.Logger — тип-значение: структура с writer-ом, уровнем и байтовым контекстом. Все методы модификации — With, Level, Output, Sample, Hook — возвращают копию, а не меняют receiver. Из этого следуют три практических правила. Первое: дочерний логгер — это снимок родителя на момент создания; если позже пересобрать base с новым полем, уже созданные дети этого поля не увидят. Второе: копию можно свободно передавать в функции и горутины — никакой общей мутируемой нагрузки, кроме writer-а, у копии нет, а writer в zerolog рассчитан на параллельные вызовы. Третье: раз логгеры — значения, поля контекста лежат в каждой копии своими байтами, и на длинных цепочках With контекст растёт для всех потомков — ещё одна причина не складывать туда лишнее. Экономнее всего разметка живёт на длинных связках: одна и та же копия логгера прокидывается через конструкторы репозиториев и сервисов, и каждая запись на выходе самодостаточна. Глобальный порог zerolog.SetGlobalLevel при этом общий для всех копий — он проверяется на каждом событии поверх локального уровня. Есть и обратная операция: GetLevel() читает порог копии, что удобно в тестах конфигурации — собрали логгер для шума на Warn, проверили GetLevel, не разбирая JSON. Отличать поля контекста от полей события и выбирать, что куда, помогает навык из [урока про поля события](/courses/zerolog/lessons/04-polya-sobytiya/): там же и таблица типизированных методов.

> **Живое место: request_id, который проплыл мимо.** Реальный кейс: хендлер создал reqLog, но в go-горутину отправки писем по привычке передал base — и все записи отправки уехали без request_id. Инцидент случился ночью: собрать трек запроса не удалось, потому что полстроки лога размечены, а полстроки — нет. Лечится дисциплиной: в горутины и вложенные вызовы передаём самый размеченный логгер из доступных, а на ревью ищем base в замыканиях горутин. Проверка одна: каждое поле, по которому вы собираетесь искать, должно быть в контексте самого глубокого логгера.

### Цена контекста: один append и один memcpy

Стоимость With раскладывается на две части. При создании логгера каждое поле контекста один раз сериализуется в байты и дописывается в буфер контекста — это единственная работа на всю жизнь копии. При каждом событии zerolog копирует готовые байты контекста в буфер события — по сути memcpy куска на десятки-сотни байт; аллокаций нет, рефлексии нет. Отключённое событие не копирует ничего: nil-событие получает не буфер, а отказ, и контекст остаётся не у дел. Сравните с альтернативой без With: те же поля дописываются в каждом вызове — больше кода, шанс пропустить и та же цена сериализации, только на каждом событии вместо одного раза. Так что With выгоднее и короче одновременно — редкий случай, когда лучший по читаемости вариант и самый быстрый совпадают. Замеры логгера в бенчмарках с контекстными полями и честные оговорки — в [уроке про производительность](/courses/zerolog/lessons/13-log-proizvoditelnost/). Проверить всё руками проще локально: песочница сайта — тематическая программа на чистом Go, а настоящий With с живым JSON запускается через go run за минуту.

> **Fields(map): когда полей много и они из конфига.** Если предзаданных полей больше пяти — например, разметка окружения из переменных среды — соберите map и передайте в Fields: ctx := base.With().Fields(map[string]interface{}{"service": "api", "region": "eu", "build": ver}). Ключи отсортируются по алфавиту, значения сериализуются по типу. Для пары полей читаемее отдельные Str, для конфига из десяти значений — Fields в цикле по секциям.

### Контекстные поля против полей события

| Аспект | Контекст (With) | Событие (цепочка вызова) |
| --- | --- | --- |
| Задаётся | один раз при создании логгера | в каждом вызове логирования |
| Известны | заранее: сервис, модуль, request_id | в момент записи: op, code, err |
| Позиция в JSON | после уровня и time | после контекста, перед message |
| Цена | сериализация один раз, далее memcpy | сериализация на каждом событии |
| Методы | тот же набор + Timestamp, Caller, Stack | тот же набор полей |
| Типичный источник | main и конструкторы | аргументы конкретной функции |

Практический порядок внедрения простой. Пройдитесь по коду и выпишите поля, которые повторяются в разных вызовах: обычно это service, env, version, module и идентификаторы потоков. Соберите базовый логгер при старте и раздайте модулям их копии через With — конструкторы хендлеров и воркеров принимают zerolog.Logger параметром. Оставьте в вызовах только уникальные поля. Через неделю посмотрите в системе сбора, как изменился поиск: записи одного запроса собираются фильтром по request_id, графики по модулям — фильтром по module. Это и есть главный дивиденд контекстных полей: не скорость записи, а предсказуемость каждой строки, которую вы будете читать глазами в три часа ночи. Перед закрытием темы проверьте два места, где With ошибается незаметно. Первое — порядок сборки: поле, добавленное в base позже, чем создана копия, в этой копии не появится, и это не баг, а снимок. Второе — имена ключей: два разных модуля, размечающие одно и то же поле с разными именами вроде user_id и userId, с гарантией дадут два графика вместо одного. Сведите имена в константы одного пакета ещё на этапе внедрения — дешевле, чем миграция после того, как на ключи опираются алерты.

### Проверка: что возвращает With

**Вопрос:** Что возвращает вызов logger.With()?

- Готовый Logger с добавленными полями
- ✅ zerolog.Context — накопитель полей, финализируемый вызовом Logger()
- *zerolog.Event, который допишет поля к следующему событию
- io.Writer, в который можно писать события вручную

With() возвращает zerolog.Context — цепочку предзаданных полей. Логгер создаёт только финализирующий метод Logger(); без него контекст ни к чему не прикрепится.

### Проверка: порядок в JSON

**Вопрос:** Где в JSON-записи окажется поле service, добавленное через With()?

- В самом конце, после message
- Перед ключом level
- ✅ После level и раньше полей, добавленных в цепочке события
- Порядок не определён и меняется от записи к записи

Контекст дописывается в буфер события сразу после уровня (и time, если он в контексте), поля события — после контекста, а message — последним при финализации. Исключение — Fields(map): его ключи сортируются по алфавиту внутри контекста.

### Какой JSON выйдет из дочернего логгера?

```go
base := zerolog.New(os.Stdout).With().Str("service", "api").Logger()

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

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

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

- ✅ {"level":"info","service":"api","module":"auth","user":"ann","message":"ready"}
- {"level":"info","module":"auth","service":"api","user":"ann","message":"ready"}
- {"level":"info","user":"ann","message":"ready","service":"api","module":"auth"}
- {"level":"info","service":"api","user":"ann","message":"ready"}

Контекст копии — это контекст родителя плюс новое поле в конце: сначала service, затем module, затем поле события user, и последним message. Порядок полей контекста — порядок вызовов With.

### Увидит ли дочерний логгер новое поле родителя?

```go
base := zerolog.New(os.Stdout).With().Str("service", "api").Logger()

reqLog := base.With().Str("request_id", "r-1").Logger()

base = base.With().Str("extra", "late").Logger()

reqLog.Info().Msg("work")
```

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

- {"level":"info","service":"api","request_id":"r-1","extra":"late","message":"work"}
- ✅ {"level":"info","service":"api","request_id":"r-1","message":"work"}
- {"level":"info","service":"api","message":"work"}
- Панику: base был пересобран

Логгер — значение: reqLog получил снимок контекста base на момент создания. Пересборка base создала третью копию, никак не связанную с reqLog, поэтому поля extra в его записях нет.

### Соберите цепочку With

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

```go
package ___

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        base := zerolog.New(os.Stdout).With().
                ___("service", "billing").
                ___().
                Logger()

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

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

Подсказка: Пакет исполняемой программы — main; строковое поле добавляет Str; время на каждое событие ставит Timestamp; дочерний модуль — снова Str; штатное событие начинает Info.

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

### Программа с базовым и дочерним логгером

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

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

1. `package main`
2. `import (`
3. `  "os"`
4. `  "github.com/rs/zerolog"`
5. `)`
6. `func main() {`
7. `  base := zerolog.New(os.Stdout).With().Str("service", "api").Logger()`
8. `  auth := base.With().Str("module", "auth").Logger()`
9. `  auth.Info().Msg("started")`
10. `}`

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

- **Logger.With()** — Метод zerolog, возвращающий zerolog.Context — накопитель предзаданных полей. Финализируется вызовом Logger() и создаёт копию логгера с этой разметкой.
- **zerolog.Context** — Строитель контекста: те же типизированные поля, что у события, плюс Timestamp, Caller, Stack, Fields, Reset. Хранит поля уже сериализованными байтами.
- **Контекстное поле** — Поле, которое логгер дописывает к каждому событию автоматически: service, env, module, request_id. В JSON стоит раньше полей события.
- **Логгер на запрос** — Копия базового логгера с request_id в контексте: reqLog := base.With().Str("request_id", id).Logger(). Размечает все записи обработки без повторов в коде.
- **Value-семантика Logger** — Logger — тип-значение: With, Level, Output возвращают копии. Дочерний логгер — снимок родителя и не видит его последующих изменений.
- **Порядок message** — Ключ message дописывается последним, при финализации события: после уровня, time, контекста и полей цепочки.

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

- With() возвращает zerolog.Context; Logger() финализирует цепочку и создаёт логгер с контекстными полями.
- Контекст хранится готовыми JSON-байтами: на событие — один memcpy, без сериализации и аллокаций.
- Порядок в JSON: уровень, time, поля контекста, поля события, последним — message.
- Цепочка base — сублоггер на модуль — логгер на запрос размечает записи без повторов в коде.
- Logger — значение: дочерний логгер — снимок родителя, позже добавленные поля в него не приезжают.
- Context.Fields(map) сортирует ключи по алфавиту — порядок внутри контекста предсказуем.
- Timestamp и Caller — тоже контекстные поля: им место в базовом логгере, а не в каждом вызове.

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

Соберите дерево логгеров на локальном Go: base с service и Timestamp, два сублоггера на вымышленные модули и логгер на запрос с request_id. Запустите и проверьте по выводу позицию каждого поля в JSON. Затем пересоберите base с дополнительным полем после создания сублоггеров и убедитесь, что записи сублоггеров не изменились. Наконец, передайте reqLog в функцию с горутиной и проверьте, что все её записи размечены.

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

### Чем Logger.With отличается от event-полей?

With задаёт поля один раз на логгере — они попадают в каждое событие этой копии. Поля события добавляются в конкретную запись и живут один вызов. Правило: общее для потока — в контекст, уникальное для записи — в цепочку вызова.

### Что произойдёт, если событие добавит поле с тем же ключом, что в контексте?

Zerolog не дедуплицирует ключи: в JSON окажутся два поля с одним именем, а парсеры возьмут последнее. Не рассчитывайте на перезапись — следите за уникальностью имён ключей контекста и события на уровне констант в коде.

### Сколько стоит With на горячем пути?

Почти ничего: поля сериализуются один раз при создании логгера, а на каждом событии готовые байты контекста копируются в буфер записи. Отключённое событие контекст не копирует вовсе. Замеры с бенчмарками — в уроке про производительность.
