Разбор структурных логов до этого момента молчал о главном практическом вопросе: куда девать поля, которые должны быть в каждой записи. Название сервиса и версия — чтобы понять, кто писал строку после того, как в кластере поднялись три релиза; окружение — чтобы отделить прод от стенда; номер запроса — чтобы склеить записи одного 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, и всё это раздаётся зависимостям через конструкторы структур. В результате каждая строка сервиса отвечает на вопросы кто, где и на каком пути — без единого повторения ключей в вызывающем коде.
Базовый логгер и логгеры компонентов
GOpackage 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: логгер в контекст запроса
GOtype 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.
Кейс: 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 с типом-ключом.
- Логгеры создают на старте компонентов и на входе запроса — не на каждый элемент в цикле.
Связи с другими уроками: седьмой урок про группы дополняет With префиксами-пространствами имён, а шестнадцатый урок про дефолтный логгер объясняет, от кого отталкивается slog.With без приёмника. Полную схему сквозных полей запроса собирает пятнадцатый урок про request ID, конкурентные записи общего логгера разбирает тринадцатый урок. Проверить логгер тестами поможет двадцатый урок, а сравнение с middleware фреймворков — курс по gin. Карта курса — на странице курса.
Проверка: иммутабельность
base := slog.Default().With("env", "prod"); child := base.With("component", "db"). Что будет в записи base.Info("готово")?
Проверка: дубликаты ключей
child := base.With("id", 1); child.Info("запись", "id", 2). Что окажется в выводе?
Какие поля будут в двух записях?
Прочитайте код и выберите, что он выведет:
base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
With("service", "billing")
api := base.With("component", "api")
api.Info("готово")
base.Info("стартуем")Соберите middleware
Допишите: предзадать поля через With, положить логгер в контекст и достать его в обработчике.
Middleware по шагам
Расставьте строки обработчика middleware по порядку: идентификатор, заголовок ответа, логгер, старт таймера, передача дальше, итоговая запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: With и логгер на запрос
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- With возвращает потомка с предзаданными полями: они печатаются в каждой записи перед аргументами вызова.
- Родителя With не меняет — из базового логгера выводят потомков на компоненты и раздают по слоям.
- Логгер на HTTP-запрос собирается в middleware и передаётся через context.WithValue с типом-ключом.
- Дубликаты ключей не фильтруются: обе пары попадут в запись, а выберет между ними парсер.
- Поля сервиса кладут в With на старте: service, env, version — и каждая строка отвечает, кто её написал.
- Создание потомка дёшево, но его место — старт компонента и вход запроса, а не горячий цикл.
Домашнее задание
Соберите middleware из урока локально и повесьте его на два маршрута: убедитесь, что записи разных запросов различаются по request_id, а записи одного запроса склеиваются фильтром. Затем добавьте поле component в логгер обработчика через With и проверьте в выводе порядок полей: предзаданные, потом аргументы вызова.
Личные заметкиПусто
Вопросы и ответы по уроку
Создавать логгер через With на каждый запрос — это дорого?
Потомок — тонкая обёртка над тем же обработчиком, поэтому на фоне обработки HTTP-запроса создание логгера незаметно. Дорогое — складывать с его помощью растущие цепочки полей в горячих циклах по элементам: там поля предзадают один раз и переиспользуют.
Как передать логгер в глубину слоёв: аргументом или через контекст?
В HTTP-приложении логгер на запрос удобно нести в контексте: слои и так принимают ctx, отдельный параметр не появляется. В коде без запросов логгер передают аргументом или полем структуры — это явнее и проще для тестов из двадцатого урока.
Можно ли перезаписать поле, заданное через With?
Перезаписи нет: обе пары окажутся в записи, и потребитель решит, какую считать. Если поле меняется по ходу запроса, например после аутентификации появился user_id, добавляйте новое имя или стройте потомка аутентификационным middleware — старому логгеру поле не присваивают повторно.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- LogValuer: отложенные и «опасные» значения
- Миграция с log на slog: мосты и совместимость
- Ошибки в логах: err как атрибут, wrap и детали
- Свой Handler: интерфейс, Record и WithAttrs
- Группы: WithGroup и вложенные поля
- Дефолтный логгер: SetDefault и старый пакет log
- TextHandler и JSONHandler: выбор формата вывода
- Ключи и атрибуты: Attr, Int, String и Any
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY