Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 18 из 25 ~16 мин +50 XP

Ошибки и стек-трейсы: zap.Error

zap error — это zap.Error(err), поле, которое превращает значение ошибки в структурную запись: строку err.Error() в поле error, а при наличии fmt.Formatter — ещё и подробный errorVerbose рядом. Разберём, что попадает в лог при обёрнутых ошибках, зачем нужен AddStacktrace, где стек приносит больше вреда, чем пользы, и чем DPanic отличается от Panic.

Содержание урока · 12 разделов

Error-лог в разгар инцидента — худшее место для догадок. Запись есть: job failed, в поле error — context deadline exceeded. Где случилось? Код из семи файлов вызывает один и тот же таймаут, и сообщение не отличит их друг от друга. Команда копирует ошибку в поиск, находит тридцать совпадений и начинает угадывать. Здесь работают два разных инструмента zap: поле error с самой ошибкой и поле stacktrace — снимок стека в момент записи. Первый отвечает «что случилось», второй — «откуда это пришло». Ниже — как ими распоряжаться: что попадает в zap.Error при обёрнутых ошибках, на каком уровне включать стек и почему от стека на ожидаемых ошибках больше шума, чем пользы.

zap error: что попадает в поле#

Поле zap error появляется в записи через конструктор zap.Error(err) — это короткая форма zap.NamedError с ключом error. Кодировка честная: err.Error() уходит строкой, и на этом базовый путь заканчивается. Три нюанса, которые стоит знать до прод-инцидента. Нил-ошибка кодируется пусто: zap.Error(nil) не даст ни error: null, ни пустой строки — поля в записи просто не будет. Если ошибка реализует fmt.Formatter, zap пишет второе поле errorVerbose с результатом err.Format("%+v") — так работают пакеты вроде pkg/errors, у которых подробный вывод с местом возникновения. И поле error — обычное поле: оно фильтруется, индексируется и считается в дашбордах как любой string в JSON, а не магический объект.

Обёрнутая ошибка в логе

GO
func loadConfig(path string) error {
        data, err := os.ReadFile(path)
        if err != nil {
                return fmt.Errorf("load config %s: %w", path, err)
        }
        return parse(data)
}

// в main:
if err := loadConfig(cfgPath); err != nil {
        logger.Error("startup failed", zap.Error(err))
}

Поле error соберёт цепочку в одну строку: load config /etc/app.yaml: open /etc/app.yaml: no such file or directory. errors.Is и errors.As работают по %w как обычно — обёртка не ломает проверку на os.ErrNotExist. В JSON это одна строка в поле error; подробный разбор с переносами появится, только если ошибка реализует fmt.Formatter — тогда zap добавит рядом поле errorVerbose.

Обёрнутые ошибки: цепочка в одной строке#

fmt.Errorf с %w строит цепочку, и err.Error() склеивает все слои через двоеточие — «checkout: charge: connection refused». В лог это идёт одной строкой в поле error, и цепочка читается глазами сверху вниз: что делали, на каком шаге, чем кончилось. Проверки через errors.Is и errors.As по цепочке проходят свободно. Где граница пользы: когда слоёв становится пять-шесть, строка превращается в простыню, которую тяжело грепать — регулярка по одному слою начинает ловить чужие записи. Мой рабочий предел — три слоя: выше этого проще дать промежуточному слою осмысленное сообщение или сразу пустить поля в лог отдельно. Это вкусовщина, но ограничение стоит поставить заранее, а не после третьей ночи с grep.

Кастомная ошибка с полями

GO
type PaymentError struct {
        OrderID string
        Gateway string
        Err     error
}

func (e *PaymentError) Error() string {
        return fmt.Sprintf("payment %s via %s: %v", e.OrderID, e.Gateway, e.Err)
}

func (e *PaymentError) Unwrap() error { return e.Err }

// при разборе:
var pe *PaymentError
if errors.As(err, &pe) {
        logger.Error("payment failed",
                zap.Error(err),
                zap.String("order_id", pe.OrderID),
                zap.String("gateway", pe.Gateway),
        )
}

Unwrap сохраняет цепочку: errors.Is найдёт внутри os.ErrPermission, errors.As — любой тип из глубины. errors.As вытаскивает конкретный тип, и поля ошибки становятся обычными zap-полями: order_id фильтруется в системе сбора, дашборд считает по нему, а не ищет подстроку в error. Сначала общая запись с zap.Error, потом отдельные поля из типа — этот порядок читается и в логе, и в коде.

Поля вместо парсинга строк#

error — одна строка в JSON, и соблазн вычленять из неё коды регэкспом в системе сбора велик. Я не люблю такой разбор: одна переформулировка сообщения — и дашборд по кодам ошибок умирает, а никто этого не заметит до следующего инцидента. Надёжнее вытащить структуру в коде — errors.As по своему типу — и уложить в отдельные поля: code, reason, order_id. Тогда уровни, алерты и графики строятся по полям, а строка error остаётся для человека. Ради этого не нужно заворачивать каждую ошибку в собственный тип: двух-трёх типов на границах модуля — платёж, внешний вызов, база — хватает на весь дашборд. Остальные ошибки честно пишутся строкой в zap.Error, и это нормально.

AddStacktrace: стек по уровню#

Стек-трейс в логах go получает опция zap.AddStacktrace(zapcore.LevelEnabler): записи выбранного уровня и выше уходят с полем stacktrace — срезом кадров на момент записи. Пресеты уже настроены: production пишет стек с Error, development — с Warn, потому что флаг Development опускает границу. Опцией границу двигают руками: AddStacktrace(zapcore.ErrorLevel) — стек только на настоящих авариях, AddStacktrace(zapcore.WarnLevel) — тоже на подозрительном. Не путайте со стеком-паникой: то, что runtime печатает при панике, — отдельный вывод, а поле stacktrace пишет сам логгер, и видно в нём именно место вызова logger.Error, со всеми промежуточными кадрами вашего кода.

Проверка: граница стека

Какая настройка добавит поле stacktrace к записям уровня Warn и выше?

Где стек вреден#

Стек — дорогой и громоздкий снимок: двадцать кадров на запись, десятки строк в хранилище, обход стека через runtime на каждый вызов. Для неожиданных аварий это золото: Internal без стека — загадка. Но у большинства записей в живом сервисе стек не нужен: NotFound в базе, валидация формы, отказ шлюза — ожидаемые ветки, место их возникновения известно заранее, потому что там стоит лог. Живое правило: стек включают на уровне, который означает «такого не должно быть», и это почти всегда Error. Ожидаемое уходит на Warn без стека, неожиданное — на Error со стеком. Тогда stacktrace в записи — сам по себе сигнал: если он появился, дежурный знает, что код пришёл туда, куда идти не должен.

Паника runtime — не структурная запись#

Паника, которую никто не поймал, печатает стек сам runtime: текстовый вывод в stderr, мимо JSON-конвейера, без request_id и без полей — система сбора увидит его разве что отдельным потоком контейнера. Это второй канал стеков, параллельный логам, и смешивать их при разборе неудобно. Практика: на границах — HTTP-посредник из урока про middleware, перехватчик gRPC, верх горутин — ставят recover, и паника попадает в zap уже структурно: zap.Any("panic", err), поле stack через debug.Stack(), поля запроса рядом. runtime-вывод остаётся страховкой для падений до этого рубежа: сбой при инициализации, ошибка в самом логгере.

DPanic, Panic и Fatal: стеки в панике#

Три старших уровня меняют поведение процесса, и стек у них особая история. DPanic пишет Error, а при включённом режиме Development паникует: инвариант, нарушенный в тестах на локальном окружении, роняет процесс и виден сразу, а в проде остаётся Error-записью. Panic пишет запись и вызывает panic — поверх поля stacktrace от логгера добавится стек самой паники от runtime. Fatal пишет запись и вызывает os.Exit(1): defer не выполнится, logger.Sync() в defer main не сработает, и хвост буферизованного вывода может не дожить до диска — это та же цена буферизации, что и в уроке про выводы. Поэтому Fatal оставляют для ситуаций, где продолжать действительно нельзя: невозможная конфигурация при старте, а не упавший запрос.

Инвариант через DPanic

GO
logger, _ := zap.NewProduction(
        zap.AddStacktrace(zapcore.WarnLevel),
)
defer logger.Sync()

if balance < 0 {
        logger.DPanic("invariant broken: balance is negative",
                zap.Int64("balance", balance),
                zap.String("order_id", order.ID),
        )
}

Собственный AddStacktrace перекрывает пресет: теперь стек пишется уже с Warn. DPanic ведёт себя по флагу Development: в проде это обычный Error, в development-сборке на локальном Go логгер паникует — инвариант роняет процесс, и его видно сразу. Поле stacktrace укажет место, где баланс стал отрицательным, без разборов по стеку паники. zap.Development() — то же включение режима, короткая форма. Поля balance и order_id лягут в запись рядом со stacktrace: стек прилагается к записи целиком, поля в нём не дублируются. В development-прогоне тот же вызов уронит тест на месте — и это желаемое поведение: инвариант, который молчит в CI, ничем не лучше отсутствующего теста.

