---
title: "Группы: WithGroup и вложенные поля"
description: "Группы в slog: WithGroup и slog Group, вложенные поля лога в JSON и Text-выводе, точечная нотация текстового формата и разумные границы групп."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/07-group/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Группы: WithGroup и вложенные поля

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

Группы в slog — способ дать полям пространство имён: конструкция slog Group собирает несколько атрибутов под одним ключом, а WithGroup назначает такую приставку всему, что логгер напишет дальше. Разберём оба способа, как вложенные поля лога выглядят в JSON и в текстовом выводе, что группируется, а что остаётся на верхнем уровне и где проходит разумная граница вложенности.

Плоский набор ключей хорошо служит до первого конфликта имён. Сервис логирует и 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 не меняет родителя — исходный логгер продолжает писать без префикса, а дочерние логгеры можно наслаивать друг на друга.

### Сквозная группа и локальная группа в одной записи

```go
package 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, лечение — обновить фильтр и зафиксировать схему полей, а не чинить логгер задним числом. Мораль: переименование пути поля — то же изменение контракта, что и переименование ключа в конфиге; о нём предупреждают тех, кто читает логи, и вписывают в схему.

### Цепочка групп для вложенной подсистемы

```go
httpLogger := 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 с пустой строкой возвращает тот же логгер: удобно для префикса, приходящего переменной.
- Пустая группа и атрибут с пустым ключом отбрасываются штатными обработчиками.
- Одна группа на подсистему, вложенность до второго уровня: глубже путь становится нечитаемым в фильтрах.

Связи с другими уроками: [четвёртый урок про атрибуты](/courses/slog/lessons/04-klyuchi-atributy/) объясняет, из чего собирается любой Attr, включая групповой. Следующий шаг по конструированию логгера — [восьмой урок про With и логгер на запрос](/courses/slog/lessons/08-with-predlog/), а сквозные поля запроса целиком разбирает [пятнадцатый урок про request ID](/courses/slog/lessons/15-request-id/). Для сборщиков вроде Loki и ELK схему полей с группами фиксирует [семнадцатый урок](/courses/slog/lessons/17-logi-dlya-sbora/). Карта курса — на [странице курса](/courses/slog/).

### Проверка: TextHandler и группы

**Вопрос:** logger := slog.New(slog.NewTextHandler(os.Stdout, nil)).WithGroup("http"); logger.Info("готово", slog.Int("code", 200)). Как выглядит строка вывода?

- ✅ http.code=200 — путь группы склеен с ключом через точку
- code=200 — WithGroup влияет только на JSON-вывод
- http:code=200 — группа отделяется двоеточием
- {"http":{"code":200}} — объект в фигурных скобках и в текстовом формате

TextHandler не умеет вложенные объекты, поэтому разворачивает путь в плоский ключ http.code=200. В JSON та же запись была бы объектом — формы разные, путь поля одинаковый.

### Проверка: пустые группы

**Вопрос:** logger.WithGroup("http").Info("готово") — что попадёт в вывод, если после WithGroup не было добавлено ни одного атрибута?

- ✅ Запись с msg на верхнем уровне: пустая группа отброшена обработчиком
- Пустой объект http без полей рядом с msg
- Ошибка: группа обязана содержать хотя бы один атрибут
- Группа http с одним нулевым атрибутом-заглушкой

Штатные JSONHandler и TextHandler отбрасывают группы, в которых не осталось непустых атрибутов: вывод не раздувается пустыми ветками. Свой Handler из двенадцатого урока вправе печатать иначе.

### Какой JSON получится?

```go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        WithGroup("http")
logger.Info("готово", slog.Group("user",
        slog.Int("id", 7), slog.String("role", "admin")))
```

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

- ✅ time, level и msg на верхнем уровне, а id и role — в объекте http.user
- Все поля плоско: id и role рядом с msg на верхнем уровне
- id и role в объекте user без http: WithGroup не действует на slog.Group
- В выводе только объект user: время, уровень и msg тоже накрыты группой

WithGroup("http") даёт префикс всем атрибутам записи, включая групповой user, — итоговый путь http.user.id. Служебные поля остаются на верхнем уровне: группы накрывают атрибуты, а не саму запись.

### Соберите вложенные поля

Допишите: назначить сквозную группу, собрать локальную группу user и типизированный атрибут с числом.

```go
base := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger := base.___("http")

logger.Info("запрос обработан",
        slog.___("user", slog.___("id", 7), slog.String("role", "admin")))
```

Подсказка: Сквозной префикс ставит WithGroup, локальную группу собирает slog.Group, число оборачивает slog.Int.

Правильные ответы в пропусках: `WithGroup`, `Group`, `Int`

### Цепочка от базового логгера до вложенной записи

Расставьте строки по порядку: базовый логгер, группа http, атрибут-группа, запись с ней, вложенная группа, запись из неё.

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

1. `base := slog.New(slog.NewJSONHandler(os.Stdout, nil))`
2. `logger := base.WithGroup("http")`
3. `user := slog.Group("user", slog.Int("id", 7))`
4. `logger.Info("запрос обработан", user, slog.String("path", "/orders"))`
5. `inner := logger.WithGroup("metrics")`
6. `inner.Info("тайминги собраны", "count", 42)`

### Карточки: группы

- **slog.Group** — Attr, собирающий несколько атрибутов под одним ключом внутри конкретного вызова записи.
- **WithGroup** — Метод логгера: возвращает потомка, у которого все последующие атрибуты получают общий префикс. Родителя не меняет.
- **точечная нотация** — Как TextHandler пишет группу: путь склеивается в плоский ключ вида http.user.id=7.
- **пустая группа** — Группа без непустых атрибутов отбрасывается штатными обработчиками и не попадает в вывод.
- **WithGroup("")** — Операция без действия: возвращается тот же логгер. Удобно, когда префикс приходит переменной.
- **служебные поля** — time, level, msg и source не группируются: они остаются на верхнем уровне при любом WithGroup.

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

- 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. Путь поля — часть контракта с теми, кто читает логи: при изменении групп обновляйте схему и предупреждайте потребителей вывода.
