Содержание урока · 10 разделов
Ошибка в проде, а в логе — одна строка error":"unexpected EOF. Где она случилась, кто её обёртывал, какая причина под верхним слоем — неизвестно: текст ошибки без контекста превращает разбор в археологию. Структурный логгер решает это полями: ошибка кладётся в JSON как данные, а не как кусок текста сообщения, причину выносят в отдельный ключ, а стек вызовов включают там, где ошибка — действительно сбой. У zerolog для этого четыре вызова: Err для поля по умолчанию, AnErr для своего ключа, Errs для массива и Stack() для стека — и у каждого есть негласный контракт, который стоит прочитать до инцидента, а не после. Контракт проще, чем кажется: Err и AnErr молча пропускают nil, Errs оформляет слайс массивом, а стек — единственный, кто требует глобальной настройки до первого использования. Логирование ошибок в Go — это всегда два вопроса: что положить в запись и где именно её логировать. Первый вопрос разбирается по вызовам ниже; второй — дисциплина, без которой даже правильно размеченные ошибки утонут в тройных записях одного и того же сбоя.
Err в zerolog: поле error из коробки#
Метод Event.Err(err) — главный способ положить ошибку в запись: поле получает имя zerolog.ErrorFieldName, по умолчанию error, и значение err.Error(). Нулевая ошибка пропускается целиком — поля error":null в ленте не появится, и это поведение стоит помнить при цепочках, где err может быть nil. Внутри метода стоит проверка на nil, поэтому вызов безопасен в любом месте цепочки и не стоит ничего. Работает Err и на других уровнях: Warn().Err(err) — нормальная форма для «упало, но пережили». Сокращение Logger.Err(err) сразу поднимает уровень до error и добавляет поле одним вызовом — удобно наверху обработки. Нюанс из док-комментария: «error-уровень с полем, если err не nil, иначе — info», то есть при nil-ошибке отдаётся Info-событие без поля, и ветка остаётся читаемой записью даже без сбоя. Главная ловушка — err.Error(): метод возвращает склеенную цепочку «outer: middle: root», и в JSON уезжает вся строка целиком. Для поиска по корню это боль: фильтр error"deadline не найдёт запись с текстом charge" timeout" deadline. Поэтому либо кладите причину отдельным ключом через AnErr, либо разматывайте цепочку errors.Is и errors.As и логируйте её явно. Для глобальной настройки есть zerolog.ErrorMarshalFunc — функция, через которую проходит каждая ошибка перед записью; ей можно вернуть структуру с типом и кодом вместо сырого текста. Сама механика полей события разобрана в уроке про поля, а правило «не склеивать ошибку в текст сообщения» — из антипаттернов логов: строка в Msgf не фильтруется, поле фильтруется.
Err, AnErr и Errs в одной записи
GOpackage main
import (
"errors"
"fmt"
"os"
"github.com/rs/zerolog"
)
var errQuota = errors.New("quota exceeded")
func charge(amount int) error {
if amount > 100 {
return fmt.Errorf("charge %d: %w", amount, errQuota)
}
return nil
}
func main() {
logger := zerolog.New(os.Stdout).With().Timestamp().Logger()
err := charge(150)
logger.Error().
Err(err).
AnErr("cause", errors.Unwrap(err)).
Msg("payment rejected")
logger.Warn().
Errs("problems", []error{errQuota, errors.New("disk full")}).
Msg("deferred")
}
// Output:
// {"level":"error","error":"charge 150: quota exceeded","cause":"quota exceeded","message":"payment rejected",...}
// {"level":"warn","problems":["quota exceeded","disk full"],"message":"deferred",...}Err пишет поле error — текст содержит всю цепочку, потому что fmt.Errorf с %w склеивает err.Error(). AnErr кладёт причину под собственным ключом cause: errors.Unwrap достаёт следующее звено, и фильтр по cause ловит корень без разбора текста. Errs сериализует слайс ошибок JSON-массивом под ключом problems — место для мульти-ошибок валидации.
AnErr и Errs: когда одного поля мало#
AnErr(key, err) — тот же Err, но под вашим ключом: cause, db_err, remote_err. Зачем второй ключ: фильтры по error ловят всё подряд, а по cause — только то, что вы пометили как причину; текст верхнего уровня и корень живут раздельно, и дашборд по cause не всплывает от посторонних ошибок. Нулевая ошибка пропускается: AnErr("cause", nil) не оставит в записи следа, поэтому условная цепочка AnErr без if работает безопасно. Errs(key, errs) — для коллекций: слайс ошибок уезжает JSON-массивом под заданным ключом, и в v1.34.0 nil-элементы сериализуются как null, а пустой слайс — пустым массивом; учитывайте это, собирая мульти-ошибку из валидации. Сценарий у Errs один — «набор проблем одного события»: список причин отказа, накопленные ошибки ретраев, итог батча. errors.Join из стандартной библиотеки вписывается в эту картину с оговоркой: он склеивает ошибки в одну, и Err покажет весь список текстом — если нужна структура массивом, держите слайс и пишите Errs. Для одиночной ошибки Err и AnErr остаются основными вызовами, а Logger.Err стоит держать в голове как короткую форму: уровень и поле одним вызовом, без отдельной цепочки.
Правила полей ошибок одним списком#
- Err — ключ error по умолчанию; глобально имя меняет zerolog.ErrorFieldName
- AnErr — тот же Err под своим ключом: cause, db_err; nil пропускается
- Errs — слайс ошибок массивом; в v1.34.0 nil-элементы становятся null
- err.Error() — вся цепочка текстом «outer: middle: root»; фильтруйте с оглядкой
- причину доставайте errors.Is, errors.As или errors.Unwrap и кладите отдельным полем
- не склеивайте ошибку в Msgf — строка не фильтруется, поле фильтруется
- Logger.Err(err) — уровень error и поле одним вызовом; при nil отдаёт Info
Stack: stacktrace в поле stack#
Стек вызовов в лог zerolog не пишет сам: у события есть метод Stack(), но он лишь ставит флаг, а поле добавляет глобальный маркер zerolog.ErrorStackMarshaler. По умолчанию маркер nil, и Stack() без настройки ничего не делает — самая тихая ловушка урока. Маршрут из коробки: zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack из пакета github.com/rs/zerolog/pkgerrors — он работает с ошибками github.com/pkg/errors, которые захватывают стек в момент создания. Тип маркера — func(err error) interface{}: собственная реализация на runtime.Callers или debug.Stack встраивается одной функцией, если тянуть pkg/errors в проект не хочется. MarshalStack разматывает цепочку через Unwrap, находит ошибку с методом StackTrace() и сериализует кадры массивом объектов с ключами source, line и func под именем zerolog.ErrorStackFieldName — по умолчанию stack. Порядок вызовов важен: Stack() ставит флаг, Err читает его, поэтому цепочка собирается как Error().Stack().Err(err) — наоборот флаг опоздает. Стек переживает обёртывание: fmt.Errorf("%w") поверх pkg/errors-ошибки не рвёт цепочку, и MarshalStack докопается до оригинала — потому стек захватывается там, где ошибка рождается, а сколько слоёв её потом обернули — неважно.
Стек через pkgerrors.MarshalStack
GOpackage main
import (
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
"github.com/rs/zerolog/pkgerrors"
"github.com/rs/zerolog"
)
func middle() error {
return errors.New("connection refused")
}
func main() {
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
err := middle()
log.Error().Stack().Err(err).Msg("dial failed")
}
// Output:
// {"level":"error","error":"connection refused","stack":[{"source":"main.go","line":15,"func":"main.middle"},...],"message":"dial failed",...}Ошибка создана errors.New из pkg/errors — в момент создания захвачен стек. ErrorStackMarshaler настроен на pkgerrors.MarshalStack, поэтому Error().Stack().Err(err) пишет и поле error с текстом, и поле stack: массив кадров с source, line и func. Порядок Stack() до Err() обязателен: Err читает флаг стека в момент вызова, а не при финализации.
Три способа потерять стек, не заметив этого. Первый — Stack() после Err(): флаг ставится, но Err уже отработал, и поле не добавится; правило — Stack() всегда раньше Err в цепочке. Второй — ErrorStackMarshaler не настроен: Stack() честно ставит флаг, маркер nil, поле stack в JSON не появляется, и вы узнаёте об этом на разборе инцидента, когда стек нужен был вчера. Третий — ошибки без кадров: errors.New из стандартной библиотеки стек не захватывает, MarshalStack вернёт nil, и запись останется без stack. Проверяйте связку один раз на старте: лог ошибки рядом с записью — и стек либо есть всегда, либо вы сразу видите, какие ошибки созданы без захвата.
Когда стек нужен, а когда мусор#
Стек — инструмент программных ошибок: паника, нарушенный инвариант, невозможная ветка. Для доменных отказов он почти всегда шум: «пользователь не найден» — это данные ответа, а не сбой, и кадры на двадцать строк лишь раздувают лог. Спорное мнение, которое сэкономит вам гигабайты: если ошибка — ожидаемая часть бизнес-логики, стек ей не нужен, хватит текста и причин в отдельных полях. Условие, при котором я прав: ожидаемые ошибки вы различаете типом или errors.Is, а не разбором строки — иначе без стека не поймёте, откуда взялся unexpected EOF. Вторая ловушка — переиспользуемые ошибки: синглтон errNotFound из пакета захватил стек при инициализации, и MarshalStack честно покажет кадры package init — правды о месте отказа там нет. Поэтому стеки имеет смысл включать там, где ошибки создаются локально на каждый вызов, а для сентинелов — ошибок-констант — стек бессмыслен с рождения. Практический компромисс: Stack() на Error-событиях, Warn и ниже — без кадров, и объём лога под контролем. Кадр стека весит заметно больше остальной записи — объект с source, line и func на каждый кадр, — и на шумном Error-потоке эти сотни байт на событие превращаются в гигабайты за месяц.
Двойное логирование — типовая коза: нижний слой пишет Warn, средний Error, наверху ещё один Error с той же ошибкой — в ленте три строки на один сбой, и алерт срабатывает трижды. Договоритесь на одном месте: ошибка логируется там, где её обрабатывают, — где принимается решение откатить, отретраить или отдать 500. Внизу — возврат с обёрткой fmt.Errorf("%w") и контекстом того, что делали, наверху — одна запись. Уровень выбирается по последствию: не доехало до клиента и пережито — Warn, упал запрос — Error. К этой дисциплине возвраты без логов относятся так же строго, как и к тройным записям: замолчанная ошибка не лучше тройной. Промежуточный слой, которому нечего добавить, просто возвращает ошибку дальше — обёртка ради обёртки удлиняет текст, не добавляя смысла. Если ошибка уходит в ответ клиенту, поле error и request_id из урока про HTTP-логи дают дежурному всё сразу: что случилось и в каком запросе искать подробности — остальное собирается фильтром по идентификатору.
Способы положить ошибку в запись#
| Вызов | Ключ в JSON | nil на входе |
|---|---|---|
| Event.Err(err) | error (ErrorFieldName) | поле не добавляется |
| Event.AnErr(key, err) | ваш ключ | поле не добавляется |
| Event.Errs(key, errs) | ваш ключ — массив | nil-элементы становятся null |
| Event.Stack().Err(err) | error + stack | без ErrorStackMarshaler стек не пишется |
| Logger.Err(err) | error, уровень error | nil даёт Info-событие без поля |
Цепочки ошибок с %w, errors.Is и errors.As — стандартная библиотека, и песочница сайта их показывает: тематическая программа на чистом Go строит обёрнутую цепочку и достаёт корень. zerolog и pkgerrors в песочнице не запустить — код урока исполняется локальным Go: go mod init, go get github.com/pkg/errors, go run main.go, и стек в JSON появится на вашем экране.
Проверка: имя поля
Какое имя поля по умолчанию заполняет Err()?
Проверка: условие стека
Что нужно, чтобы Stack() добавил поле stack?
Проверка: nil в AnErr
Что сделает AnErr("cause", nil)?
Что уедет в поле error?
Прочитайте код и выберите, что он выведет:
root := errors.New("deadline")
err := fmt.Errorf("charge: %w", root)
log.Error().Err(err).Msg("failed")Включите стеки
Восстановите настройку: маркер, реализация, уровень события и флаг стека.
Программа со стеком
Расставьте строки в правильном порядке: импорты, настройка маркера и запись со стеком.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки
Что дальше по курсу#
Ошибка в JSON — уже половина диагностики; курс доводит её до конца. Производительность покажет, сколько стоит событие с Err на горячем пути и где включать Disabled, урок про структуру JSON — как устроены вложенные объекты и массивы вроде поля stack, а тесты логов научат проверять error и cause в тестах буфером. Справочное закрепление: соберите три слоя — репозиторий, сервис, хендлер — и проведите одну ошибку через все, обёртывая в каждом fmt.Errorf с %w; в access-логе должен остаться один Error с текстом цепочки и причинами в отдельных полях.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Err — ключ error (zerolog.ErrorFieldName); AnErr — свой ключ; Errs — массив.
- nil у Err и AnErr пропускается; в Errs nil-элементы становятся null.
- err.Error() склеивает цепочку «outer: middle: root» — фильтруйте с оглядкой.
- Stack() ставит флаг; поле stack пишет ErrorStackMarshaler, например pkgerrors.MarshalStack.
- Порядок: Error().Stack().Err(err) — флаг должен быть поставлен до Err.
- Стек — для программных ошибок; ожидаемые доменные отказы живут текстом и причинами.
- Ошибку логируем раз — там, где обрабатываем; внизу обёртка, наверху запись.
- Сентинел-ошибки захватывают стек при init — кадры init в логе ничего не значат.
Домашнее задание
Соберите программу урока и убедитесь, что в JSON уехали и error, и cause. Перепишите сценарий на errors.Join и сравните: Err покажет склеенный текст, Errs — массивом. Включите pkgerrors.MarshalStack, создайте ошибку через pkg/errors глубоко в функции-внуке и проверьте в поле stack кадры с её именем. Сломайте порядок — Stack() после Err() — и запишите своими словами, чего не хватило в записи. Наконец, добавьте Warn-событие с AnErr("cause", nil) и найдите его в ленте: поле cause не появится — это и есть контракт nil. Замерьте размер записи со стеком и без — разница в байтах станет аргументом, когда кто-нибудь предложит включить кадры на Warn.
Личные заметкиПусто
Вопросы и ответы по уроку
Как залогировать ошибку под своим ключом, а не error?
AnErr: logger.Error().AnErr("db_err", err).Msg("query failed") — значение то же, ключ ваш. Обычный Err продолжает писать в error, поэтому верхний текст и конкретика живут раздельно; nil в AnErr пропускается целиком, и условные цепочки работают без if.
Почему поле stack пустое, хотя Stack() вызван?
Три причины по порядку проверки: Stack() вызван после Err(), маркер zerolog.ErrorStackMarshaler не настроен, ошибка создана без захвата стека — errors.New из stdlib или сентинел-константа. Настройте pkgerrors.MarshalStack и создавайте ошибки через pkg/errors там, где стек нужен. Четвёртая причина экзотичнее: имя поля переименовано через zerolog.ErrorStackFieldName, и вы ищете в JSON stack, хотя запись содержит другое имя.
Куда девать ошибки из errors.Join?
Join возвращает одну ошибку, чей Error() склеивает все звенья, — Err покажет текст целиком. Для структуры удобнее Errs: передайте слайс ошибок массивом под одним ключом, но помните про nil-элементы — в v1.34.0 они сериализуются как null, а пустой слайс — пустым массивом.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила