1. Почему не стоит логировать в каждой функции
Если вы когда-нибудь отлаживали программу методом “давайте поставим Println везде”, то вы уже знаете чувство: сначала кажется, что вы гений наблюдаемости, а через 10 минут в консоли появляется текстовый суп, в котором тонет всё важное. Логи работают точно так же. Если логировать “везде”, то логи перестают быть системой навигации и превращаются в шум — как если бы GPS каждую секунду говорил вам: “Вы всё ещё едете”.
Главная проблема не в том, что логов много. Проблема в том, что они начинают дублировать друг друга и мешают понять, где “точка принятия решения”. В Go это особенно заметно, потому что ошибки принято возвращать наверх по стеку вызовов, добавляя контекст через wrapping. Если ещё и логировать на каждом уровне, вы получите 3–7 одинаковых сообщений, которые описывают одну и ту же проблему разными словами — и это не помогает, а мешает.
Чтобы логирование было полезным, нам нужна дисциплина: лог — это побочный эффект, и у него должен быть хозяин. В нормальном приложении “хозяин логов” сидит на границе, где мы решаем, что делать с ошибкой: показать пользователю, вернуть HTTP-ответ, завершить процесс, повторить попытку и так далее.
2. Граница приложения: что это и почему логируем чаще всего там
Термин “граница” звучит пафосно, как будто мы защищаем государство от вторжения багов. На деле всё проще: граница — это место, где ваш код встречается с внешним миром. Для CLI это main() и обработчики команд, которые читают аргументы и печатают результат. Для HTTP — handler, который читает запрос и пишет ответ. Для импорта файла — функция, которая открывает файл и пишет отчёт. Для библиотечного пакета — публичная функция API, которую вызывает чужой код.
Почему граница так важна? Потому что только на границе понятно, “что мы делаем дальше”. Внутри бизнес-логики (условный AddTask) мы обычно не хотим решать, печатать ли что-то пользователю, логировать ли ошибку как Error, или считать это “ожидаемой” ситуацией. Внутренняя функция должна честно сказать: “я не смогла, вот ошибка” — и вернуть error. А уже граница решит, что это значит для пользователя и для логов.
Удобно держать в голове простую схему “слоёв” (даже если у нас пока маленький проект):
flowchart TD
A[CLI: main/command] --> B[app-слой: use cases]
B --> C[storage/adapter]
C --> D[OS/FS/Network]
A:::border
D:::outside
classDef border fill:#f2f8ff,stroke:#1c6dd0,stroke-width:2px;
classDef outside fill:#fff7f2,stroke:#d0671c,stroke-width:2px;
Граница здесь — слева (CLI) и справа (взаимодействие с ОС/сетью как “внешний мир”). Но с точки зрения логирования нас прежде всего интересует левая граница: именно она должна решать, сколько и как писать в логи.
3. CLI как граница: один финальный лог на ошибку
Сейчас мы продолжим развивать наше учебное приложение — простой todo-менеджер. Пусть у нас есть команда add, которая добавляет задачу, и list, которая печатает список. Пользователь ждёт, что stdout будет пригоден для скриптов (таблица/JSON), а stderr — для ошибок и диагностики.
Самый здоровый каркас для CLI в Go выглядит так: main() создаёт зависимости, вызывает run() и уже там решает, что делать с ошибкой. run() возвращает error, а не делает log.Fatal внутри — иначе вы начинаете “убивать процесс” глубоко в коде и теряете контроль (и заодно рискуете пропустить defer, если он был важен).
Минимальный скелет:
package main
import (
"log/slog"
"os"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
if err := run(logger, os.Args[1:]); err != nil {
logger.Error("command failed", slog.Any("err", err))
os.Exit(1)
}
}
Обратите внимание на смысл: логирование ошибки происходит один раз — там, где мы приняли решение “это фатально для команды” (exit code 1). Это и есть граница.
А теперь пример того, как легко “сломать” ситуацию, если логировать внутри:
package main
import (
"log/slog"
)
func doSomething(logger *slog.Logger) error {
logger.Error("failed to do something") // плохо: логируем тут...
return errSomething // ...и ещё возвращаем ошибку
}
Если main() тоже залогирует err, у вас получится дубль. Пользователь увидит два Error про одну проблему. А если ещё и storage залогирует — будет три.
В CLI полезно держать в голове правило: внутренние функции возвращают ошибку, граница решает — логировать или нет.
4. Контекст добавляем в ошибку, а не в лог
Типичная причина, почему новичок начинает логировать в каждой функции: страх потерять контекст. “Если я просто верну err, то наверху никто не поймёт, где это случилось!”. Это нормальный страх, но решается он не логами, а тем, что в Go ошибки принято оборачивать и добавлять контекст через fmt.Errorf("...: %w").
То есть внутри слоёв мы не логируем, а делаем так:
package storage
import (
"fmt"
"os"
)
func loadFile(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read tasks file %q: %w", path, err)
}
return b, nil
}
Идея “ошибка поднимается наверх с контекстом” — это один из основных паттернов Go, и он специально поддерживается стандартной библиотекой: вы оборачиваете ошибку, сохраняя причину, но добавляя свою “рамку смысла”.
Ключевой эффект для логирования: внутри вы добавили контекст, снаружи (на границе) — один раз залогировали итоговую ошибку. Вы получаете и понятность, и отсутствие дублей.
5. Где логировать в CLI: старт, финиш и длительность
Теперь давайте представим, что мы хотим видеть в логах длительность выполнения команды list (например, чтение файла может быть медленным). Это реальная диагностика, а не “болтовня ради болтовни”. И логировать это уместно на границе: команда началась, команда закончилась, есть duration.
Пример “аккуратного” логирования выполнения команды:
package main
import (
"log/slog"
"time"
)
func run(logger *slog.Logger, args []string) error {
start := time.Now()
err := runList(args) // допустим, внутри возвращаем error, но не логируем
dur := time.Since(start)
if err != nil {
logger.Error("list failed", slog.String("operation", "list"), slog.Duration("duration", dur), slog.Any("err", err))
return err
}
logger.Info("list ok", slog.String("operation", "list"), slog.Duration("duration", dur))
return nil
}
Да, здесь логирование происходит внутри run(), но run() в нашем приложении — это часть границы (CLI orchestration). Это место, где мы управляем сценарием команды, а не “внутренний домен”.
А вот если runList внутри начнёт писать logger.Info("opened file"), logger.Info("parsed JSON"), logger.Info("sorted tasks"), вы быстро получите ситуацию: команда “list” стала производить 20 строк логов даже в штатном случае. В итоге, когда случится реальная ошибка, её будет сложнее заметить.
6. HTTP как граница: логирует handler, а не домен
Хотя наше основное приложение сейчас CLI, важно заранее привыкнуть к одной мысли: для HTTP правило точно такое же, только “пользователь” сидит по сети. Граница — это ваш handler: он читает запрос, вызывает логику, пишет ответ.
В классической статье про обработку ошибок в web-приложении на Go идея формулируется очень “по-гошному”: handler может вернуть “ошибку для разработчика” и “сообщение для пользователя”, а уже внешний слой решает, что логировать и что отправлять клиенту.
Мини-иллюстрация (упрощённо, без углубления в дизайн HTTP API):
package httpapi
import (
"log/slog"
"net/http"
)
func handler(logger *slog.Logger, app App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := app.Do(r.Context()); err != nil {
logger.Error("request failed", slog.String("operation", "do"), slog.Any("err", err))
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
}
Здесь снова видно правило: app.Do(...) не логирует. Он возвращает ошибку. А boundary (handler) решает: логируем, отвечаем 500, не раскрываем подробности клиенту.
И ещё одно важное следствие: не каждая ошибка в HTTP — это “Error” в логах. Например, пользователь прислал некорректный параметр. Это 400, но с точки зрения сервера это может быть обычная ситуация. Для неё часто достаточно Info или Warn (или даже вообще без логов, если это частый шумный кейс). Решение опять же принимает граница, потому что только она знает, что именно считается “нормой” для вашего продукта.
7. Политика без дублей: одна проблема — один Error-лог
Теперь соберём всё в одну понятную мысль. Дубли появляются, когда:
- внутренняя функция залогировала проблему;
- вернула ошибку;
- верхний слой залогировал её ещё раз.
Чтобы это не происходило, удобно выбрать для проекта простую политику:
Политика: Error-лог пишется ровно там, где мы “приняли решение по ошибке”.
Для CLI это обычно main()/run() (выставили exit code).
Для HTTP это handler/middleware (выбрали статус-код и тело ответа).
А что делать внутренним слоям? Внутренние слои делают две вещи: возвращают error и добавляют контекст через wrapping.
Сравнение “как было бы плохо” и “как лучше” можно показать на одном и том же примере.
Плохой вариант (дубли):
package storage
import (
"log/slog"
"os"
)
func Load(logger *slog.Logger, path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
logger.Error("read failed") // дубль
return nil, err
}
return b, nil
}
Хороший вариант (контекст через ошибку):
package storage
import (
"fmt"
"os"
)
func Load(path string) ([]byte, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("load %q: %w", path, err)
}
return b, nil
}
И уже на границе:
package main
import (
"log/slog"
)
func run(logger *slog.Logger) error {
_, err := storage.Load("tasks.json")
if err != nil {
logger.Error("command failed", slog.Any("err", err))
return err
}
return nil
}
В результате у вас один понятный Error-лог, но внутри него уже есть вся цепочка контекста: “command failed: load "tasks.json": open ...: no such file”.
Мини-таблица: что логировать и где
Чтобы не держать всё в голове, полезно зафиксировать простой договор. Его можно буквально положить в README проекта.
| Событие | Где происходит | Что делаем | Уровень |
|---|---|---|---|
| Команда стартовала (add/list) | CLI boundary (run) | лог “start” с operation | Info (умеренно) |
| Команда успешно завершилась | CLI boundary | лог “ok” + duration | Info |
| Некорректные аргументы | CLI boundary | печать usage в stderr, лог по желанию | чаще без Error |
| Не удалось прочитать файл/БД | boundary решает | вернуть error вверх, на границе: один Error | Error |
| Проблема “ожидаемая” (not found, пусто) | app/storage | вернуть доменную ошибку/результат | чаще без Error |
| HTTP запрос завершился 500 | HTTP boundary | один Error с operation, duration, err | Error |
| HTTP запрос завершился 400 | HTTP boundary | обычно без Error, можно Info/Warn | Info/Warn |
Важный момент: таблица не говорит “что писать в сообщении”. Она фиксирует место ответственности. Это ключ к тому, чтобы логи не превращались в эхо-камеру.
8. Когда логировать внутри слоёв всё-таки допустимо
Фраза “логировать только на границе” — отличное правило по умолчанию, но не закон физики. Иногда лог внутри слоя оправдан. Проблема в том, что новичок обычно начинает с “иногда”, а заканчивает “всегда” — и вот мы снова в мире текстового супа.
Логирование внутри слоя чаще всего оправдано, когда происходит важное событие, которое не отражается в ошибке, и оно действительно полезно для диагностики. Например, вы включили кэш и хотите знать, был hit или miss. Или вы делаете ретраи и хотите знать, что была вторая попытка. Или вы намеренно “проглатываете” ошибку (редко, но бывает) и хотите оставить след, что это произошло.
Но даже в этих случаях стоит держать дисциплину: внутренние логи почти всегда должны быть уровня Debug (или иногда Info), потому что если вы считаете событие настолько серьёзным, что это Error, вероятно, это должна быть ошибка, которую нужно вернуть наверх.
И тут помогает настройка уровня логирования: на локальной разработке можно включить Debug, а в обычном запуске оставить Info. Тогда внутренние “швы” системы видны только тогда, когда вы реально отлаживаете, а не постоянно.
9. Типичные ошибки
Ошибка №1: логирование в каждой функции “на всякий случай”.
Обычно это начинается с благих намерений: “я боюсь потерять контекст”. Но в результате вы теряете главное — способность быстро увидеть, где именно ошибка стала фатальной для команды или запроса. Контекст добавляйте через wrapping ошибок, а Error-лог оставляйте на границе, где принимается решение.
Ошибка №2: дублирование одной и той же ошибки на нескольких уровнях.
Очень частый сценарий: storage залогировал “cannot open file”, app залогировал “load failed”, main залогировал “command failed”. В итоге три Error-записи, и все про одно. Правило “одна проблема — один Error-лог” лечит это почти полностью: внутренние слои возвращают ошибку, граница логирует.
Ошибка №3: попытка “объяснять пользователю” через логи.
Логи — это для разработчика. Пользователю нужны короткие и понятные сообщения в stderr (CLI) или в HTTP-ответе (HTTP). Если смешать эти миры, вы получите либо утечку внутренних деталей, либо “шум” в пользовательском выводе, который ломает скрипты и тесты. И да, если вы когда-нибудь выводили JSON в stdout, а потом добавили туда лог “processing…” — вы знаете, как это больно.
Ошибка №4: “лечить” отсутствие контекста дополнительными logger.Error(...) вместо wrapping.
Если на верхнем уровне непонятно, что именно пошло не так, это не повод вставлять лог в середине. Это повод добавить контекст в возвращаемую ошибку (fmt.Errorf("...: %w", err)). Такой подход масштабируется и в CLI, и в HTTP, и не превращает систему в генератор дублей.
Ошибка №5: логировать все 4xx как Error в HTTP.
Некорректный ввод клиента — это часто не “поломка системы”, а обычная жизнь. Если каждую ошибку пользователя писать как Error, ваши реальные сбои (500) утонут в потоке “клиент прислал ерунду”. Разделяйте смысл: где ошибка клиента, а где ошибка сервера, и выбирайте уровень на границе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