---
title: "Ошибки в логах: err как атрибут, wrap и детали"
description: "Ошибки в slog: err как атрибут, цепочки %w и errors.As, LogValuer для своих ошибок, стеки и AddSource — разбор лог ошибок go без дублей строк."
course: "Логирование в Go: структурные логи на slog"
url: "https://golangmaster.ru/courses/slog/lessons/11-oshibki-v-logah/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Ошибки в логах: err как атрибут, wrap и детали

*Курс «Логирование в Go: структурные логи на slog», урок 11 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/slog/lessons/11-oshibki-v-logah/*

Разбор сбоя начинается с лога, и план slog ошибки держится на трёх решениях: ошибка идёт в запись атрибутом как есть, цепочка %w сохраняется до места обработки, а детали достают errors.Is и errors.As там, где принимается решение. Разберём, что печатает обработчик для err, что видно из текста цепочки, как свернуть свою ошибку в группу полей через LogValuer и почему дубли строк сбивают алерты.

Ошибки чаще других событий попадают в лог и чаще других его портят: 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-атрибутом

```go
package 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: поля своего типа в лог

```go
type 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.** Оба имени встречаются в проектах, ломается то, что их два. Коллектор строит дашборд по одному полю, и ошибки с другим именем проходят мимо. Выберите одно имя на сервис, пропишите его в чек-листе ревью — и новые записи совпадут со старыми без миграции ретроспективы. Проверка простая: два запроса к коллектору по 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](/courses/slog/lessons/09-logvalue/) показывает механику отложенных значений и масок, [третий урок про уровни](/courses/slog/lessons/03-urovni-logov/) — семантику Error. Обработчик с AddSource собирается в [двенадцатом уроке про свой Handler](/courses/slog/lessons/12-svoj-handler/), дубли строк как антипаттерн разбирает [двадцать третий урок](/courses/slog/lessons/23-antipatterny/). Идиомы wrap со стороны языка — в [уроке про оборачивание ошибок](/courses/go-advanced/lessons/15-oshibki-wrapping/) курса по продвинутому Go, база возвратов — в [уроке про ошибки и возвраты](/courses/go-basics/lessons/21-oshibki-i-vozvraty/). Карта курса — на [странице курса](/courses/slog/).

### Проверка: err в JSON-записи

**Вопрос:** slog.Error("вызов не прошёл", "err", err) при JSONHandler. Что окажется в поле err, если тип ошибки не реализует json.Marshaler?

- ✅ Строка err.Error(): текст всей цепочки обёрток через двоеточие
- JSON-объект со всеми полями типа ошибки
- Стек вызовов до места создания ошибки
- Запись не выйдет: error нельзя передавать значением

Штатные обработчики печатают ошибку текстом Error(). Поля типа и стек в текст не входят — их достают errors.As, LogValuer и обёртки со стеком.

### Проверка: где логировать

**Вопрос:** Репозиторий получил сбой соединения и возвращает ошибку наверх. Где должна появиться запись?

- ✅ Наверху, где решают судьбу вызова: промежуточные слои оборачивают %w и молчат
- В репозитории: чем раньше залогировано, тем надёжнее
- На каждом слое пути: так история сбоя полнее
- Нигде: возвращённую ошибку логировать нельзя

Лог-и-возврат даёт дубли строк и ложные срабатывания алертов. Правило одного места: запись пишет тот, кто принимает решение — 500, ретрай или выход.

### Что окажется в поле err?

```go
base := errors.New("нет файла настроек")
err := fmt.Errorf("читаю конфиг: %w", base)
err = fmt.Errorf("старт сервиса: %w", err)

logger.Error("запуск прерван", "err", err)
```

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

- ✅ err: "старт сервиса: читаю конфиг: нет файла настроек"
- err: "старт сервиса" — внутренние обёртки потерялись
- err: "нет файла настроек" — печатается только корень
- Три записи — по одной на каждую обёртку

Error() склеивает тексты цепочки через двоеточие, и в атрибуте виден маршрут сбоя. Это текст: типы и поля достают errors.Is и errors.As уже по этому значению.

### Соберите ветку обработки

Допишите: узнавание sentinel, извлечение типа, уровень записи и обёртку с контекстом.

```go
if err != nil {
        if errors.___(err, sql.ErrNoRows) {
                return ErrNotFound
        }
        var apiErr *APIError
        if errors.___(err, &apiErr) {
                slog.___("вызов не прошёл", "code", apiErr.Code, "err", err)
                return err
        }
        return fmt.___("читаю счёт: %w", err)
}
```

Подсказка: Узнавание sentinel — errors.Is, извлечение типа — errors.As, уровень записи — slog.Error, обёртка с контекстом — fmt.Errorf.

Правильные ответы в пропусках: `Is`, `As`, `Error`, `Errorf`

### Обработка в хендлере

Расставьте строки обработчика по порядку: загрузка, ветка ошибки, запись с полями, ответ клиенту, выход, успех.

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

1. `data, err := repo.Load(orderID)`
2. `if err != nil {`
3. `        slog.Error("заказ не загружен", "order_id", orderID, "err", err)`
4. `        http.Error(w, "код обращения "+reqID, http.StatusInternalServerError)`
5. `        return`
6. `}`

### Карточки: ошибки в логах

- **err как атрибут** — Ошибка передаётся в запись как есть: штатные обработчики печатают err.Error() строкой.
- **%w** — Обёртка fmt.Errorf с %w хранит цепочку: текст виден через двоеточия, типы — через errors.Is/As.
- **errors.As** — Достаёт поля своего типа из цепочки: код ответа и путь идут в лог отдельными атрибутами.
- **LogValuer у ошибки** — Свой тип возвращает GroupValue с полями: один вызов Any печатает группу без ручного As.
- **одно место записи** — Логирует слой, который решает судьбу ошибки; промежуточные только оборачивают %w.
- **AddSource** — Опция обработчика: поле source с файлом и строкой вызова лога; стек ошибки приносят обёртки или debug.Stack().

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

- 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(). Стек на каждую запись — дорогой и шумный вариант.
