---
title: "With: предзаданные поля и логгер на запрос"
description: "Метод slog With: предзаданные поля сервиса, логгер с полями go на один HTTP-запрос, иммутабельность логгеров и ловушка дубликатов ключей при повторном With."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/08-with-predlog/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# With: предзаданные поля и логгер на запрос

*Курс «Логирование в Go: структурные логи на slog», урок 8 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/08-with-predlog/*

Метод slog With решает задачу, которую в текстовых логах обычно закрывают копипастой: поля, которые обязаны быть в каждой строке — имя сервиса, окружение, номер запроса, — задаются один раз, а дальше логгер с полями go носит их сам. Разберём, как устроены потомки логгера, почему родителя With не меняет, как собрать логгер на один HTTP-запрос в middleware и где прячется ловушка дубликатов ключей.

Разбор структурных логов до этого момента молчал о главном практическом вопросе: куда девать поля, которые должны быть в каждой записи. Название сервиса и версия — чтобы понять, кто писал строку после того, как в кластере поднялись три релиза; окружение — чтобы отделить прод от стенда; номер запроса — чтобы склеить записи одного HTTP-вызова из разных слоёв. Писать их аргументами в каждом вызове Info — путь к опечаткам и пропущенным полям: рано или поздно один обработчик забудет номер запроса, и его строки выпадут из общей истории. В slog для этого есть метод With: он возвращает новый логгер, у которого перечисленные атрибуты уже вписаны в каждую будущую запись. Из этого простого метода вырастает вся дисциплина логгера на проекте: базовый логгер с полями сервиса, логгеры компонентов поверх него и логгер на один запрос в middleware — всё из одной точки конструирования.

### slog With: логгер с полями go без копипасты

Сигнатура метода минималистична: logger.With(attrs ...Attr) возвращает новый логгер, поэтому разбор slog with начинается с двух его свойств. Первое — накопление: вызовы With можно наслаивать, base.With("service", "billing").With("component", "db") даст логгер, в каждой записи которого оба поля; атрибуты, добавленные при записи, идут после предзаданных. Второе — совместимость с WithGroup из седьмого урока: поля и группы конструируются цепочкой в любом осмысленном порядке. Метод существует и у глобального логгера: slog.With(...) отталкивается от дефолтного и удобен на старте, пока своя точка сборки не заведена. Практический шаблон выглядит так: в main собирается базовый логгер с полями сервиса и окружения, из него выводятся логгеры компонентов с полем component, и всё это раздаётся зависимостям через конструкторы структур. В результате каждая строка сервиса отвечает на вопросы кто, где и на каком пути — без единого повторения ключей в вызывающем коде.

### Базовый логгер и логгеры компонентов

```go
package main

import (
        "log/slog"
        "os"
)

func main() {
        base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
                With("service", "billing", "env", os.Getenv("APP_ENV"))

        api := base.With("component", "api")
        db := base.With("component", "db")

        api.Info("слушаю порт", "port", 8080)
        db.Info("миграции применены", "count", 3)
        base.Info("старт сервиса завершён")
}
```

Поля service и env предзаданы один раз и приедут во все три записи, поле component различит строки компонентов, а аргументы конкретного вызова идут после предзаданных. Дочерние логгеры бесплатны в прямом смысле слова: они делят с родителем один обработчик и отличаются только накопленной цепочкой атрибутов.

### Иммутабельность: родителя With не меняет

With возвращает нового потомка и оставляет родителя как был — на этом свойстве стоит весь стиль конструирования. base продолжает писать без component после того, как из него вывели api и db; функция, получившая логгер и добавившая себе поле через With, не изменит логгер, которым пользуется вызывающая сторона. Поэтому потомков безопасно раздавать по слоям и не нужно защищать мьютексом при конструировании: сами записи, как разберёт тринадцатый урок, конкурентно безопасны, а цепочка With на старте — однопоточная работа. Из иммутабельности следует и стиль хранения: логгер держат полем структуры, которое заполняет конструктор, а не глобальной переменной, которую переприсваивают по ходу дела. Цена потомка мала: логгер — тонкая обёртка над общим Handler, атрибуты накапливаются в цепочке без копирования существующих записей. Отсюда правило: создавать логгеры при старте компонентов и в middleware — нормально, на каждый элемент в горячем цикле — уже транжинство, о нём отдельный warn ниже.

### Логгер на один HTTP-запрос

Самое прибыльное применение With — логгер на один HTTP-запрос. В middleware на входе создаются поля, общие для всех записей этого вызова: request_id, метод, путь, иногда идентификатор пользователя после аутентификации. Логгер с этими полями кладётся в контекст запроса через context.WithValue с собственным типом-ключом — строковые ключи из урока про контекст здесь только риск коллизий. Дальше любой слой достаёт логгер из контекста и пишет обычные записи: каждое сообщение несёт номер запроса без единого лишнего аргумента в вызывающем коде, и записи одного вызова склеиваются фильтром по request_id за секунды. Обработчикам достаточно знать, что логгер в контексте есть; если он там отсутствует — вернётся дефолтный, и приложение продолжит работать, просто с меньшим контекстом в строках. Всю картину сквозных полей с генерацией и пробросом идентификаторов разбирает пятнадцатый урок, здесь — сама механика With и контекста. Для сравнения подходов в фреймворках полезно заглянуть в middleware из курса по gin: там та же идея решается средствами c.Set и c.Get.

### Middleware: логгер в контекст запроса

```go
type loggerKey struct{}

func withLogger(base *slog.Logger, next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                id := newRequestID()
                logger := base.With("request_id", id, "method", r.Method)
                start := time.Now()

                ctx := context.WithValue(r.Context(), loggerKey{}, logger)
                next.ServeHTTP(w, r.WithContext(ctx))

                logger.Info("запрос завершён", "duration", time.Since(start))
        })
}
```

Логгер собирается один раз на входе и несёт номер запроса во все записи вызова — включая строку о завершении с длительностью. Ключ-структура loggerKey гарантирует уникальность значения в контексте, а срез контекста делается на копии запроса: r.WithContext не меняет входящий объект.

### Дубликаты ключей: обе пары останутся в записи

У накопления полей есть неприятная особенность, о которой стоит знать до первого инцидента: slog не проверяет уникальность ключей. Если логгер получил id через With, а в вызове записи передали тот же ключ с другим значением, обе пары попадут в запись: в JSON появятся два поля id, в текстовом выводе два вхождения id=... Штатные обработчики ничего не выбрасывают и не перезаписывают — что добавили, то и напечатано. Кто из дублей «правильный», решает уже потребитель: парсеры и коллекторы берут первое или последнее вхождение в зависимости от своей конфигурации, и одна и та же запись в двух системах может выглядеть по-разному. Практический вывод: ключи из With в вызовах записи не повторяют; если поле обязано измениться, это новое поле с новым именем вроде retried_of или итоговое значение вместо промежуточного. Проверить себя просто: строка с двумя одинаковыми ключами в JSON — уже повод поискать, где родился второй With.

> **With внутри обработчика: дубли request_id в каждой строке.** Кейс: middleware кладёт логгер с request_id в контекст, а внутри обработчика кто-то решил подстраховаться и добавил ещё один With("request_id", id) — от логгера из контекста, с тем же самым значением. Каждая запись запроса получила пару request_id дважды: один раз от middleware, второй от обработчика. Коллектор брал первое вхождение, дашборд второе — значения совпадали, и полгода никто не замечал, пока middleware не переехал на генератор идентификаторов с другим форматом: в одной записи соседствовали два разных номера, и слияние строк запроса развалилось. Диагноз: grep по двум request_id в одном JSON. Лечение: поле принадлежит ровно одному слою — тот, кто получил логгер с предзаданным ключом, больше этот ключ не добавляет.

Отдельный вопрос — как логгер ведёт себя в тестах и в коде без запросов. В тестах потомков создают свободно: каждый кейс строит логгер со своими полями и отдаёт его тестируемой функции, а захват вывода в память из двадцатого урока проверяет, что нужное поле дошло до записи. В фоновых задачах, где HTTP-контекста нет, логгер на задачу собирается тем же методом: With с полем job и идентификатором запуска — и записи ночного батча склеиваются фильтром так же легко, как записи запроса. Правило одно для всех мест: точка конструирования — вход в область видимости, будь то запрос, задача или компонент, а не глубина внутри неё. Если вы замечаете, что With вызывается по три раза в одной функции, поля стоит поднять на уровень выше — к точке, где эта область видимости началась.

### Что кладут в With на каждом уровне

| Уровень | Поля | Кто создаёт |
| --- | --- | --- |
| базовый логгер | service, env, version | main при старте |
| компонент | component: api, db, cache | конструктор структуры при старте |
| HTTP-запрос | request_id, method, path | middleware на входе вызова |
| пользователь | user_id после аутентификации | аутентификационный middleware |

> **Код урока — локально, интерактивы — на странице.** В песочнице нет ни log/slog, ни net/http — там работают fmt, strings, strconv, math, sort, errors и time. Механику предзаданных полей честно разминают на структурах: соберите тип Logger с полем prefix и методом With, который возвращает копию с дополненным префиксом — иммутабельность видно сразу. Полный slog With с контекстом запускается локально через go run; интерактивы ниже проверят иммутабельность, порядок полей в записи и сборку middleware.

### Правила With

- With возвращает нового потомка: родительский логгер продолжает писать без добавленных полей.
- Поля, заданные через With, идут в записи раньше аргументов конкретного вызова.
- Ключ из With не повторяют в вызовах записи: обе пары попадут в вывод, а разрешит их потребитель.
- Базовый логгер с полями сервиса собирают в main, логгеры компонентов — в конструкторах структур.
- Логгер на запрос собирается в middleware и передаётся через context.WithValue с типом-ключом.
- Логгеры создают на старте компонентов и на входе запроса — не на каждый элемент в цикле.

Связи с другими уроками: [седьмой урок про группы](/courses/slog/lessons/07-group/) дополняет With префиксами-пространствами имён, а [шестнадцатый урок про дефолтный логгер](/courses/slog/lessons/06-default-logger/) объясняет, от кого отталкивается slog.With без приёмника. Полную схему сквозных полей запроса собирает [пятнадцатый урок про request ID](/courses/slog/lessons/15-request-id/), конкурентные записи общего логгера разбирает [тринадцатый урок](/courses/slog/lessons/13-konkurentnost/). Проверить логгер тестами поможет [двадцатый урок](/courses/slog/lessons/20-testy-logov/), а сравнение с middleware фреймворков — [курс по gin](/courses/gin/lessons/06-middleware/). Карта курса — на [странице курса](/courses/slog/).

### Проверка: иммутабельность

**Вопрос:** base := slog.Default().With("env", "prod"); child := base.With("component", "db"). Что будет в записи base.Info("готово")?

- ✅ Только env=prod: With вернул потомка, родителя он не менял
- env=prod и component=db: поле переписалось и в родителя
- Только component=db: последний With действует глобально
- Ничего: base теперь недействителен, запись упадёт с паникой

With конструирует нового потомка и не трогает приёмник: base не знает о db. Это свойство позволяет раздавать потомков по слоям, не опасаясь, что один слой добавит поле другому.

### Проверка: дубликаты ключей

**Вопрос:** child := base.With("id", 1); child.Info("запись", "id", 2). Что окажется в выводе?

- ✅ Обе пары: id=1 и id=2 в одной записи
- Только id=2: значение из вызова переписало поле из With
- Только id=1: ключи из With защищены от перезаписи
- Ошибка компиляции: повтор ключа в одной записи запрещён

Проверки уникальности в slog нет: штатные обработчики печатают обе пары подряд. Какую из них выберет парсер — вопрос его конфигурации, поэтому повторять ключ из With в вызове записи не стоит.

### Какие поля будут в двух записях?

```go
base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        With("service", "billing")
api := base.With("component", "api")

api.Info("готово")
base.Info("стартуем")
```

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

- ✅ Первая запись: service и component; вторая: только service
- Обе записи: service и component — With меняет оба логгера
- Первая запись: только component; вторая: только service
- Обе записи без полей: With добавляет поля лишь при печати

api получил service по наследству и собственный component, base остался с одним service: предзаданные поля живут в конкретном логгере и его потомках, а не в общей памяти.

### Соберите middleware

Допишите: предзадать поля через With, положить логгер в контекст и достать его в обработчике.

```go
logger := base.___("request_id", id, "method", r.Method)
ctx := context.___(r.Context(), loggerKey{}, logger)

// в обработчике:
l := slog.___(ctx)
l.Info("тело запроса принято", "bytes", n)
```

Подсказка: Поля предзадаёт With, в контекст кладёт WithValue, а достаёт логгер FromContext — с дефолтом, если его там нет.

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

### Middleware по шагам

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

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

1. `id := newRequestID()`
2. `w.Header().Set("X-Request-ID", id)`
3. `logger := base.With("request_id", id, "method", r.Method)`
4. `start := time.Now()`
5. `next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), loggerKey{}, logger)))`
6. `logger.Info("запрос завершён", "duration", time.Since(start))`

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

- **Logger.With** — Возвращает потомка с предзаданными атрибутами: они печатаются в каждой записи перед аргументами вызова.
- **иммутабельность** — With не меняет приёмник: родитель пишет без новых полей, потомков безопасно раздавать по слоям.
- **цена потомка** — Логгер — тонкая обёртка над общим Handler: создание потомка дёшево, но в горячем цикле его не делают.
- **логгер на запрос** — With(request_id, method) в middleware, логгер кладётся в контекст и склеивает записи одного вызова.
- **дубликат ключа** — Повтор ключа из With в вызове записи даёт обе пары в выводе: разрешение остаётся парсеру.
- **slog.FromContext** — Возвращает логгер из контекста или дефолтный, если его там нет: обработчик продолжает работать без middleware.

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

- With возвращает потомка с предзаданными полями: они печатаются в каждой записи перед аргументами вызова.
- Родителя With не меняет — из базового логгера выводят потомков на компоненты и раздают по слоям.
- Логгер на HTTP-запрос собирается в middleware и передаётся через context.WithValue с типом-ключом.
- Дубликаты ключей не фильтруются: обе пары попадут в запись, а выберет между ними парсер.
- Поля сервиса кладут в With на старте: service, env, version — и каждая строка отвечает, кто её написал.
- Создание потомка дёшево, но его место — старт компонента и вход запроса, а не горячий цикл.

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

Соберите middleware из урока локально и повесьте его на два маршрута: убедитесь, что записи разных запросов различаются по request_id, а записи одного запроса склеиваются фильтром. Затем добавьте поле component в логгер обработчика через With и проверьте в выводе порядок полей: предзаданные, потом аргументы вызова.

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

### Создавать логгер через With на каждый запрос — это дорого?

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

### Как передать логгер в глубину слоёв: аргументом или через контекст?

В HTTP-приложении логгер на запрос удобно нести в контексте: слои и так принимают ctx, отдельный параметр не появляется. В коде без запросов логгер передают аргументом или полем структуры — это явнее и проще для тестов из двадцатого урока.

### Можно ли перезаписать поле, заданное через With?

Перезаписи нет: обе пары окажутся в записи, и потребитель решит, какую считать. Если поле меняется по ходу запроса, например после аутентификации появился user_id, добавляйте новое имя или стройте потомка аутентификационным middleware — старому логгеру поле не присваивают повторно.
