---
title: "Структура JSON: ключи и вложенность"
description: "Структура JSON в zerolog: имена time, level и message, порядок ключей, Dict и Array для вложенности, Interface с тегами encoding/json и плоские поля для Loki."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/19-log-formaty-json/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Структура JSON: ключи и вложенность

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

Строка JSON из zerolog читается системой сбора, а не человеком: порядок ключей, имена level, time и message, глубина вложенности — всё это определит, что станет колонкой и фильтром в Loki, а что придётся доставать регуляркой. Это урок про форму записи: что именно окажется в строке, кто задаёт имена ключей и почему плоская структура дешевле глубокой. Разберём скелет записи, константы имён из globals.go, вложенность без рефлексии через Dict и Array, интерфейс Interface с тегами encoding/json и правила сбора для системы.

Анатомия записи фиксирована. Событие рождается с ключом level — порогом, который перед записью проходит через LevelFieldMarshalFunc. Затем ложится байтовый контекст логгера: то, что собрано через With — service, request_id, timestamp. Потом едут ваши поля события в порядке вызовов. Последним, при финализации, дописывается message — и только если оно непустое: Send с пустым сообщением даёт запись без ключа message вовсе. Порядок подтверждается исходниками: newEvent сначала пишет уровень, затем добавляет контекст логгера, поля цепочки следом. Парсеру порядок безразличен — JSON-объект неупорядочен по спецификации, — но людям и diff-ам порядок важен: стабильные первые ключи дают визуальные якоря при просмотре ленты, а одинаковый порядок полей между релизами делает дифы логов читаемыми. Фиксированный скелет — не косметика: первые ключи парсеры пайплайна разбирают с минимальными настройками, а хвост ленты глазами просматривает дежурный при tail — когда level и service всегда на своих местах, аномалия видна на первой сотне строк. Отсюда простое правило: контекст — для того, что есть у каждого события модуля или запроса, поля — для факта конкретной записи; это разделение из [урока про поля события](/courses/zerolog/lessons/04-polya-sobytiya/) прямо диктует и структуру JSON: два источника, два участка строки, message всегда в хвосте.

### Скелет записи целиком

```go
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        logger := zerolog.New(os.Stdout).With().
                Str("service", "orders").Timestamp().Logger()

        logger.Info().Str("op", "create").Int("items", 3).Msg("order created")
}

// {"level":"info","service":"orders","time":"2024-04-06T12:00:00+03:00",
//  "op":"create","items":3,"message":"order created"}
```

Порядок ключей здесь виден целиком: level дописан при создании события, затем контекст — service и time в том порядке, как вызывались в With, затем поля события и message последним. Контекст лежит в логгере байтами, поэтому на каждое событие не тратится ни одной пересборки — строка склеивается из готовых кусков.

### Имена ключей: переменные zerolog

Имена скелетных ключей — переменные пакета, а не константы: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте: чужая система сбора, которая ждёт ts вместо time, — одна строка до создания логгера. Это обычные переменные без защиты: присваивайте до запуска горутин, чтобы не устроить гонку на горячем пути. Переопределение живёт в инициализации пакета логирования — первая строка main или func init, до первого события и до старта воркеров: переменные читаются на каждой записи, и менять их в рантайме — значит гонять присваивание наперегонки с записью, безо всякой синхронизации вокруг них. Значение уровня тоже настраивается — LevelFieldMarshalFunc превращает info в INFO, если формат вашей ленты требует верхний регистр. Время — TimeFieldFormat: RFC3339 по умолчанию, а TimeFormatUnix и TimeFormatUnixMs дают целые числа, что экономит байты и упрощает сортировку. Что важно понимать честно: API для массового переименования собственных полей в zerolog нет — ключ из Str("user_id") навсегда ваш строковый литерал. Имена фиксируются константами вашего пакета логирования, и их смена — осознанная миграция дашбордов, а не правка одной строки. Поле error из Err — часть того же скелетного словаря: алерты и дежурные ищут именно его, поэтому переопределять ErrorFieldName без крайней необходимости не стоит.

