Содержание урока · 9 разделов
Плоский набор ключей хорошо служит до первого конфликта имён. Сервис логирует и HTTP-запросы, и пользователей, и метрики кэша — и вот поле id в одной записи означает номер запроса, в другой номер пользователя, а в третьей версию схемы. Структурные поля решают типизацией, но не пространством имён: ключ один, а смыслов у него несколько. В slog для этого есть группы — атрибуты, собранные под общим ключом, — и два способа их заводить. Точечный: slog.Group внутри вызова Info оборачивает конкретный набор полей. Сквозной: logger.WithGroup(name) возвращает логгер, у которого каждая последующая запись получает общий префикс. Группы переводят вывод из плоского списка в иерархию: user.id и request.id перестают спорить за одно имя, а дашборды и алерты находят нужное поле по пути. В этом уроке — механика обоих способов, вид вывода в двух форматах, границы групп и соглашения, после которых вложенные поля перестают быть сюрпризом для того, кто будет читать их в три ночи.
slog Group и WithGroup: два способа собрать группу#
Разбор slog group начинается с функции Group: она принимает имя и любое число атрибутов и возвращает Attr, который обработчик развернёт в подобъект. Её место — внутри вызова записи, рядом с обычными атрибутами: slog.Info("запрос обработан", slog.Group("user", slog.Int("id", 7), slog.String("role", "admin"))). Второй способ — метод WithGroup на логгере: logger.WithGroup("http") возвращает новый логгер, у которого все последующие атрибуты получат группу http. Разница в охвате: Group действует на один вызов и расставляет поля локально, WithGroup — на весь остаток жизни конкретного логгера, поэтому его место в конструировании, рядом с With, а не в горячем коде обработчика. Способы сочетаются: логгер с WithGroup("http") внутри записи получает ещё и slog.Group("user"), и в выводе поля окажутся на пути http.user.id. Как и With, метод WithGroup не меняет родителя — исходный логгер продолжает писать без префикса, а дочерние логгеры можно наслаивать друг на друга.
Сквозная группа и локальная группа в одной записи
GOpackage main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
WithGroup("http")
logger.Info("запрос обработан",
slog.Group("user",
slog.Int("id", 7),
slog.String("role", "admin")),
slog.Int("status", 200))
}Группа http назначена через WithGroup, поэтому и status, и вложенная группа user попадают под неё. В JSON это объект http с полями user.id, user.role и status; msg, time и level остаются на верхнем уровне. Тот же код с TextHandler напечатает http.user.id=7 через точечную нотацию.
Вложенные поля лога: как группа выглядит в выводе#
Формат определяет, как именно вложенные поля лога доедут до читателя. JSONHandler разворачивает группу в настоящий вложенный объект: поля оказываются на путях вида http.user.id, и парсер коллектора читает их как дерево — это главная причина заводить группы в сервисах, чьи логи собирает Loki или ELK. TextHandler группирует иначе: вложенности нет, вместо неё точечная нотация — путь склеивается в один ключ http.user.id=7, потому что текстовый формат не умеет вложенные объекты. Формы разные, путь один, поэтому схемы полей, о которых пойдёт речь в семнадцатом уроке, переносятся между форматами без переделки. Есть и правила отбрасывания, одинаковые для штатных обработчиков. Группа, в которой не осталось ни одного непустого атрибута, в вывод не попадает вовсе: условия часто порождают пустые ветки, и лог не раздувается секциями-пустышками. Атрибут с пустым ключом внутри группы отбрасывается так же, как и на верхнем уровне. Свой Handler из двенадцатого урока вправе вести себя иначе — это его контракт, но до поры до времени держитесь поведения штатных.
Группа user с полями id и role в двух форматах#
| Формат | Как выглядит | Что важно |
|---|---|---|
| JSONHandler | объект: http.user.id и http.user.role вложены | коллектор читает поля как дерево |
| TextHandler | точечная нотация: http.user.id=7 http.user.role=admin | вложенности нет, путь склеен в ключ |
| пустая группа | в выводе отсутствует | штатные обработчики отбрасывают её целиком |
| атрибут с пустым ключом | в выводе отсутствует | правило то же, что и на верхнем уровне |
Границы групп: что попадает, а что нет#
WithGroup влияет на атрибуты, добавленные после него, и только на них. Служебные поля записи — время, уровень, сообщение и, при AddSource, место вызова — остаются на верхнем уровне в любом формате: дежурному нужно, чтобы msg и level находились поиском по одному и тому же пути во всех строках, а не в разных ветках дерева. Метод WithGroup с пустой строкой — операция без действия: он возвращает тот же логгер без группы, чем удобно пользоваться в шаблонном коде, где префикс приходит переменной, которая иногда пуста. Отсюда же практическая граница: вложенность глубже двух уровней превращает путь в строку, которую сложно напечатать в фильтре, а конфликт имён решается и на одном уровне, если подсистемы названы аккуратно. Ещё одна граница — порядок конструирования: группа применяется к атрибутам, добавленным после WithGroup, но не к тем, что логгер унаследовал от родительского With до разветвления; вспомните это, когда поле неожиданно окажется не в той ветке.
Кейс: дашборд ошибок строился по полю user.id на верхнем уровне, пока логгер писался без групп. Потом в middleware добавили WithGroup("http") — и поле переехало на путь http.user.id. Записи не исчезли, алерт молчал, а ретеншн запросов в дашборде вернул пустой график: поле существовало, но по другому пути. Диагноз нашёлся сравнением двух свежих строк JSON, лечение — обновить фильтр и зафиксировать схему полей, а не чинить логгер задним числом. Мораль: переименование пути поля — то же изменение контракта, что и переименование ключа в конфиге; о нём предупреждают тех, кто читает логи, и вписывают в схему.
Цепочка групп для вложенной подсистемы
GOhttpLogger := base.WithGroup("http")
reqLogger := httpLogger.WithGroup("request")
reqLogger.Info("обработан",
slog.Int("status", 200),
slog.Group("timing",
slog.Duration("total", elapsed)))Два WithGroup подряд образуют путь http.request, а Group внутри записи добавляет ветку timing. Итоговые пути в JSON: http.request.status и http.request.timing.total. Если elapsed равен нулю и группа timing осталась бы без атрибутов, штатный обработчик её отбросил бы — ветка не появится в выводе.
Практика: группы по подсистемам#
Соглашение, которое держится в проектах без усилий: одна группа верхнего уровня на подсистему и не больше одной вложенной. http, db, cache, auth — четыре префикса закрывают большинство сервисов, а внутри них пути второго уровня вроде http.request или db.query уточняют контекст. Такое дерево читается в фильтрах и не спорит с полями соседей: db.id и http.id живут раздельно, хотя ключ называется одинаково. Второе правило — называть группы именами подсистем, а не именами типов: группа user про доменную сущность уместна, а группа string, названная по типу значения, лишняя. Третье — не дублировать имена атрибутов внутри одной группы: поле id в двух разных группах — норма, поле id и рядом второй id в одной — глюк для парсера. Схему полей полезно держать рядом с кодом, потому что дежурный, строящий фильтр, смотрит в неё, а не в исходники; подробнее о схеме для сборщиков — в семнадцатом уроке. А про то, как логгер с префиксами доехал бы до конкретного запроса целиком, — восьмой урок про With и логгер на запрос.
Пара сценариев, где новички спотыкаются о группы. Первый: поле добавлено до WithGroup, и оно «не попало» под префикс — это не баг, а порядок конструирования: атрибуты и группы накапливаются цепочкой, и всё, что создано до ветвления, остаётся на общем пути. Лечится переупорядочиванием: сначала префикс, потом поля. Второй сценарий: ожидание, что WithGroup повлияет на уже существующие записи — не повлияет, группы живут на конкретном логгере и его потомках, родительскую запись никто не переписывает задним числом. Третий: путь вида http.http.id при двух одинаковых WithGroup выглядит как двойная работа — это знак, что middleware наслоился дважды, и тот же детектор дубликатов, о котором пойдёт речь в восьмом уроке, здесь применим. Общий вывод: группы — часть контракта вывода, а не украшение; их переименовывают с тем же уведомлением потребителей, что и обычные имена полей.
Пакета log/slog в браузерной песочнице нет — там работают fmt, strings, strconv, math, sort, errors и time. Механику точечной нотации честно разминают на strings.Join: соберите путь группы из частей с разделителем-точкой и сравните с выводом локального TextHandler. Полные slog.Group и WithGroup запускаются локально через go run; интерактивы ниже проверят нотацию, отбрасывание пустых групп и границы действия WithGroup.
Правила групп#
- slog.Group собирает атрибуты под одним ключом в конкретном вызове, WithGroup — назначает префикс логгеру на всё дальнейшее.
- WithGroup и With не меняют родителя: исходный логгер продолжает писать без префикса.
- msg, level, time и source группами не накрываются — служебные поля остаются на верхнем уровне.
- WithGroup с пустой строкой возвращает тот же логгер: удобно для префикса, приходящего переменной.
- Пустая группа и атрибут с пустым ключом отбрасываются штатными обработчиками.
- Одна группа на подсистему, вложенность до второго уровня: глубже путь становится нечитаемым в фильтрах.
Связи с другими уроками: четвёртый урок про атрибуты объясняет, из чего собирается любой Attr, включая групповой. Следующий шаг по конструированию логгера — восьмой урок про With и логгер на запрос, а сквозные поля запроса целиком разбирает пятнадцатый урок про request ID. Для сборщиков вроде Loki и ELK схему полей с группами фиксирует семнадцатый урок. Карта курса — на странице курса.
Проверка: TextHandler и группы
logger := slog.New(slog.NewTextHandler(os.Stdout, nil)).WithGroup("http"); logger.Info("готово", slog.Int("code", 200)). Как выглядит строка вывода?
Проверка: пустые группы
logger.WithGroup("http").Info("готово") — что попадёт в вывод, если после WithGroup не было добавлено ни одного атрибута?
Какой JSON получится?
Прочитайте код и выберите, что он выведет:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
WithGroup("http")
logger.Info("готово", slog.Group("user",
slog.Int("id", 7), slog.String("role", "admin")))Соберите вложенные поля
Допишите: назначить сквозную группу, собрать локальную группу user и типизированный атрибут с числом.
Цепочка от базового логгера до вложенной записи
Расставьте строки по порядку: базовый логгер, группа http, атрибут-группа, запись с ней, вложенная группа, запись из неё.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: группы
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- slog.Group оборачивает атрибуты в один вызов, WithGroup назначает префикс всему дальнейшему выводу логгера.
- В JSON группа — вложенный объект, в текстовом формате — плоский ключ с точками: путь поля одинаков.
- Служебные поля time, level, msg и source не попадают под группы.
- WithGroup с пустой строкой — без действия, пустая группа отбрасывается штатными обработчиками.
- Группы решают конфликты имён: db.id и http.id живут раздельно при одном ключе.
- Соглашение: одна группа на подсистему, вложенность не глубже второго уровня.
Домашнее задание
Соберите локально логгер с WithGroup("db") и записью, где внутри есть slog.Group("query", ...) с тремя полями. Проверьте вывод в обоих форматах и найдите путь каждого поля. Затем добавьте вторую группу через ещё один WithGroup и убедитесь, что пути склеились, а служебные поля остались на верхнем уровне.
Личные заметкиПусто
Вопросы и ответы по уроку
Group или WithGroup: что выбрать?
Group — когда нужно собрать несколько полей под общим ключом в одной записи, например user или timing. WithGroup — когда префикс должен носить весь логгер, например подсистема db или http. В боевом коде WithGroup живёт при конструировании, Group — в точке записи.
Влияют ли группы на производительность?
Группа — обычный атрибут с составным значением: цена сопоставима с несколькими полями верхнего уровня и заметна только на горячем пути с миллионами записей. Если запись отброшена по уровню, атрибуты не форматируются, а расходы на их сбор уже уплачены в месте вызова — это разбирает урок про производительность.
Почему поле оказалось не там, где ждёт дашборд?
Скорее всего, логгер получил WithGroup после того, как схема полей была зафиксирована, — путь сменился с user.id на http.user.id. Путь поля — часть контракта с теми, кто читает логи: при изменении групп обновляйте схему и предупреждайте потребителей вывода.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- With: предзаданные поля и логгер на запрос
- LogValuer: отложенные и «опасные» значения
- Миграция с log на slog: мосты и совместимость
- Ошибки в логах: err как атрибут, wrap и детали
- Дефолтный логгер: SetDefault и старый пакет log
- TextHandler и JSONHandler: выбор формата вывода
- Ключи и атрибуты: Attr, Int, String и Any
- Уровни логов: Debug, Info, Warn и Error
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY