JavaRush /Курси /Go SELF /Де логувати — на межі CLI/HTTP, а не «всюди»

Де логувати — на межі CLI/HTTP, а не «всюди»

Go SELF
Рівень 52 , Лекція 3
Відкрита

1. Чому не варто логувати в кожній функції

Якщо ви колись налагоджували програму методом «давайте поставимо Println усюди», то вже знаєте це відчуття: спочатку здається, що ви геній спостережуваності, а за 10 хвилин у консолі зʼявляється суцільний текстовий суп, у якому тоне все важливе. Логи працюють так само. Якщо логувати скрізь, вони перестають бути системою орієнтування й перетворюються на шум — ніби GPS щосекунди повторював би: «Ви все ще їдете».

Головна проблема не в тому, що логів багато. Проблема в тому, що вони починають дублювати одне одного і заважають зрозуміти, де саме ухвалюють рішення. У Go це особливо помітно, тому що помилки заведено повертати вгору стеком викликів, додаючи контекст через обгортання. Якщо ще й логувати на кожному рівні, ви отримаєте 3–7 однакових повідомлень, які описують одну й ту саму проблему різними словами. Це не допомагає, а лише заважає.

Щоб логування було корисним, нам потрібна дисципліна: лог — це побічний ефект, і за нього має відповідати конкретне місце. У нормальному застосунку «власник логів» сидить на межі — там, де ми вирішуємо, що робити з помилкою: показати користувачу, повернути HTTP-відповідь, завершити процес, повторити спробу тощо.

2. Межа застосунку: що це й чому ми найчастіше логуємо саме там

Термін «межа» звучить пафосно, ніби ми захищаємо державу від нашестя багів. Насправді все простіше: межа — це місце, де ваш код зустрічається із зовнішнім світом. Для CLI це main() і обробники команд, які читають аргументи та друкують результат. Для HTTP — handler, який читає запит і пише відповідь. Для роботи з файлом — функція, яка відкриває файл і формує звіт. Для бібліотечного пакета — публічна функція API, яку викликає чужий код.

Чому межа така важлива? Тому що лише на межі зрозуміло, що робити далі. Усередині бізнес-логіки (умовний AddTask) ми зазвичай не хочемо вирішувати, чи друкувати щось користувачу, чи логувати помилку як Error, чи вважати це «очікуваною» ситуацією. Внутрішня функція має чесно сказати: «я не змогла, ось помилка» — і повернути error. А вже межа вирішить, що це означає для користувача і для логів.

Зручно тримати в голові просту схему «шарів» (навіть якщо в нас поки невеликий проєкт):

flowchart TD
    A[CLI: main/команда] --> B[рівень застосунку: сценарії використання]
    B --> C[сховище/адаптер]
    C --> D[ОС/ФС/мережа]

    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-оркестрації. Це місце, де ми керуємо сценарієм команди, а не внутрішнім доменом.

А от якщо runList усередині почне писати logger.Info("opened file"), logger.Info("parsed JSON"), logger.Info("sorted tasks"), ви швидко отримаєте ситуацію, коли команда «list» почне продукувати 20 рядків логів навіть у штатному режимі. У результаті, коли станеться реальна помилка, її буде складніше помітити.

6. HTTP як межа: handler логує, а не домен

Хоча наш основний застосунок зараз CLI, важливо заздалегідь звикнути до однієї думки: для HTTP правило точно таке саме, тільки користувач уже в мережі. Межа — це ваш handler: він читає запит, викликає логіку, пише відповідь.

У класичній статті про обробку помилок у вебзастосунку на 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(...) не логує. Він повертає помилку. А межа (handler) вирішує: логуємо, відповідаємо 500, не розкриваємо подробиці клієнту.

І ще один важливий наслідок: не кожна помилка в HTTP — це «Error» у логах. Наприклад, користувач надіслав неправильний параметр. Це 400, але з погляду сервера це може бути звичайна ситуація. Для неї часто достатньо Info або Warn (або навіть узагалі без логів, якщо це частий шумний кейс). Рішення знову ж таки ухвалює межа, бо лише вона знає, що саме вважається «нормою» для вашого продукту.