### Dict и Array: вложенность без рефлексии

Два способа собрать вложенное. Dict: zerolog.Dict() создаёт безымянное событие, вы наполняете его обычными методами полей, а e.Dict(key, dict) закрывает его фигурной скобкой и вклеивает под вашим ключом; тот же метод есть у Context для контекста. Array: zerolog.Arr() создаёт массив с тем же словарём методов — Str, Int, Dur, Time, Object, Dict; e.Array(key, arr) записывает его значением ключа. Оба пути — типизированные методы, дописывающие байты: рефлексии нет, аллокаций почти нет, вложенность стоит примерно столько же, сколько плоские поля. Практический ориентир — группы, у которых есть смысл как целое: http с method, path и status, client с ip и версией приложения. Поля, которые фильтруются по отдельности, вложенности не просят: level, request_id и op живут наверху. Для структур с поведением есть Object: тип реализует LogObjectMarshaler с методом MarshalZerologObject(e *Event) и сам раскладывает себя по полям события; EmbedObject вклеивает те же поля без имени-обёртки. Третий путь — Interface: если значение не умеет MarshalZerologObject, оно уезжает через encoding/json с выключенным HTML-экранированием, и имена ключей определяют теги json структуры. Именно поэтому Object и Dict — инструменты горячего пути, а Interface — редких дампов: цена и предсказуемость различаются, экономику этого выбора разбирает [урок про производительность](/courses/zerolog/lessons/13-log-proizvoditelnost/).

### Объект, словарь и массив в одной записи

```go
package main

import (
        "github.com/rs/zerolog"
        "github.com/rs/zerolog/log"
)

type request struct {
        Method string
        Path   string
}

func (r request) MarshalZerologObject(e *zerolog.Event) {
        e.Str("method", r.Method).Str("path", r.Path)
}

func main() {
        log.Info().
                Object("request", request{"POST", "/api/orders"}).
                Dict("client", zerolog.Dict().Str("ip", "203.0.113.7")).
                Array("ids", zerolog.Arr().Int(101).Int(102)).
                Msg("batch accepted")
}

// {"level":"info","time":"...","request":{"method":"POST","path":"/api/orders"},
//  "client":{"ip":"203.0.113.7"},"ids":[101,102],"message":"batch accepted"}
```

Object зовёт MarshalZerologObject и вклеивает объект под ключом request: метод сам решает, какие поля и в каком порядке уедут. Dict собирает подобъект на месте из методов полей, Array пишет массив чисел. Все три пути обходятся без рефлексии: байты дописываются в буфер теми же Append-методами, что и плоские поля события.

Вложенность чаще всего нужна в двух местах. Первое — контекст: поля, общие для всех событий запроса, собираются в With и могут быть группой — Context.Dict вклеивает объект прямо в байтовый контекст логгера, и каждое событие уносит его без повторных вызовов. Второе — событие-отчёт: дамп конфигурации при старте, сводка пакета задач, результат батча. Промежуточные варианты — Dict в каждой записи горячей цепочки «на всякий случай» — это байты, которые платят все события ради одного, кому они были нужны; правило из урока про условные поля применимо и здесь: собирайте вложенность там, где она объясняет запись, а не украшает её. Отдельный случай — слайс своих структур: вместо ручного перебора с Dict на каждый элемент определите тип-маршалер. У LogArrayMarshaler есть метод MarshalZerologArray(a *Array) — типизированный путь, который пишет массив целиком через те же методы, и e.Array принимает такой тип напрямую, без ручной сборки. Маршалер переиспользуется: один и тот же тип годится и для Object, и для EmbedObject, и для контекста, и для события — раскладка по полям описана один раз, рядом с самим типом.

### Что окажется в JSON

| Метод | Форма в JSON | Когда брать |
| --- | --- | --- |
| Dict(key, zerolog.Dict()) | объект под ключом | группировка родственных полей без своих типов |
| Array(key, zerolog.Arr()) | массив значений | списки идентификаторов и тегов |
| Object(key, obj) | объект из MarshalZerologObject | свои типы на горячем пути |
| EmbedObject(obj) | поля объекта прямо в событии | плоская запись без имени-обёртки |
| Interface / Any | то, что выдаст encoding/json | редкие дампы структур с тегами json |
| RawJSON(key, b) | готовые байты как есть | значение уже сериализовано, например из кэша |

> **Живое место: Interface меняет лог молча.** Если структура уехала в Interface, форму записи контролируют теги json: переименовали поле, добавили omitempty — и лог сменил форму без единой правки в вызовах. Непроэкспортированное поле вообще исчезнет из вывода: encoding/json его не видит. Если структура лога важна, закрывает её тест: буфер и сравнение ключей ловит смену формы раньше дежурного.

### Плоская структура для Loki

Loki собирает ленту JSON-объектов: каждая строка — самостоятельный объект, и запросы LogQL разбирают его ключи. Практические следствия для zerolog. Первое: ключи верхнего уровня — ваши главные фильтры; json-парсер LogQL достаёт их и даёт фильтровать ленту по значению, поэтому service, op, level и request_id должны лежать наверху, а не внутри объектов. Второе: вложенные объекты парсер разворачивает в плоские имена с подчёркиваниями — method внутри объекта http станет http_method; один уровень вложенности переживается спокойно, но каждый следующий — место, где имена перестают быть очевидными. Третье: message остаётся текстом для полнотекстового поиска, а level — стандартным ключом для распределения по уровням; оба имени заданы скелетом zerolog, и это совпадает с ожиданиями сборщика. Четвёртое — время: если пайплайн не настроен взять time как метку события, запись получит время приёма, и при задержках доставки графики будут врать. TimeFieldFormat в виде Unix-миллисекунд упрощает разбор: число парсится однозначно, а RFC3339 со смещением требует явного формата. Типовой разбор глазами дежурного: селектор по service, затем json-парсер, затем фильтр по level — все три слова это ключи вашей записи, и ни одно не появилось бы там само. Плоская запись делает каждую строку самодостаточной для такого разбора: логи разных сервисов склеиваются в одну ленту, и фильтр отделяет чужое. Итог: глубина максимум один уровень, ключи наверху, message — факт, level — стандартный, время — машиночитаемое.

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

- скелет записи: level, контекст из With, поля события в порядке вызовов, message последним
- имена скелета — переменные zerolog: time, level, message, error; менять до старта горутин
- собственные ключи — константы вашего пакета: переименование поля это миграция дашбордов
- вложенность — Dict, Array и Object: методы полей, без рефлексии и без сюрпризов
- Interface — для редких дампов: форму записи диктуют теги json структуры
- для Loki: ключи верхнего уровня, глубина не больше одного уровня, level и message стандартные
- time — машиночитаемым: Unix-миллисекунды парсятся пайплайном однозначнее, чем RFC3339
- каждый уровень вложенности — плюс скобки и байты, минус очевидность имён в фильтрах

### Цена вложенности и ключ error

