Программирование GoОбработка ошибокGo-разработчик серверных приложений

В многослойном Go сервисе каждый слой добавляет контекст к одной ошибке: на каком уровне её обычно следует ...

В многослойном Go-сервисе каждый слой добавляет контекст к одной ошибке: на каком уровне её обычно следует логировать, чтобы не получить дублирование и не потерять диагностику?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Обычно ошибку следует логировать один раз на границе, которая принимает решение о завершении операции: например, в HTTP-, RPC- или job-обработчике. Внутренние слои должны добавлять контекст, оборачивать и возвращать ошибку, но не логировать её повторно.

Исключение — слой действительно обработал ошибку, выполнил fallback или зафиксировал самостоятельное значимое событие. Тогда логируется именно это событие, а не бездумно дублируется вся цепочка ошибки.

Исторический контекст

В Go ошибки являются обычными значениями, поэтому функция может передавать их вверх по стеку вместе с дополнительным контекстом. Механизм wrapping позволяет сохранить исходную причину для машинной обработки через errors.Is и errors.As, не ограничиваясь текстом сообщения.

Такой подход отделяет передачу ошибки от её окончательной интерпретации. Если каждый слой одновременно возвращает и логирует одну ошибку, появляются повторяющиеся записи, хотя фактически произошёл один сбой.

Постановка проблемы

Представим цепочку: репозиторий получил ошибку базы данных, сервис обернул её, обработчик обернул ещё раз и записал ошибку в лог. Если каждый промежуточный слой тоже пишет лог, одна проблема может появиться в нём несколько раз с разными сообщениями.

Это затрудняет подсчёт ошибок, завышает алерты и усложняет поиск первопричины. Дополнительный риск возникает при повторах: временная ошибка может быть записана на каждой попытке, а затем ещё раз — после окончательного отказа.

Подробное решение

Внутренний слой должен возвращать ошибку с полезным контекстом: какая операция не выполнена, над каким объектом и по какой причине. Контекст должен описывать место и действие, но не подменять исходную ошибку и не превращать её в текст, недоступный для errors.Is или errors.As.

Граница приложения обычно отвечает за логирование, потому что именно она знает итог операции: запрос завершился ошибкой, задача будет повторена или клиенту нужно вернуть определённый статус. На этой границе можно добавить корреляционный идентификатор, идентификатор пользователя, длительность и другие поля, которых не было во внутренних слоях.

Если слой распознал ожидаемую ошибку и обработал её, логирование может быть не нужно вовсе. Например, отсутствие записи, которое является штатным вариантом для конкретного сценария, лучше преобразовать в результат «не найдено», чем записывать как аварийный сбой.

Если несколько слоёв выполняют независимые операции, их ошибки могут быть агрегированы через errors.Join. Логировать следует итог, а не каждую обёртку; при этом для диагностики можно отдельно вывести структурированные причины, если это действительно нужно и поддерживается форматом логов.

Главный компромисс — между локальной диагностикой и единым владением ошибкой. Локальный лог иногда нужен для события, которое не будет видно выше, но правило должно быть явным: каждый лог отвечает за отдельное событие, а не просто за факт передачи той же ошибки.

Ситуация из практики

Функция доступа к базе данных возвращает ошибку с контекстом операции. Сервисный слой добавляет сведения о бизнес-действии, а HTTP-обработчик определяет статус ответа и пишет одну запись с полной цепочкой причины.

Вариант с логированием на каждом уровне проще реализовать локально, но он создаёт дублирование и затрудняет метрики. Вариант без логирования вообще сохраняет чистую архитектуру, однако может потерять сведения о запросе, длительности и результате, если верхний слой их не добавит.

Выбранный вариант — возвращать обёрнутую ошибку из внутренних слоёв и логировать её на границе приложения. Если сервис выполняет повтор, он отдельно записывает событие повтора с номером попытки, а после окончательного отказа граница логирует итоговую ошибку. В результате одна операция имеет понятную историю без многократного логирования одной причины.

Что кандидаты часто упускают

  1. Нужно ли логировать ошибку после каждого wrapping?

    Нет. Wrapping добавляет контекст для вызывающего кода, но сам по себе не является отдельным событием, которое нужно записывать в лог. Повторная запись одной цепочки создаёт шум и может исказить метрики.

    Исключение возможно, если слой выполняет самостоятельное действие: например, переключается на резервный источник или исчерпал попытки повтора. В таком случае полезно записать событие действия, указав его результат и существенные поля, но не обязательно печатать всю ошибку повторно.

  2. Что делать, если ошибка ожидаемая и определяется через errors.Is?

    Её нужно обработать согласно контракту, а не автоматически считать аварией. Например, ошибка отсутствия объекта может преобразоваться в ответ «не найдено» и не попасть в журнал ошибок.

    Если ожидаемый случай важен для наблюдаемости, его можно учитывать отдельной метрикой или записывать на подходящем уровне как информационное событие. Проверять такой случай следует через errors.Is, а не сравнением текстов сообщений.

  3. Когда внутренний слой всё же имеет право логировать ошибку?

    Когда он является последней точкой, где доступна важная информация, и гарантировано, что выше ошибка не будет снова записана как тот же сбой. Например, автономный worker может сам завершать задачу и не иметь внешнего обработчика, который отвечает за логирование.

    Даже в этом случае лучше явно определить владельца логирования. Если ошибка передаётся дальше, внутренний слой обычно должен вернуть её, а сведения, необходимые для диагностики, добавить в структурированный контекст или обёртку.