Содержание урока · 10 разделов
- Slog ошибки: err как атрибут записи
- Цепочка %w: что видно в логе, а что прячется
- LogValuer для error: своя ошибка целиком в полях
- Где брать детали ошибки
- Лог ошибок go: один раз там, где обработали
- Стеки и место вызова: AddSource и recover
- Правила ошибок в логах
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Ошибки чаще других событий попадают в лог и чаще других его портят: err разворачивают в строку через fmt.Sprintf и теряют структуру, логируют на каждом слое пути наверх, а детали — код ответа, путь, лимит — прячутся внутри типа, куда дежурный не заглянет. В slog для ошибок нет отдельного API: ошибка — обычное значение, и всё решают три правила. Первое: err идёт в запись атрибутом как есть, без склеек, — обработчик сам вызовет Error() и напечатает текст цепочки. Второе: детали достают не в момент печати, а в момент обработки — errors.Is для узнавания знакомой ошибки, errors.As для полей своего типа, LogValuer для сворачивания ошибки в группу полей. Третье: запись пишет слой, который принимает решение, — промежуточные слои только оборачивают. В этом уроке — механика атрибута err, что видно из цепочки %w, где брать детали и почему один сбой не должен выглядеть тремя инцидентами. Всё это проверяется одним взглядом на JSON-запись: если в ней есть ключ err с текстом цепочки и структурные поля рядом — разбор инцидента начнётся с ответов, а не с вопросов.
Slog ошибки: err как атрибут записи#
Пара «err», err — конвенция, которую slog не навязывает, но её ждут коллекторы, дашборды и глаза дежурного. Разбор slog ошибки начинается с механики: err в вызове записи превращается в Value типа Any, и штатные обработчики печатают его через Error() одной строкой; JSONHandler пишет текст, если тип не реализует json.Marshaler, TextHandler поступает так же. Из этого следуют три практических вывода. Первый: не склеивайте ошибку в msg — fmt.Sprintf("не удалось: %v", err) кладёт всё в одно поле msg, и фильтр по ключу err перестаёт видеть запись. Второй: nil-ошибку не логируют — пара «err», nil напечатает null и замусорит поток, поэтому ветка записи живёт под if err != nil. Третий: имя ключа фиксируется на весь сервис — err или error, но одно: алерт строится по имени поля, и ошибки с соседним именем пройдут мимо дашборда.
Обёртка %w и запись с err-атрибутом
GOpackage main
import (
"errors"
"fmt"
"log/slog"
"os"
)
func loadConfig() error {
_, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("читаю конфиг: %w", err)
}
return nil
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
if err := loadConfig(); err != nil {
logger.Error("старт не удался", "err", err)
os.Exit(1)
}
}В записи поле err содержит текст всей цепочки: «читаю конфиг: open config.yaml: no such file or directory». Сообщение обёртки и текст исходной ошибки склеились через двоеточие — маршрут сбоя читается сверху вниз, а значение при этом осталось настоящей ошибкой: errors.Is и errors.As работают по нему же.
Цепочка %w: что видно в логе, а что прячется#
Обёртка fmt.Errorf с %w — основа пути ошибки наверх: каждый слой добавляет свой контекст, и Error() возвращает сообщения, склеенные через двоеточие. В логе эта склейка видна: err = «старт сервиса: читаю конфиг: нет файла» читается как маршрут сбоя. Но текст — только поверхность. Под ним живут тип и значение: sentinel для сравнения через errors.Is и поля своего типа через errors.As — в тексте их нет. Поэтому правило такое: текст цепочки идёт в атрибут err для человека, а то, по чему принимается решение и строятся дашборды, — отдельными полями. Пример из жизни: сбой вызова API нёс код 429 и путь, по которому ударили; строка в логе была, но разбирать ретраи было не по чему, пока errors.As не стал доставать тип и поля не уехали отдельными атрибутами. Отдельно про пары глаголов форматирования: %v и %+v сохраняют только текст — цепочка обёрнута ими, и errors.Is с errors.As по ней уже не пройдут, потому что типов в значении больше нет. %w — единственный вариант, когда и текст виден, и машина доберётся до типов. Идиомы wrap со стороны языка уже разобраны в курсе по продвинутому Go — здесь логовая сторона той же истории.
errors.As: поля своего типа в лог
GOtype APIError struct {
Code int
Path string
}
func (e *APIError) Error() string {
return fmt.Sprintf("api %d по %s", e.Code, e.Path)
}
func reportCall(logger *slog.Logger, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
logger.Error("вызов не прошёл", "code", apiErr.Code, "path", apiErr.Path, "err", err)
return
}
logger.Error("вызов не прошёл", "err", err)
}Ветвление по типу даёт структурные поля там, где они есть: код и путь пригодятся для дашборда ретраев, полный текст цепочки остаётся рядом в err. Вторая ветка ловит всё остальное — незнакомые ошибки не пропадают.
LogValuer для error: своя ошибка целиком в полях#
Когда свои типы ошибок ходят по сервису, вызывать errors.As в каждом месте записи утомительно. Выход — интерфейс LogValuer из девятого урока: тип ошибки реализует метод LogValue и возвращает slog.GroupValue с полями. Тогда один вызов slog.Any("err", apiErr) печатает группу code, path, retry_after — без ручного As в вызывающем коде. Обработчик вызывает Resolve в момент записи, поэтому ленивость и маскирование из девятого урока работают и здесь: поле с токеном из ошибки можно спрятать в обёртке. Граница приёма: LogValue описывает стабильную схему полей, а не всё подряд — меняющиеся детали остаются в тексте Error(). Если тип чужой и правок не ждёт, ту же задачу решает обёртка с LogValue поверх него: поля вынимаются один раз в обёртке и печатаются группой.
Где брать детали ошибки#
| Деталь | Как достать | Когда нужна |
|---|---|---|
| текст цепочки | err.Error() — уже в атрибуте err | быстрый глазами разбор строки |
| узнавание sentinel | errors.Is(err, sql.ErrNoRows) | ветка поведения по известной ошибке |
| поля своего типа | errors.As(err, &target) | код ответа, путь, лимит — атрибутами |
| место вызова лога | AddSource в HandlerOptions | поиск точки записи в большом сервисе |
| стек паники | recover плюс debug.Stack() | горутина упала без весточки |
Лог ошибок go: один раз там, где обработали#
Правило одного места записи — главное в разборе лог ошибок go: запись пишет слой, который принимает решение — вернуть 500, отложить задачу, уйти в ретрай или завершить процесс. Слой посередине ошибку оборачивает fmt.Errorf с %w и возвращает наверх: он не знает, что с ней сделают, и его строка — только шум. Нарушение правила видно по логам: один сбой — три-пять записей с одинаковым текстом, алерт срабатывает по каждой, дежурный считает инциденты. Отдельно про уровни: Error — для событий, где сервис не смог сделать обещанное; ожидаемые ветки вроде «пользователь не найден» живут на Info или Warn, иначе Error-дашборд показывает нагрузку, а не проблемы. Семантику уровней раскладывает третий урок, а здесь зафиксируем связку: уровень и место записи решаются вместе, в точке обработки.
Кейс: репозиторий залогировал сбой соединения, сервис добавил свою запись, хендлер третью — алерт на Error-дашборде сработал трижды за секунду и поднял дежурного из-за одного оборванного TCP-соединения. Разбор занял час: три строки выглядели тремя инцидентами, хотя err-текст во всех был один. Лечение: записи из репозитория и сервиса убрали — слои оборачивают %w и возвращают; логирует хендлер, который решает судьбу запроса и добавляет request_id. Примета для поиска таких мест: два и больше одинаковых err-текста в одну секунду от соседних уровней стека.
Стеки и место вызова: AddSource и recover#
slog не пишет стек при каждой записи, и это разумно: стек на Info замусорил бы вывод. Что доступно: опция AddSource у HandlerOptions добавляет в запись поле source с файлом и строкой вызова лога — удобно искать точку записи в большом сервисе, хотя и с ценой на каждую запись. Стек самой ошибки slog не собирает: если он нужен, его приносят обёртки, которые запоминают стек в момент создания ошибки, или debug.Stack() в recover. Горутина без recover погибает молча и роняет процесс — recover ставят в обёртке горутины и пишут slog.Error("паника в горутине", "err", r, "stack", string(debug.Stack())). Точку сбоя в распределённой системе точнее покажет span из трейсера — двадцать второй урок о нём. Плата за AddSource ощутима на горячем пути: обработчик достаёт имя файла и строку на каждую запись, поэтому включают её адресно — на уровне Debug в стенде или на Error в проде, где каждая запись и так редкая.
Оба имени встречаются в проектах, ломается то, что их два. Коллектор строит дашборд по одному полю, и ошибки с другим именем проходят мимо. Выберите одно имя на сервис, пропишите его в чек-листе ревью — и новые записи совпадут со старыми без миграции ретроспективы. Проверка простая: два запроса к коллектору по err и по error должны отличаться не больше, чем на записи до введения правила.
Пакета log/slog в песочнице нет — работают fmt, strings, strconv, math, sort, errors и time. Зато errors здесь целиком: соберите цепочку из двух обёрток fmt.Errorf с %w, пройдите её errors.Is и errors.As и напечатайте текст через fmt.Println — механика wrap честная. Полный пример с JSONHandler и AddSource запускается локально через go run; интерактивы ниже проверят вывод err-атрибута, место логирования и сборку ветки обработки.
Правила ошибок в логах#
- err идёт в запись атрибутом как есть: склейка fmt.Sprintf в msg ломает фильтр по ключу.
- Имя ключа одно на сервис: err или error — выбор фиксируется и не смешивается.
- nil-ошибку не логируют: ветка записи живёт под if err != nil.
- Слой без решения оборачивает fmt.Errorf с %w и возвращает наверх без записи.
- Запись пишет тот, кто решает судьбу: 500, ретрай, откладка или выход из процесса.
- Детали достают errors.Is и errors.As в месте обработки или LogValuer у своего типа.
Связи с другими уроками: девятый урок про LogValuer показывает механику отложенных значений и масок, третий урок про уровни — семантику Error. Обработчик с AddSource собирается в двенадцатом уроке про свой Handler, дубли строк как антипаттерн разбирает двадцать третий урок. Идиомы wrap со стороны языка — в уроке про оборачивание ошибок курса по продвинутому Go, база возвратов — в уроке про ошибки и возвраты. Карта курса — на странице курса.
Проверка: err в JSON-записи
slog.Error("вызов не прошёл", "err", err) при JSONHandler. Что окажется в поле err, если тип ошибки не реализует json.Marshaler?
Проверка: где логировать
Репозиторий получил сбой соединения и возвращает ошибку наверх. Где должна появиться запись?
Что окажется в поле err?
Прочитайте код и выберите, что он выведет:
base := errors.New("нет файла настроек")
err := fmt.Errorf("читаю конфиг: %w", base)
err = fmt.Errorf("старт сервиса: %w", err)
logger.Error("запуск прерван", "err", err)Соберите ветку обработки
Допишите: узнавание sentinel, извлечение типа, уровень записи и обёртку с контекстом.
Обработка в хендлере
Расставьте строки обработчика по порядку: загрузка, ветка ошибки, запись с полями, ответ клиенту, выход, успех.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: ошибки в логах
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- err идёт в запись атрибутом: штатные обработчики печатают err.Error() — текст всей цепочки.
- Текст цепочки от %w читается как маршрут сбоя, но поля и типы из него не достать.
- errors.Is узнаёт sentinel, errors.As достаёт поля своего типа — детали идут отдельными атрибутами.
- LogValuer у своей ошибки печатает группу полей без ручного errors.As в каждом месте.
- Запись об ошибке пишет слой, который решает судьбу; промежуточные слои только оборачивают %w.
- Стека по умолчанию нет: AddSource даёт место вызова лога, панику ловит recover с debug.Stack().
Домашнее задание
Соберите локально цепочку из двух обёрток fmt.Errorf с %w и залогируйте её одним вызовом Error с err-атрибутом: проверьте, что текст цепочки виден, а errors.As по этому значению достаёт поля вашего типа. Затем добавьте своей ошибке LogValue с группой полей и сравните JSON-вывод двух подходов.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему err в JSON-логе выглядит строкой, а не объектом?
Штатные обработчики печатают err.Error() — текст цепочки; это держит вывод предсказуемым. Поля своего типа доставайте errors.As и пишите отдельными атрибутами или сверните ошибку в группу через LogValuer — тогда в записи появится объект с полями.
Нужно ли логировать ошибку на каждом слое?
Нет: слой без решения оборачивает fmt.Errorf с %w и возвращает наверх. Запись пишет тот, кто решает судьбу — вернуть 500, уйти в ретрай, отложить задачу. Иначе один сбой даёт три-пять строк, а алерт срабатывает по каждой.
Как в лог попадает стек ошибки?
По умолчанию никак. AddSource добавляет файл и строку вызова лога; для стека ошибки используйте обёртки, запоминающие стек в момент создания, а при панике — recover и debug.Stack(). Стек на каждую запись — дорогой и шумный вариант.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Свой Handler: интерфейс, Record и WithAttrs
- Конкурентность: один логгер на сервис
- Производительность: Enabled и горячий путь
- Сквозные поля: request ID через слои
- Миграция с log на slog: мосты и совместимость
- LogValuer: отложенные и «опасные» значения
- With: предзаданные поля и логгер на запрос
- Группы: WithGroup и вложенные поля
- Все курсы по Go
Следующая ступень учебного пути
PostgreSQL в Go: драйвер pgx с нуля до продакшена
Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.
- Подключаться к PostgreSQL через pgx и pgxpool
- Сканировать строки в структуры и справляться с NULL
- Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
- Проводить транзакции и массовые вставки через Batch и COPY