Что будет с nil-ошибкой?

Прочитайте код и выберите, что он выведет:

logger.Error("job failed", zap.Error(nil))

Проверка: цепочка обёрток

err = fmt.Errorf("checkout: %w", fmt.Errorf("charge: %w", base)). Что покажет zap.Error(err)?

Стеки по пресетам и опциям#

НастройкаСтек с уровняЗаметка
production пресетErrorграница уже включена в конфиг
development пресетWarnDevelopment: true опускает границу стека
AddStacktrace(zapcore.WarnLevel)Warnручная граница: стек и на подозрительном
zap.AddCaller()нет стекаcaller — одна строка файла, дешевле стека

Соберите разбор ошибки

Восстановите логгер со стеком и раскладку ошибки по полям.

logger, _ := zap.NewProduction( zap.(zapcore.ErrorLevel), ) var pe *PaymentError if errors.(err, &pe) { logger.Error("payment failed", zap.Error(err), zap.String("order_id", pe.), ) }

Соберите запись ошибки

Расставьте строки: логгер, ошибка, запись.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: ошибки и стеки

Термин

zap.Error(err)

нажмите, чтобы перевернуть
Определение

Поле error со строкой err.Error(); nil-ошибка поле не создаёт вовсе.

1 / 6

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

Цену поля stacktrace в цифрах — обход стека, аллокации — разбирает урок про производительность; тесты на поля ошибки удобно писать через zaptest и observer. Как ошибки проходят по цепочке Gin-обработчиков и где их логировать — в уроке про ошибки Gin; карта остальных тем — на странице курса.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • zap.Error пишет err.Error() строкой; nil-ошибка поле не создаёт.
  • Цепочка %w склеивается в одну строку; errors.Is и errors.As работают по ней как обычно.
  • Ошибку со структурой раскладывайте через errors.As в отдельные поля — дашборды живут по полям, а не по подстрокам.
  • AddStacktrace включает поле stacktrace с выбранного уровня: production — Error+, development — Warn+.
  • Стек — для неожиданного: ожидаемые ветки логируются Warn без стека.
  • DPanic паникует только в development; Fatal вызывает os.Exit и обходит defer и Sync.
  • Formatter у ошибки даёт второе поле errorVerbose с подробным выводом.

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

Заверните ошибку файла в свой тип с полями Path и Op, добавьте Unwrap и напишите разбор через errors.As: zap.Error(err) плюс отдельные поля. Соберите логгер с AddStacktrace(zapcore.ErrorLevel) и проверьте на локальном Go: у Warn-записи стека нет, у Error-записи поле stacktrace есть. Найдите в JSON строку load config и убедитесь, что цепочка сообщений читается сверху вниз. Затем соберите логгер с zap.Development() и проверьте, что DPanic-вызов роняет тест с записью в выводе.

Личные заметкиПусто
Хранятся только в вашем браузере (Zap · Ошибки и стек-трейсы: zap.Error)

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

Почему бы всегда не писать стек?

Стек стоит обхода runtime на запись, десятки строк в хранилище и шума при разборе. Для ожидаемых ошибок место возникновения известно заранее. Стек нужен там, где код оказался неожиданно — за это отвечает уровень Error и AddStacktrace.

Что лучше: pkg/errors или свои типы?

pkg/errors даёт стек в самой ошибке и %+v, но тянет зависимость и стек на каждый вызов. Свои типы с Unwrap и полями дороже написать, зато в лог попадает структура, а не текст. В новых проектах обычно хватает стандартных fmt.Errorf и errors.As.

zap.Error(err) и err == nil — зачем вообще вызывать?

Часто ошибка приходит из функции, которую проверили выше, и поле строят в общем слайсе полей. zap.Error(nil) безопасен: поле просто не появится в записи. Но читать такой код тяжело — лучше собирать поля по условию.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Сравните с альтернативами

Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.

Следующая ступень учебного пути

Colly25 уроков~9 чпрактика25 уроков уже доступны

Colly: парсинг и веб-скрапинг на Go

Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.

  • Писать скраперы на Colly от первого Visit до обхода целого сайта
  • Вынимать данные через CSS-селекторы и складывать в структуры Go
  • Настраивать темп: задержки, параллелизм, лимиты на домен
  • Работать с капризными сайтами: заголовки, cookies, прокси