JavaRush /Курси /Go SELF /Гігієна логів: не логувати секрети, не шуміти

Гігієна логів: не логувати секрети, не шуміти

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

1. Чому гігієна логів важлива

Коли ви тільки починаєте програмувати, логи здаються «розумним fmt.Println»: ними швидко можна підглянути, де ви опинилися, і що лежить у змінній. Але в реальних програмах логи живуть довго, копіюються далеко й читаються не лише вами. Тому «гігієна» — це про акуратність: що писати, куди, в якому обсязі й у якому стилі, щоб потім не довелося червоніти, розбирати інцидент і пояснювати, чому пароль опинився в логах.

Уявіть, що логи — це не особистий щоденник, а дошка оголошень у підʼїзді: ви можете писати корисні речі, але краще не приклеювати туди ксерокопію паспорта й PIN-код банківської картки.

Невелика схема допоможе закріпити, куди розлітаються логи й чому вони рідко залишаються лише на вашому ноутбуці:

flowchart LR
    A[Застосунок] -->|stderr: логи| B[Консоль/сервіс]
    B --> C[Файл/ротація]
    B --> D[Збирач логів]
    D --> E[Сховище/пошук]
    E --> F[Команда розробки]
    E --> G[Підтримка/черговий]

2. Секрети та персональні дані в логах

Що вважається секретом

Коли кажуть «не логуйте секрети», новачок зазвичай киває й думає: «Гаразд, пароль не логуємо». І це хороший початок, але на практиці секретів більше, і вони підступніші. У логах особливо часто спливають токени, ключі API, заголовки авторизації, cookie, значення змінних середовища, «корисні» дампи структур і взагалі все, що зручно надрукувати «на хвилинку», а потім забути видалити.

Окрема категорія — персональні дані. Формально це може бути не «секрет» у криптографічному сенсі, але наслідки витоку бувають нітрохи не менш серйозні: електронна пошта, телефон, адреса, ПІБ, а іноді навіть вміст задач чи нотаток користувача. У нашому навчальному TODO-застосунку, наприклад, текст задачі може бути будь-яким: від «купити молоко» до «зустріч із лікарем о 14:30, діагноз такий-то». І якщо ви логуєте title задачі «для налагодження», то раптово робите дуже погану річ.

Корисно тримати просту таблицю «що робимо з даними»:

Категорія Приклади Що робимо в логах
Секрети доступу пароль, API key, Authorization: Bearer ..., ідентифікатор сесії Не логуємо. Якщо дуже потрібно — маскуємо (редагуємо).
Персональні дані електронна пошта, телефон, адреса, ПІБ, вміст нотаток Зазвичай не логуємо. Якщо потрібно — логуємо мінімум (наприклад, user_id, але не email).
Внутрішні деталі системи абсолютні шляхи, «сирі» помилки ОС У логах — можна, але обережно: не перетворюйте їх на витік і не показуйте користувачу.
Технічні метрики тривалість, кількість записів Логуємо охоче: це безпечно й дуже корисно.

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

Маскування секретів

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

Найпростіший прийом — показувати лише останні 4 символи (або перші 4), а середину замінювати на ***. Це не криптографія, але як гігієнічний мінімум працює.

Приклад маленької функції для маскування:

package main

import "strings"

func maskSecret(s string) string {
	if len(s) <= 4 {
		return "****"
	}
	return strings.Repeat("*", len(s)-4) + s[len(s)-4:]
}

Тепер застосуємо це в логах. Припустімо, наш CLI колись отримує токен (наприклад, через env-конфіг, який ви вже зустрічали раніше). Ми не будемо логувати його «як є»:

package main

import (
	"log/slog"
	"os"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stderr, nil))

	token := os.Getenv("TODO_TOKEN")
	log.Info("конфігурацію завантажено", slog.String("token", maskSecret(token)))
}

Тут важливо памʼятати про психологічну пастку: «я залогую токен тільки в Debug». А потім хтось вмикає Debug на проді «на пʼять хвилин», і токени їдуть у централізований збирач. Тому правило просте: секрет не повинен потрапляти в лог на жодному рівні. Якщо вже дуже потрібно щось повʼязати, логуйте не секрет, а безпечний ідентифікатор (наприклад, token_id, якщо він у вас є), або маску.

3. Не шуміти: рівні логів за змістом

Шумні логи — це коли в них багато тексту, але мало змісту. Найнеприємніше в шумі те, що він не виглядає помилкою: програма працює, логи пишуться… просто ніхто їх не читає. А коли стається реальна проблема, ви відкриваєте логи й бачите 50 тисяч рядків “starting”, “processing”, “still processing”, “processing again”, і десь серед них губиться одне справжнє попередження.