Вложенность не бесплатна до бесконечности. Каждая скобка — байты, каждый подобъект — вызовы методов и ветвления; на тысячах событий в секунду разница между плоским событием из шести полей и трёхуровневой конструкцией заметна в профиле — та же экономика, что и в производительности. Отдельно про значения-объекты в Interface: encoding/json упакует и слайсы, и вложенные структуры, но заплачено будет рефлексией на каждой записи; если такой дамп нужен раз в сутки при старте — цена незаметна, а если он переехал в обработчик запроса, профиль покажет рост аллокаций раньше, чем вы успеете удивиться. Читаемость страдает раньше скорости: вложенный объект занимает строку на каждый уровень и в консоли, и в дампе, и поиск глазами по ленте замедляется. Договоритесь в команде на одном правиле: два-три имени объектов максимум — например, http для запроса и client для источника, — внутри всё плоско. Err стоит отдельно: его ключ задаётся ErrorFieldName и по умолчанию равен error, значение — текст ошибки или пропуск при nil, как разобрано в уроке про поля. Этот ключ тоже часть словаря сборщика: алерты строятся по error, и переопределять имя стоит только вместе с ними. Поле Errs для слайса ошибок и AnErr для ошибки-объекта завершают семейство — оно целиком разобрано в уроке про логирование ошибок.

> **Про песочницу.** В песочнице библиотека не исполняется: программа урока разбирается построчно, а форма JSON видна в комментариях к коду. Локально достаточно go run main.go — настоящие строки ленты покажут и порядок ключей, и вложенность так, как их увидит Loki.

### Проверка: хвост записи

**Вопрос:** Какой ключ в JSON-записи zerolog дописывается последним?

- level — он же дописывается первым при создании события
- time — временная метка всегда в конце
- ✅ message — при финализации, если сообщение непустое
- последний ключ, добавленный вызовом Str

Порядок фиксирован: level, контекст логгера, поля события в порядке вызовов и message последним при финализации. Пустое сообщение убирает ключ вовсе: Send пишет запись без message.

### Проверка: Dict против Interface

**Вопрос:** Чем Dict выгоднее Interface для вложенного объекта?

- Пишет меньше байт за счёт сжатия ключей
- ✅ Собирается методами полей: без рефлексии, без аллокаций, без влияния тегов
- Поддерживает вложенность на любую глубину
- Автоматически сортирует ключи внутри объекта

Dict наполняется теми же Append-методами, что и плоские поля: тип известен при компиляции, рефлексии нет. Interface уезжает через encoding/json — рефлексия, аллокации и форма, зависящая от тегов структуры.

### Что окажется в JSON?

```go
log.Info().
        Dict("http", zerolog.Dict().
                Str("method", "GET").
                Int("status", 200)).
        Msg("done")
```

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

- ✅ {"level":"info","http":{"method":"GET","status":200},"message":"done"}
- {"level":"info","http_method":"GET","http_status":200,"message":"done"}
- {"level":"info","http":["GET",200],"message":"done"}
- {"http":{"method":"GET","status":200},"level":"info","message":"done"}

Dict вклеивает объект под ключом http в порядке вызовов полей; уровень идёт первым, message — последним. Разворот в http_method делает парсер Loki при чтении, а не zerolog при записи.

### Собрать вложенное событие

Восстановите вызовы: объект под ключом, сам объект и список идентификаторов.

```go
log.Info().
        ___("client", zerolog.___().Str("ip", ip)).
        ___("ids", zerolog.Arr().Int(101).Int(102)).
        Msg("batch accepted")
```

Подсказка: Ключ с объектом — метод Dict, сам объект создаёт zerolog.Dict, список значений — Array.

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

### Событие с вложенностью

Расставьте строки в правильном порядке: импорты, функция с вложенным событием из словаря и массива.

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

1. `package main`
2. `import (`
3. `  "github.com/rs/zerolog"`
4. `  "github.com/rs/zerolog/log"`
5. `)`
6. `func main() {`
7. `  log.Info().`
8. `    Dict("client", zerolog.Dict().Str("ip", "203.0.113.7")).`
9. `    Array("ids", zerolog.Arr().Int(101).Int(102)).`
10. `    Msg("batch accepted")`
11. `}`

### Карточки: структура JSON