7. Політика без дублів: одна проблема — один Error-лог

Тепер зберемо все в одну зрозумілу думку. Дублікати з’являються, коли:

  1. внутрішня функція залогувала проблему;
  2. повернула помилку;
  3. верхній шар залогував її ще раз.

Щоб цього не відбувалося, зручно вибрати для проєкту просту політику:

Політика: Error-лог пишеться рівно там, де ми ухвалили рішення щодо помилки.
Для CLI це зазвичай main()/run() (встановили exit code).
Для HTTP це handler/middleware (обрали статус-код і тіло відповіді).

А що робити внутрішнім шарам? Внутрішні шари роблять дві речі: повертають error і додають контекст через обгортання.

Порівняння того, як робити не варто, і як краще, можна показати на одному й тому самому прикладі.

Поганий варіант (дублювання):

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-межа (run) лог «start» з operation Info (помірно)
Команда успішно завершилася CLI-межа лог «ok» + duration Info
Некоректні аргументи CLI-межа виведення довідки до stderr, лог — за бажанням частіше без Error
Не вдалося прочитати файл або БД межа вирішує повернути error вгору, а на межі — один Error Error
Проблема очікувана (not found, порожній результат) app/storage повернути доменну помилку або результат частіше без Error
HTTP-запит завершився 500 HTTP-межа один Error з operation, duration, err Error
HTTP-запит завершився 400 HTTP-межа зазвичай без Error, можна Info/Warn Info/Warn

Важливий момент: таблиця не говорить, що писати в повідомленні. Вона фіксує місце відповідальності. Це ключ до того, щоб логи не перетворювалися на ехо-камеру.

8. Коли логувати всередині шарів усе-таки допустимо

Фраза «логувати лише на межі» — чудове правило за замовчуванням, але не закон фізики. Іноді лог усередині шару виправданий. Проблема в тому, що новачок зазвичай починає з «іноді», а закінчує «завжди» — і ми знову повертаємося до текстового супу.

Логування всередині шару найчастіше виправдане, коли відбувається важлива подія, яка не відображається в помилці, і вона справді корисна для діагностики. Наприклад, ви увімкнули кеш і хочете знати, був hit чи miss. Або ви робите ретраї й хочете знати, що була друга спроба. Або ви навмисно «ковтаєте» помилку (рідко, але буває) і хочете залишити слід, що це сталося.

Але навіть у цих випадках варто тримати дисципліну: внутрішні логи майже завжди мають бути рівня Debug (або інколи Info), тому що якщо ви вважаєте подію настільки серйозною, що це Error, імовірно, це має бути помилка, яку потрібно повернути нагору.

І тут допомагає налаштування рівня логування: під час локальної розробки можна ввімкнути Debug, а у звичайному запуску залишити Info. Тоді внутрішні «шви» системи видно лише тоді, коли ви справді налагоджуєте, а не постійно.

9. Типові помилки

Помилка № 1: логування в кожній функції «про всяк випадок».
Зазвичай це починається з благих намірів: «я боюся втратити контекст». Але в результаті ви втрачаєте головне — здатність швидко побачити, де саме помилка стала фатальною для команди або запиту. Контекст додавайте через обгортання помилок, а 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(...) замість обгортання.
Якщо на верхньому рівні незрозуміло, що саме пішло не так, це не привід вставляти лог посередині. Це привід додати контекст у помилку, яку ви повертаєте (fmt.Errorf("...: %w", err)). Такий підхід масштабується і в CLI, і в HTTP, і не перетворює систему на генератор дублікатів.

Помилка № 5: логувати всі 4xx як Error в HTTP.
Некоректне введення клієнта — це часто не «поломка системи», а звичайне життя. Якщо кожну помилку користувача писати як Error, ваші реальні збої (500) потонуть у потоці «клієнт надіслав неправильні дані». Розділяйте сенс: де помилка клієнта, а де помилка сервера, і обирайте рівень на межі.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