Щоб логи не перетворювалися на суцільний шум, у slog є рівні, і ми зобовʼязані користуватися ними за змістом, а не за настроєм. Згадаємо модель рівнів як «гучність події»:

Рівень Коли доречний Типовий зміст
Debug
налагодження, деталі, кроки алгоритму «що саме сталося всередині»
Info
нормальні важливі події «що ми зробили корисного»
Warn
дивно, але не аварія «щось підозріле»
Error
операція не вдалася «ми не зробили того, що обіцяли»

Тепер головне: рівень — це фільтр шуму. Якщо ви все пишете як Info, фільтра немає. Якщо ви все пишете як Error, ви самі собі підпалюєте тривожність. Тому дисципліна така: детальну балаканину — у Debug, робочі факти — у Info, підозріле — у Warn, провали — у Error.

І ще один важливий нюанс: якщо ви передаєте помилку далі й логуєте її там, не треба по дорозі логувати кожен крок. Це повʼязано з ідеєю «логувати на межі», щоб не отримувати дублікати, і чудово поєднується з рекомендацією розділяти «повідомлення користувачу» та «подробицю для розробника».

4. Структурні логи: стабільність, ключі та обсяг

Повідомлення має бути стабільним

Дуже хочеться писати логи «по-людськи»: «Ой-ой-ой, щось пішло не так, зараз спробуємо ще раз, тримайтеся!». Але логи читають не лише очима — їх ще шукають, групують, порівнюють, а інколи будують на їх основі статистику. Тому хороші логи мають одну важливу властивість: стабільність.

Стабільне повідомлення — це короткий, повторюваний текст, який класифікує подію, а деталі лежать у полях. Тоді пошук і аналіз стають простішими: ви шукаєте "задачу створено" і бачите всі такі події, а не 20 варіантів «створив задачу», «задачу створено», «створення задачі пройшло успішно», «ура, ми створили задачу».

Порівняйте два підходи. Поганий — бо в тексті все змінюється:

log.Info("задачу створено #42 з назвою 'Купити молоко' за 12ms")

Хороший — бо текст стабільний, а деталі в полях:

log.Info(
	"задачу створено",
	slog.Int("id", 42),
	slog.Int("title_len", 8),
	slog.Int("duration_ms", 12),
)

Зверніть увагу на хитрість: замість title ми пишемо title_len. Так, зараз це менш зручно, зате ви не зіллєте в лог вміст користувацьких даних. І саме так працює гігієна: логи корисні, але не токсичні.

Один зміст — один ключ

Коли ви починаєте додавати поля, виникає нова небезпека: не витоки й не шум, а безлад ключів. В одному місці ви пишете op, в іншому operation, у третьому action. В одному місці taskId, в іншому id, у третьому task_id. Підсумок передбачуваний: фільтрувати й шукати неможливо. Ніби структурні логи є, а відчуття — як від каші.

Тому ми фіксуємо маленький «словничок» і дотримуємося його:

Ключ Що означає Приклад
component
підсистема/шар
"cli", "storage"
operation
дія
"task_add", "task_list"
id
ідентифікатор сутності
id=42
duration
тривалість
duration=15ms
err
помилка
err="..."

Також важливо тримати тип стабільним. Якщо id — це число, нехай воно всюди буде числом. Інакше ви отримаєте ситуацію, де в половині логів id=42, а в іншій половині id="42", і фільтр раптово перестає працювати.

Не перетворювати Any на смітник

log/slog дає спокусливу кнопку: slog.Any("something", x). Здається, що можна просто закинути туди весь обʼєкт — і проблема зникне. Але це часта пастка.

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

Тому практика така: замість «логувати весь світ» ми логуємо мінімальний набір ознак, який допомагає діагностиці. Часто це id, count, duration і акуратний err.

Наприклад, під час виведення списку задач нам зазвичай не потрібен повний дамп масиву задач у лог. Нам достатньо знати, скільки їх:

package main

import (
	"log/slog"
	"os"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stderr, nil))

	tasks := []string{"купити молоко", "писати код", "спати"}
	log.Info("задачі завантажено", slog.Int("count", len(tasks)))
}

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

5. Застосовуємо гігієну в TODO CLI й не ламаємо stdout

