Содержание урока · 12 разделов
- zap error: что попадает в поле
- Обёрнутые ошибки: цепочка в одной строке
- Поля вместо парсинга строк
- AddStacktrace: стек по уровню
- Где стек вреден
- Паника runtime — не структурная запись
- DPanic, Panic и Fatal: стеки в панике
- Стеки по пресетам и опциям
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
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, а не магический объект.
Обёрнутая ошибка в логе
GOfunc 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.
Кастомная ошибка с полями
GOtype 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 в записи — сам по себе сигнал: если он появился, дежурный знает, что код пришёл туда, куда идти не должен.
Опция AddStacktrace(zapcore.WarnLevel), поставленная «на всякий случай», превращает каждый NotFound в двадцать строк кадров. Хранилище логов раздувается в разы, а полезные Error-стеки тонут в мусоре. Стек — не украшение записи, а инструмент для неожиданного.
Паника 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 оставляют для ситуаций, где продолжать действительно нельзя: невозможная конфигурация при старте, а не упавший запрос.
Библиотечный код, вызывающий logger.Fatal, убивает чужой процесс: os.Exit(1) не спрашивает хост, defer'ы приложения не выполнятся, буферизованный хвост пропадёт. Fatal — решение владельца процесса, а не зависимости: библиотека возвращает error, и умирать решает main. Уровень Fatal в сигнатурах библиотек — повод для правки вверх по потоку.
Инвариант через DPanic
GOlogger, _ := 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, ничем не лучше отсутствующего теста.
defer logger.Sync() без проверки возвращаемого значения — привычка, за которую платят хвостом: буферизованный writer при неудачной синхронизации теряет записи молча, и увидеть это можно только по return-значению. В main ошибку Sync проверяют явно и сообщают о ней хотя бы в stderr: потерянный хвост в разгар инцидента дороже лишней строки.
Что будет с nil-ошибкой?
Прочитайте код и выберите, что он выведет:
logger.Error("job failed", zap.Error(nil))Проверка: цепочка обёрток
err = fmt.Errorf("checkout: %w", fmt.Errorf("charge: %w", base)). Что покажет zap.Error(err)?
Стеки по пресетам и опциям#
| Настройка | Стек с уровня | Заметка |
|---|---|---|
| production пресет | Error | граница уже включена в конфиг |
| development пресет | Warn | Development: true опускает границу стека |
| AddStacktrace(zapcore.WarnLevel) | Warn | ручная граница: стек и на подозрительном |
| zap.AddCaller() | нет стека | caller — одна строка файла, дешевле стека |
Соберите разбор ошибки
Восстановите логгер со стеком и раскладку ошибки по полям.
Соберите запись ошибки
Расставьте строки: логгер, ошибка, запись.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки и стеки
Что дальше по курсу#
Цену поля 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-вызов роняет тест с записью в выводе.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему бы всегда не писать стек?
Стек стоит обхода runtime на запись, десятки строк в хранилище и шума при разборе. Для ожидаемых ошибок место возникновения известно заранее. Стек нужен там, где код оказался неожиданно — за это отвечает уровень Error и AddStacktrace.
Что лучше: pkg/errors или свои типы?
pkg/errors даёт стек в самой ошибке и %+v, но тянет зависимость и стек на каждый вызов. Свои типы с Unwrap и полями дороже написать, зато в лог попадает структура, а не текст. В новых проектах обычно хватает стандартных fmt.Errorf и errors.As.
zap.Error(err) и err == nil — зачем вообще вызывать?
Часто ошибка приходит из функции, которую проверили выше, и поле строят в общем слайсе полей. zap.Error(nil) безопасен: поле просто не появится в записи. Но читать такой код тяжело — лучше собирать поля по условию.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси