golang

Структурированные логи + локальный стек вызовов: эволюция обработки ошибок в Go

  • четверг, 5 июня 2025 г. в 00:00:06
https://habr.com/ru/articles/915660/


Каждый Go-разработчик знаком с этим паттерном — создание обёрток для ошибок с дублированием метаданных:


func (*SomeObject).SomeMethod(val any) error {
  if err := otherMethod(val); err != nil {
    return fmt.Errorf("otherMethod %w with val %v", err, val)
  }
  return nil
}

Проблемы такого подхода:


  • Дублирование названий методов в сообщениях об ошибках
  • Ручное добавление метаданных (аргументы, переменные) в каждое место
  • Сложность отслеживания места возникновения ошибки при нескольких точках выхода
  • Засорение кода — повторяющийся boilerplate
  • Отсутствие структуры — все метаданные упакованы в одной строке

Что если объединить мощь структурированного логирования (slog) с автоматическим сбором локального стека вызовов. Результат — чистый код и информативные логи.


Решение: структурированные ошибки + автоматический стек


Было:


func (*SomeObject).SomeMethod(val any) error {
  if err := otherMethod(val); err != nil {
    slog.Error(err, "val", val) // дублирование
    return fmt.Errorf("someMethod %w with val %v", err, val)
  }
  return nil
}

Стало:


func (*SomeObject).SomeMethod(val any) (err error) {
  defer log.DebugOrError("debug description", &err)

  if err = otherMethod(val); err != nil {
    return log.WrapError(err, "val", val)
  }
  return nil
}

Ключевые возможности


1. Автоматический стек вызовов


Больше не нужно вручную указывать названия методов — стек собирается автоматически:


level=ERROR msg="other error" val=nil 
stack[0]="lib.go:26" stack[1]="lib.go:22"

2. Умная идентификация точек ошибок


При нескольких возможных местах возникновения ошибки:


func (*SomeObject).SomeMethod() (err error) {
  defer log.DebugOrError("debug description", &err)

  if err := otherMethod1(); err != nil {
    return log.WrapError(err)  // line 25
  }

  if err := otherMethod2(); err != nil {
    return log.WrapError(err)  // line 29
  }
  return nil
}

Получаем точное указание места в логах:


level=ERROR msg="other error 1"
stack[0]="lib.go:25" stack[1]="lib.go:22"

level=ERROR msg="other error 2"
stack[0]="lib.go:29" stack[1]="lib.go:22"

3. Создание новых структурированных ошибок


err = log.NewError("text", "key1", "value1", "key2", "value2")

4. Умное обёртывание


При многократном обёртывании стек сохраняется от первого вызова:


err = log.WrapError(err, "arg1", "val1") // стек записывается здесь
err = log.WrapError(err, "arg2", "val2") // стек сохраняется

5. Защита от забытых обёрток


Если забыли обернуть ошибку, DebugOrError всё равно добавит стек:


func (*SomeObject).SomeMethod() (err error) {
  // стек всё равно добавится, но только на этот вызов
  defer log.DebugOrError("debug description", &err) 

  return errors.New("unwrapped error") 
}

6. Накопление метаданных


Аргументы из всех уровней обёртывания объединяются:


func (*SomeObject).SomeMethod() (err error) {
  defer log.DebugOrError("debug description", &err, "arg3", "val3")

  err = log.NewError("my error", "arg1", "val1")
  // ...
  err = log.WrapError(err, "arg2", "val2")

  return err
}

Результат:


level=ERROR msg="my error" arg1=val1 arg2=val2 arg3=val3
stack[0]="lib.go:25" stack[1]="lib.go:22"

Архитектура решения


Структурированные ошибки


type CustomError struct {
    error
    Args  []any          // структурированные аргументы
    Stack []*CallInfo    // локальный стек вызовов
}

Умный сбор стека


  • Автоматически определяет границы проекта по go.mod
  • Показывает только релевантный код (исключает stdlib и зависимости)
  • Предоставляет читаемые пути относительно корня проекта
  • Кеширует метаинформацию для производительности

API для логирования


// Логирование с автоматическим переключением уровня при ошибке
logger.DebugOrError("operation completed", &err, "user_id", 123)
logger.InfoOrError("data processed", &err, "records", count)
logger.WarnOrError("cache miss", &err, "key", cacheKey)

Преимущества


Чистота кода — убрали boilerplate
Автоматические метаданные — стек и аргументы собираются сами
IDE-friendly — клик по ссылке ведёт прямо к проблемному коду
Производительность — кеширование метаданных проекта
Безопасность — защита от забытых обёрток
Масштабируемость — работает с любой структурой проекта


Заключение


Структурированные ошибки с локальным стеком вызовов — это естественная эволюция обработки ошибок в Go. Они сохраняют философию явности языка, но избавляют от рутинного копипаста, делая код чище, а отладку — приятнее.


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


Интеграция с телеметрией


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


Grafana + Loki: Структурированный формат позволяет строить более точные запросы и дашборды. Автоматические поля стека (stack[0], stack[1]) становятся удобными фильтрами для группировки ошибок по местам возникновения.


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


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


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




Полная реализация и её наглядное применение в репозитории пет-проекта


Disclaimer: Предлагаемое решение не претендует на роль панацеи от всех проблем логирования в Go. Это эксперимент, направленный на упрощение повседневной работы с ошибками. Автор открыт для обратной связи и предложений по дальнейшему развитию идеи.