Тепер давайте приземлимо все це на наш навчальний проєкт: CLI для керування задачами. Ми вже домовилися, що результат команди йде в stdout, а діагностика — у stderr. Отже, наш slog-логер налаштовуємо на os.Stderr, а друк таблиць, JSON чи списків залишаємо через fmt/tabwriter у stdout (ви вже знайомилися з цим розділенням раніше).

Зробимо маленький «скелет» запуску команди, де вимірюємо тривалість і логуємо її як поле. Тут важливо: повідомлення стабільне, деталі — у полях.

package main

import (
	"log/slog"
	"os"
	"time"
)

func main() {
	log := slog.New(slog.NewTextHandler(os.Stderr, nil))

	start := time.Now()
	exitCode := 0 // припустімо, команда успішна

	log.Info("команду завершено",
		slog.String("component", "cli"),
		slog.String("operation", "todo"),
		slog.Int("exit_code", exitCode),
		slog.Duration("duration", time.Since(start)),
	)
}

Тепер приклад «додати задачу»: логуємо факт додавання за id, але не пишемо title повністю — памʼятаємо про персональні дані.

package main

import (
	"log/slog"
)

func addTask(log *slog.Logger, id int, title string) {
	log.Info("задачу створено",
		slog.String("operation", "task_add"),
		slog.Int("id", id),
		slog.Int("title_len", len(title)),
	)
}

А ось приклад обробки помилки: користувачу ми покажемо коротке повідомлення, а в лог запишемо err і контекст операції.

package main

import (
	"fmt"
	"log/slog"
)

func run(log *slog.Logger) int {
	err := fmt.Errorf("open storage: permission denied")

	if err != nil {
		log.Error("команду не вдалося виконати",
			slog.String("operation", "task_list"),
			slog.Any("err", err),
		)
		fmt.Println("ПОМИЛКА: не вдалося отримати список задач") // stdout: коротко для користувача
		return 1
	}
	return 0
}

Тут є тонкий момент: іноді розробник за звичкою хоче показати користувачу err.Error() — мовляв, нехай бачить правду. Але часто це розкриває внутрішні деталі: шляхи до файлів, системні повідомлення, внутрішні типи помилок. Для публічного інтерфейсу це погана ідея: нутрощі мають залишатися всередині, а контракт із користувачем — стабільним і безпечним.

6. Типові помилки під час гігієни логів

Ця тема здається простою рівно до першого реального інциденту, коли ви раптово розумієте, що логи читає не лише людина, яка їх написала, а дані там живуть довше, ніж ваша памʼять про те, чому ви так написали. Нижче — граблі, на які наступають навіть дуже бадьорі новачки, а якщо чесно, то й не лише новачки, причому майже завжди з найкращих намірів.

Помилка № 1: логувати секрет «лише на час налагодження», а потім забути.
Це класика жанру: ви додали log.Debug("token", ...), перевірили, що все працює, а потім забули прибрати. Через тиждень хтось вмикає Debug на проді, і токени їдуть у сховище логів. Лікується нудно: секрети не логуємо взагалі; максимум — маска або безпечний ідентифікатор.

Помилка № 2: плутати корисну діагностику та користувацьке повідомлення.
Коли в CLI у stdout вилітає «open /var/lib/todo/data.json: permission denied», ви одночасно розкриваєте нутрощі й ламаєте формат результату. Правильніше тримати користувацьке повідомлення коротким і стабільним, а подробиці, включно з початковим err, відправляти в лог на stderr.

Помилка № 3: перетворювати Info на «ми живі, ми живі, ми живі» в кожному рядку.
Якщо ви пишете Info на кожному кроці циклу, лог стає шумом. У підсумку реальні попередження ніхто не бачить. Зазвичай допомагає просте правило: Info — для значущих подій (старт/фініш команди, важливий результат), «покрокова балаканина» — у Debug і лише коли це справді потрібно.

Помилка № 4: змінювати ключі й типи полів від місця до місця.
Сьогодні ви пишете taskId=42, завтра id="42", післязавтра task_id=42. Ззовні все виглядає як «структурні логи», але фільтрація стає неможливою. Оберіть словник ключів (operation, component, id, duration, err) і дотримуйтеся його, а id тримайте одного типу.

Помилка № 5: логувати цілі структури через Any, не думаючи про склад даних.
Усередині структури може опинитися секрет або персональні дані. Крім того, логи роздуваються й стають непридатними для читання. Краще логувати мінімальні ознаки: id, count, duration, плюс err у разі помилки. Якщо треба діагностувати вміст — робіть це точково й усвідомлено.

1
Опитування
Логування, рівень 52, лекція 4
Недоступний
Логування
Логування
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