- **TimestampFieldName** — Переменная пакета со значением time. Переопределяется при старте до горутин; формат значения задаёт TimeFieldFormat — RFC3339 или Unix-миллисекунды.
- **MessageFieldName** — Переменная со значением message: дописывается последним при финализации, если сообщение непустое. Send с пустым сообщением пишет запись без этого ключа.
- **Dict** — zerolog.Dict() создаёт безымянное событие, e.Dict(key, ...) вклеивает его объектом под ключом. Методы полей — без рефлексии.
- **Array и Arr** — zerolog.Arr() создаёт массив со словарём методов; e.Array(key, arr) записывает его значением. Свои типы — через LogArrayMarshaler.
- **Object и EmbedObject** — Тип реализует MarshalZerologObject и сам раскладывает поля: Object кладёт под ключ, EmbedObject — прямо в событие. Без рефлексии.
- **Interface** — Значение уезжает через encoding/json с выключенным HTML-экранированием; имена ключей диктуют теги json. Инструмент редких дампов, не горячего пути.

### Что дальше по курсу

Следующий урок — [тесты логов](/courses/zerolog/lessons/20-testy-logov/): bytes.Buffer вместо writer-а, разбор JSON в тесте и фиксация структуры из этого урока как контракта. После — [логи и метрики Prometheus](/courses/zerolog/lessons/21-logi-i-metriki/): ключи записи становятся колонками дашбордов, а счётчик рядом с событием превращает ленту в алерт. Структура, которую вы сегодня зафиксировали, — это и есть контракт, который будут защищать тесты.

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

- Скелет записи: level, контекст из With, поля в порядке вызовов, message последним.
- Имена скелета — переменные пакета: time, level, message, error, caller; менять до старта горутин.
- Dict и Array собирают вложенность методами полей — без рефлексии и аллокаций.
- Object и EmbedObject — свои типы через MarshalZerologObject; Interface — encoding/json и теги.
- Форму Interface-поля меняют теги json: фиксируйте структуру тестом.
- Для Loki: ключи наверху, глубина до одного уровня, message — факт, level — стандарт.
- Время — машиночитаемое: TimeFormatUnixMs парсится пайплайном однозначнее RFC3339.
- RawJSON пишет готовые байты без проверок и экранирования — ответственность на вызывающем.

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

Соберите событие с тремя видами структуры: Dict для клиента, Array для идентификаторов и Object на собственном типе с MarshalZerologObject — и рассмотрите JSON: проверьте порядок ключей и то, что message в хвосте. Переопределите TimestampFieldName на ts при старте и убедитесь, что лента сменила имя ключа. Затем добавьте структуру с тегами json и передайте её через Interface: переименуйте поле, добавьте omitempty и посмотрите, как форма записи поменялась без правки вызова. Напишите тест, который пишет это событие в буфер и сверяет набор ключей верхнего уровня — он станет стражем контракта. Напоследок посчитайте байты: плоский вариант против варианта с тремя уровнями вложенности — разницу увидите на первой тысяче событий.

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

### Какие имена ключей использует zerolog в JSON?

Скелетные имена — переменные пакета: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте программы до запуска горутин — это обычные переменные без защиты от гонок. Собственные поля именуете вы: API массового переименования в библиотеке нет, ключи Str — ваши строковые литералы.

### Как вложить объект или массив в запись zerolog?

Dict: zerolog.Dict() создаёт безымянное событие, методы полей наполняют его, а e.Dict(key, dict) вклеивает объект под ключом. Array: zerolog.Arr() с тем же словарём методов и e.Array(key, arr). Для своих типов — Object с методом MarshalZerologObject. Все пути пишут байты напрямую; Interface остаётся для значений, которые сериализует encoding/json по тегам.

### Какая структура JSON нужна Loki?

Плоская: json-парсер LogQL берёт ключи верхнего уровня как поля фильтров, а вложенные объекты разворачивает в имена с подчёркиваниями. Держите service, level, request_id и op наверху и ограничьте глубину одним уровнем. Настройте пайплайн на разбор time как метки события, иначе записи получат время приёма, и графики соврут при задержках доставки.
